diff --git a/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol new file mode 100644 index 00000000..d89fe5ba --- /dev/null +++ b/contracts/compliance/modular/modules/InitialLockupPeriodModule.sol @@ -0,0 +1,233 @@ +// 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 "../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); + +/// @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 { + uint256 amount; + uint256 releaseTimestamp; + } + + struct LockedDetails { + uint256 totalLocked; + LockedTokens[] lockedTokens; + } + + mapping(address compliance => uint256 lockupPeriod) private _lockupPeriods; + mapping(address compliance => mapping(address user => LockedDetails)) private _lockedDetails; + + /// @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 _lockupPeriodInDays the lockup period in days. + function setLockupPeriod(uint256 _lockupPeriodInDays) external onlyComplianceCall { + _lockupPeriods[msg.sender] = _lockupPeriodInDays * 1 days; + + emit LockupPeriodSet(msg.sender, _lockupPeriodInDays); + } + + /// @inheritdoc IModule + function moduleTransferAction(address _from, address /*_to*/, uint256 _value) external override onlyComplianceCall { + if (_from == address(0)) { + return; + } + + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_from]; + uint256 freeAmount = + IToken(IModularCompliance(msg.sender).getTokenBound()).balanceOf(_from) - lockedDetails.totalLocked; + if (_value > freeAmount) { + _updateLockedTokens(_from, _value - freeAmount); + } + } + + /// @inheritdoc IModule + function moduleMintAction(address _to, uint256 _value) external override onlyComplianceCall { + LockedDetails storage lockedDetails = _lockedDetails[msg.sender][_to]; + lockedDetails.totalLocked += _value; + lockedDetails.lockedTokens.push( + LockedTokens({ + amount: _value, + releaseTimestamp: block.timestamp + _lockupPeriods[msg.sender] + }) + ); + } + + /// @inheritdoc IModule + function moduleBurnAction(address _from, uint256 _value) external override onlyComplianceCall { + 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( + freeAmount >= _value, + InsufficientBalanceTokensLocked(_from, _value, freeAmount) + ); + + if (_value > freeAmount) { + _updateLockedTokens(_from, _value - freeAmount); + } + } + + /// @inheritdoc IModule + function moduleCheck(address _from, address /*_to*/, uint256 _value, address _compliance) external + view override returns (bool) { + if (_from == address(0)) { + return true; + } + + LockedDetails storage lockedDetails = _lockedDetails[_compliance][_from]; + uint256 balance = IToken(IModularCompliance(_compliance).getTokenBound()).balanceOf(_from); + + return (balance - lockedDetails.totalLocked) >= _value + || (balance - lockedDetails.totalLocked + _calculateUnlockedAmount(lockedDetails)) >= _value; + } + + /// @inheritdoc IModule + function canComplianceBind(address /*_compliance*/) external pure 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 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 { + 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 == lockedDetails.lockedTokens.length - 1) { + lockedDetails.lockedTokens.pop(); + break; + } else { + lockedDetails.lockedTokens[i] = lockedDetails.lockedTokens[lockedDetails.lockedTokens.length - 1]; + lockedDetails.lockedTokens.pop(); + } + } else { + lockedDetails.lockedTokens[i].amount -= _value; + break; + } + } + else { + i++; + } + } + } + + /// @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 _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; + } + } + } + +} \ 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..b3c31472 --- /dev/null +++ b/test/compliances/module-initial-lockup-period.test.ts @@ -0,0 +1,169 @@ +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(days: number) { + await ethers.provider.send('evm_increaseTime', [days * 24 * 60 * 60]); + await ethers.provider.send('evm_mine', []); + } + + async function setLockupPeriod(compliance: ModularCompliance, complianceModule: InitialLockupPeriodModule, lockupPeriod: number) { + return compliance.callModuleFunction( + new ethers.Interface(['function setLockupPeriod(uint256 _lockupPeriod)']).encodeFunctionData('setLockupPeriod', [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: { complianceModule }, + accounts: { aliceWallet }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + await expect(complianceModule.connect(aliceWallet).setLockupPeriod(10)).to.be.revertedWithCustomError( + complianceModule, + 'OnlyBoundComplianceCanCall', + ); + }); + + it('should set lockup period correctly', async () => { + const { + suite: { compliance, complianceModule }, + } = await loadFixture(deployInitialLockupPeriodModuleFullSuite); + + const lockupPeriod = 10; + 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 = 10; + 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; + }); + }); + }); + + 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); + }); + }); +}); 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: {