Skip to content

Commit

Permalink
Merge pull request #232 from TokenySolutions/BT-360-min-spend-country
Browse files Browse the repository at this point in the history
BT-354 Minimum transfer by country module
  • Loading branch information
Joachim-Lebrun authored Dec 16, 2024
2 parents c3cf7c5 + 83b6ff0 commit 07b45bf
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 0 deletions.
108 changes: 108 additions & 0 deletions contracts/compliance/modular/modules/MinTransferByCountryModule.sol
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));
}
}
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export namespace contracts {
export const TransferRestrictModule: ContractJSON;
export const TokenListingRestrictionsModule: ContractJSON;
export const InvestorCountryCapModule: ContractJSON;
export const MinTransferByCountrytModule: ContractJSON;
}

export namespace interfaces {
Expand Down
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const TransferFeesModule = require('./artifacts/contracts/compliance/modular/mod
const TransferRestrictModule = require('./artifacts/contracts/compliance/modular/modules/TransferRestrictModule.sol/TransferRestrictModule.json');
const TokenListingRestrictionsModule = require('./artifacts/contracts/compliance/modular/modules/TokenListingRestrictionsModule.sol/TokenListingRestrictionsModule.json');
const InvestorCountryCapModule = require('./artifacts/contracts/compliance/modular/modules/InvestorCountryCapModule.sol/InvestorCountryCapModule.json');
const MinTransferByCountrytModule = require('./artifacts/contracts/compliance/modular/modules/MinTransferByCountrytModule.sol/MinTransferByCountrytModule.json');

module.exports = {
contracts: {
Expand Down Expand Up @@ -141,6 +142,7 @@ module.exports = {
TransferRestrictModule,
TokenListingRestrictionsModule,
InvestorCountryCapModule,
MinTransferByCountrytModule,
},
interfaces: {
IToken,
Expand Down
186 changes: 186 additions & 0 deletions test/compliances/module-min-transfer-by-country.ts
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;
});
});
});

0 comments on commit 07b45bf

Please sign in to comment.