From f881aa123082998b40b8a51cb4a9fa0575f5f2bd Mon Sep 17 00:00:00 2001 From: aliarbak Date: Fri, 6 Oct 2023 14:51:56 +0300 Subject: [PATCH] (TFM) Add transfer fees module (#168) * (TFM) Add transfer fees module * Remove describe.only from unit tests * Update set fees function * Add is plug and play function --- .../modular/modules/TransferFeesModule.sol | 204 ++++++++++ index.js | 2 + test/compliances/module-transfer-fees.test.ts | 376 ++++++++++++++++++ 3 files changed, 582 insertions(+) create mode 100644 contracts/compliance/modular/modules/TransferFeesModule.sol create mode 100644 test/compliances/module-transfer-fees.test.ts diff --git a/contracts/compliance/modular/modules/TransferFeesModule.sol b/contracts/compliance/modular/modules/TransferFeesModule.sol new file mode 100644 index 00000000..cb2b7d3a --- /dev/null +++ b/contracts/compliance/modular/modules/TransferFeesModule.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0 +// +// :+#####%%%%%%%%%%%%%%+ +// .-*@@@%+.:+%@@@@@%%#***%@@%= +// :=*%@@@#=. :#@@% *@@@%= +// .-+*%@%*-.:+%@@@@@@+. -*+: .=#. :%@@@%- +// :=*@@@@%%@@@@@@@@@%@@@- .=#@@@%@%= =@@@@#. +// -=+#%@@%#*=:. :%@@@@%. -*@@#*@@@@@@@#=:- *@@@@+ +// =@@%=:. :=: *@@@@@%#- =%*%@@@@#+-. =+ :%@@@%- +// -@@%. .+@@@ =+=-. @@#- +@@@%- =@@@@%: +// :@@@. .+@@#%: : .=*=-::.-%@@@+*@@= +@@@@#. +// %@@: +@%%* =%@@@@@@@@@@@#. .*@%- +@@@@*. +// #@@= .+@@@@%:=*@@@@@- :%@%: .*@@@@+ +// *@@* +@@@#-@@%-:%@@* +@@#. :%@@@@- +// -@@% .:-=++*##%%%@@@@@@@@@@@@*. :@+.@@@%: .#@@+ =@@@@#: +// .@@@*-+*#%%%@@@@@@@@@@@@@@@@%%#**@@%@@@. *@=*@@# :#@%= .#@@@@#- +// -%@@@@@@@@@@@@@@@*+==-:-@@@= *@# .#@*-=*@@@@%= -%@@@* =@@@@@%- +// -+%@@@#. %@%%= -@@:+@: -@@* *@@*-:: -%@@%=. .*@@@@@# +// *@@@* +@* *@@##@@- #@*@@+ -@@= . :+@@@#: .-+@@@%+- +// +@@@%*@@:..=@@@@* .@@@* .#@#. .=+- .=%@@@*. :+#@@@@*=: +// =@@@@%@@@@@@@@@@@@@@@@@@@@@@%- :+#*. :*@@@%=. .=#@@@@%+: +// .%@@= ..... .=#@@+. .#@@@*: -*%@@@@%+. +// +@@#+===---:::... .=%@@*- +@@@+. -*@@@@@%+. +// -@@@@@@@@@@@@@@@@@@@@@@%@@@@= -@@@+ -#@@@@@#=. +// ..:::---===+++***###%%%@@@#- .#@@+ -*@@@@@#=. +// @@@@@@+. +@@*. .+@@@@@%=. +// -@@@@@= =@@%: -#@@@@%+. +// +@@@@@. =@@@= .+@@@@@*: +// #@@@@#:%@@#. :*@@@@#- +// @@@@@%@@@= :#@@@@+. +// :@@@@@@@#.:#@@@%- +// +@@@@@@-.*@@@*: +// #@@@@#.=@@@+. +// @@@@+-%@%= +// :@@@#%@%= +// +@@@@%- +// :#%%= +// +/** + * NOTICE + * + * The T-REX software is licensed under a proprietary license or the GPL v.3. + * If you choose to receive it under the GPL v.3 license, the following applies: + * T-REX is a suite of smart contracts implementing the ERC-3643 standard and + * developed by Tokeny to manage and transfer financial assets on EVM blockchains + * + * Copyright (C) 2023, Tokeny sàrl. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +pragma solidity 0.8.17; + +import "../IModularCompliance.sol"; +import "../../../token/IToken.sol"; +import "../../../roles/AgentRole.sol"; +import "./AbstractModule.sol"; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract TransferFeesModule is AbstractModule, Ownable { + /// Struct of fees + struct Fee { + uint256 rate; // min = 0, max = 10000, 0.01% = 1, 1% = 100, 100% = 10000 + address collector; + } + + /// Mapping for compliance fees + mapping(address => Fee) private _fees; + + /** + * this event is emitted whenever a fee definition is updated for the given compliance address + * the event is emitted by 'setFee'. + * compliance is the compliance contract address + * _rate is the rate of the fee (0.01% = 1, 1% = 100, 100% = 10000) + * _collector is the collector wallet address + */ + event FeeUpdated(address indexed compliance, uint256 _rate, address _collector); + + error FeeRateIsOutOfRange(address compliance, uint256 rate); + + error CollectorAddressIsNotVerified(address compliance, address collector); + + /** + * @dev Sets the fee rate and collector of the given compliance + * @param _rate is the rate of the fee (0.01% = 1, 1% = 100, 100% = 10000) + * @param _collector is the collector wallet address + * Only the owner of the Compliance smart contract can call this function + * Collector wallet address must be verified + */ + function setFee(uint256 _rate, address _collector) external onlyComplianceCall { + address tokenAddress = IModularCompliance(msg.sender).getTokenBound(); + if (_rate > 10000) { + revert FeeRateIsOutOfRange(msg.sender, _rate); + } + + IIdentityRegistry identityRegistry = IToken(tokenAddress).identityRegistry(); + if (!identityRegistry.isVerified(_collector)) { + revert CollectorAddressIsNotVerified(msg.sender, _collector); + } + + _fees[msg.sender].rate = _rate; + _fees[msg.sender].collector = _collector; + emit FeeUpdated(msg.sender, _rate, _collector); + } + + /** + * @dev See {IModule-moduleTransferAction}. + */ + function moduleTransferAction(address _from, address _to, uint256 _value) external override onlyComplianceCall { + address senderIdentity = _getIdentity(msg.sender, _from); + address receiverIdentity = _getIdentity(msg.sender, _to); + + if (senderIdentity == receiverIdentity) { + return; + } + + Fee memory fee = _fees[msg.sender]; + if (fee.rate == 0 || _from == fee.collector || _to == fee.collector) { + return; + } + + uint256 feeAmount = (_value * fee.rate) / 10000; + if (feeAmount == 0) { + return; + } + + IToken token = IToken(IModularCompliance(msg.sender).getTokenBound()); + bool sent = token.forcedTransfer(_to, fee.collector, feeAmount); + require(sent, "transfer fee collection failed"); + } + + /** + * @dev See {IModule-moduleMintAction}. + */ + // solhint-disable-next-line no-empty-blocks + function moduleMintAction(address _to, uint256 _value) external override onlyComplianceCall {} + + /** + * @dev See {IModule-moduleBurnAction}. + */ + // solhint-disable-next-line no-empty-blocks + function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall {} + + /** + * @dev See {IModule-moduleCheck}. + */ + // solhint-disable-next-line no-unused-vars + function moduleCheck(address _from, address _to, uint256 _value, address _compliance) external view override returns (bool) { + return true; + } + + /** + * @dev getter for `_fees` variable + * @param _compliance the Compliance smart contract to be checked + * returns the Fee + */ + function getFee(address _compliance) external view returns (Fee memory) { + return _fees[_compliance]; + } + + /** + * @dev See {IModule-canComplianceBind}. + */ + function canComplianceBind(address _compliance) external view returns (bool) { + address tokenAddress = IModularCompliance(_compliance).getTokenBound(); + return AgentRole(tokenAddress).isAgent(address(this)); + } + + /** + * @dev See {IModule-isPlugAndPlay}. + */ + function isPlugAndPlay() external pure returns (bool) { + return false; + } + + /** + * @dev See {IModule-name}. + */ + function name() public pure returns (string memory _name) { + return "TransferFeesModule"; + } + + /** + * @dev Returns the ONCHAINID (Identity) of the _userAddress + * @param _userAddress Address of the wallet + * internal function, can be called only from the functions of the Compliance smart contract + */ + function _getIdentity(address _compliance, address _userAddress) internal view returns (address) { + return address(IToken(IModularCompliance(_compliance).getTokenBound()).identityRegistry().identity + (_userAddress)); + } +} diff --git a/index.js b/index.js index 0ec69df1..bc76910a 100644 --- a/index.js +++ b/index.js @@ -62,6 +62,7 @@ const ExchangeMonthlyLimitsModule = require('./artifacts/contracts/compliance/mo const TimeExchangeLimitsModule = require('./artifacts/contracts/compliance/modular/modules/TimeExchangeLimitsModule.sol/TimeExchangeLimitsModule.json'); const TimeTransfersLimitsModule = require('./artifacts/contracts/compliance/modular/modules/TimeTransfersLimitsModule.sol/TimeTransfersLimitsModule.json'); const SupplyLimitModule = require('./artifacts/contracts/compliance/modular/modules/SupplyLimitModule.sol/SupplyLimitModule.json'); +const TransferFeesModule = require('./artifacts/contracts/compliance/modular/modules/TransferFeesModule.sol/TransferFeesModule.json'); module.exports = { contracts: { @@ -117,6 +118,7 @@ module.exports = { TimeExchangeLimitsModule, TimeTransfersLimitsModule, SupplyLimitModule, + TransferFeesModule, }, interfaces: { IToken, diff --git a/test/compliances/module-transfer-fees.test.ts b/test/compliances/module-transfer-fees.test.ts new file mode 100644 index 00000000..a6e29ef2 --- /dev/null +++ b/test/compliances/module-transfer-fees.test.ts @@ -0,0 +1,376 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { deploySuiteWithModularCompliancesFixture } from '../fixtures/deploy-full-suite.fixture'; + +async function deployTransferFeesFullSuite() { + const context = await loadFixture(deploySuiteWithModularCompliancesFixture); + const complianceModule = await ethers.deployContract('TransferFeesModule'); + await context.suite.token.addAgent(complianceModule.address); + await context.suite.compliance.bindToken(context.suite.token.address); + await context.suite.compliance.addModule(complianceModule.address); + + const identity = await context.suite.identityRegistry.identity(context.accounts.aliceWallet.address); + await context.suite.identityRegistry.connect(context.accounts.tokenAgent).registerIdentity(context.accounts.charlieWallet.address, identity, 0); + + return { + ...context, + suite: { + ...context.suite, + complianceModule, + }, + }; +} + +describe('Compliance Module: TransferFees', () => { + it('should deploy the TransferFees contract and bind it to the compliance', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + + expect(context.suite.complianceModule.address).not.to.be.undefined; + expect(await context.suite.compliance.isModuleBound(context.suite.complianceModule.address)).to.be.true; + }); + + describe('.setFee', () => { + describe('when calling directly', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.anotherWallet.address; + + await expect(context.suite.complianceModule.connect(context.accounts.anotherWallet).setFee(1, collector)).to.revertedWith( + 'only bound compliance can call', + ); + }); + }); + + describe('when calling via compliance', () => { + describe('when rate is greater than the max', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.anotherWallet.address; + + await expect( + context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [10001, collector]), + context.suite.complianceModule.address, + ), + ).to.be.revertedWithCustomError(context.suite.complianceModule, `FeeRateIsOutOfRange`); + }); + }); + + describe('when collector address is not verified', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.anotherWallet.address; + + await expect( + context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1, collector]), + context.suite.complianceModule.address, + ), + ).to.be.revertedWithCustomError(context.suite.complianceModule, `CollectorAddressIsNotVerified`); + }); + }); + + describe('when collector address is verified', () => { + it('should set the fee', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.aliceWallet.address; + + const tx = await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1, collector]), + context.suite.complianceModule.address, + ); + + await expect(tx).to.emit(context.suite.complianceModule, 'FeeUpdated').withArgs(context.suite.compliance.address, 1, collector); + + const fee = await context.suite.complianceModule.getFee(context.suite.compliance.address); + expect(fee.rate).to.be.eq(1); + expect(fee.collector).to.be.eq(collector); + }); + }); + }); + }); + + describe('.getFee', () => { + it('should return the fee', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.aliceWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1, collector]), + context.suite.complianceModule.address, + ); + + const fee = await context.suite.complianceModule.getFee(context.suite.compliance.address); + expect(fee.rate).to.be.eq(1); + expect(fee.collector).to.be.eq(collector); + }); + }); + + describe('.isPlugAndPlay', () => { + it('should return false', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + expect(await context.suite.complianceModule.isPlugAndPlay()).to.be.false; + }); + }); + + describe('.canComplianceBind', () => { + describe('when the module is not registered as a token agent', () => { + it('should return false', async () => { + const context = await loadFixture(deploySuiteWithModularCompliancesFixture); + await context.suite.compliance.bindToken(context.suite.token.address); + const complianceModule = await ethers.deployContract('TransferFeesModule'); + + const result = await complianceModule.canComplianceBind(context.suite.compliance.address); + expect(result).to.be.false; + }); + }); + + describe('when the module is registered as a token agent', () => { + it('should return true', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const result = await context.suite.complianceModule.canComplianceBind(context.suite.compliance.address); + expect(result).to.be.true; + }); + }); + }); + + describe('.moduleTransferAction', () => { + describe('when calling directly', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const from = context.accounts.aliceWallet.address; + const to = context.accounts.bobWallet.address; + + await expect(context.suite.complianceModule.moduleTransferAction(from, to, 10)).to.revertedWith('only bound compliance can call'); + }); + }); + + describe('when calling via compliance', () => { + describe('when from and to belong to the same identity', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1000, collector]), + context.suite.complianceModule.address, + ); + + const from = context.accounts.aliceWallet.address; + const to = context.accounts.anotherWallet.address; + const identity = await context.suite.identityRegistry.identity(from); + await context.suite.identityRegistry.connect(context.accounts.tokenAgent).registerIdentity(to, identity, 0); + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [from, to, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(0); + }); + }); + + describe('when fee is zero', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [0, collector]), + context.suite.complianceModule.address, + ); + + const from = context.accounts.aliceWallet.address; + const to = context.accounts.bobWallet.address; + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [from, to, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(0); + }); + }); + + describe('when sender is the collector', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1000, collector]), + context.suite.complianceModule.address, + ); + + const to = context.accounts.bobWallet.address; + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [collector, to, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(0); + }); + }); + + describe('when receiver is the collector', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1000, collector]), + context.suite.complianceModule.address, + ); + + const from = context.accounts.bobWallet.address; + await context.suite.token.connect(context.accounts.tokenAgent).mint(collector, 5000); + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [from, collector, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(5000); + }); + }); + + describe('when calculated fee amount is zero', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1, collector]), + context.suite.complianceModule.address, + ); + + const from = context.accounts.aliceWallet.address; + const to = context.accounts.bobWallet.address; + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [from, to, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(0); + }); + }); + + describe('when calculated fee amount is higher than zero', () => { + it('should transfer the fee amount', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const collector = context.accounts.charlieWallet.address; + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function setFee(uint256 _rate, address _collector)']).encodeFunctionData('setFee', [1000, collector]), + context.suite.complianceModule.address, + ); + + const from = context.accounts.aliceWallet.address; + const to = context.accounts.bobWallet.address; + + await context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleTransferAction(address _from, address _to, uint256 _value)']).encodeFunctionData( + 'moduleTransferAction', + [from, to, 80], + ), + context.suite.complianceModule.address, + ); + + const collectedAmount = await context.suite.token.balanceOf(collector); + expect(collectedAmount).to.be.eq(8); // 10% of 80 + + const toBalance = await context.suite.token.balanceOf(to); + expect(toBalance).to.be.eq(492); // it had 500 tokens before + }); + }); + }); + }); + + describe('.moduleMintAction', () => { + describe('when calling from a random wallet', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + + await expect(context.suite.complianceModule.moduleMintAction(context.accounts.anotherWallet.address, 10)).to.be.revertedWith( + 'only bound compliance can call', + ); + }); + }); + + describe('when calling as the compliance', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + + await expect( + context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleMintAction(address, uint256)']).encodeFunctionData('moduleMintAction', [ + context.accounts.anotherWallet.address, + 10, + ]), + context.suite.complianceModule.address, + ), + ).to.eventually.be.fulfilled; + }); + }); + }); + + describe('.moduleBurnAction', () => { + describe('when calling from a random wallet', () => { + it('should revert', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + + await expect(context.suite.complianceModule.moduleBurnAction(context.accounts.anotherWallet.address, 10)).to.be.revertedWith( + 'only bound compliance can call', + ); + }); + }); + + describe('when calling as the compliance', () => { + it('should do nothing', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + + await expect( + context.suite.compliance.callModuleFunction( + new ethers.utils.Interface(['function moduleBurnAction(address, uint256)']).encodeFunctionData('moduleBurnAction', [ + context.accounts.anotherWallet.address, + 10, + ]), + context.suite.complianceModule.address, + ), + ).to.eventually.be.fulfilled; + }); + }); + }); + + describe('.moduleCheck', () => { + it('should return true', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + const from = context.accounts.aliceWallet.address; + const to = context.accounts.bobWallet.address; + expect(await context.suite.complianceModule.moduleCheck(from, to, 100, context.suite.compliance.address)).to.be.true; + }); + }); + + describe('.name', () => { + it('should return the name of the module', async () => { + const context = await loadFixture(deployTransferFeesFullSuite); + expect(await context.suite.complianceModule.name()).to.be.equal('TransferFeesModule'); + }); + }); +});