diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md new file mode 100644 index 00000000..4b731310 --- /dev/null +++ b/.changeset/wet-results-doubt.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +`ERC7984RwaModularCompliance`: Support compliance modules for confidential RWAs. diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 9a784cf5..35e730f8 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; @@ -9,6 +9,12 @@ import {IERC7984} from "./IERC7984.sol"; interface IERC7984Rwa is IERC7984, IERC165 { /// @dev Returns true if the contract is paused, false otherwise. function paused() external view returns (bool); + /// @dev Returns true if has admin role, false otherwise. + function isAdmin(address account) external view returns (bool); + /// @dev Returns true if agent, false otherwise. + function isAgent(address account) external view returns (bool); + /// @dev Returns true if admin or agent, false otherwise. + function isAdminOrAgent(address account) external view returns (bool); /// @dev Returns whether an account is allowed to interact with the token. function isUserAllowed(address account) external view returns (bool); /// @dev Returns the confidential frozen balance of an account. @@ -61,3 +67,28 @@ interface IERC7984Rwa is IERC7984, IERC165 { euint64 encryptedAmount ) external returns (euint64); } + +/// @dev Interface for confidential RWA with modular compliance. +interface IERC7984RwaModularCompliance { + enum ComplianceModuleType { + AlwaysOn, + TransferOnly + } + + /// @dev Checks if a compliance module is installed. + function isModuleInstalled(ComplianceModuleType moduleType, address module) external view returns (bool); + /// @dev Installs a transfer compliance module. + function installModule(ComplianceModuleType moduleType, address module) external; + /// @dev Uninstalls a transfer compliance module. + function uninstallModule(ComplianceModuleType moduleType, address module) external; +} + +/// @dev Interface for confidential RWA transfer compliance module. +interface IERC7984RwaComplianceModule { + /// @dev Returns magic number if it is a module. + function isModule() external returns (bytes4); + /// @dev Checks if a transfer is compliant. Should be non-mutating. + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (ebool); + /// @dev Performs operation after transfer. + function postTransfer(address from, address to, euint64 encryptedAmount) external; +} diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol new file mode 100644 index 00000000..fbf84e0d --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC7984RwaBalanceCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol"; + +contract ERC7984RwaBalanceCapModuleMock is ERC7984RwaBalanceCapModule, SepoliaConfig { + event AmountEncrypted(euint64 amount); + + constructor(address compliance) ERC7984RwaBalanceCapModule(compliance) {} + + function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { + FHE.allowThis(encryptedAmount = FHE.asEuint64(amount)); + FHE.allow(encryptedAmount, msg.sender); + emit AmountEncrypted(encryptedAmount); + } +} diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol new file mode 100644 index 00000000..73d5d163 --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC7984RwaComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol"; + +// solhint-disable func-name-mixedcase +contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, SepoliaConfig { + bool private _compliant = false; + + event PostTransfer(); + event PreTransfer(); + + constructor(address compliance) ERC7984RwaComplianceModule(compliance) {} + + function $_setCompliant() public { + _compliant = true; + } + + function $_unsetCompliant() public { + _compliant = false; + } + + function _isCompliantTransfer( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal override returns (ebool) { + emit PreTransfer(); + return FHE.asEbool(_compliant); + } + + function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal override { + emit PostTransfer(); + } +} diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol new file mode 100644 index 00000000..b3d5631a --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ERC7984RwaInvestorCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol"; + +contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, SepoliaConfig { + constructor(address token, uint64 maxInvestor) ERC7984RwaInvestorCapModule(token, maxInvestor) {} +} diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol new file mode 100644 index 00000000..b1ec993a --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; +import {ERC7984Rwa} from "../../token/ERC7984/extensions/ERC7984Rwa.sol"; +import {ERC7984RwaModularCompliance} from "../../token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol"; + +contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, SepoliaConfig { + constructor( + string memory name, + string memory symbol, + string memory tokenUri, + address admin + ) ERC7984Rwa(admin) ERC7984(name, symbol, tokenUri) {} +} diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index f2dcf8e7..feb7cf46 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -66,6 +66,11 @@ abstract contract ERC7984Rwa is return hasRole(AGENT_ROLE, account); } + /// @dev Returns true if admin or agent, false otherwise. + function isAdminOrAgent(address account) public view virtual returns (bool) { + return isAdmin(account) || isAgent(account); + } + /// @dev Adds agent. function addAgent(address account) public virtual onlyAdmin { _grantRole(AGENT_ROLE, account); diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol new file mode 100644 index 00000000..6a45bf18 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC7984} from "../../../../interfaces/IERC7984.sol"; +import {FHESafeMath} from "../../../../utils/FHESafeMath.sol"; +import {ERC7984RwaComplianceModule} from "./ERC7984RwaComplianceModule.sol"; + +/** + * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the balance of an investor. + */ +abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { + using EnumerableSet for *; + + euint64 private _maxBalance; + + event MaxBalanceSet(euint64 newMaxBalance); + + constructor(address token) ERC7984RwaComplianceModule(token) { + _token = token; + } + + /// @dev Sets max balance of an investor with proof. + function setMaxBalance(externalEuint64 maxBalance, bytes calldata inputProof) public virtual onlyTokenAdmin { + euint64 maxBalance_ = FHE.fromExternal(maxBalance, inputProof); + FHE.allowThis(_maxBalance = maxBalance_); + emit MaxBalanceSet(maxBalance_); + } + + /// @dev Sets max balance of an investor. + function setMaxBalance(euint64 maxBalance) public virtual onlyTokenAdmin { + FHE.allowThis(_maxBalance = maxBalance); + emit MaxBalanceSet(maxBalance); + } + + /// @dev Gets max balance of an investor. + function getMaxBalance() public view virtual returns (euint64) { + return _maxBalance; + } + + /// @dev Internal function which checks if a transfer is compliant. + function _isCompliantTransfer( + address /*from*/, + address to, + euint64 encryptedAmount + ) internal override returns (ebool compliant) { + if (to == address(0)) { + return FHE.asEbool(true); // if burning + } + euint64 balance = IERC7984(_token).confidentialBalanceOf(to); + _getTokenHandleAllowance(balance); + _getTokenHandleAllowance(encryptedAmount); + (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease(balance, encryptedAmount); + compliant = FHE.and(increased, FHE.le(futureBalance, _maxBalance)); + } +} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol new file mode 100644 index 00000000..988b2f93 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC7984Rwa, IERC7984RwaComplianceModule} from "../../../../interfaces/IERC7984Rwa.sol"; +import {HandleAccessManager} from "../../../../utils/HandleAccessManager.sol"; + +/** + * @dev A contract which allows to build a transfer compliance module for confidential Real World Assets (RWAs). + */ +abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule, HandleAccessManager { + address internal immutable _token; + + /// @dev The sender is not the token. + error SenderNotToken(address account); + /// @dev The sender is not the token admin. + error SenderNotTokenAdmin(address account); + /// @dev The sender is not a token agent. + error SenderNotTokenAgent(address account); + /// @dev The sender is not the token admin or a token agent. + error SenderNotTokenAdminOrTokenAgent(address account); + + /// @dev Throws if called by any account other than the token. + modifier onlyToken() { + require(msg.sender == _token, SenderNotToken(msg.sender)); + _; + } + + /// @dev Throws if called by any account other than the token admin. + modifier onlyTokenAdmin() { + require(IERC7984Rwa(_token).isAdmin(msg.sender), SenderNotTokenAdmin(msg.sender)); + _; + } + + /// @dev Throws if called by any account other than a token agent. + modifier onlyTokenAgent() { + require(IERC7984Rwa(_token).isAgent(msg.sender), SenderNotTokenAgent(msg.sender)); + _; + } + + /// @dev Throws if called by any account other than the token admin or a token agent. + modifier onlyTokenAdminOrTokenAgent() { + require(IERC7984Rwa(_token).isAdminOrAgent(msg.sender), SenderNotTokenAdminOrTokenAgent(msg.sender)); + _; + } + + constructor(address token) { + _token = token; + } + + /// @inheritdoc IERC7984RwaComplianceModule + function isModule() public pure override returns (bytes4) { + return this.isModule.selector; + } + + /// @inheritdoc IERC7984RwaComplianceModule + function isCompliantTransfer( + address from, + address to, + euint64 encryptedAmount + ) public virtual onlyToken returns (ebool compliant) { + FHE.allow(compliant = _isCompliantTransfer(from, to, encryptedAmount), msg.sender); + } + + /// @inheritdoc IERC7984RwaComplianceModule + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual onlyToken { + _postTransfer(from, to, encryptedAmount); + } + + /// @dev Internal function which checks if a transfer is compliant. + function _isCompliantTransfer( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal virtual returns (ebool); + + /// @dev Internal function which performs operation after transfer. + function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal virtual { + // default to no-op + } + + /// @dev Allow modules to get access to token handles during transaction. + function _getTokenHandleAllowance(euint64 handle) internal virtual { + _getTokenHandleAllowance(handle, false); + } + + /// @dev Allow modules to get access to token handles. + function _getTokenHandleAllowance(euint64 handle, bool persistent) internal virtual { + if (FHE.isInitialized(handle)) { + HandleAccessManager(_token).getHandleAllowance(euint64.unwrap(handle), address(this), persistent); + } + } + + function _validateHandleAllowance(bytes32 handle) internal view override onlyTokenAdminOrTokenAgent {} +} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol new file mode 100644 index 00000000..099f3cd9 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC7984} from "../../../../interfaces/IERC7984.sol"; +import {ERC7984RwaComplianceModule} from "./ERC7984RwaComplianceModule.sol"; + +/** + * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the number of investors. + */ +abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { + uint64 private _maxInvestor; + euint64 private _investors; + + event MaxInvestorSet(uint64 maxInvestor); + + constructor(address token, uint64 maxInvestor) ERC7984RwaComplianceModule(token) { + _maxInvestor = maxInvestor; + } + + /// @dev Sets max number of investors. + function setMaxInvestor(uint64 maxInvestor) public virtual onlyTokenAdmin { + _maxInvestor = maxInvestor; + emit MaxInvestorSet(maxInvestor); + } + + /// @dev Gets max number of investors. + function getMaxInvestor() public view virtual returns (uint64) { + return _maxInvestor; + } + + /// @dev Gets current number of investors. + function getCurrentInvestor() public view virtual returns (euint64) { + return _investors; + } + + /// @dev Internal function which checks if a transfer is compliant. + function _isCompliantTransfer( + address /*from*/, + address to, + euint64 encryptedAmount + ) internal override returns (ebool compliant) { + euint64 balance = IERC7984(_token).confidentialBalanceOf(to); + _getTokenHandleAllowance(balance); + _getTokenHandleAllowance(encryptedAmount); + compliant = FHE.or( + FHE.or( + FHE.asEbool(to == address(0)), // return true if burning + FHE.eq(encryptedAmount, FHE.asEuint64(0)) // or zero amount + ), + FHE.or( + FHE.gt(balance, FHE.asEuint64(0)), // or already investor + FHE.lt(_investors, FHE.asEuint64(_maxInvestor)) // or not reached max investors limit + ) + ); + } + + /// @dev Internal function which performs operation after transfer. + function _postTransfer(address /*from*/, address to, euint64 encryptedAmount) internal override { + euint64 balance = IERC7984(_token).confidentialBalanceOf(to); + _getTokenHandleAllowance(balance); + _getTokenHandleAllowance(encryptedAmount); + if (!FHE.isInitialized(_investors)) { + _investors = FHE.asEuint64(0); + } + _investors = FHE.select(FHE.eq(balance, encryptedAmount), FHE.add(_investors, FHE.asEuint64(1)), _investors); + _investors = FHE.select(FHE.eq(balance, FHE.asEuint64(0)), FHE.sub(_investors, FHE.asEuint64(1)), _investors); + FHE.allowThis(_investors); + } +} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol new file mode 100644 index 00000000..79fe7717 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC7984RwaModularCompliance, IERC7984RwaComplianceModule} from "../../../../interfaces/IERC7984Rwa.sol"; +import {HandleAccessManager} from "../../../../utils/HandleAccessManager.sol"; +import {ERC7984Rwa} from "../ERC7984Rwa.sol"; + +/** + * @dev Extension of {ERC7984Rwa} that supports compliance modules for confidential Real World Assets (RWAs). + * Inspired by ERC-7579 modules. + */ +abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularCompliance, HandleAccessManager { + using EnumerableSet for *; + + EnumerableSet.AddressSet private _alwaysOnModules; + EnumerableSet.AddressSet private _transferOnlyModules; + + /// @dev Emitted when a module is installed. + event ModuleInstalled(ComplianceModuleType moduleType, address module); + /// @dev Emitted when a module is uninstalled. + event ModuleUninstalled(ComplianceModuleType moduleType, address module); + + /// @dev The module type is not supported. + error ERC7984RwaUnsupportedModuleType(ComplianceModuleType moduleType); + /// @dev The address is not a transfer compliance module. + error ERC7984RwaNotTransferComplianceModule(address module); + /// @dev The module is already installed. + error ERC7984RwaAlreadyInstalledModule(ComplianceModuleType moduleType, address module); + /// @dev The module is already uninstalled. + error ERC7984RwaAlreadyUninstalledModule(ComplianceModuleType moduleType, address module); + /// @dev The sender is not a compliance module. + error SenderNotComplianceModule(address account); + + /** + * @dev Check if a certain module typeId is supported. + * + * Supported module types: + * + * * Transfer compliance module + * * Force transfer compliance module + */ + function supportsModule(ComplianceModuleType moduleType) public view virtual returns (bool) { + return moduleType == ComplianceModuleType.AlwaysOn || moduleType == ComplianceModuleType.TransferOnly; + } + + /// @inheritdoc IERC7984RwaModularCompliance + function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { + return _isModuleInstalled(moduleType, module); + } + + /** + * @inheritdoc IERC7984RwaModularCompliance + * @dev Consider gas footprint of the module before adding it since all modules will perform + * all steps (pre-check, compliance check, post-hook) in a single transaction. + */ + function installModule(ComplianceModuleType moduleType, address module) public virtual onlyAdmin { + _installModule(moduleType, module); + } + + /// @inheritdoc IERC7984RwaModularCompliance + function uninstallModule(ComplianceModuleType moduleType, address module) public virtual onlyAdmin { + _uninstallModule(moduleType, module); + } + + /// @dev Checks if a compliance module is installed. + function _isModuleInstalled( + ComplianceModuleType moduleType, + address module + ) internal view virtual returns (bool installed) { + if (moduleType == ComplianceModuleType.AlwaysOn) return _alwaysOnModules.contains(module); + if (moduleType == ComplianceModuleType.TransferOnly) return _transferOnlyModules.contains(module); + } + + /// @dev Internal function which installs a transfer compliance module. + function _installModule(ComplianceModuleType moduleType, address module) internal virtual { + require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); + (bool success, bytes memory returnData) = module.staticcall( + abi.encodePacked(IERC7984RwaComplianceModule.isModule.selector) + ); + require( + success && bytes4(returnData) == IERC7984RwaComplianceModule.isModule.selector, + ERC7984RwaNotTransferComplianceModule(module) + ); + + if (moduleType == ComplianceModuleType.AlwaysOn) { + require(_alwaysOnModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + } else if (moduleType == ComplianceModuleType.TransferOnly) { + require(_transferOnlyModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + } + emit ModuleInstalled(moduleType, module); + } + + /// @dev Internal function which uninstalls a transfer compliance module. + function _uninstallModule(ComplianceModuleType moduleType, address module) internal virtual { + require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); + if (moduleType == ComplianceModuleType.AlwaysOn) { + require(_alwaysOnModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); + } else if (moduleType == ComplianceModuleType.TransferOnly) { + require(_transferOnlyModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); + } + emit ModuleUninstalled(moduleType, module); + } + + /** + * @dev Updates confidential balances. It transfers zero if it does not follow + * transfer compliance. Runs hooks after the transfer. + */ + function _update( + address from, + address to, + euint64 encryptedAmount + ) internal virtual override returns (euint64 transferred) { + transferred = super._update( + from, + to, + FHE.select( + FHE.and( + _checkAlwaysBefore(from, to, encryptedAmount), + _checkOnlyBeforeTransfer(from, to, encryptedAmount) + ), + encryptedAmount, + FHE.asEuint64(0) + ) + ); + _runAlwaysAfter(from, to, transferred); + _runOnlyAfterTransfer(from, to, transferred); + } + + /** + * @dev Forces the update of confidential balances. It transfers zero if it does not + * follow force transfer compliance. Runs hooks after the force transfer. + */ + function _forceUpdate( + address from, + address to, + euint64 encryptedAmount + ) internal virtual override returns (euint64 transferred) { + transferred = super._update( + from, + to, + FHE.select(_checkAlwaysBefore(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0)) + ); + _runAlwaysAfter(from, to, transferred); + } + + /// @dev Checks always-on compliance. + function _checkAlwaysBefore( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (ebool compliant) { + if (!FHE.isInitialized(encryptedAmount)) { + return FHE.asEbool(true); + } + address[] memory modules = _alwaysOnModules.values(); + uint256 modulesLength = modules.length; + compliant = FHE.asEbool(true); + for (uint256 i = 0; i < modulesLength; i++) { + compliant = FHE.and( + compliant, + IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + ); + } + } + + /// @dev Checks transfer-only compliance. + function _checkOnlyBeforeTransfer( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (ebool compliant) { + if (!FHE.isInitialized(encryptedAmount)) { + return FHE.asEbool(true); + } + address[] memory modules = _transferOnlyModules.values(); + uint256 modulesLength = modules.length; + compliant = FHE.asEbool(true); + for (uint256 i = 0; i < modulesLength; i++) { + compliant = FHE.and( + compliant, + IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + ); + } + } + + /// @dev Runs always after. + function _runAlwaysAfter(address from, address to, euint64 encryptedAmount) internal virtual { + address[] memory modules = _alwaysOnModules.values(); + uint256 modulesLength = modules.length; + for (uint256 i = 0; i < modulesLength; i++) { + IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + } + } + + /// @dev Runs only after transfer. + function _runOnlyAfterTransfer(address from, address to, euint64 encryptedAmount) internal virtual { + address[] memory modules = _transferOnlyModules.values(); + uint256 modulesLength = modules.length; + for (uint256 i = 0; i < modulesLength; i++) { + IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + } + } + + /// @dev Allow modules to get access to token handles over {HandleAccessManager-getHandleAllowance}. + function _validateHandleAllowance(bytes32) internal view override { + require( + _alwaysOnModules.contains(msg.sender) || _transferOnlyModules.contains(msg.sender), + SenderNotComplianceModule(msg.sender) + ); + } +} diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts new file mode 100644 index 00000000..52ffa2e9 --- /dev/null +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -0,0 +1,452 @@ +import { callAndGetResult } from '../../../helpers/event'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { BytesLike } from 'ethers'; +import { ethers, fhevm } from 'hardhat'; + +const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; +const alwaysOnType = 0; +const transferOnlyType = 1; +const moduleTypes = [alwaysOnType, transferOnlyType]; +const maxInverstor = 2; +const maxBalance = 100; +const adminRole = ethers.ZeroHash; + +const fixture = async () => { + const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); + const token = ( + await ethers.deployContract('ERC7984RwaModularComplianceMock', ['name', 'symbol', 'uri', admin.address]) + ).connect(anyone); + await token.connect(admin).addAgent(agent1); + const alwaysOnModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ + await token.getAddress(), + ]); + const transferOnlyModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ + await token.getAddress(), + ]); + const investorCapModule = await ethers.deployContract('ERC7984RwaInvestorCapModuleMock', [ + await token.getAddress(), + maxInverstor, + ]); + const balanceCapModule = await ethers.deployContract('ERC7984RwaBalanceCapModuleMock', [await token.getAddress()]); + const encryptedInput = await fhevm + .createEncryptedInput(await balanceCapModule.getAddress(), admin.address) + .add64(maxBalance) + .encrypt(); + await balanceCapModule + .connect(admin) + ['setMaxBalance(bytes32,bytes)'](encryptedInput.handles[0], encryptedInput.inputProof); + return { + token, + alwaysOnModule, + transferOnlyModule, + investorCapModule, + balanceCapModule, + admin, + agent1, + agent2, + recipient, + anyone, + }; +}; + +describe('ERC7984RwaModularCompliance', function () { + describe('Support module', async function () { + for (const type of moduleTypes) { + it(`should support module type ${type}`, async function () { + const { token } = await fixture(); + await expect(token.supportsModule(type)).to.eventually.be.true; + }); + } + }); + + describe('Instal module', async function () { + for (const type of moduleTypes) { + it(`should install module type ${type}`, async function () { + const { token, investorCapModule, admin } = await fixture(); + await expect(token.connect(admin).installModule(type, investorCapModule)) + .to.emit(token, 'ModuleInstalled') + .withArgs(type, investorCapModule); + await expect(token.isModuleInstalled(type, investorCapModule)).to.eventually.be.true; + }); + } + + it('should not install module if not admin', async function () { + const { token, investorCapModule, anyone } = await fixture(); + await expect(token.connect(anyone).installModule(alwaysOnType, investorCapModule)) + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, adminRole); + }); + + for (const type of moduleTypes) { + it('should not install module if not module', async function () { + const { token, admin } = await fixture(); + const notModule = '0x0000000000000000000000000000000000000001'; + await expect(token.connect(admin).installModule(type, notModule)) + .to.be.revertedWithCustomError(token, 'ERC7984RwaNotTransferComplianceModule') + .withArgs(notModule); + await expect(token.isModuleInstalled(type, notModule)).to.eventually.be.false; + }); + } + + for (const type of moduleTypes) { + it(`should not install module type ${type} if already installed`, async function () { + const { token, investorCapModule, admin } = await fixture(); + await token.connect(admin).installModule(type, investorCapModule); + await expect(token.connect(admin).installModule(type, investorCapModule)) + .to.be.revertedWithCustomError(token, 'ERC7984RwaAlreadyInstalledModule') + .withArgs(type, await investorCapModule.getAddress()); + }); + } + }); + + describe('Uninstal module', async function () { + for (const type of moduleTypes) { + it(`should remove module type ${type}`, async function () { + const { token, investorCapModule, admin } = await fixture(); + await token.connect(admin).installModule(type, investorCapModule); + await expect(token.connect(admin).uninstallModule(type, investorCapModule)) + .to.emit(token, 'ModuleUninstalled') + .withArgs(type, investorCapModule); + await expect(token.isModuleInstalled(type, investorCapModule)).to.eventually.be.false; + }); + } + }); + + describe('Modules', async function () { + for (const forceTransfer of [false, true]) { + for (const compliant of [true, false]) { + it(`should ${forceTransfer ? 'force transfer' : 'transfer'} ${ + compliant ? 'if' : 'zero if not' + } compliant`, async function () { + const { token, alwaysOnModule, transferOnlyModule, admin, agent1, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(alwaysOnType, alwaysOnModule); + await token.connect(admin).installModule(transferOnlyType, transferOnlyModule); + const amount = 100; + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + // set compliant for initial mint + await alwaysOnModule.$_setCompliant(); + await transferOnlyModule.$_setCompliant(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)']( + recipient.address, + encryptedMint.handles[0], + encryptedMint.inputProof, + ); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient.address), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(amount); + const encryptedMint2 = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + if (compliant) { + await alwaysOnModule.$_setCompliant(); + await transferOnlyModule.$_setCompliant(); + } else { + await alwaysOnModule.$_unsetCompliant(); + await transferOnlyModule.$_unsetCompliant(); + } + if (!forceTransfer) { + await token.connect(recipient).setOperator(agent1.address, (await time.latest()) + 1000); + } + const tx = token + .connect(agent1) + [ + forceTransfer + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](recipient.address, anyone.address, encryptedMint2.handles[0], encryptedMint2.inputProof); + const [, , transferredHandle] = await callAndGetResult(tx, transferEventSignature); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(compliant ? amount : 0); + await expect(tx).to.emit(alwaysOnModule, 'PreTransfer').to.emit(alwaysOnModule, 'PostTransfer'); + if (forceTransfer) { + await expect(tx) + .to.not.emit(transferOnlyModule, 'PreTransfer') + .to.not.emit(transferOnlyModule, 'PostTransfer'); + } else { + await expect(tx).to.emit(transferOnlyModule, 'PreTransfer').to.emit(transferOnlyModule, 'PostTransfer'); + } + }); + } + } + }); + + describe('Balance cap module', async function () { + for (const withProof of [false, true]) { + it(`should set max balance ${withProof ? 'with proof' : ''}`, async function () { + const { admin, balanceCapModule } = await fixture(); + let params = [] as unknown as [encryptedAmount: BytesLike, inputProof: BytesLike]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await balanceCapModule.getAddress(), admin.address) + .add64(maxBalance + 100) + .encrypt(); + params.push(handles[0], inputProof); + } else { + const [newBalance] = await callAndGetResult( + balanceCapModule.connect(admin).createEncryptedAmount(maxBalance + 100), + 'AmountEncrypted(bytes32)', + ); + params.push(newBalance); + } + await expect( + balanceCapModule + .connect(admin) + [withProof ? 'setMaxBalance(bytes32,bytes)' : 'setMaxBalance(bytes32)'](...params), + ) + .to.emit(balanceCapModule, 'MaxBalanceSet') + .withArgs(params[0]); + await balanceCapModule + .connect(admin) + .getHandleAllowance(await balanceCapModule.getMaxBalance(), admin.address, true); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await balanceCapModule.getMaxBalance(), + await balanceCapModule.getAddress(), + admin, + ), + ).to.eventually.equal(maxBalance + 100); + }); + } + for (const withProof of [false, true]) { + it(`should not set max balance if not admin ${withProof ? 'with proof' : ''}`, async function () { + const { admin, balanceCapModule, anyone } = await fixture(); + const [newBalance] = await callAndGetResult( + balanceCapModule.connect(admin).createEncryptedAmount(maxBalance + 100), + 'AmountEncrypted(bytes32)', + ); + const oldBalance = await balanceCapModule.getMaxBalance(); + const params = [newBalance]; + if (withProof) { + params.push('0x'); + } + await expect( + balanceCapModule + .connect(anyone) + [withProof ? 'setMaxBalance(bytes32,bytes)' : 'setMaxBalance(bytes32)'](...params), + ) + .to.be.revertedWithCustomError(balanceCapModule, 'SenderNotTokenAdmin') + .withArgs(anyone.address); + await expect(balanceCapModule.getMaxBalance()).to.eventually.equal(oldBalance); + }); + } + + for (const type of moduleTypes) { + it(`should transfer if compliant to balance cap module with type ${type}`, async function () { + const { token, admin, agent1, balanceCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(type, balanceCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); + const amount = 25; + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(amount) + .encrypt(); + const [, , transferredHandle] = await callAndGetResult( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(amount); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(75); + }); + } + + it(`should transfer zero if not compliant to balance cap module`, async function () { + const { token, admin, agent1, balanceCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(transferOnlyType, balanceCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](anyone, encryptedMint.handles[0], encryptedMint.inputProof); + const amount = 25; + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(amount) + .encrypt(); + const [, , transferredHandle] = await callAndGetResult( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(0); + }); + + it('should transfer if compliant because burning', async function () { + const { token, admin, agent1, balanceCapModule, recipient } = await fixture(); + await token.connect(admin).installModule(alwaysOnType, balanceCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); + const amount = 25; + const encryptedBurnValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + const [, , transferredHandle] = await callAndGetResult( + token + .connect(agent1) + ['confidentialBurn(address,bytes32,bytes)']( + recipient, + encryptedBurnValueInput.handles[0], + encryptedBurnValueInput.inputProof, + ), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(25); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(75); + }); + }); + + describe('Investor cap module', async function () { + for (const type of moduleTypes) { + it(`should transfer if compliant to investor cap module else zero with type ${type}`, async function () { + const { token, admin, agent1, investorCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(type, investorCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + for (const investor of [ + recipient.address, // investor#1 + ethers.Wallet.createRandom().address, //investor#2 + ]) { + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](investor, encryptedMint.handles[0], encryptedMint.inputProof); + } + await investorCapModule + .connect(admin) + .getHandleAllowance(await investorCapModule.getCurrentInvestor(), admin.address, true); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await investorCapModule.getCurrentInvestor(), + await investorCapModule.getAddress(), + admin, + ), + ) + .to.eventually.equal(await investorCapModule.getMaxInvestor()) + .to.equal(2); + const amount = 25; + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(amount) + .encrypt(); + // trying to transfer to investor#3 (anyone) but number of investors is capped + const [, , transferredHandle] = await callAndGetResult( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(0); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(100); + // current investor should be unchanged + await investorCapModule + .connect(admin) + .getHandleAllowance(await investorCapModule.getCurrentInvestor(), admin.address, true); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await investorCapModule.getCurrentInvestor(), + await investorCapModule.getAddress(), + admin, + ), + ).to.eventually.equal(2); + }); + } + + it('should set max investor', async function () { + const { token, admin, investorCapModule } = await fixture(); + const newMaxInvestor = 100; + await token.connect(admin).installModule(alwaysOnType, investorCapModule); + await expect(investorCapModule.connect(admin).setMaxInvestor(newMaxInvestor)) + .to.emit(investorCapModule, 'MaxInvestorSet') + .withArgs(newMaxInvestor); + await expect(investorCapModule.getMaxInvestor()).to.eventually.equal(newMaxInvestor); + }); + + it('should not set max investor if not admin', async function () { + const { token, admin, anyone, investorCapModule } = await fixture(); + const newMaxInvestor = maxInverstor + 10; + await token.connect(admin).installModule(alwaysOnType, investorCapModule); + await expect(investorCapModule.connect(anyone).setMaxInvestor(newMaxInvestor)) + .to.be.revertedWithCustomError(investorCapModule, 'SenderNotTokenAdmin') + .withArgs(anyone.address); + await expect(investorCapModule.getMaxInvestor()).to.eventually.equal(maxInverstor); + }); + }); +});