From 8f7f3b16e58da8a018c9314f0db8531728e64dbf Mon Sep 17 00:00:00 2001 From: Philippe Gonday Date: Mon, 9 Dec 2024 15:07:33 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8()=20Add=20InitialLockupPeriod=20m?= =?UTF-8?q?odule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/InitialLockupPeriodModule.sol | 161 ++++++++++++++++++ test/compliance.test.ts | 7 +- .../module-initial-lockup-period.test.ts | 149 ++++++++++++++++ test/compliances/module-max-balance.test.ts | 11 +- test/fixtures/deploy-full-suite.fixture.ts | 2 + 5 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 contracts/compliance/modular/modules/InitialLockupPeriodModule.sol create mode 100644 test/compliances/module-initial-lockup-period.test.ts diff --git a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol new file mode 100644 index 00000000..46127d2b --- /dev/null +++ b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0 +// This contract is also licensed under the Creative Commons Attribution-NonCommercial 4.0 International License. +// +// :+#####%%%%%%%%%%%%%%+ +// .-*@@@%+.:+%@@@@@%%#***%@@%= +// :=*%@@@#=. :#@@% *@@@%= +// .-+*%@%*-.:+%@@@@@@+. -*+: .=#. :%@@@%- +// :=*@@@@%%@@@@@@@@@%@@@- .=#@@@%@%= =@@@@#. +// -=+#%@@%#*=:. :%@@@@%. -*@@#*@@@@@@@#=:- *@@@@+ +// =@@%=:. :=: *@@@@@%#- =%*%@@@@#+-. =+ :%@@@%- +// -@@%. .+@@@ =+=-. @@#- +@@@%- =@@@@%: +// :@@@. .+@@#%: : .=*=-::.-%@@@+*@@= +@@@@#. +// %@@: +@%%* =%@@@@@@@@@@@#. .*@%- +@@@@*. +// #@@= .+@@@@%:=*@@@@@- :%@%: .*@@@@+ +// *@@* +@@@#-@@%-:%@@* +@@#. :%@@@@- +// -@@% .:-=++*##%%%@@@@@@@@@@@@*. :@+.@@@%: .#@@+ =@@@@#: +// .@@@*-+*#%%%@@@@@@@@@@@@@@@@%%#**@@%@@@. *@=*@@# :#@%= .#@@@@#- +// -%@@@@@@@@@@@@@@@*+==-:-@@@= *@# .#@*-=*@@@@%= -%@@@* =@@@@@%- +// -+%@@@#. %@%%= -@@:+@: -@@* *@@*-:: -%@@%=. .*@@@@@# +// *@@@* +@* *@@##@@- #@*@@+ -@@= . :+@@@#: .-+@@@%+- +// +@@@%*@@:..=@@@@* .@@@* .#@#. .=+- .=%@@@*. :+#@@@@*=: +// =@@@@%@@@@@@@@@@@@@@@@@@@@@@%- :+#*. :*@@@%=. .=#@@@@%+: +// .%@@= ..... .=#@@+. .#@@@*: -*%@@@@%+. +// +@@#+===---:::... .=%@@*- +@@@+. -*@@@@@%+. +// -@@@@@@@@@@@@@@@@@@@@@@%@@@@= -@@@+ -#@@@@@#=. +// ..:::---===+++***###%%%@@@#- .#@@+ -*@@@@@#=. +// @@@@@@+. +@@*. .+@@@@@%=. +// -@@@@@= =@@%: -#@@@@%+. +// +@@@@@. =@@@= .+@@@@@*: +// #@@@@#:%@@#. :*@@@@#- +// @@@@@%@@@= :#@@@@+. +// :@@@@@@@#.:#@@@%- +// +@@@@@@-.*@@@*: +// #@@@@#.=@@@+. +// @@@@+-%@%= +// :@@@#%@%= +// +@@@@%- +// :#%%= +// +/** + * 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) 2024, 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 . + * + * This specific smart contract is also licensed under the Creative Commons + * Attribution-NonCommercial 4.0 International License (CC-BY-NC-4.0), + * which prohibits commercial use. For commercial inquiries, please contact + * Tokeny sàrl for licensing options. + */ + +pragma solidity 0.8.27; + +import "./AbstractModuleUpgradeable.sol"; + +error InsufficientBalanceTokensLocked(address user, uint256 value, uint256 availableAmount); + +event LockupPeriodSet(address indexed compliance, uint256 lockupPeriod); + + +contract InitialLockupPeriodModule is AbstractModuleUpgradeable { + + struct LockedTokens { + uint256 amount; + uint256 releaseTimestamp; + } + + mapping(address compliance => uint256 lockupPeriod) private _lockupPeriods; + mapping(address compliance => mapping(address user => LockedTokens[] lockedTokens)) private _lockedTokens; + + /// @dev initializes the contract and sets the initial state. + function initialize() external initializer { + __AbstractModule_init(); + } + + /// @dev sets the lockup period for a compliance contract. + /// @param _compliance the address of the compliance contract. + /// @param _lockupPeriod the lockup period in seconds. + function setLockupPeriod(address _compliance, uint256 _lockupPeriod) external onlyComplianceCall { + _lockupPeriods[_compliance] = _lockupPeriod; + + emit LockupPeriodSet(_compliance, _lockupPeriod); + } + + /// @inheritdoc IModule + function moduleTransferAction(address _from, address /*_to*/, uint256 _value) external override onlyComplianceCall { + if (_from != address(0)) { + // Check if the user has enough unlocked tokens to transfer + uint256 availableAmount = _calculateAvailableAmount(msg.sender, _from); + if (_value > availableAmount) { + revert InsufficientBalanceTokensLocked(_from, _value, availableAmount); + } + } + } + + /// @inheritdoc IModule + function moduleMintAction(address _to, uint256 _value) external override onlyComplianceCall { + _lockedTokens[msg.sender][_to].push( + LockedTokens({ + amount: _value, + releaseTimestamp: block.timestamp + _lockupPeriods[msg.sender] + }) + ); + } + + /// @inheritdoc IModule + // solhint-disable-next-line no-empty-blocks + function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall { } + + /// @inheritdoc IModule + function moduleCheck(address _from, address /*_to*/, uint256 _value, address _compliance) external + view override returns (bool) { + return _from == address(0) || _calculateAvailableAmount(_compliance, _from) >= _value; + } + + /// @inheritdoc IModule + function canComplianceBind(address /*_compliance*/) external view override returns (bool) { + return true; + } + + /// @inheritdoc IModule + function isPlugAndPlay() external pure override returns (bool) { + return true; + } + + /// @inheritdoc IModule + function name() external pure override returns (string memory _name) { + return "InitialLockupPeriodModule"; + } + + /// @dev calculates the available amount of unlocked tokens for a user. + /// @param _compliance the address of the compliance contract. + /// @param _user the address of the user. + /// @return _availableAmount the available amount of unlocked tokens. + function _calculateAvailableAmount(address _compliance, address _user) internal view returns (uint256 _availableAmount) { + uint256 periodsLength = _lockedTokens[_compliance][_user].length; + for (uint256 i; i < periodsLength; i++) { + if (_lockedTokens[_compliance][_user][i].releaseTimestamp <= block.timestamp) { + _availableAmount += _lockedTokens[_compliance][_user][i].amount; + } + } + } + +} \ No newline at end of file diff --git a/test/compliance.test.ts b/test/compliance.test.ts index 40f21014..7b66ab1f 100644 --- a/test/compliance.test.ts +++ b/test/compliance.test.ts @@ -104,8 +104,11 @@ describe('ModularCompliance', () => { describe('when token is a zero address', () => { it('should revert', async () => { const { - suite: { compliance }, - } = await loadFixture(deploySuiteWithModularCompliancesFixture); + authorities: { trexImplementationAuthority }, + } = await loadFixture(deployFullSuiteFixture); + + const complianceProxy = await ethers.deployContract('ModularComplianceProxy', [trexImplementationAuthority.target]); + const compliance = await ethers.getContractAt('ModularCompliance', complianceProxy.target); await expect(compliance.unbindToken(ethers.ZeroAddress)).to.be.revertedWithCustomError(compliance, 'ZeroAddress'); }); diff --git a/test/compliances/module-initial-lockup-period.test.ts b/test/compliances/module-initial-lockup-period.test.ts new file mode 100644 index 00000000..2e0e242e --- /dev/null +++ b/test/compliances/module-initial-lockup-period.test.ts @@ -0,0 +1,149 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { deploySuiteWithModularCompliancesFixture } from '../fixtures/deploy-full-suite.fixture'; +import { ModularCompliance, InitialLockupPeriodModule } from '../../typechain-types'; + +describe('InitialLockupPeriodModule', () => { + // Test fixture + async function deployInitialLockupPeriodModuleFullSuite() { + const context = await loadFixture(deploySuiteWithModularCompliancesFixture); + + const module = await ethers.deployContract('InitialLockupPeriodModule'); + const proxy = await ethers.deployContract('ModuleProxy', [module.target, module.interface.encodeFunctionData('initialize')]); + const complianceModule = await ethers.getContractAt('InitialLockupPeriodModule', proxy.target); + + await context.suite.compliance.bindToken(context.suite.token.target); + await context.suite.compliance.addModule(complianceModule.target); + + // Reset already minted tokens to have 0 balance + const token = context.suite.token; + await token + .connect(context.accounts.tokenAgent) + .burn(context.accounts.aliceWallet.address, await token.balanceOf(context.accounts.aliceWallet.address)); + await token + .connect(context.accounts.tokenAgent) + .burn(context.accounts.bobWallet.address, await token.balanceOf(context.accounts.bobWallet.address)); + + return { + ...context, + suite: { + ...context.suite, + complianceModule, + }, + accounts: { + ...context.accounts, + complianceSigner: await ethers.getImpersonatedSigner(context.suite.compliance.target.toString()), + }, + }; + } + + async function increaseTimestamp(timestamp: number) { + await ethers.provider.send('evm_increaseTime', [timestamp]); + await ethers.provider.send('evm_mine', []); + } + + async function setLockupPeriod(compliance: ModularCompliance, complianceModule: InitialLockupPeriodModule, lockupPeriod: number) { + return compliance.callModuleFunction( + new ethers.Interface(['function setLockupPeriod(address _compliance, uint256 _lockupPeriod)']).encodeFunctionData('setLockupPeriod', [ + compliance.target, + lockupPeriod, + ]), + complianceModule.target, + ); + } + + describe('Initialization', () => { + it('should initialize correctly', async () => { + const { + suite: { complianceModule }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + expect(await complianceModule.name()).to.equal('InitialLockupPeriodModule'); + expect(await complianceModule.isPlugAndPlay()).to.be.true; + }); + }); + + describe('Lockup Period Management', () => { + it('should revert when calling by not owner', async () => { + const { + suite: { compliance, complianceModule }, + accounts: { aliceWallet }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + await expect(complianceModule.connect(aliceWallet).setLockupPeriod(compliance.target, 100)).to.be.revertedWithCustomError( + complianceModule, + 'OnlyBoundComplianceCanCall', + ); + }); + + it('should set lockup period correctly', async () => { + const { + suite: { compliance, complianceModule }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 100; + const tx = await setLockupPeriod(compliance, complianceModule, lockupPeriod); + + await expect(tx).to.emit(complianceModule, 'LockupPeriodSet').withArgs(compliance.target, lockupPeriod); + }); + }); + + describe('Transfer Checks', () => { + it('should allow transfer after lockup period', async () => { + const { + suite: { compliance, complianceModule, token }, + accounts: { aliceWallet, bobWallet, tokenAgent, complianceSigner }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 100; + await setLockupPeriod(compliance, complianceModule, lockupPeriod); + + await token.connect(tokenAgent).mint(aliceWallet.address, 100); + + // Advance time beyond lockup period + await increaseTimestamp(lockupPeriod + 1); + expect(await complianceModule.connect(complianceSigner).moduleCheck(aliceWallet.address, bobWallet.address, 100, compliance.target)).to.be.true; + }); + + it('should prevent transfer during lockup period', async () => { + const { + suite: { compliance, complianceModule, token }, + accounts: { aliceWallet, bobWallet, tokenAgent }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 100; + await setLockupPeriod(compliance, complianceModule, lockupPeriod); + + await token.connect(tokenAgent).mint(aliceWallet.address, 100); + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 100, compliance.target)).to.be.false; + + await increaseTimestamp(lockupPeriod - 1); + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 100, compliance.target)).to.be.false; + }); + + describe('Transfer Checks with multiple lockup periods', () => { + it('should allow transfer after lockup period', async () => { + const { + suite: { compliance, complianceModule, token }, + accounts: { aliceWallet, bobWallet, tokenAgent }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 100; + await setLockupPeriod(compliance, complianceModule, lockupPeriod); + + await token.connect(tokenAgent).mint(aliceWallet.address, 100); + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 100, compliance.target)).to.be.false; + + await increaseTimestamp(lockupPeriod); + await token.connect(tokenAgent).mint(aliceWallet.address, 100); + + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 200, compliance.target)).to.be.false; + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 100, compliance.target)).to.be.true; + + await increaseTimestamp(lockupPeriod); + expect(await complianceModule.moduleCheck(aliceWallet.address, bobWallet.address, 200, compliance.target)).to.be.true; + }); + }); + }); +}); diff --git a/test/compliances/module-max-balance.test.ts b/test/compliances/module-max-balance.test.ts index 7e05b525..af4b654c 100644 --- a/test/compliances/module-max-balance.test.ts +++ b/test/compliances/module-max-balance.test.ts @@ -52,9 +52,14 @@ describe('Compliance Module: MaxBalance', () => { describe('when token totalSupply is greater than zero', () => { describe('when compliance preset status is false', () => { it('should return false', async () => { - const context = await loadFixture(deployMaxBalanceFullSuite); - await context.suite.token.connect(context.accounts.tokenAgent).mint(context.accounts.aliceWallet.address, 1000); - expect(await context.suite.complianceModule.canComplianceBind(context.suite.compliance.target)).to.be.false; + const context = await loadFixture(deploySuiteWithModularCompliancesFixture); + const module = await ethers.deployContract('MaxBalanceModule'); + const proxy = await ethers.deployContract('ModuleProxy', [module.target, module.interface.encodeFunctionData('initialize')]); + const complianceModule = await ethers.getContractAt('MaxBalanceModule', proxy.target); + await context.suite.compliance.bindToken(context.suite.token.target); + + expect(await context.suite.token.totalSupply()).to.be.greaterThan(0); + expect(await complianceModule.canComplianceBind(context.suite.compliance.target)).to.be.false; }); }); diff --git a/test/fixtures/deploy-full-suite.fixture.ts b/test/fixtures/deploy-full-suite.fixture.ts index 6d3cafba..d5b8066d 100644 --- a/test/fixtures/deploy-full-suite.fixture.ts +++ b/test/fixtures/deploy-full-suite.fixture.ts @@ -234,6 +234,8 @@ export async function deploySuiteWithModularCompliancesFixture() { const complianceBeta = await ethers.deployContract('ModularCompliance'); await complianceBeta.init(); + await context.suite.token.connect(context.accounts.deployer).setCompliance(compliance.target); + return { ...context, suite: { From 781a5a118a012a76f0e46403301105bf1fc29f60 Mon Sep 17 00:00:00 2001 From: Philippe Gonday Date: Fri, 13 Dec 2024 09:54:59 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B()=20Update=20on=20setLockupPer?= =?UTF-8?q?iod=20after=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modular/modules/InitialLockupPeriodModule.sol | 7 +++---- test/compliances/module-initial-lockup-period.test.ts | 9 +++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol index 46127d2b..f54f2e0a 100644 --- a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol +++ b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol @@ -91,12 +91,11 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { } /// @dev sets the lockup period for a compliance contract. - /// @param _compliance the address of the compliance contract. /// @param _lockupPeriod the lockup period in seconds. - function setLockupPeriod(address _compliance, uint256 _lockupPeriod) external onlyComplianceCall { - _lockupPeriods[_compliance] = _lockupPeriod; + function setLockupPeriod(uint256 _lockupPeriod) external onlyComplianceCall { + _lockupPeriods[msg.sender] = _lockupPeriod; - emit LockupPeriodSet(_compliance, _lockupPeriod); + emit LockupPeriodSet(msg.sender, _lockupPeriod); } /// @inheritdoc IModule diff --git a/test/compliances/module-initial-lockup-period.test.ts b/test/compliances/module-initial-lockup-period.test.ts index 2e0e242e..53620cba 100644 --- a/test/compliances/module-initial-lockup-period.test.ts +++ b/test/compliances/module-initial-lockup-period.test.ts @@ -45,10 +45,7 @@ describe('InitialLockupPeriodModule', () => { async function setLockupPeriod(compliance: ModularCompliance, complianceModule: InitialLockupPeriodModule, lockupPeriod: number) { return compliance.callModuleFunction( - new ethers.Interface(['function setLockupPeriod(address _compliance, uint256 _lockupPeriod)']).encodeFunctionData('setLockupPeriod', [ - compliance.target, - lockupPeriod, - ]), + new ethers.Interface(['function setLockupPeriod(uint256 _lockupPeriod)']).encodeFunctionData('setLockupPeriod', [lockupPeriod]), complianceModule.target, ); } @@ -67,11 +64,11 @@ describe('InitialLockupPeriodModule', () => { describe('Lockup Period Management', () => { it('should revert when calling by not owner', async () => { const { - suite: { compliance, complianceModule }, + suite: { complianceModule }, accounts: { aliceWallet }, } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); - await expect(complianceModule.connect(aliceWallet).setLockupPeriod(compliance.target, 100)).to.be.revertedWithCustomError( + await expect(complianceModule.connect(aliceWallet).setLockupPeriod(100)).to.be.revertedWithCustomError( complianceModule, 'OnlyBoundComplianceCanCall', ); From 7bf4a98b0b9fe3133da6c7f50e125f4d7469a3cd Mon Sep 17 00:00:00 2001 From: Philippe Gonday Date: Tue, 17 Dec 2024 09:26:45 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB()=20Update=20after=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/InitialLockupPeriodModule.sol | 108 ++++++++++++++---- .../module-initial-lockup-period.test.ts | 33 +++++- 2 files changed, 113 insertions(+), 28 deletions(-) diff --git a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol index f54f2e0a..1acf5449 100644 --- a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol +++ b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol @@ -68,13 +68,23 @@ pragma solidity 0.8.27; +import "../IModularCompliance.sol"; +import "../../../token/IToken.sol"; import "./AbstractModuleUpgradeable.sol"; +/// @dev Error emitted when the user has insufficient balance because of locked tokens. +/// @param user the address of the user. +/// @param value the value of the transfer. +/// @param availableAmount the available amount of unlocked tokens. error InsufficientBalanceTokensLocked(address user, uint256 value, uint256 availableAmount); -event LockupPeriodSet(address indexed compliance, uint256 lockupPeriod); - +/// @notice Event emitted when the lockup period is set. +/// @param compliance the address of the compliance contract. +/// @param lockupPeriodInDays the lockup period in days. +event LockupPeriodSet(address indexed compliance, uint256 lockupPeriodInDays); +/// @title InitialLockupPeriodModule +/// @notice Enforces a lockup period for all investors whenever they receive tokens through primary emissions contract InitialLockupPeriodModule is AbstractModuleUpgradeable { struct LockedTokens { @@ -83,7 +93,7 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { } mapping(address compliance => uint256 lockupPeriod) private _lockupPeriods; - mapping(address compliance => mapping(address user => LockedTokens[] lockedTokens)) private _lockedTokens; + mapping(address compliance => mapping(address user => LockedTokens[])) private _lockedTokens; /// @dev initializes the contract and sets the initial state. function initialize() external initializer { @@ -91,21 +101,24 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { } /// @dev sets the lockup period for a compliance contract. - /// @param _lockupPeriod the lockup period in seconds. - function setLockupPeriod(uint256 _lockupPeriod) external onlyComplianceCall { - _lockupPeriods[msg.sender] = _lockupPeriod; + /// @param _lockupPeriodInDays the lockup period in days. + function setLockupPeriod(uint256 _lockupPeriodInDays) external onlyComplianceCall { + _lockupPeriods[msg.sender] = _lockupPeriodInDays * 1 days; - emit LockupPeriodSet(msg.sender, _lockupPeriod); + emit LockupPeriodSet(msg.sender, _lockupPeriodInDays); } /// @inheritdoc IModule - function moduleTransferAction(address _from, address /*_to*/, uint256 _value) external override onlyComplianceCall { - if (_from != address(0)) { - // Check if the user has enough unlocked tokens to transfer - uint256 availableAmount = _calculateAvailableAmount(msg.sender, _from); - if (_value > availableAmount) { - revert InsufficientBalanceTokensLocked(_from, _value, availableAmount); - } + function moduleTransferAction(address _from, address /*_to*/, uint256 _value) external override onlyComplianceCall { + if (_from == address(0)) { + return; + } + + (uint256 lockedAmount, uint256 unlockedAmount) = _calculateLockedAmount(msg.sender, _from); + uint256 freeAmount = + IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) - lockedAmount - unlockedAmount; + if (_value > freeAmount) { + _updateLockedTokens(_from, _value - freeAmount); } } @@ -120,17 +133,32 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { } /// @inheritdoc IModule - // solhint-disable-next-line no-empty-blocks - function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall { } + function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall { + (uint256 lockedAmount, uint256 unlockedAmount) = _calculateLockedAmount(msg.sender, _from); + uint256 previousBalance = IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) + _value; + + require( + (previousBalance - lockedAmount) >= _value, + InsufficientBalanceTokensLocked(_from, _value, previousBalance - lockedAmount) + ); + + uint256 freeAmount = previousBalance - lockedAmount - unlockedAmount; + if (_value > freeAmount) { + _updateLockedTokens(_from, _value - freeAmount); + } + } /// @inheritdoc IModule function moduleCheck(address _from, address /*_to*/, uint256 _value, address _compliance) external view override returns (bool) { - return _from == address(0) || _calculateAvailableAmount(_compliance, _from) >= _value; + (uint256 lockedAmount, ) = _calculateLockedAmount(_compliance, _from); + + return _from == address(0) + || IToken(IModularCompliance(_compliance).getTokenBound()).balanceOf(_from) - lockedAmount >= _value; } /// @inheritdoc IModule - function canComplianceBind(address /*_compliance*/) external view override returns (bool) { + function canComplianceBind(address /*_compliance*/) external pure override returns (bool) { return true; } @@ -144,15 +172,49 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { return "InitialLockupPeriodModule"; } - /// @dev calculates the available amount of unlocked tokens for a user. + /// @dev updates the locked tokens for a user. + /// @param _user the address of the user. + /// @param _value the amount of tokens to unlock. + function _updateLockedTokens(address _user, uint256 _value) internal { + LockedTokens[] storage lockedTokens = _lockedTokens[msg.sender][_user]; + for (uint256 i; _value > 0 && i < lockedTokens.length; ) { + if (lockedTokens[i].releaseTimestamp <= block.timestamp) { + if (_value >= lockedTokens[i].amount) { + _value -= lockedTokens[i].amount; + + // Remove entry + if (i == lockedTokens.length - 1) { + lockedTokens.pop(); + break; + } else { + lockedTokens[i] = lockedTokens[lockedTokens.length - 1]; + lockedTokens.pop(); + } + } else { + lockedTokens[i].amount -= _value; + break; + } + } + else { + i++; + } + } + } + + /// @dev calculates the locked amount of tokens for a user. /// @param _compliance the address of the compliance contract. /// @param _user the address of the user. - /// @return _availableAmount the available amount of unlocked tokens. - function _calculateAvailableAmount(address _compliance, address _user) internal view returns (uint256 _availableAmount) { + /// @return _lockedAmount the locked amount of tokens. + /// @return _unlockedAmount the unlocked amount of tokens. + function _calculateLockedAmount(address _compliance, address _user) internal view + returns (uint256 _lockedAmount, uint256 _unlockedAmount) { uint256 periodsLength = _lockedTokens[_compliance][_user].length; for (uint256 i; i < periodsLength; i++) { - if (_lockedTokens[_compliance][_user][i].releaseTimestamp <= block.timestamp) { - _availableAmount += _lockedTokens[_compliance][_user][i].amount; + if (_lockedTokens[_compliance][_user][i].releaseTimestamp > block.timestamp) { + _lockedAmount += _lockedTokens[_compliance][_user][i].amount; + } + else { + _unlockedAmount += _lockedTokens[_compliance][_user][i].amount; } } } diff --git a/test/compliances/module-initial-lockup-period.test.ts b/test/compliances/module-initial-lockup-period.test.ts index 53620cba..b3c31472 100644 --- a/test/compliances/module-initial-lockup-period.test.ts +++ b/test/compliances/module-initial-lockup-period.test.ts @@ -38,8 +38,8 @@ describe('InitialLockupPeriodModule', () => { }; } - async function increaseTimestamp(timestamp: number) { - await ethers.provider.send('evm_increaseTime', [timestamp]); + async function increaseTimestamp(days: number) { + await ethers.provider.send('evm_increaseTime', [days * 24 * 60 * 60]); await ethers.provider.send('evm_mine', []); } @@ -68,7 +68,7 @@ describe('InitialLockupPeriodModule', () => { accounts: { aliceWallet }, } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); - await expect(complianceModule.connect(aliceWallet).setLockupPeriod(100)).to.be.revertedWithCustomError( + await expect(complianceModule.connect(aliceWallet).setLockupPeriod(10)).to.be.revertedWithCustomError( complianceModule, 'OnlyBoundComplianceCanCall', ); @@ -79,7 +79,7 @@ describe('InitialLockupPeriodModule', () => { suite: { compliance, complianceModule }, } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); - const lockupPeriod = 100; + const lockupPeriod = 10; const tx = await setLockupPeriod(compliance, complianceModule, lockupPeriod); await expect(tx).to.emit(complianceModule, 'LockupPeriodSet').withArgs(compliance.target, lockupPeriod); @@ -93,7 +93,7 @@ describe('InitialLockupPeriodModule', () => { accounts: { aliceWallet, bobWallet, tokenAgent, complianceSigner }, } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); - const lockupPeriod = 100; + const lockupPeriod = 10; await setLockupPeriod(compliance, complianceModule, lockupPeriod); await token.connect(tokenAgent).mint(aliceWallet.address, 100); @@ -143,4 +143,27 @@ describe('InitialLockupPeriodModule', () => { }); }); }); + + describe('Burn Checks', () => { + it('should allow burn after lockup period', async () => { + const { + suite: { compliance, complianceModule, token }, + accounts: { aliceWallet, tokenAgent }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 10; + await setLockupPeriod(compliance, complianceModule, lockupPeriod); + + await token.connect(tokenAgent).mint(aliceWallet.address, 100); + + // Burn will fail because the lockup period is not over + await expect(token.connect(tokenAgent).burn(aliceWallet.address, 100)) + .to.be.revertedWithCustomError(complianceModule, 'InsufficientBalanceTokensLocked') + .withArgs(aliceWallet.address, 100, 0); + + // Burn will succeed because the lockup period is over + await increaseTimestamp(lockupPeriod); + await token.connect(tokenAgent).burn(aliceWallet.address, 100); + }); + }); }); From d4370d08733763b52a857fd04b034dece28564f3 Mon Sep 17 00:00:00 2001 From: Philippe Gonday Date: Tue, 17 Dec 2024 10:12:01 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB()=20Gas=20optim=20after=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/InitialLockupPeriodModule.sol | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol index 1acf5449..d89fe5ba 100644 --- a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol +++ b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol @@ -92,8 +92,13 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { uint256 releaseTimestamp; } + struct LockedDetails { + uint256 totalLocked; + LockedTokens[] lockedTokens; + } + mapping(address compliance => uint256 lockupPeriod) private _lockupPeriods; - mapping(address compliance => mapping(address user => LockedTokens[])) private _lockedTokens; + mapping(address compliance => mapping(address user => LockedDetails)) private _lockedDetails; /// @dev initializes the contract and sets the initial state. function initialize() external initializer { @@ -114,9 +119,9 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { return; } - (uint256 lockedAmount, uint256 unlockedAmount) = _calculateLockedAmount(msg.sender, _from); + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_from]; uint256 freeAmount = - IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) - lockedAmount - unlockedAmount; + IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) - lockedDetails.totalLocked; if (_value > freeAmount) { _updateLockedTokens(_from, _value - freeAmount); } @@ -124,7 +129,9 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { /// @inheritdoc IModule function moduleMintAction(address _to, uint256 _value) external override onlyComplianceCall { - _lockedTokens[msg.sender][_to].push( + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_to]; + lockedDetails.totalLocked += _value; + lockedDetails.lockedTokens.push( LockedTokens({ amount: _value, releaseTimestamp: block.timestamp + _lockupPeriods[msg.sender] @@ -134,15 +141,20 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { /// @inheritdoc IModule function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall { - (uint256 lockedAmount, uint256 unlockedAmount) = _calculateLockedAmount(msg.sender, _from); + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_from]; uint256 previousBalance = IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) + _value; + uint256 freeAmount = previousBalance - lockedDetails.totalLocked; + + if (freeAmount < _value) { + // We need to calculate more accurately the free amount, as totalLocked can include now unlocked tokens. + freeAmount = freeAmount + _calculateUnlockedAmount(lockedDetails); + } require( - (previousBalance - lockedAmount) >= _value, - InsufficientBalanceTokensLocked(_from, _value, previousBalance - lockedAmount) + freeAmount >= _value, + InsufficientBalanceTokensLocked(_from, _value, freeAmount) ); - uint256 freeAmount = previousBalance - lockedAmount - unlockedAmount; if (_value > freeAmount) { _updateLockedTokens(_from, _value - freeAmount); } @@ -151,10 +163,15 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { /// @inheritdoc IModule function moduleCheck(address _from, address /*_to*/, uint256 _value, address _compliance) external view override returns (bool) { - (uint256 lockedAmount, ) = _calculateLockedAmount(_compliance, _from); + if (_from == address(0)) { + return true; + } + + LockedDetails storage lockedDetails = _lockedDetails[_compliance][_from]; + uint256 balance = IToken(IModularCompliance(_compliance).getTokenBound()).balanceOf(_from); - return _from == address(0) - || IToken(IModularCompliance(_compliance).getTokenBound()).balanceOf(_from) - lockedAmount >= _value; + return (balance - lockedDetails.totalLocked) >= _value + || (balance - lockedDetails.totalLocked + _calculateUnlockedAmount(lockedDetails)) >= _value; } /// @inheritdoc IModule @@ -176,22 +193,22 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { /// @param _user the address of the user. /// @param _value the amount of tokens to unlock. function _updateLockedTokens(address _user, uint256 _value) internal { - LockedTokens[] storage lockedTokens = _lockedTokens[msg.sender][_user]; - for (uint256 i; _value > 0 && i < lockedTokens.length; ) { - if (lockedTokens[i].releaseTimestamp <= block.timestamp) { - if (_value >= lockedTokens[i].amount) { - _value -= lockedTokens[i].amount; + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_user]; + for (uint256 i; _value > 0 && i < lockedDetails.lockedTokens.length; ) { + if (lockedDetails.lockedTokens[i].releaseTimestamp <= block.timestamp) { + if (_value >= lockedDetails.lockedTokens[i].amount) { + _value -= lockedDetails.lockedTokens[i].amount; // Remove entry - if (i == lockedTokens.length - 1) { - lockedTokens.pop(); + if (i == lockedDetails.lockedTokens.length - 1) { + lockedDetails.lockedTokens.pop(); break; } else { - lockedTokens[i] = lockedTokens[lockedTokens.length - 1]; - lockedTokens.pop(); + lockedDetails.lockedTokens[i] = lockedDetails.lockedTokens[lockedDetails.lockedTokens.length - 1]; + lockedDetails.lockedTokens.pop(); } } else { - lockedTokens[i].amount -= _value; + lockedDetails.lockedTokens[i].amount -= _value; break; } } @@ -201,20 +218,14 @@ contract InitialLockupPeriodModule is AbstractModuleUpgradeable { } } - /// @dev calculates the locked amount of tokens for a user. - /// @param _compliance the address of the compliance contract. - /// @param _user the address of the user. - /// @return _lockedAmount the locked amount of tokens. + /// @dev calculates the unlocked amount of tokens for a user. + /// @param _details the locked details of the user. /// @return _unlockedAmount the unlocked amount of tokens. - function _calculateLockedAmount(address _compliance, address _user) internal view - returns (uint256 _lockedAmount, uint256 _unlockedAmount) { - uint256 periodsLength = _lockedTokens[_compliance][_user].length; - for (uint256 i; i < periodsLength; i++) { - if (_lockedTokens[_compliance][_user][i].releaseTimestamp > block.timestamp) { - _lockedAmount += _lockedTokens[_compliance][_user][i].amount; - } - else { - _unlockedAmount += _lockedTokens[_compliance][_user][i].amount; + function _calculateUnlockedAmount(LockedDetails storage _details) internal view + returns (uint256 _unlockedAmount) { + for (uint256 i; i < _details.lockedTokens.length; i++) { + if (_details.lockedTokens[i].releaseTimestamp <= block.timestamp) { + _unlockedAmount += _details.lockedTokens[i].amount; } } }