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); + }); + }); });