diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md new file mode 100644 index 00000000..682d4763 --- /dev/null +++ b/.changeset/wet-results-doubt.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +`ERC7984Hooked`: Call external hooks before and after transfer of confidential tokens. diff --git a/contracts/interfaces/IERC7984HookModule.sol b/contracts/interfaces/IERC7984HookModule.sol new file mode 100644 index 00000000..d07e3e6a --- /dev/null +++ b/contracts/interfaces/IERC7984HookModule.sol @@ -0,0 +1,28 @@ +// 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 an ERC-7984 hook module. +interface IERC7984HookModule is IERC165 { + /** + * @dev Hook that runs before a transfer. Should be non-mutating. Transient access is already granted + * to the module for `encryptedAmount`. + */ + function preTransfer(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. + * + * NOTE: The module uninstallation will succeed even if the function reverts. + */ + function onUninstall(bytes calldata deinitData) external; +} diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index ec34ddfc..b15d3f87 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -9,44 +9,64 @@ import {IERC7984} from "./IERC7984.sol"; 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, diff --git a/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol new file mode 100644 index 00000000..4d7a9ee9 --- /dev/null +++ b/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC7984Hooked} from "../../../../token/ERC7984/extensions/ERC7984Hooked.sol"; +import {ERC7984Mock} from "../../ERC7984Mock.sol"; + +contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock, Ownable { + constructor( + string memory name, + string memory symbol, + string memory tokenUri, + address admin + ) ERC7984Mock(name, symbol, tokenUri) Ownable(admin) {} + + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984Mock, ERC7984Hooked) returns (euint64) { + return super._update(from, to, amount); + } + + function _authorizeModuleChange() internal virtual override onlyOwner {} +} diff --git a/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol new file mode 100644 index 00000000..d62513ce --- /dev/null +++ b/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.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 {IERC7984} from "../../../../interfaces/IERC7984.sol"; +import {ERC7984HookModule} from "../../../../token/ERC7984/utils/ERC7984HookModule.sol"; + +contract ERC7984HookModuleMock is ERC7984HookModule, 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 _preTransfer(address token, address from, address, euint64) internal override returns (ebool) { + euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); + + if (FHE.isInitialized(fromBalance)) { + _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/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol new file mode 100644 index 00000000..f3118e35 --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -0,0 +1,157 @@ +// 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 {IERC7984HookModule} from "./../../../interfaces/IERC7984HookModule.sol"; +import {HandleAccessManager} from "./../../../utils/HandleAccessManager.sol"; +import {ERC7984} from "./../ERC7984.sol"; + +/** + * @dev Extension of {ERC7984} that supports hook modules. Inspired by ERC-7579 modules. + * + * Modules are called before and after transfers. Before the transfer, 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. Modules may revert on either call, which will propagate + * and revert the entire transaction. + * + * NOTE: Hook modules are trusted contracts--they have access to any private state the token has access to. + */ +abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { + using EnumerableSet for *; + + EnumerableSet.AddressSet private _modules; + + /// @dev Emitted when a module is installed. + event ERC7984HookedModuleInstalled(address module); + /// @dev Emitted when a module is uninstalled. + event ERC7984HookedModuleUninstalled(address module); + + /// @dev The address is not a valid module. + error ERC7984HookedInvalidModule(address module); + /// @dev The module is already installed. + error ERC7984HookedDuplicateModule(address module); + /// @dev The module is not installed. + error ERC7984HookedNonexistentModule(address module); + /// @dev The maximum number of modules has been exceeded. + error ERC7984HookedExceededMaxModules(); + + modifier onlyAuthorizedModuleChange() { + _authorizeModuleChange(); + _; + } + + /// @dev Checks if a module is installed. + function isModuleInstalled(address module) public view virtual returns (bool) { + return _modules.contains(module); + } + + /** + * @dev Installs a hook module. + * + * Consider gas footprint of the module before adding it since all modules will perform + * both steps (pre-hook, post-hook) on all transfers. + */ + function installModule(address module, bytes memory initData) public virtual onlyAuthorizedModuleChange { + _installModule(module, initData); + } + + /// @dev Uninstalls a hook module. + function uninstallModule(address module, bytes memory deinitData) public virtual onlyAuthorizedModuleChange { + _uninstallModule(module, deinitData); + } + + /** + * @dev Returns a slice of the list of modules installed on the token with inclusive start and exclusive end. + * + * TIP: Use an end value of type(uint256).max to get the entire list of modules. + */ + function modules(uint256 start, uint256 end) public view virtual returns (address[] memory) { + return _modules.values(start, end); + } + + /// @dev Returns the maximum number of modules that can be installed. + function maxModules() public view virtual returns (uint256) { + return 15; + } + + /// @dev Authorization logic for installing and uninstalling modules. Must be implemented by the concrete contract. + function _authorizeModuleChange() internal virtual; + + /// @dev Internal function which installs a hook module. + function _installModule(address module, bytes memory initData) internal virtual { + require(_modules.length() < maxModules(), ERC7984HookedExceededMaxModules()); + require( + ERC165Checker.supportsInterface(module, type(IERC7984HookModule).interfaceId), + ERC7984HookedInvalidModule(module) + ); + require(_modules.add(module), ERC7984HookedDuplicateModule(module)); + + IERC7984HookModule(module).onInstall(initData); + + emit ERC7984HookedModuleInstalled(module); + } + + /// @dev Internal function which uninstalls a module. + function _uninstallModule(address module, bytes memory deinitData) internal virtual { + require(_modules.remove(module), ERC7984HookedNonexistentModule(module)); + + LowLevelCall.callNoReturn(module, abi.encodeCall(IERC7984HookModule.onUninstall, (deinitData))); + + emit ERC7984HookedModuleUninstalled(module); + } + + /** + * @dev See {ERC7984-_update}. + * + * Modified to run pre and post transfer hooks. Zero tokens are transferred if a module does not approve + * the transfer. + */ + function _update( + address from, + address to, + euint64 encryptedAmount + ) internal virtual override returns (euint64 transferred) { + euint64 amountToTransfer = FHE.select( + _runPreTransferHooks(from, to, encryptedAmount), + encryptedAmount, + FHE.asEuint64(0) + ); + transferred = super._update(from, to, amountToTransfer); + _runPostTransferHooks(from, to, transferred); + } + + /// @dev Runs the pre-transfer hooks for all modules. + function _runPreTransferHooks( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (ebool compliant) { + address[] memory modules_ = modules(0, type(uint256).max); + 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, IERC7984HookModule(modules_[i]).preTransfer(from, to, encryptedAmount)); + } + } + + /// @dev Runs the post-transfer hooks for all modules. + function _runPostTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { + address[] memory modules_ = modules(0, type(uint256).max); + uint256 modulesLength = modules_.length; + for (uint256 i = 0; i < modulesLength; i++) { + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules_[i]); + IERC7984HookModule(modules_[i]).postTransfer(from, to, encryptedAmount); + } + } + + /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow modules to access any handle the token has access to. + function _validateHandleAllowance(bytes32) internal view override returns (bool) { + return _modules.contains(msg.sender); + } +} diff --git a/contracts/token/ERC7984/utils/ERC7984HookModule.sol b/contracts/token/ERC7984/utils/ERC7984HookModule.sol new file mode 100644 index 00000000..985094c9 --- /dev/null +++ b/contracts/token/ERC7984/utils/ERC7984HookModule.sol @@ -0,0 +1,87 @@ +// 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 {IERC7984HookModule} from "./../../../interfaces/IERC7984HookModule.sol"; +import {HandleAccessManager} from "./../../../utils/HandleAccessManager.sol"; + +/** + * @dev An abstract base contract for building ERC-7984 hook modules. Compatible with {ERC7984Hooked}. + */ +abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { + /// @dev The caller `user` does not have access to the encrypted amount `amount`. + error ERC7984HookModuleUnauthorizedUseOfEncryptedAmount(euint64 amount, address user); + + /// @inheritdoc IERC7984HookModule + function preTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { + require( + FHE.isAllowed(encryptedAmount, msg.sender), + ERC7984HookModuleUnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) + ); + ebool compliant = _preTransfer(msg.sender, from, to, encryptedAmount); + FHE.allowTransient(compliant, msg.sender); + return compliant; + } + + /// @inheritdoc IERC7984HookModule + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { + require( + FHE.isAllowed(encryptedAmount, msg.sender), + ERC7984HookModuleUnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) + ); + _postTransfer(msg.sender, from, to, encryptedAmount); + } + + /// @inheritdoc IERC7984HookModule + function onInstall(bytes calldata initData) public virtual {} + + /// @inheritdoc IERC7984HookModule + function onUninstall(bytes calldata deinitData) public virtual {} + + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC7984HookModule).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Internal function which runs before a transfer. Transient access is already granted to the module + * for `encryptedAmount`. If additional handle access is needed from the token, call {_getTokenHandleAllowance}. + * + * NOTE: ACL allowance on `encryptedAmount` is already checked for `msg.sender` in {preTransfer}. + */ + function _preTransfer( + 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}. + * + * NOTE: ACL allowance on `encryptedAmount` is already checked for `msg.sender` in {postTransfer}. + */ + 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/token/README.adoc b/contracts/token/README.adoc index 1c0c40d1..76c64397 100644 --- a/contracts/token/README.adoc +++ b/contracts/token/README.adoc @@ -13,7 +13,9 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a - {ERC7984Omnibus}: An extension of {ERC7984} that emits additional events for omnibus transfers, which contain encrypted addresses for the sub-account sender and recipient. - {ERC7984Rwa}: Extension of {ERC7984} that supports confidential Real World Assets (RWAs) by providing compliance checks, transfer controls and enforcement actions. - {ERC7984Votes}: An extension of {ERC7984} that supports confidential vote tracking and delegation via {VotesConfidential}. +- {ERC7984Hooked}: An extension of {ERC7984} that calls pre and post transfer hooks on installed modules. - {ERC7984Utils}: A library that provides the on-transfer callback check used by {ERC7984}. +- {ERC7984HookModule}: A an abstract, base implementation for hook modules compatible with {ERC7984Hooked}. == Core {{ERC7984}} @@ -26,6 +28,8 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a {{ERC7984Omnibus}} {{ERC7984Rwa}} {{ERC7984Votes}} +{{ERC7984Hooked}} == Utilities -{{ERC7984Utils}} \ No newline at end of file +{{ERC7984Utils}} +{{ERC7984HookModule}} \ No newline at end of file diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index caaccf9a..c0eabe8a 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -34,6 +34,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)', @@ -41,6 +43,12 @@ export const SIGNATURES = { 'unblockUser(address)', 'unpause()', ], + ERC7984HookModule: [ + 'preTransfer(address,address,bytes32)', + 'postTransfer(address,address,bytes32)', + 'onInstall(bytes)', + 'onUninstall(bytes)', + ], }; export const INTERFACE_IDS = mapValues(SIGNATURES, interfaceId); 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/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts new file mode 100644 index 00000000..38802b26 --- /dev/null +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -0,0 +1,157 @@ +import { $ERC7984Hooked } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984Hooked.sol/$ERC7984Hooked'; +import { INTERFACE_IDS, INVALID_ID } from '../../../helpers/interface'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +describe('ERC7984Hooked', function () { + beforeEach(async function () { + const [admin, holder, recipient, anyone] = await ethers.getSigners(); + const token = (await ethers.deployContract('$ERC7984HookedMock', ['name', 'symbol', 'uri', admin])).connect( + anyone, + ) as $ERC7984Hooked; + const hookModule = await ethers.deployContract('$ERC7984HookModuleMock'); + + Object.assign(this, { + token, + hookModule, + admin, + recipient, + holder, + anyone, + }); + }); + + describe('install module', async function () { + it('should emit event', async function () { + await expect(this.token.$_installModule(this.hookModule, '0x')) + .to.emit(this.token, 'ERC7984HookedModuleInstalled') + .withArgs(this.hookModule); + }); + + it('should call `onInstall` on the module', async function () { + await expect(this.token.$_installModule(this.hookModule, '0xffff')) + .to.emit(this.hookModule, 'OnInstall') + .withArgs('0xffff'); + }); + + it('should add module to modules list', async function () { + await this.token.$_installModule(this.hookModule, '0x'); + await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.true; + await expect(this.token.modules(0, ethers.MaxInt256)).to.eventually.deep.equal([this.hookModule.target]); + }); + + it('should gate via `_authorizeModuleChange`', async function () { + await expect(this.token.connect(this.anyone).installModule(this.hookModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'OwnableUnauthorizedAccount') + .withArgs(this.anyone); + + await this.token.connect(this.admin).installModule(this.hookModule, '0x'); + }); + + it('should run module check', async function () { + const notModule = '0x0000000000000000000000000000000000000001'; + await expect(this.token.$_installModule(notModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984HookedInvalidModule') + .withArgs(notModule); + }); + + it('should not install module if max modules exceeded', async function () { + const max = Number(await this.token.maxModules()); + + for (let i = 0; i < max; i++) { + const module = await ethers.deployContract('$ERC7984HookModuleMock'); + await this.token.$_installModule(module, '0x'); + } + + const extraModule = await ethers.deployContract('$ERC7984HookModuleMock'); + await expect(this.token.$_installModule(extraModule, '0x')).to.be.revertedWithCustomError( + this.token, + 'ERC7984HookedExceededMaxModules', + ); + }); + + it('should not install module if already installed', async function () { + await this.token.$_installModule(this.hookModule, '0x'); + await expect(this.token.$_installModule(this.hookModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984HookedDuplicateModule') + .withArgs(this.hookModule); + }); + }); + + describe('uninstall module', async function () { + beforeEach(async function () { + await this.token.$_installModule(this.hookModule, '0x'); + }); + + it('should emit event', async function () { + await expect(this.token.$_uninstallModule(this.hookModule, '0x')) + .to.emit(this.token, 'ERC7984HookedModuleUninstalled') + .withArgs(this.hookModule); + }); + + it('should fail if module not installed', async function () { + const newModule = await ethers.deployContract('$ERC7984HookModuleMock'); + + await expect(this.token.$_uninstallModule(newModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984HookedNonexistentModule') + .withArgs(newModule); + }); + + it('should call `onUninstall` on the module', async function () { + await expect(this.token.$_uninstallModule(this.hookModule, '0xffff')) + .to.emit(this.hookModule, 'OnUninstall') + .withArgs('0xffff'); + }); + + it('should remove module from modules list', async function () { + await this.token.$_uninstallModule(this.hookModule, '0x'); + await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.false; + }); + + it("should not revert if module's `onUninstall` reverts", async function () { + await this.hookModule.setRevertOnUninstall(true); + await this.token.$_uninstallModule(this.hookModule, '0x'); + }); + + it('should gate via `_authorizeModuleChange`', async function () { + await expect(this.token.connect(this.anyone).uninstallModule(this.hookModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'OwnableUnauthorizedAccount') + .withArgs(this.anyone); + + await this.token.connect(this.admin).uninstallModule(this.hookModule, '0x'); + await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.false; + }); + }); + + describe('hooks on transfer', async function () { + beforeEach(async function () { + await this.token.$_installModule(this.hookModule, '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.hookModule, '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.hookModule, 'PostTransfer'); + }); + + for (const approve of [true, false]) { + it(`should react correctly to module ${approve ? 'approval' : 'denial'}`, async function () { + await this.hookModule.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); + }); + } + }); +}); diff --git a/test/token/ERC7984/ERC7984Votes.test.ts b/test/token/ERC7984/extensions/ERC7984Votes.test.ts similarity index 100% rename from test/token/ERC7984/ERC7984Votes.test.ts rename to test/token/ERC7984/extensions/ERC7984Votes.test.ts diff --git a/test/token/ERC7984/utils/ERC7984HookModule.test.ts b/test/token/ERC7984/utils/ERC7984HookModule.test.ts new file mode 100644 index 00000000..cc067f25 --- /dev/null +++ b/test/token/ERC7984/utils/ERC7984HookModule.test.ts @@ -0,0 +1,53 @@ +import { INTERFACE_IDS, INVALID_ID } from '../../../helpers/interface'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +describe('ERC7984HookModules', function () { + beforeEach(async function () { + const [admin, holder, recipient, anyone] = await ethers.getSigners(); + const hookModule = await ethers.deployContract('$ERC7984HookModuleMock'); + + Object.assign(this, { + hookModule, + admin, + holder, + recipient, + anyone, + }); + }); + + describe('ERC165', function () { + it('should support interface', async function () { + await expect(this.hookModule.supportsInterface(INTERFACE_IDS.ERC7984HookModule)).to.eventually.be.true; + }); + + it('should not support interface', async function () { + await expect(this.hookModule.supportsInterface(INVALID_ID)).to.eventually.be.false; + }); + }); + + describe('preTransfer', function () { + it('should revert if the caller does not have access to the encrypted amount', async function () { + const encryptedAmount = await fhevm + .createEncryptedInput(this.hookModule.target, this.holder.address) + .add64(100) + .encrypt(); + + await expect( + this.hookModule.preTransfer(this.holder.address, this.recipient.address, encryptedAmount.handles[0]), + ).to.be.revertedWithCustomError(this.hookModule, 'ERC7984HookModuleUnauthorizedUseOfEncryptedAmount'); + }); + }); + + describe('postTransfer', function () { + it('should revert if the caller does not have access to the encrypted amount', async function () { + const encryptedAmount = await fhevm + .createEncryptedInput(this.hookModule.target, this.holder.address) + .add64(100) + .encrypt(); + await expect( + this.hookModule.postTransfer(this.holder.address, this.recipient.address, encryptedAmount.handles[0]), + ).to.be.revertedWithCustomError(this.hookModule, 'ERC7984HookModuleUnauthorizedUseOfEncryptedAmount'); + }); + }); +});