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/finance/compliance/ComplianceModuleConfidential.sol b/contracts/finance/compliance/ComplianceModuleConfidential.sol new file mode 100644 index 00000000..ee3fb49b --- /dev/null +++ b/contracts/finance/compliance/ComplianceModuleConfidential.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {IComplianceModuleConfidential} from "./../../interfaces/IComplianceModuleConfidential.sol"; +import {IERC7984Rwa} 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 ComplianceModuleConfidential is IComplianceModuleConfidential, ERC165 { + error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); + + /// @dev Thrown when the sender is not authorized to call the given function. + error NotAuthorized(address account); + + /// @dev Thrown when the sender is not an admin of the token. + modifier onlyTokenAdmin(address token) { + require(IERC7984Rwa(token).isAdmin(msg.sender), NotAuthorized(msg.sender)); + _; + } + + /// @dev Thrown when the sender is not an agent of the token. + modifier onlyTokenAgent(address token) { + require(IERC7984Rwa(token).isAgent(msg.sender), NotAuthorized(msg.sender)); + _; + } + + /// @inheritdoc IComplianceModuleConfidential + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { + ebool compliant = _isCompliantTransfer(msg.sender, from, to, encryptedAmount); + FHE.allowTransient(compliant, msg.sender); + return compliant; + } + + /// @inheritdoc IComplianceModuleConfidential + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { + _postTransfer(msg.sender, from, to, encryptedAmount); + } + + /// @inheritdoc IComplianceModuleConfidential + function onInstall(bytes calldata initData) public virtual {} + + /// @inheritdoc IComplianceModuleConfidential + function onUninstall(bytes calldata deinitData) public virtual {} + + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IComplianceModuleConfidential).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Internal function which checks if a transfer is compliant. Transient access is already granted to the module + * for `encryptedAmount`. If additional handle access is needed from the token, call {_getTokenHandleAllowance}. + */ + function _isCompliantTransfer( + address token, + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (ebool); + + /** + * @dev Internal function which performs operations after transfers. Transient access is already granted to the module + * for `encryptedAmount`. If additional handle access is needed from the token, call {_getTokenHandleAllowance}. + */ + function _postTransfer( + address /*token*/, + 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(address token, euint64 handle) internal virtual { + _getTokenHandleAllowance(token, handle, false); + } + + /// @dev Allow modules to get access to token handles. + function _getTokenHandleAllowance(address token, euint64 handle, bool persistent) internal virtual { + if (FHE.isInitialized(handle)) { + HandleAccessManager(token).getHandleAllowance(euint64.unwrap(handle), address(this), persistent); + } + } +} diff --git a/contracts/interfaces/IComplianceModuleConfidential.sol b/contracts/interfaces/IComplianceModuleConfidential.sol new file mode 100644 index 00000000..8731978f --- /dev/null +++ b/contracts/interfaces/IComplianceModuleConfidential.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {euint64, ebool} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; + +/// @dev Interface for confidential RWA transfer compliance module. +interface IComplianceModuleConfidential is IERC165 { + /** + * @dev Checks if a transfer is compliant. Should be non-mutating. Transient access is already granted + * to the module for `encryptedAmount`. + */ + 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; + + /// @dev Performs operations after installation. + function onInstall(bytes calldata initData) external; + + /// @dev Performs operations after uninstallation. + function onUninstall(bytes calldata deinitData) external; +} diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index ec34ddfc..0f1793d0 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -2,51 +2,71 @@ // OpenZeppelin Confidential Contracts (last updated v0.3.0) (interfaces/IERC7984Rwa.sol) 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 {IERC7984} from "./IERC7984.sol"; /// @dev Interface for confidential RWA contracts. interface IERC7984Rwa is IERC7984 { /// @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 whether an account is allowed to interact with the token. function canTransact(address account) external view returns (bool); + /// @dev Returns the confidential frozen balance of an account. function confidentialFrozen(address account) external view returns (euint64); + /// @dev Returns the confidential available (unfrozen) balance of an account. Up to {IERC7984-confidentialBalanceOf}. function confidentialAvailable(address account) external returns (euint64); + /// @dev Pauses contract. function pause() external; + /// @dev Unpauses contract. function unpause() external; + /// @dev Blocks a user account. function blockUser(address account) external; + /// @dev Unblocks a user account. function unblockUser(address account) external; + /// @dev Sets confidential amount of token for an account as frozen with proof. function setConfidentialFrozen( address account, externalEuint64 encryptedAmount, bytes calldata inputProof ) external; + /// @dev Sets confidential amount of token for an account as frozen. function setConfidentialFrozen(address account, euint64 encryptedAmount) external; + /// @dev Mints confidential amount of tokens to account with proof. function confidentialMint( address to, externalEuint64 encryptedAmount, bytes calldata inputProof ) external returns (euint64); + /// @dev Mints confidential amount of tokens to account. function confidentialMint(address to, euint64 encryptedAmount) external returns (euint64); + /// @dev Burns confidential amount of tokens from account with proof. function confidentialBurn( address account, externalEuint64 encryptedAmount, bytes calldata inputProof ) external returns (euint64); + /// @dev Burns confidential amount of tokens from account. function confidentialBurn(address account, euint64 encryptedAmount) external returns (euint64); + /// @dev Forces transfer of confidential amount of tokens from account to account with proof by skipping compliance checks. function forceConfidentialTransferFrom( address from, @@ -54,6 +74,7 @@ interface IERC7984Rwa is IERC7984 { externalEuint64 encryptedAmount, bytes calldata inputProof ) external returns (euint64); + /// @dev Forces transfer of confidential amount of tokens from account to account by skipping compliance checks. function forceConfidentialTransferFrom( address from, @@ -61,3 +82,15 @@ interface IERC7984Rwa is IERC7984 { euint64 encryptedAmount ) external returns (euint64); } + +/// @dev Interface for confidential RWA with modular compliance. +interface IERC7984RwaModularCompliance { + /// @dev Checks if a compliance module is installed. + function isModuleInstalled(address module) external view returns (bool); + + /// @dev Installs a transfer compliance module. + function installModule(address module, bytes calldata initData) external; + + /// @dev Uninstalls a transfer compliance module. + function uninstallModule(address module, bytes calldata deinitData) external; +} diff --git a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol new file mode 100644 index 00000000..cedc748e --- /dev/null +++ b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ComplianceModuleConfidential} from "./../../finance/compliance/ComplianceModuleConfidential.sol"; +import {IERC7984} from "./../../interfaces/IERC7984.sol"; + +contract ComplianceModuleConfidentialMock is ComplianceModuleConfidential, ZamaEthereumConfig { + bool public isCompliant = true; + bool public revertOnUninstall = false; + + event PostTransfer(); + event PreTransfer(); + + event OnInstall(bytes initData); + event OnUninstall(bytes deinitData); + + function onInstall(bytes calldata initData) public override { + emit OnInstall(initData); + super.onInstall(initData); + } + + function onUninstall(bytes calldata deinitData) public override { + if (revertOnUninstall) { + revert("Revert on uninstall"); + } + + emit OnUninstall(deinitData); + super.onUninstall(deinitData); + } + + function setIsCompliant(bool isCompliant_) public { + isCompliant = isCompliant_; + } + + function setRevertOnUninstall(bool revertOnUninstall_) public { + revertOnUninstall = revertOnUninstall_; + } + + function _isCompliantTransfer(address token, address from, address, euint64) internal override returns (ebool) { + euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); + + if (euint64.unwrap(fromBalance) != 0) { + _getTokenHandleAllowance(token, fromBalance); + assert(FHE.isAllowed(fromBalance, address(this))); + } + + emit PreTransfer(); + return FHE.asEbool(isCompliant); + } + + function _postTransfer(address token, address from, address to, euint64 amount) internal override { + emit PostTransfer(); + super._postTransfer(token, from, to, amount); + } +} diff --git a/contracts/mocks/token/ERC7984Mock.sol b/contracts/mocks/token/ERC7984Mock.sol index 792d8625..7a0d919f 100644 --- a/contracts/mocks/token/ERC7984Mock.sol +++ b/contracts/mocks/token/ERC7984Mock.sol @@ -37,6 +37,13 @@ contract ERC7984Mock is ERC7984, ZamaEthereumConfig { return encryptedAddr; } + function confidentialTransfer(address to, uint64 amount) public returns (euint64) { + euint64 ciphertext = FHE.asEuint64(amount); + FHE.allowTransient(ciphertext, msg.sender); + + return confidentialTransfer(to, ciphertext); + } + function _update(address from, address to, euint64 amount) internal virtual override returns (euint64 transferred) { transferred = super._update(from, to, amount); FHE.allow(confidentialTotalSupply(), _OWNER); diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol new file mode 100644 index 00000000..b0a1d337 --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC7984} from "./../../token/ERC7984/ERC7984.sol"; +import {ERC7984Rwa} from "./../../token/ERC7984/extensions/ERC7984Rwa.sol"; +import {ERC7984RwaModularCompliance} from "./../../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; + +contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984Mock { + constructor( + string memory name, + string memory symbol, + string memory tokenUri, + address admin + ) ERC7984Rwa(admin) ERC7984Mock(name, symbol, tokenUri) {} + + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC7984, ERC7984RwaModularCompliance) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984Mock, ERC7984RwaModularCompliance) returns (euint64) { + return super._update(from, to, amount); + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol new file mode 100644 index 00000000..6ef629dd --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC165Checker} from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import {LowLevelCall} from "@openzeppelin/contracts/utils/LowLevelCall.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IComplianceModuleConfidential} from "./../../../interfaces/IComplianceModuleConfidential.sol"; +import {IERC7984RwaModularCompliance} 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. + * + * Compliance modules are called before transfers and after transfers. Before the transfer, compliance modules + * conduct checks to see if they approve the given transfer and return an encrypted boolean. If any module + * returns false, the transferred amount becomes 0. After the transfer, modules are notified of the final transfer + * amount and may do accounting as necessary. Compliance modules may revert on either call, which will propagate + * and revert the entire transaction. + * + * NOTE: Force transfers bypass the compliance checks before the transfer. All transfers call compliance modules after the transfer. + */ +abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularCompliance, HandleAccessManager { + using EnumerableSet for *; + + EnumerableSet.AddressSet private _complianceModules; + + /// @dev Emitted when a module is installed. + event ModuleInstalled(address module); + /// @dev Emitted when a module is uninstalled. + event ModuleUninstalled(address module); + + /// @dev The address is not a valid compliance module. + error ERC7984RwaInvalidModule(address module); + /// @dev The module is already installed. + error ERC7984RwaDuplicateModule(address module); + /// @dev The module is not installed. + error ERC7984RwaNonexistentModule(address module); + /// @dev The maximum number of modules has been exceeded. + error ERC7984RwaExceededMaxModules(); + + /// @inheritdoc IERC7984RwaModularCompliance + function isModuleInstalled(address module) public view virtual returns (bool) { + return _complianceModules.contains(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(address module, bytes memory initData) public virtual onlyAdmin { + _installModule(module, initData); + } + + /// @inheritdoc IERC7984RwaModularCompliance + function uninstallModule(address module, bytes memory deinitData) public virtual onlyAdmin { + _uninstallModule(module, deinitData); + } + + /// @dev Returns the list of modules installed on the token. + function maxComplianceModules() public view virtual returns (address[] memory) { + return _complianceModules.values(); + } + + /// @dev Returns the maximum number of modules that can be installed. + function maxComplianceModules() public view virtual returns (uint256) { + return 15; + } + + /// @inheritdoc ERC7984Rwa + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC7984RwaModularCompliance).interfaceId || super.supportsInterface(interfaceId); + } + + /// @dev Internal function which installs a transfer compliance module. + function _installModule(address module, bytes memory initData) internal virtual { + require(_complianceModules.length() < maxComplianceModules(), ERC7984RwaExceededMaxModules()); + require( + ERC165Checker.supportsInterface(module, type(IComplianceModuleConfidential).interfaceId), + ERC7984RwaInvalidModule(module) + ); + require(_complianceModules.add(module), ERC7984RwaDuplicateModule(module)); + + IComplianceModuleConfidential(module).onInstall(initData); + + emit ModuleInstalled(module); + } + + /// @dev Internal function which uninstalls a compliance module. + function _uninstallModule(address module, bytes memory deinitData) internal virtual { + require(_complianceModules.remove(module), ERC7984RwaNonexistentModule(module)); + + // ignore success purposely to avoid modules that revert on uninstall + LowLevelCall.callNoReturn(module, abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); + + emit ModuleUninstalled(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) { + euint64 amountToTransfer = FHE.select( + _checkCompliance(from, to, encryptedAmount), + encryptedAmount, + FHE.asEuint64(0) + ); + transferred = super._update(from, to, amountToTransfer); + _postTransferCompliance(from, to, transferred); + } + + /** + * @dev Forces the update of confidential balances. Bypasses compliance checks + * before the transfer. Runs hooks after the force transfer. + */ + function _forceUpdate( + address from, + address to, + euint64 encryptedAmount + ) internal virtual override returns (euint64 transferred) { + transferred = super._forceUpdate(from, to, encryptedAmount); + _postTransferCompliance(from, to, transferred); + } + + /// @dev Checks all compliance modules. + function _checkCompliance( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (ebool compliant) { + address[] memory modules = _complianceModules.values(); + uint256 modulesLength = modules.length; + compliant = FHE.asEbool(true); + for (uint256 i = 0; i < modulesLength; i++) { + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules[i]); + compliant = FHE.and( + compliant, + IComplianceModuleConfidential(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + ); + } + } + + /// @dev Runs the post-transfer hooks for all compliance modules. This runs after all transfers (including force transfers). + function _postTransferCompliance(address from, address to, euint64 encryptedAmount) internal virtual { + address[] memory modules = _complianceModules.values(); + uint256 modulesLength = modules.length; + for (uint256 i = 0; i < modulesLength; i++) { + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules[i]); + IComplianceModuleConfidential(modules[i]).postTransfer(from, to, encryptedAmount); + } + } + + /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow compliance modules to access any handle. + function _validateHandleAllowance(bytes32) internal view override returns (bool) { + return _complianceModules.contains(msg.sender); + } +} diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index caaccf9a..3d5c76be 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -23,6 +23,11 @@ export const SIGNATURES = { 'symbol()', ], ERC7984ERC20Wrapper: ['underlying()', 'unwrap(address,address,bytes32,bytes)', 'wrap(address,uint256)'], + ERC7984RWAModularCompliance: [ + 'isModuleInstalled(address)', + 'installModule(address,bytes)', + 'uninstallModule(address,bytes)', + ], ERC7984RWA: [ 'blockUser(address)', 'confidentialAvailable(address)', @@ -34,6 +39,8 @@ export const SIGNATURES = { 'forceConfidentialTransferFrom(address,address,bytes32,bytes)', 'forceConfidentialTransferFrom(address,address,bytes32)', 'canTransact(address)', + 'isAdmin(address)', + 'isAgent(address)', 'pause()', 'paused()', 'setConfidentialFrozen(address,bytes32,bytes)', diff --git a/test/token/ERC7984/ERC7984.test.ts b/test/token/ERC7984/ERC7984.test.ts index 5b430c00..d74e6773 100644 --- a/test/token/ERC7984/ERC7984.test.ts +++ b/test/token/ERC7984/ERC7984.test.ts @@ -349,7 +349,7 @@ describe('ERC7984', function () { functionParams.unshift(from); await contract.connect(sender).confidentialTransferFrom(...functionParams); } else { - await contract.connect(sender).confidentialTransfer(...functionParams); + await contract.connect(sender)['confidentialTransfer(address,bytes32)'](...functionParams); } } } diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts new file mode 100644 index 00000000..9758a554 --- /dev/null +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -0,0 +1,234 @@ +import { $ERC7984RwaModularCompliance } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol/$ERC7984RwaModularCompliance'; +import { INTERFACE_IDS, INVALID_ID } from '../../../helpers/interface'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +const adminRole = ethers.ZeroHash; + +describe('ERC7984RwaModularCompliance', function () { + beforeEach(async function () { + const [admin, agent1, agent2, holder, recipient, anyone] = await ethers.getSigners(); + const token = ( + await ethers.deployContract('$ERC7984RwaModularComplianceMock', ['name', 'symbol', 'uri', admin]) + ).connect(anyone) as $ERC7984RwaModularCompliance; + await token.connect(admin).addAgent(agent1); + const complianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + + Object.assign(this, { + token, + complianceModule, + admin, + agent1, + agent2, + recipient, + holder, + anyone, + }); + }); + + describe('ERC165', async function () { + it('should support interface', async function () { + await expect(this.token.supportsInterface(INTERFACE_IDS.ERC7984)).to.eventually.be.true; + await expect(this.token.supportsInterface(INTERFACE_IDS.ERC7984RWA)).to.eventually.be.true; + await expect(this.token.supportsInterface(INTERFACE_IDS.ERC7984RWAModularCompliance)).to.eventually.be.true; + }); + + it('should not support interface', async function () { + await expect(this.token.supportsInterface(INVALID_ID)).to.eventually.be.false; + }); + }); + + describe('install module', async function () { + it('should emit event', async function () { + await expect(this.token.$_installModule(this.complianceModule, '0x')) + .to.emit(this.token, 'ModuleInstalled') + .withArgs(this.complianceModule); + }); + + it('should call `onInstall` on the module', async function () { + await expect(this.token.$_installModule(this.complianceModule, '0xffff')) + .to.emit(this.complianceModule, 'OnInstall') + .withArgs('0xffff'); + }); + + it('should add module to modules list', async function () { + await this.token.$_installModule(this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(this.complianceModule)).to.eventually.be.true; + }); + + it('should gate to admin', async function () { + await expect(this.token.connect(this.anyone).installModule(this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') + .withArgs(this.anyone, adminRole); + + await this.token.connect(this.admin).installModule(this.complianceModule, '0x'); + }); + + it('should run module check', async function () { + const notModule = '0x0000000000000000000000000000000000000001'; + await expect(this.token.$_installModule(notModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaInvalidModule') + .withArgs(notModule); + }); + + it('should not install module if max modules exceeded', async function () { + const max = Number(await this.token.maxComplianceModules()); + + for (let i = 0; i < max; i++) { + const module = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + await this.token.$_installModule(module, '0x'); + } + + const extraModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + await expect(this.token.$_installModule(extraModule, '0x')).to.be.revertedWithCustomError( + this.token, + 'ERC7984RwaExceededMaxModules', + ); + }); + + it('should not install module if already installed', async function () { + await this.token.$_installModule(this.complianceModule, '0x'); + await expect(this.token.$_installModule(this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaDuplicateModule') + .withArgs(this.complianceModule); + }); + }); + + describe('uninstall module', async function () { + beforeEach(async function () { + await this.token.$_installModule(this.complianceModule, '0x'); + }); + + it('should emit event', async function () { + await expect(this.token.$_uninstallModule(this.complianceModule, '0x')) + .to.emit(this.token, 'ModuleUninstalled') + .withArgs(this.complianceModule); + }); + + it('should fail if module not installed', async function () { + const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + + await expect(this.token.$_uninstallModule(newComplianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNonexistentModule') + .withArgs(newComplianceModule); + }); + + it('should call `onUninstall` on the module', async function () { + await expect(this.token.$_uninstallModule(this.complianceModule, '0xffff')) + .to.emit(this.complianceModule, 'OnUninstall') + .withArgs('0xffff'); + }); + + it('should remove module from modules list', async function () { + await this.token.$_uninstallModule(this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(this.complianceModule)).to.eventually.be.false; + }); + + it("should not revert if module's `onUninstall` reverts", async function () { + await this.complianceModule.setRevertOnUninstall(true); + await this.token.$_uninstallModule(this.complianceModule, '0x'); + }); + + it('should gate to admin', async function () { + await expect(this.token.connect(this.anyone).uninstallModule(this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') + .withArgs(this.anyone, adminRole); + + await this.token.connect(this.admin).uninstallModule(this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(this.complianceModule)).to.eventually.be.false; + }); + }); + + describe('check compliance on transfer', async function () { + beforeEach(async function () { + await this.token.$_installModule(this.complianceModule, '0x'); + await this.token['$_mint(address,uint64)'](this.holder, 1000); + }); + + it('should call pre-transfer hooks', async function () { + await expect( + this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n), + ).to.emit(this.complianceModule, 'PreTransfer'); + }); + + it('should call post-transfer hooks', async function () { + await expect( + this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n), + ).to.emit(this.complianceModule, 'PostTransfer'); + }); + + for (const approve of [true, false]) { + it(`should react correctly to compliance ${approve ? 'approval' : 'denial'}`, async function () { + await this.complianceModule.setIsCompliant(approve); + await this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n); + + const recipientBalance = await this.token.confidentialBalanceOf(this.recipient); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, recipientBalance, this.token.target, this.recipient), + ).to.eventually.equal(approve ? 100 : 0); + }); + } + + describe('force transfer', function () { + it('should not call pre-transfer hook', async function () { + const encryptedAmount = await fhevm + .createEncryptedInput(this.token.target, this.agent1.address) + .add64(100n) + .encrypt(); + + await expect( + this.token + .connect(this.agent1) + ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( + this.holder, + this.recipient, + encryptedAmount.handles[0], + encryptedAmount.inputProof, + ), + ).to.not.emit(this.complianceModule, 'PreTransfer'); + }); + + it('should call post-transfer hook', async function () { + const encryptedAmount = await fhevm + .createEncryptedInput(this.token.target, this.agent1.address) + .add64(100n) + .encrypt(); + + await expect( + this.token + .connect(this.agent1) + ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( + this.holder, + this.recipient, + encryptedAmount.handles[0], + encryptedAmount.inputProof, + ), + ).to.emit(this.complianceModule, 'PostTransfer'); + }); + + it('should pass compliance even if module denies', async function () { + await this.complianceModule.setIsCompliant(false); + + const encryptedAmount = await fhevm + .createEncryptedInput(this.token.target, this.agent1.address) + .add64(100n) + .encrypt(); + + await this.token + .connect(this.agent1) + ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( + this.holder, + this.recipient, + encryptedAmount.handles[0], + encryptedAmount.inputProof, + ); + + const recipientBalance = await this.token.confidentialBalanceOf(this.recipient); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, recipientBalance, this.token.target, this.recipient), + ).to.eventually.equal(100); + }); + }); + }); +});