Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BT-354 Minimum transfer by country module #232

Merged
merged 4 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
});
});
});
Loading