Skip to content

Commit 07b45bf

Browse files
Merge pull request #232 from TokenySolutions/BT-360-min-spend-country
BT-354 Minimum transfer by country module
2 parents c3cf7c5 + 83b6ff0 commit 07b45bf

File tree

4 files changed

+297
-0
lines changed

4 files changed

+297
-0
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// SPDX-License-Identifier: GPL-3.0
2+
pragma solidity 0.8.27;
3+
4+
import "../IModularCompliance.sol";
5+
import "../../../token/IToken.sol";
6+
import "./AbstractModuleUpgradeable.sol";
7+
8+
event MinimumTransferAmountSet(address indexed compliance, uint16 indexed country, uint256 amount);
9+
10+
11+
/**
12+
* @title MinTransferByCountry Module
13+
* @dev Enforces minimum transfer amounts for token holders from specified countries
14+
* when creating new investors for that country
15+
*/
16+
contract MinTransferByCountryModule is AbstractModuleUpgradeable {
17+
18+
mapping(address compliance => mapping(uint16 country => uint256 minAmount)) private _minimumTransferAmounts;
19+
20+
function initialize() external initializer {
21+
__AbstractModule_init();
22+
}
23+
24+
/**
25+
* @dev Sets minimum transfer amount for a country
26+
* @param country Country code
27+
* @param amount Minimum transfer amount
28+
*/
29+
function setMinimumTransferAmount(uint16 country, uint256 amount) external onlyComplianceCall {
30+
_minimumTransferAmounts[msg.sender][country] = amount;
31+
32+
emit MinimumTransferAmountSet(msg.sender, country, amount);
33+
}
34+
35+
/// @inheritdoc IModule
36+
// solhint-disable-next-line no-empty-blocks
37+
function moduleTransferAction(address _from, address _to, uint256 _value) external {}
38+
39+
/// @inheritdoc IModule
40+
// solhint-disable-next-line no-empty-blocks
41+
function moduleMintAction(address _to, uint256 _value) external {}
42+
43+
/// @inheritdoc IModule
44+
// solhint-disable-next-line no-empty-blocks
45+
function moduleBurnAction(address _from, uint256 _value) external {}
46+
47+
/// @inheritdoc IModule
48+
function moduleCheck(
49+
address _from,
50+
address _to,
51+
uint256 _amount,
52+
address _compliance
53+
) external view override returns (bool) {
54+
uint16 recipientCountry = _getCountry(_compliance, _to);
55+
if (_minimumTransferAmounts[_compliance][recipientCountry] == 0) {
56+
return true;
57+
}
58+
59+
// Check for internal transfer in same country
60+
address idFrom = _getIdentity(_compliance, _from);
61+
address idTo = _getIdentity(_compliance, _to);
62+
if (idFrom == idTo) {
63+
uint16 senderCountry = _getCountry(_compliance, _from);
64+
return senderCountry == recipientCountry
65+
|| _amount >= _minimumTransferAmounts[_compliance][recipientCountry];
66+
}
67+
68+
IToken token = IToken(IModularCompliance(_compliance).getTokenBound());
69+
// Check for new user
70+
return token.balanceOf(_to) > 0
71+
|| _amount >= _minimumTransferAmounts[_compliance][recipientCountry];
72+
}
73+
74+
/// @inheritdoc IModule
75+
function canComplianceBind(address /*_compliance*/) external pure override returns (bool) {
76+
return true;
77+
}
78+
79+
/// @inheritdoc IModule
80+
function isPlugAndPlay() external pure override returns (bool) {
81+
return true;
82+
}
83+
84+
/**
85+
* @dev Module name
86+
*/
87+
function name() public pure returns (string memory) {
88+
return "MinTransferByCountryModule";
89+
}
90+
91+
92+
/// @dev function used to get the country of a wallet address.
93+
/// @param _compliance the compliance contract address for which the country verification is required
94+
/// @param _userAddress the address of the wallet to be checked
95+
/// @return the ISO 3166-1 standard country code of the wallet owner
96+
function _getCountry(address _compliance, address _userAddress) internal view returns (uint16) {
97+
return IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().investorCountry(_userAddress);
98+
}
99+
100+
/// @dev Returns the ONCHAINID (Identity) of the _userAddress
101+
/// @param _compliance the compliance contract address for which the country verification is required
102+
/// @param _userAddress Address of the wallet
103+
/// @return the ONCHAINID (Identity) of the _userAddress
104+
function _getIdentity(address _compliance, address _userAddress) internal view returns (address) {
105+
return address(IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().identity
106+
(_userAddress));
107+
}
108+
}

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export namespace contracts {
7373
export const TransferRestrictModule: ContractJSON;
7474
export const TokenListingRestrictionsModule: ContractJSON;
7575
export const InvestorCountryCapModule: ContractJSON;
76+
export const MinTransferByCountrytModule: ContractJSON;
7677
}
7778

7879
export namespace interfaces {

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const TransferFeesModule = require('./artifacts/contracts/compliance/modular/mod
7575
const TransferRestrictModule = require('./artifacts/contracts/compliance/modular/modules/TransferRestrictModule.sol/TransferRestrictModule.json');
7676
const TokenListingRestrictionsModule = require('./artifacts/contracts/compliance/modular/modules/TokenListingRestrictionsModule.sol/TokenListingRestrictionsModule.json');
7777
const InvestorCountryCapModule = require('./artifacts/contracts/compliance/modular/modules/InvestorCountryCapModule.sol/InvestorCountryCapModule.json');
78+
const MinTransferByCountrytModule = require('./artifacts/contracts/compliance/modular/modules/MinTransferByCountrytModule.sol/MinTransferByCountrytModule.json');
7879

7980
module.exports = {
8081
contracts: {
@@ -141,6 +142,7 @@ module.exports = {
141142
TransferRestrictModule,
142143
TokenListingRestrictionsModule,
143144
InvestorCountryCapModule,
145+
MinTransferByCountrytModule,
144146
},
145147
interfaces: {
146148
IToken,
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers';
2+
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers';
3+
import { ethers } from 'hardhat';
4+
import { expect } from 'chai';
5+
import { deploySuiteWithModularCompliancesFixture } from '../fixtures/deploy-full-suite.fixture';
6+
import { MinTransferByCountryModule, ModularCompliance } from '../../index.js';
7+
8+
describe('MinTransferByCountryModule', () => {
9+
// Test fixture
10+
async function deployMinTransferByCountryModuleFullSuite() {
11+
const context = await loadFixture(deploySuiteWithModularCompliancesFixture);
12+
13+
const module = await ethers.deployContract('MinTransferByCountryModule');
14+
const proxy = await ethers.deployContract('ModuleProxy', [module.target, module.interface.encodeFunctionData('initialize')]);
15+
const complianceModule = await ethers.getContractAt('MinTransferByCountryModule', proxy.target);
16+
17+
await context.suite.compliance.bindToken(context.suite.token.target);
18+
await context.suite.compliance.addModule(complianceModule.target);
19+
20+
return {
21+
...context,
22+
suite: {
23+
...context.suite,
24+
complianceModule,
25+
},
26+
};
27+
}
28+
29+
async function setMinimumTransferAmount(
30+
compliance: ModularCompliance,
31+
complianceModule: MinTransferByCountryModule,
32+
deployer: SignerWithAddress,
33+
countryCode: bigint,
34+
minAmount: bigint,
35+
) {
36+
return compliance
37+
.connect(deployer)
38+
.callModuleFunction(
39+
new ethers.Interface(['function setMinimumTransferAmount(uint16 country, uint256 amount)']).encodeFunctionData('setMinimumTransferAmount', [
40+
countryCode,
41+
minAmount,
42+
]),
43+
complianceModule.target,
44+
);
45+
}
46+
47+
describe('Initialization', () => {
48+
it('should initialize correctly', async () => {
49+
const {
50+
suite: { compliance, complianceModule },
51+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
52+
53+
expect(await complianceModule.name()).to.equal('MinTransferByCountryModule');
54+
expect(await complianceModule.isPlugAndPlay()).to.be.true;
55+
expect(await complianceModule.canComplianceBind(compliance.target)).to.be.true;
56+
});
57+
});
58+
59+
describe('Basic operations', () => {
60+
it('Should mint/burn/transfer tokens if no minimum transfer amount is set', async () => {
61+
const {
62+
suite: { token },
63+
accounts: { tokenAgent, aliceWallet, bobWallet },
64+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
65+
66+
await token.connect(tokenAgent).mint(aliceWallet.address, 10);
67+
await token.connect(aliceWallet).transfer(bobWallet.address, 10);
68+
await token.connect(tokenAgent).burn(bobWallet.address, 10);
69+
});
70+
});
71+
72+
describe('Country Settings', () => {
73+
it('should set minimum transfer amount for a country', async () => {
74+
const {
75+
suite: { compliance, complianceModule },
76+
accounts: { deployer },
77+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
78+
79+
const countryCode = 42n;
80+
const minAmount = ethers.parseEther('100');
81+
const tx = await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
82+
await expect(tx).to.emit(complianceModule, 'MinimumTransferAmountSet').withArgs(compliance.target, countryCode, minAmount);
83+
});
84+
85+
it('should revert when other than compliance tries to set minimum transfer amount', async () => {
86+
const {
87+
suite: { complianceModule },
88+
accounts: { aliceWallet },
89+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
90+
91+
const countryCode = 1;
92+
const minAmount = ethers.parseEther('100');
93+
94+
await expect(complianceModule.connect(aliceWallet).setMinimumTransferAmount(countryCode, minAmount)).to.be.revertedWithCustomError(
95+
complianceModule,
96+
'OnlyBoundComplianceCanCall',
97+
);
98+
});
99+
});
100+
101+
describe('Transfer Validation', () => {
102+
it('should allow transfer when amount meets minimum requirement', async () => {
103+
const {
104+
suite: { compliance, complianceModule, identityRegistry },
105+
accounts: { deployer, aliceWallet, bobWallet },
106+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
107+
108+
const countryCode = await identityRegistry.investorCountry(aliceWallet.address);
109+
const minAmount = ethers.parseEther('100');
110+
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
111+
112+
const transferAmount = ethers.parseEther('150');
113+
expect(await complianceModule.moduleCheck(bobWallet.address, aliceWallet.address, transferAmount, compliance.target)).to.be.true;
114+
});
115+
116+
it('should prevent transfer when amount is below minimum requirement', async () => {
117+
const {
118+
suite: { compliance, complianceModule, identityRegistry },
119+
accounts: { deployer, charlieWallet, bobWallet },
120+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
121+
122+
const countryCode = await identityRegistry.investorCountry(charlieWallet.address);
123+
const minAmount = ethers.parseEther('100');
124+
125+
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
126+
const transferAmount = ethers.parseEther('99');
127+
expect(await complianceModule.moduleCheck(bobWallet.address, charlieWallet.address, transferAmount, compliance.target)).to.be.false;
128+
});
129+
130+
it('should allow transfer when no minimum amount is set for country', async () => {
131+
const {
132+
suite: { compliance, complianceModule },
133+
accounts: { aliceWallet, charlieWallet },
134+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
135+
136+
expect(await complianceModule.moduleCheck(aliceWallet.address, charlieWallet.address, 1, compliance.target)).to.be.true;
137+
});
138+
139+
it('should allow transfer when user as already a balance', async () => {
140+
const {
141+
suite: { compliance, complianceModule, identityRegistry },
142+
accounts: { deployer, aliceWallet, bobWallet },
143+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
144+
145+
const countryCode = await identityRegistry.investorCountry(bobWallet.address);
146+
const minAmount = ethers.parseEther('100');
147+
148+
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
149+
expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 1, compliance.target)).to.be.true;
150+
});
151+
152+
it('should allow transfer when transfer into same identity and same country with amount below the minimum amount set', async () => {
153+
const {
154+
suite: { compliance, complianceModule, identityRegistry },
155+
accounts: { deployer, aliceWallet, anotherWallet, tokenAgent },
156+
identities: { aliceIdentity },
157+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
158+
159+
const countryCode = await identityRegistry.investorCountry(aliceWallet.address);
160+
161+
await identityRegistry.connect(tokenAgent).registerIdentity(anotherWallet.address, aliceIdentity, countryCode);
162+
163+
const minAmount = ethers.parseEther('100');
164+
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
165+
166+
expect(await complianceModule.moduleCheck(aliceWallet.address, anotherWallet.address, 1, compliance.target)).to.be.true;
167+
});
168+
169+
it('should prevent transfer when transfer into same identity and different country with amount below the minimum amount set', async () => {
170+
const {
171+
suite: { compliance, complianceModule, identityRegistry },
172+
accounts: { deployer, aliceWallet, anotherWallet, tokenAgent },
173+
identities: { aliceIdentity },
174+
} = await loadFixture(deployMinTransferByCountryModuleFullSuite);
175+
176+
const countryCode = 1n + (await identityRegistry.investorCountry(aliceWallet.address));
177+
178+
await identityRegistry.connect(tokenAgent).registerIdentity(anotherWallet.address, aliceIdentity, countryCode);
179+
180+
const minAmount = ethers.parseEther('100');
181+
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount);
182+
183+
expect(await complianceModule.moduleCheck(aliceWallet.address, anotherWallet.address, 1, compliance.target)).to.be.false;
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)