-
Notifications
You must be signed in to change notification settings - Fork 129
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #232 from TokenySolutions/BT-360-min-spend-country
BT-354 Minimum transfer by country module
- Loading branch information
Showing
4 changed files
with
297 additions
and
0 deletions.
There are no files selected for viewing
108 changes: 108 additions & 0 deletions
108
contracts/compliance/modular/modules/MinTransferByCountryModule.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
// SPDX-License-Identifier: GPL-3.0 | ||
pragma solidity 0.8.27; | ||
|
||
import "../IModularCompliance.sol"; | ||
import "../../../token/IToken.sol"; | ||
import "./AbstractModuleUpgradeable.sol"; | ||
|
||
event MinimumTransferAmountSet(address indexed compliance, uint16 indexed country, uint256 amount); | ||
|
||
|
||
/** | ||
* @title MinTransferByCountry Module | ||
* @dev Enforces minimum transfer amounts for token holders from specified countries | ||
* when creating new investors for that country | ||
*/ | ||
contract MinTransferByCountryModule is AbstractModuleUpgradeable { | ||
|
||
mapping(address compliance => mapping(uint16 country => uint256 minAmount)) private _minimumTransferAmounts; | ||
|
||
function initialize() external initializer { | ||
__AbstractModule_init(); | ||
} | ||
|
||
/** | ||
* @dev Sets minimum transfer amount for a country | ||
* @param country Country code | ||
* @param amount Minimum transfer amount | ||
*/ | ||
function setMinimumTransferAmount(uint16 country, uint256 amount) external onlyComplianceCall { | ||
_minimumTransferAmounts[msg.sender][country] = amount; | ||
|
||
emit MinimumTransferAmountSet(msg.sender, country, amount); | ||
} | ||
|
||
/// @inheritdoc IModule | ||
// solhint-disable-next-line no-empty-blocks | ||
function moduleTransferAction(address _from, address _to, uint256 _value) external {} | ||
|
||
/// @inheritdoc IModule | ||
// solhint-disable-next-line no-empty-blocks | ||
function moduleMintAction(address _to, uint256 _value) external {} | ||
|
||
/// @inheritdoc IModule | ||
// solhint-disable-next-line no-empty-blocks | ||
function moduleBurnAction(address _from, uint256 _value) external {} | ||
|
||
/// @inheritdoc IModule | ||
function moduleCheck( | ||
address _from, | ||
address _to, | ||
uint256 _amount, | ||
address _compliance | ||
) external view override returns (bool) { | ||
uint16 recipientCountry = _getCountry(_compliance, _to); | ||
if (_minimumTransferAmounts[_compliance][recipientCountry] == 0) { | ||
return true; | ||
} | ||
|
||
// Check for internal transfer in same country | ||
address idFrom = _getIdentity(_compliance, _from); | ||
address idTo = _getIdentity(_compliance, _to); | ||
if (idFrom == idTo) { | ||
uint16 senderCountry = _getCountry(_compliance, _from); | ||
return senderCountry == recipientCountry | ||
|| _amount >= _minimumTransferAmounts[_compliance][recipientCountry]; | ||
} | ||
|
||
IToken token = IToken(IModularCompliance(_compliance).getTokenBound()); | ||
// Check for new user | ||
return token.balanceOf(_to) > 0 | ||
|| _amount >= _minimumTransferAmounts[_compliance][recipientCountry]; | ||
} | ||
|
||
/// @inheritdoc IModule | ||
function canComplianceBind(address /*_compliance*/) external pure override returns (bool) { | ||
return true; | ||
} | ||
|
||
/// @inheritdoc IModule | ||
function isPlugAndPlay() external pure override returns (bool) { | ||
return true; | ||
} | ||
|
||
/** | ||
* @dev Module name | ||
*/ | ||
function name() public pure returns (string memory) { | ||
return "MinTransferByCountryModule"; | ||
} | ||
|
||
|
||
/// @dev function used to get the country of a wallet address. | ||
/// @param _compliance the compliance contract address for which the country verification is required | ||
/// @param _userAddress the address of the wallet to be checked | ||
/// @return the ISO 3166-1 standard country code of the wallet owner | ||
function _getCountry(address _compliance, address _userAddress) internal view returns (uint16) { | ||
return IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().investorCountry(_userAddress); | ||
} | ||
|
||
/// @dev Returns the ONCHAINID (Identity) of the _userAddress | ||
/// @param _compliance the compliance contract address for which the country verification is required | ||
/// @param _userAddress Address of the wallet | ||
/// @return the ONCHAINID (Identity) of the _userAddress | ||
function _getIdentity(address _compliance, address _userAddress) internal view returns (address) { | ||
return address(IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().identity | ||
(_userAddress)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; | ||
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; | ||
import { ethers } from 'hardhat'; | ||
import { expect } from 'chai'; | ||
import { deploySuiteWithModularCompliancesFixture } from '../fixtures/deploy-full-suite.fixture'; | ||
import { MinTransferByCountryModule, ModularCompliance } from '../../index.js'; | ||
|
||
describe('MinTransferByCountryModule', () => { | ||
// Test fixture | ||
async function deployMinTransferByCountryModuleFullSuite() { | ||
const context = await loadFixture(deploySuiteWithModularCompliancesFixture); | ||
|
||
const module = await ethers.deployContract('MinTransferByCountryModule'); | ||
const proxy = await ethers.deployContract('ModuleProxy', [module.target, module.interface.encodeFunctionData('initialize')]); | ||
const complianceModule = await ethers.getContractAt('MinTransferByCountryModule', proxy.target); | ||
|
||
await context.suite.compliance.bindToken(context.suite.token.target); | ||
await context.suite.compliance.addModule(complianceModule.target); | ||
|
||
return { | ||
...context, | ||
suite: { | ||
...context.suite, | ||
complianceModule, | ||
}, | ||
}; | ||
} | ||
|
||
async function setMinimumTransferAmount( | ||
compliance: ModularCompliance, | ||
complianceModule: MinTransferByCountryModule, | ||
deployer: SignerWithAddress, | ||
countryCode: bigint, | ||
minAmount: bigint, | ||
) { | ||
return compliance | ||
.connect(deployer) | ||
.callModuleFunction( | ||
new ethers.Interface(['function setMinimumTransferAmount(uint16 country, uint256 amount)']).encodeFunctionData('setMinimumTransferAmount', [ | ||
countryCode, | ||
minAmount, | ||
]), | ||
complianceModule.target, | ||
); | ||
} | ||
|
||
describe('Initialization', () => { | ||
it('should initialize correctly', async () => { | ||
const { | ||
suite: { compliance, complianceModule }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
expect(await complianceModule.name()).to.equal('MinTransferByCountryModule'); | ||
expect(await complianceModule.isPlugAndPlay()).to.be.true; | ||
expect(await complianceModule.canComplianceBind(compliance.target)).to.be.true; | ||
}); | ||
}); | ||
|
||
describe('Basic operations', () => { | ||
it('Should mint/burn/transfer tokens if no minimum transfer amount is set', async () => { | ||
const { | ||
suite: { token }, | ||
accounts: { tokenAgent, aliceWallet, bobWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
await token.connect(tokenAgent).mint(aliceWallet.address, 10); | ||
await token.connect(aliceWallet).transfer(bobWallet.address, 10); | ||
await token.connect(tokenAgent).burn(bobWallet.address, 10); | ||
}); | ||
}); | ||
|
||
describe('Country Settings', () => { | ||
it('should set minimum transfer amount for a country', async () => { | ||
const { | ||
suite: { compliance, complianceModule }, | ||
accounts: { deployer }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = 42n; | ||
const minAmount = ethers.parseEther('100'); | ||
const tx = await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
await expect(tx).to.emit(complianceModule, 'MinimumTransferAmountSet').withArgs(compliance.target, countryCode, minAmount); | ||
}); | ||
|
||
it('should revert when other than compliance tries to set minimum transfer amount', async () => { | ||
const { | ||
suite: { complianceModule }, | ||
accounts: { aliceWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = 1; | ||
const minAmount = ethers.parseEther('100'); | ||
|
||
await expect(complianceModule.connect(aliceWallet).setMinimumTransferAmount(countryCode, minAmount)).to.be.revertedWithCustomError( | ||
complianceModule, | ||
'OnlyBoundComplianceCanCall', | ||
); | ||
}); | ||
}); | ||
|
||
describe('Transfer Validation', () => { | ||
it('should allow transfer when amount meets minimum requirement', async () => { | ||
const { | ||
suite: { compliance, complianceModule, identityRegistry }, | ||
accounts: { deployer, aliceWallet, bobWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = await identityRegistry.investorCountry(aliceWallet.address); | ||
const minAmount = ethers.parseEther('100'); | ||
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
|
||
const transferAmount = ethers.parseEther('150'); | ||
expect(await complianceModule.moduleCheck(bobWallet.address, aliceWallet.address, transferAmount, compliance.target)).to.be.true; | ||
}); | ||
|
||
it('should prevent transfer when amount is below minimum requirement', async () => { | ||
const { | ||
suite: { compliance, complianceModule, identityRegistry }, | ||
accounts: { deployer, charlieWallet, bobWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = await identityRegistry.investorCountry(charlieWallet.address); | ||
const minAmount = ethers.parseEther('100'); | ||
|
||
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
const transferAmount = ethers.parseEther('99'); | ||
expect(await complianceModule.moduleCheck(bobWallet.address, charlieWallet.address, transferAmount, compliance.target)).to.be.false; | ||
}); | ||
|
||
it('should allow transfer when no minimum amount is set for country', async () => { | ||
const { | ||
suite: { compliance, complianceModule }, | ||
accounts: { aliceWallet, charlieWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
expect(await complianceModule.moduleCheck(aliceWallet.address, charlieWallet.address, 1, compliance.target)).to.be.true; | ||
}); | ||
|
||
it('should allow transfer when user as already a balance', async () => { | ||
const { | ||
suite: { compliance, complianceModule, identityRegistry }, | ||
accounts: { deployer, aliceWallet, bobWallet }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = await identityRegistry.investorCountry(bobWallet.address); | ||
const minAmount = ethers.parseEther('100'); | ||
|
||
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 1, compliance.target)).to.be.true; | ||
}); | ||
|
||
it('should allow transfer when transfer into same identity and same country with amount below the minimum amount set', async () => { | ||
const { | ||
suite: { compliance, complianceModule, identityRegistry }, | ||
accounts: { deployer, aliceWallet, anotherWallet, tokenAgent }, | ||
identities: { aliceIdentity }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = await identityRegistry.investorCountry(aliceWallet.address); | ||
|
||
await identityRegistry.connect(tokenAgent).registerIdentity(anotherWallet.address, aliceIdentity, countryCode); | ||
|
||
const minAmount = ethers.parseEther('100'); | ||
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
|
||
expect(await complianceModule.moduleCheck(aliceWallet.address, anotherWallet.address, 1, compliance.target)).to.be.true; | ||
}); | ||
|
||
it('should prevent transfer when transfer into same identity and different country with amount below the minimum amount set', async () => { | ||
const { | ||
suite: { compliance, complianceModule, identityRegistry }, | ||
accounts: { deployer, aliceWallet, anotherWallet, tokenAgent }, | ||
identities: { aliceIdentity }, | ||
} = await loadFixture(deployMinTransferByCountryModuleFullSuite); | ||
|
||
const countryCode = 1n + (await identityRegistry.investorCountry(aliceWallet.address)); | ||
|
||
await identityRegistry.connect(tokenAgent).registerIdentity(anotherWallet.address, aliceIdentity, countryCode); | ||
|
||
const minAmount = ethers.parseEther('100'); | ||
await setMinimumTransferAmount(compliance, complianceModule, deployer, countryCode, minAmount); | ||
|
||
expect(await complianceModule.moduleCheck(aliceWallet.address, anotherWallet.address, 1, compliance.target)).to.be.false; | ||
}); | ||
}); | ||
}); |