From 61729c23b9292f6b0cb7006fdca718d5bd88a307 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:34:10 +0200 Subject: [PATCH 001/111] Init `ERC7984Rwa` extension. --- .changeset/new-crews-boil.md | 5 + contracts/interfaces/draft-IERCXXXXCRwa.sol | 65 ++++++++++++ contracts/token/extensions/ERC7984Rwa.sol | 109 ++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 .changeset/new-crews-boil.md create mode 100644 contracts/interfaces/draft-IERCXXXXCRwa.sol create mode 100644 contracts/token/extensions/ERC7984Rwa.sol diff --git a/.changeset/new-crews-boil.md b/.changeset/new-crews-boil.md new file mode 100644 index 00000000..e8c18888 --- /dev/null +++ b/.changeset/new-crews-boil.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +Add `ERC7984Rwa` extension. diff --git a/contracts/interfaces/draft-IERCXXXXCRwa.sol b/contracts/interfaces/draft-IERCXXXXCRwa.sol new file mode 100644 index 00000000..58348480 --- /dev/null +++ b/contracts/interfaces/draft-IERCXXXXCRwa.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {IConfidentialFungibleToken} from "./IConfidentialFungibleToken.sol"; + +/// @dev Interface for confidential RWA contracts. +interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { + /// @dev Emmited when the ownership of the contract changes. + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + /// @dev Emmited when the contract is paused. + event Paused(address account); + /// @dev Emmited when the contract is unpaused. + event Unpaused(address account); + + /// @dev The caller account is not authorized to perform an operation. + error OwnableUnauthorizedAccount(address account); + /// @dev The owner is not a valid owner account. (eg. `address(0)`) + error OwnableInvalidOwner(address owner); + /// @dev The operation failed because the contract is paused. + error EnforcedPause(); + /// @dev The operation failed because the contract is not paused. + error ExpectedPause(); + + /// @dev Gets the address of the owner + function owner() external view returns (address); + /// @dev Transfers contract ownership to an account. + function transferOwnership(address _newOwner) external; + /// @dev Returns true if the contract is paused, and false otherwise. + function paused() external view returns (bool); + /// @dev Pauses contract. + function pause() external; + /// @dev Unpauses contract. + function unpause() external; + /// @dev Returns the confidential frozen balance of an account. + function confidentialFrozen(address acount) external view returns (euint64); + /// @dev Sets confidential amount of token for an account as frozen with proof. + function setConfidentialFrozen(address acount, externalEuint64 encryptedAmount, bytes calldata inputProof) external; + /// @dev Sets confidential amount of token for an account as frozen. + function setConfidentialFrozen(address acount, euint64 encryptedAmount) external; + /// @dev Receives and executes a batch of function calls on this contract. + function multicall(bytes[] calldata data) external returns (bytes[] memory results); + /// @dev Mints confidential amount of tokens to account with proof. + function mint(address to, externalEuint64 encryptedAmount, bytes calldata inputProof) external returns (euint64); + /// @dev Mints confidential amount of tokens to account. + function mint(address to, euint64 encryptedAmount) external; + /// @dev Burns confidential amount of tokens from account with proof. + function burn( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) external returns (euint64); + /// @dev Burns confidential amount of tokens from account. + function burn(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 forceTransfer( + address from, + address to, + 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 forceTransfer(address from, address to, euint64 encryptedAmount) external returns (euint64); +} diff --git a/contracts/token/extensions/ERC7984Rwa.sol b/contracts/token/extensions/ERC7984Rwa.sol new file mode 100644 index 00000000..2df8eb8b --- /dev/null +++ b/contracts/token/extensions/ERC7984Rwa.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IERCXXXXCRwa} from "./../../interfaces/draft-IERCXXXXCRwa.sol"; +import {ConfidentialFungibleToken} from "./../ConfidentialFungibleToken.sol"; + +/** + * @dev Extension of {ConfidentialFungibleToken} supporting confidential Real World Assets. + */ +abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Multicall, ERC165 { + /// @dev The caller account is not authorized to perform the operation. + error UnauthorizedSender(address account); + /// @dev The transfer does not follow token compliance. + error UncompliantTransfer(address from, address to, euint64 encryptedAmount); + + constructor() {} + + /// @dev Checks the sender is the owner or an authorized agent. + modifier onlyOwnerOrAgent() { + require( + _msgSender() == owner(), + //TODO: Add agent condition + UnauthorizedSender(_msgSender()) + ); + _; + } + + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERCXXXXCRwa).interfaceId || super.supportsInterface(interfaceId); + } + + /// @dev Pauses contract. + function pause() public virtual onlyOwnerOrAgent { + _pause(); + } + + /// @dev Unpauses contract. + function unpause() public virtual onlyOwnerOrAgent { + _unpause(); + } + + /// @dev Mints confidential amount of tokens to account with proof. + function mint( + address to, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public virtual returns (euint64) { + return mint(to, FHE.fromExternal(encryptedAmount, inputProof)); + } + + /// @dev Mints confidential amount of tokens to account. + function mint(address to, euint64 encryptedAmount) public virtual onlyOwnerOrAgent returns (euint64) { + return _mint(to, encryptedAmount); + } + + /// @dev Burns confidential amount of tokens from account with proof. + function burn( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public virtual returns (euint64) { + return burn(account, FHE.fromExternal(encryptedAmount, inputProof)); + } + + /// @dev Burns confidential amount of tokens from account. + function burn(address account, euint64 encryptedAmount) public virtual onlyOwnerOrAgent returns (euint64) { + return _burn(account, encryptedAmount); + } + + /// @dev Forces transfer of confidential amount of tokens from account to account with proof by skipping compliance checks. + function forceTransfer( + address from, + address to, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public virtual returns (euint64) { + return forceTransfer(from, to, FHE.fromExternal(encryptedAmount, inputProof)); + } + + /// @dev Forces transfer of confidential amount of tokens from account to account by skipping compliance checks. + function forceTransfer( + address from, + address to, + euint64 encryptedAmount + ) public virtual onlyOwnerOrAgent returns (euint64) { + //TODO: Add checks + return super._update(from, to, encryptedAmount); + } + + function _update( + address from, + address to, + euint64 encryptedAmount + ) internal override whenNotPaused returns (euint64) { + //TODO: Add checks + require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + return super._update(from, to, encryptedAmount); + } + + /// @dev Checks if a transfer follows token compliance. + function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); +} From 639e5a2c12fe1dd6d5ac060cc365dcdd64aa60bb Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:41:42 +0200 Subject: [PATCH 002/111] Fix typos --- contracts/interfaces/draft-IERCXXXXCRwa.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/contracts/interfaces/draft-IERCXXXXCRwa.sol b/contracts/interfaces/draft-IERCXXXXCRwa.sol index 58348480..400dcf4a 100644 --- a/contracts/interfaces/draft-IERCXXXXCRwa.sol +++ b/contracts/interfaces/draft-IERCXXXXCRwa.sol @@ -7,11 +7,11 @@ import {IConfidentialFungibleToken} from "./IConfidentialFungibleToken.sol"; /// @dev Interface for confidential RWA contracts. interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { - /// @dev Emmited when the ownership of the contract changes. + /// @dev Emitted when the ownership of the contract changes. event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); - /// @dev Emmited when the contract is paused. + /// @dev Emitted when the contract is paused. event Paused(address account); - /// @dev Emmited when the contract is unpaused. + /// @dev Emitted when the contract is unpaused. event Unpaused(address account); /// @dev The caller account is not authorized to perform an operation. @@ -34,11 +34,15 @@ interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { /// @dev Unpauses contract. function unpause() external; /// @dev Returns the confidential frozen balance of an account. - function confidentialFrozen(address acount) external view returns (euint64); + function confidentialFrozen(address account) external view returns (euint64); /// @dev Sets confidential amount of token for an account as frozen with proof. - function setConfidentialFrozen(address acount, externalEuint64 encryptedAmount, bytes calldata inputProof) external; + function setConfidentialFrozen( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) external; /// @dev Sets confidential amount of token for an account as frozen. - function setConfidentialFrozen(address acount, euint64 encryptedAmount) external; + function setConfidentialFrozen(address account, euint64 encryptedAmount) external; /// @dev Receives and executes a batch of function calls on this contract. function multicall(bytes[] calldata data) external returns (bytes[] memory results); /// @dev Mints confidential amount of tokens to account with proof. From 85546dddd2cfe6be0bdc7d647531f828ea96581a Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:08:21 +0200 Subject: [PATCH 003/111] Add agent role --- contracts/interfaces/draft-IERCXXXXCRwa.sol | 17 +-- .../token/extensions/ERC7984Freezable.sol | 17 +++ contracts/token/extensions/ERC7984Rwa.sol | 101 ++++++++++++++---- 3 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 contracts/token/extensions/ERC7984Freezable.sol diff --git a/contracts/interfaces/draft-IERCXXXXCRwa.sol b/contracts/interfaces/draft-IERCXXXXCRwa.sol index 400dcf4a..3586e90c 100644 --- a/contracts/interfaces/draft-IERCXXXXCRwa.sol +++ b/contracts/interfaces/draft-IERCXXXXCRwa.sol @@ -2,13 +2,12 @@ pragma solidity ^0.8.24; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {IConfidentialFungibleToken} from "./IConfidentialFungibleToken.sol"; /// @dev Interface for confidential RWA contracts. -interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { - /// @dev Emitted when the ownership of the contract changes. - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); +interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165, IAccessControl { /// @dev Emitted when the contract is paused. event Paused(address account); /// @dev Emitted when the contract is unpaused. @@ -23,10 +22,6 @@ interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { /// @dev The operation failed because the contract is not paused. error ExpectedPause(); - /// @dev Gets the address of the owner - function owner() external view returns (address); - /// @dev Transfers contract ownership to an account. - function transferOwnership(address _newOwner) external; /// @dev Returns true if the contract is paused, and false otherwise. function paused() external view returns (bool); /// @dev Pauses contract. @@ -35,6 +30,8 @@ interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { function unpause() external; /// @dev Returns the confidential frozen balance of an account. function confidentialFrozen(address account) external view returns (euint64); + /// @dev Returns the available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. + function confidentialAvailable(address account) external returns (euint64); /// @dev Sets confidential amount of token for an account as frozen with proof. function setConfidentialFrozen( address account, @@ -67,3 +64,9 @@ interface IERCXXXXCRwa is IConfidentialFungibleToken, IERC165 { /// @dev Forces transfer of confidential amount of tokens from account to account by skipping compliance checks. function forceTransfer(address from, address to, euint64 encryptedAmount) external returns (euint64); } + +/// @dev Interface for confidential RWA compliance. +interface IERCXXXXCRWACompliance { + /// @dev Checks if a transfer follows token compliance. + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); +} diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol new file mode 100644 index 00000000..47b1819f --- /dev/null +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; + +abstract contract ERC7984Freezable { + function confidentialFrozen(address account) public view virtual returns (euint64); + function confidentialAvailable(address account) public virtual returns (euint64); + function setConfidentialFrozen( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public virtual; + function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual; + function _checkFreezer() internal virtual; +} diff --git a/contracts/token/extensions/ERC7984Rwa.sol b/contracts/token/extensions/ERC7984Rwa.sol index 2df8eb8b..11c4b314 100644 --- a/contracts/token/extensions/ERC7984Rwa.sol +++ b/contracts/token/extensions/ERC7984Rwa.sol @@ -3,49 +3,93 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {IERCXXXXCRwa} from "./../../interfaces/draft-IERCXXXXCRwa.sol"; import {ConfidentialFungibleToken} from "./../ConfidentialFungibleToken.sol"; +import {ERC7984Freezable} from "./ERC7984Freezable.sol"; /** * @dev Extension of {ConfidentialFungibleToken} supporting confidential Real World Assets. */ -abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Multicall, ERC165 { +abstract contract ERC7984Rwa is + ConfidentialFungibleToken, + Pausable, + ERC7984Freezable, + Multicall, + ERC165, + AccessControl +{ + bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); + /// @dev The caller account is not authorized to perform the operation. error UnauthorizedSender(address account); /// @dev The transfer does not follow token compliance. error UncompliantTransfer(address from, address to, euint64 encryptedAmount); - constructor() {} + constructor( + string memory name, + string memory symbol, + string memory tokenUri + ) ConfidentialFungibleToken(name, symbol, tokenUri) { + _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } - /// @dev Checks the sender is the owner or an authorized agent. - modifier onlyOwnerOrAgent() { - require( - _msgSender() == owner(), - //TODO: Add agent condition - UnauthorizedSender(_msgSender()) - ); + /// @dev Checks if the sender is an admin. + modifier onlyAdmin() { + require(isAdmin(_msgSender()), UnauthorizedSender(_msgSender())); + _; + } + + /// @dev Checks if the sender is an agent. + modifier onlyAgent() { + require(isAgent(_msgSender()), UnauthorizedSender(_msgSender())); + _; + } + + /// @dev Checks if the sender is an admin or an agent. + modifier onlyAdminOrAgent() { + require(isAdmin(_msgSender()) || isAgent(_msgSender()), UnauthorizedSender(_msgSender())); _; } /// @inheritdoc ERC165 - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, AccessControl) returns (bool) { return interfaceId == type(IERCXXXXCRwa).interfaceId || super.supportsInterface(interfaceId); } /// @dev Pauses contract. - function pause() public virtual onlyOwnerOrAgent { + function pause() public virtual onlyAdminOrAgent { _pause(); } /// @dev Unpauses contract. - function unpause() public virtual onlyOwnerOrAgent { + function unpause() public virtual onlyAdminOrAgent { _unpause(); } + /// @dev Returns true if has admin role, false otherwise. + function isAdmin(address account) public virtual returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, account); + } + + /// @dev Returns true if agent, false otherwise. + function isAgent(address account) public virtual returns (bool) { + return hasRole(AGENT_ROLE, account); + } + + /// @dev Adds agent. + function addAgent(address account) public virtual { + _addAgent(account); + } + + /// @dev Removes agent. + function removeAgent(address account) public virtual { + _removeAgent(account); + } + /// @dev Mints confidential amount of tokens to account with proof. function mint( address to, @@ -56,7 +100,7 @@ abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Mu } /// @dev Mints confidential amount of tokens to account. - function mint(address to, euint64 encryptedAmount) public virtual onlyOwnerOrAgent returns (euint64) { + function mint(address to, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { return _mint(to, encryptedAmount); } @@ -70,7 +114,7 @@ abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Mu } /// @dev Burns confidential amount of tokens from account. - function burn(address account, euint64 encryptedAmount) public virtual onlyOwnerOrAgent returns (euint64) { + function burn(address account, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { return _burn(account, encryptedAmount); } @@ -89,9 +133,26 @@ abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Mu address from, address to, euint64 encryptedAmount - ) public virtual onlyOwnerOrAgent returns (euint64) { - //TODO: Add checks - return super._update(from, to, encryptedAmount); + ) public virtual onlyAdminOrAgent returns (euint64 transferred) { + transferred = ConfidentialFungibleToken._update(from, to, encryptedAmount); // bypass frozen & compliance checks + setConfidentialFrozen( + from, + FHE.select( + FHE.gt(transferred, confidentialAvailable((from))), + confidentialBalanceOf(from), + confidentialFrozen(from) + ) + ); + } + + /// @dev Adds an agent. + function _addAgent(address account) internal virtual { + _grantRole(AGENT_ROLE, account); + } + + /// @dev Removes an agent. + function _removeAgent(address account) internal virtual { + _grantRole(AGENT_ROLE, account); } function _update( @@ -99,11 +160,13 @@ abstract contract ERC7984Rwa is ConfidentialFungibleToken, Ownable, Pausable, Mu address to, euint64 encryptedAmount ) internal override whenNotPaused returns (euint64) { - //TODO: Add checks require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + // frozen check perfomed through inheritance return super._update(from, to, encryptedAmount); } + function _checkFreezer() internal override onlyAdminOrAgent {} + /// @dev Checks if a transfer follows token compliance. function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); } From 0029ad7583408d827accb680b55e80a95ef3d4fa Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:12:29 +0200 Subject: [PATCH 004/111] Update spelling --- contracts/token/extensions/ERC7984Rwa.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/extensions/ERC7984Rwa.sol b/contracts/token/extensions/ERC7984Rwa.sol index 11c4b314..22ec226e 100644 --- a/contracts/token/extensions/ERC7984Rwa.sol +++ b/contracts/token/extensions/ERC7984Rwa.sol @@ -161,7 +161,7 @@ abstract contract ERC7984Rwa is euint64 encryptedAmount ) internal override whenNotPaused returns (euint64) { require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); - // frozen check perfomed through inheritance + // frozen check performed through inheritance return super._update(from, to, encryptedAmount); } From b2174ea6ef0bacfe8ae771e4efb14c6d2019abbe Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:33:21 +0200 Subject: [PATCH 005/111] Add pausable & roles tests --- contracts/mocks/token/ERC7984RwaMock.sol | 42 +++++++++++ contracts/token/extensions/ERC7984Rwa.sol | 10 +-- test/token/extensions/ERC7984Rwa.test.ts | 87 +++++++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 contracts/mocks/token/ERC7984RwaMock.sol create mode 100644 test/token/extensions/ERC7984Rwa.test.ts diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol new file mode 100644 index 00000000..c7064125 --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Impl} from "@fhevm/solidity/lib/Impl.sol"; +import {ERC7984Rwa} from "../../token/extensions/ERC7984Rwa.sol"; + +// solhint-disable func-name-mixedcase +contract ERC7984RwaMock is ERC7984Rwa, SepoliaConfig { + mapping(address account => euint64 encryptedAmount) private _frozenBalances; + bool public compliantTransfer; + + constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} + + function $_mint(address to, uint64 amount) public returns (euint64 transferred) { + return _mint(to, FHE.asEuint64(amount)); + } + + function _isCompliantTransfer( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal override returns (bool) { + return compliantTransfer; + } + + // TODO: Remove all below + function confidentialAvailable(address /*account*/) public override returns (euint64) { + return FHE.asEuint64(0); + } + function confidentialFrozen(address account) public view override returns (euint64) { + return _frozenBalances[account]; + } + function setConfidentialFrozen( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public override {} + function setConfidentialFrozen(address account, euint64 encryptedAmount) public override {} +} diff --git a/contracts/token/extensions/ERC7984Rwa.sol b/contracts/token/extensions/ERC7984Rwa.sol index 22ec226e..3f84c291 100644 --- a/contracts/token/extensions/ERC7984Rwa.sol +++ b/contracts/token/extensions/ERC7984Rwa.sol @@ -71,12 +71,12 @@ abstract contract ERC7984Rwa is } /// @dev Returns true if has admin role, false otherwise. - function isAdmin(address account) public virtual returns (bool) { + function isAdmin(address account) public view virtual returns (bool) { return hasRole(DEFAULT_ADMIN_ROLE, account); } /// @dev Returns true if agent, false otherwise. - function isAgent(address account) public virtual returns (bool) { + function isAgent(address account) public view virtual returns (bool) { return hasRole(AGENT_ROLE, account); } @@ -146,13 +146,13 @@ abstract contract ERC7984Rwa is } /// @dev Adds an agent. - function _addAgent(address account) internal virtual { + function _addAgent(address account) internal virtual onlyAdminOrAgent { _grantRole(AGENT_ROLE, account); } /// @dev Removes an agent. - function _removeAgent(address account) internal virtual { - _grantRole(AGENT_ROLE, account); + function _removeAgent(address account) internal virtual onlyAdminOrAgent { + _revokeRole(AGENT_ROLE, account); } function _update( diff --git a/test/token/extensions/ERC7984Rwa.test.ts b/test/token/extensions/ERC7984Rwa.test.ts new file mode 100644 index 00000000..027265da --- /dev/null +++ b/test/token/extensions/ERC7984Rwa.test.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; + +/* eslint-disable no-unexpected-multiline */ +describe('ERC7984Rwa', function () { + async function deployFixture() { + const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); + const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); + token.connect(anyone); + return { token, admin, agent1, agent2, recipient, anyone }; + } + + describe('Pausable', async function () { + it('should pause & unpause', async function () { + const { token, admin, agent1 } = await deployFixture(); + await token + .connect(admin) + .addAgent(agent1) + .then(tx => tx.wait()); + for (const manager of [admin, agent1]) { + expect(await token.paused()).is.false; + await token + .connect(manager) + .pause() + .then(tx => tx.wait()); + expect(await token.paused()).is.true; + await token + .connect(manager) + .unpause() + .then(tx => tx.wait()); + expect(await token.paused()).is.false; + } + }); + + it('should not pause if neither admin nor agent', async function () { + const { token, anyone } = await deployFixture(); + await expect(token.connect(anyone).pause()) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + + it('should not unpause if neither admin nor agent', async function () { + const { token, anyone } = await deployFixture(); + await expect(token.connect(anyone).unpause()) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + }); + + describe('Roles', async function () { + it('should check admin', async function () { + const { token, admin, anyone } = await deployFixture(); + expect(await token.isAdmin(admin)).is.true; + expect(await token.isAdmin(anyone)).is.false; + }); + + it('should check/add/remove agent', async function () { + const { token, admin, agent1 } = await deployFixture(); + expect(await token.isAgent(agent1)).is.false; + await token + .connect(admin) + .addAgent(agent1) + .then(tx => tx.wait()); + expect(await token.isAgent(agent1)).is.true; + await token + .connect(admin) + .removeAgent(agent1) + .then(tx => tx.wait()); + expect(await token.isAgent(agent1)).is.false; + }); + + it('should not add agent if neither admin nor agent', async function () { + const { token, agent1, anyone } = await deployFixture(); + await expect(token.connect(anyone).addAgent(agent1)) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + + it('should not remove agent if neither admin nor agent', async function () { + const { token, agent1, anyone } = await deployFixture(); + await expect(token.connect(anyone).removeAgent(agent1)) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + }); +}); +/* eslint-disable no-unexpected-multiline */ From ce8286c207915dc903371c5273ae1b0d552b2974 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:52:09 +0200 Subject: [PATCH 006/111] Add mint/burn/force/transfer tests --- contracts/mocks/token/ERC7984RwaMock.sol | 34 +- contracts/token/extensions/ERC7984Rwa.sol | 8 +- test/token/extensions/ERC7984Rwa.test.ts | 483 +++++++++++++++++++++- 3 files changed, 497 insertions(+), 28 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index c7064125..fbaa06dc 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -3,17 +3,27 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, euint64, externalEuint64, ebool} from "@fhevm/solidity/lib/FHE.sol"; import {Impl} from "@fhevm/solidity/lib/Impl.sol"; import {ERC7984Rwa} from "../../token/extensions/ERC7984Rwa.sol"; +import {FHESafeMath} from "../../utils/FHESafeMath.sol"; +import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; // solhint-disable func-name-mixedcase -contract ERC7984RwaMock is ERC7984Rwa, SepoliaConfig { +contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { mapping(address account => euint64 encryptedAmount) private _frozenBalances; bool public compliantTransfer; constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} + function $_setCompliantTransfer() public { + compliantTransfer = true; + } + + function $_unsetCompliantTransfer() public { + compliantTransfer = false; + } + function $_mint(address to, uint64 amount) public returns (euint64 transferred) { return _mint(to, FHE.asEuint64(amount)); } @@ -27,8 +37,14 @@ contract ERC7984RwaMock is ERC7984Rwa, SepoliaConfig { } // TODO: Remove all below - function confidentialAvailable(address /*account*/) public override returns (euint64) { - return FHE.asEuint64(0); + function confidentialAvailable(address account) public override returns (euint64) { + (ebool success, euint64 unfrozen) = FHESafeMath.tryDecrease( + confidentialBalanceOf(account), + confidentialFrozen(account) + ); + unfrozen = FHE.select(success, unfrozen, FHE.asEuint64(0)); + FHE.allowThis(unfrozen); + return unfrozen; } function confidentialFrozen(address account) public view override returns (euint64) { return _frozenBalances[account]; @@ -37,6 +53,12 @@ contract ERC7984RwaMock is ERC7984Rwa, SepoliaConfig { address account, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public override {} - function setConfidentialFrozen(address account, euint64 encryptedAmount) public override {} + ) public override { + return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); + } + function setConfidentialFrozen(address account, euint64 encryptedAmount) public override { + FHE.allowThis(_frozenBalances[account] = encryptedAmount); + } + + function _validateHandleAllowance(bytes32 handle) internal view override onlyAdminOrAgent {} } diff --git a/contracts/token/extensions/ERC7984Rwa.sol b/contracts/token/extensions/ERC7984Rwa.sol index 3f84c291..98f54ae7 100644 --- a/contracts/token/extensions/ERC7984Rwa.sol +++ b/contracts/token/extensions/ERC7984Rwa.sol @@ -118,6 +118,7 @@ abstract contract ERC7984Rwa is return _burn(account, encryptedAmount); } + //TODO: Rename all to confidential /// @dev Forces transfer of confidential amount of tokens from account to account with proof by skipping compliance checks. function forceTransfer( address from, @@ -134,14 +135,11 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) public virtual onlyAdminOrAgent returns (euint64 transferred) { + euint64 available = confidentialAvailable(from); transferred = ConfidentialFungibleToken._update(from, to, encryptedAmount); // bypass frozen & compliance checks setConfidentialFrozen( from, - FHE.select( - FHE.gt(transferred, confidentialAvailable((from))), - confidentialBalanceOf(from), - confidentialFrozen(from) - ) + FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) ); } diff --git a/test/token/extensions/ERC7984Rwa.test.ts b/test/token/extensions/ERC7984Rwa.test.ts index 027265da..0f5ff399 100644 --- a/test/token/extensions/ERC7984Rwa.test.ts +++ b/test/token/extensions/ERC7984Rwa.test.ts @@ -1,11 +1,16 @@ +import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; -import { ethers } from 'hardhat'; +import { ethers, fhevm } from 'hardhat'; /* eslint-disable no-unexpected-multiline */ describe('ERC7984Rwa', function () { async function deployFixture() { const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); + await token + .connect(admin) + .addAgent(agent1) + .then(tx => tx.wait()); token.connect(anyone); return { token, admin, agent1, agent2, recipient, anyone }; } @@ -13,10 +18,6 @@ describe('ERC7984Rwa', function () { describe('Pausable', async function () { it('should pause & unpause', async function () { const { token, admin, agent1 } = await deployFixture(); - await token - .connect(admin) - .addAgent(agent1) - .then(tx => tx.wait()); for (const manager of [admin, agent1]) { expect(await token.paused()).is.false; await token @@ -55,18 +56,20 @@ describe('ERC7984Rwa', function () { }); it('should check/add/remove agent', async function () { - const { token, admin, agent1 } = await deployFixture(); - expect(await token.isAgent(agent1)).is.false; - await token - .connect(admin) - .addAgent(agent1) - .then(tx => tx.wait()); - expect(await token.isAgent(agent1)).is.true; - await token - .connect(admin) - .removeAgent(agent1) - .then(tx => tx.wait()); - expect(await token.isAgent(agent1)).is.false; + const { token, admin, agent1, agent2 } = await deployFixture(); + for (const manager of [admin, agent1]) { + expect(await token.isAgent(agent2)).is.false; + await token + .connect(manager) + .addAgent(agent2) + .then(tx => tx.wait()); + expect(await token.isAgent(agent2)).is.true; + await token + .connect(manager) + .removeAgent(agent2) + .then(tx => tx.wait()); + expect(await token.isAgent(agent2)).is.false; + } }); it('should not add agent if neither admin nor agent', async function () { @@ -83,5 +86,451 @@ describe('ERC7984Rwa', function () { .withArgs(anyone); }); }); + + describe('Mintable', async function () { + it('should mint by admin or agent', async function () { + const { admin, agent1, recipient } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) + .then(tx => tx.wait()); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token + .connect(manager) + .getHandleAllowance(balanceHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(100); + } + }); + + it('should not mint if neither admin nor agent', async function () { + const { token, recipient, anyone } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), anyone.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(anyone) + ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + + it('should not mint if transfer not compliant', async function () { + const { token, admin, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(admin) + ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'UncompliantTransfer') + .withArgs(ethers.ZeroAddress, recipient, encryptedInput.handles[0]); + }); + + it('should not mint if paused', async function () { + const { token, admin, recipient } = await deployFixture(); + await token + .connect(admin) + .pause() + .then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(admin) + ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ).to.be.revertedWithCustomError(token, 'EnforcedPause'); + }); + }); + + describe('Burnable', async function () { + it('should burn by admin or agent', async function () { + const { admin, agent1, recipient } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) + .then(tx => tx.wait()); + const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); + await token + .connect(manager) + .getHandleAllowance(balanceBeforeHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), manager), + ).to.eventually.greaterThan(0); + await token + .connect(manager) + ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) + .then(tx => tx.wait()); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token + .connect(manager) + .getHandleAllowance(balanceHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(0); + } + }); + + it('should not burn if neither admin nor agent', async function () { + const { token, recipient, anyone } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), anyone.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(anyone) + ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone); + }); + + it('should not mint if transfer not compliant', async function () { + const { token, admin, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(admin) + ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'UncompliantTransfer') + .withArgs(recipient, ethers.ZeroAddress, encryptedInput.handles[0]); + }); + + it('should not burn if paused', async function () { + const { token, admin, recipient } = await deployFixture(); + await token + .connect(admin) + .pause() + .then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(admin) + ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ).to.be.revertedWithCustomError(token, 'EnforcedPause'); + }); + }); + + describe('Force transfer', async function () { + it('should force transfer by admin or agent', async function () { + const { admin, agent1, recipient, anyone } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ) + .then(tx => tx.wait()); + // set frozen (50 available and about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(50) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ) + .then(tx => tx.wait()); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(25) + .encrypt(); + await token.$_unsetCompliantTransfer().then(tx => tx.wait()); + expect(await token.compliantTransfer()).to.be.false; + await token + .connect(manager) + ['forceTransfer(address,address,bytes32,bytes)']( + recipient, + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ) + .then(tx => tx.wait()); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token + .connect(manager) + .getHandleAllowance(balanceHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token + .connect(manager) + .getHandleAllowance(frozenHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), + ).to.eventually.equal(50); // frozen is left unchanged + } + }); + + it('should force transfer even if frozen', async function () { + const { admin, agent1, recipient, anyone } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ) + .then(tx => tx.wait()); + // set frozen (only 20 available but about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(80) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ) + .then(tx => tx.wait()); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(25) + .encrypt(); + await token.$_unsetCompliantTransfer().then(tx => tx.wait()); + expect(await token.compliantTransfer()).to.be.false; + // should force transfer even if paused + await token + .connect(manager) + .pause() + .then(tx => tx.wait()); + expect(await token.paused()).to.be.true; + await token + .connect(manager) + ['forceTransfer(address,address,bytes32,bytes)']( + recipient, + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ) + .then(tx => tx.wait()); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token + .connect(manager) + .getHandleAllowance(balanceHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token + .connect(manager) + .getHandleAllowance(frozenHandle, manager, true) + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); // frozen got reset to available balance + } + }); + }); + + describe('Transfer', async function () { + it('should transfer', async function () { + const { token, admin: manager, recipient, anyone } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ) + .then(tx => tx.wait()); + // set frozen (50 available and about to transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(50) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ) + .then(tx => tx.wait()); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(25) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + expect(await token.compliantTransfer()).to.be.true; + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + ).to.emit(token, 'ConfidentialTransfer'); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(75); + }); + + it('should not transfer if paused', async function () { + const { token, admin: manager, recipient, anyone } = await deployFixture(); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(25) + .encrypt(); + await token + .connect(manager) + .pause() + .then(tx => tx.wait()); + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + ).to.be.revertedWithCustomError(token, 'EnforcedPause'); + }); + + it('should not transfer if transfer not compliant', async function () { + const { token, recipient, anyone } = await deployFixture(); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(25) + .encrypt(); + expect(await token.compliantTransfer()).to.be.false; + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'UncompliantTransfer') + .withArgs(recipient, anyone, encryptedTransferValueInput.handles[0]); + }); + + it('should not transfer if frozen', async function () { + const { token, admin: manager, recipient, anyone } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token + .connect(manager) + ['mint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ) + .then(tx => tx.wait()); + // set frozen (20 available but about to transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(80) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ) + .then(tx => tx.wait()); + const encryptedTransferValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(25) + .encrypt(); + await token.$_setCompliantTransfer().then(tx => tx.wait()); + expect(await token.compliantTransfer()).to.be.true; + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedTransferValueInput.handles[0], + encryptedTransferValueInput.inputProof, + ), + ).to.emit(token, 'ConfidentialTransfer'); + /* TODO: Enable when freezable ready + // Balance is unchanged + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(100); + */ + }); + }); }); /* eslint-disable no-unexpected-multiline */ From 6cb98a5d9df91ad7e8daecf81f8deef4d066ae14 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:01:58 +0200 Subject: [PATCH 007/111] Remove tmp freezable --- contracts/token/extensions/ERC7984Freezable.sol | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 contracts/token/extensions/ERC7984Freezable.sol diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol deleted file mode 100644 index 47b1819f..00000000 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.27; - -import {euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; - -abstract contract ERC7984Freezable { - function confidentialFrozen(address account) public view virtual returns (euint64); - function confidentialAvailable(address account) public virtual returns (euint64); - function setConfidentialFrozen( - address account, - externalEuint64 encryptedAmount, - bytes calldata inputProof - ) public virtual; - function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual; - function _checkFreezer() internal virtual; -} From d2562aa38430818af9297f20a0e0605c7e5015d3 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:30:20 +0200 Subject: [PATCH 008/111] Name ERC7984Rwa --- .../interfaces/{draft-IERCXXXXCRwa.sol => IERC7984Rwa.sol} | 4 ++-- contracts/token/ERC7984/extensions/ERC7984Rwa.sol | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename contracts/interfaces/{draft-IERCXXXXCRwa.sol => IERC7984Rwa.sol} (97%) diff --git a/contracts/interfaces/draft-IERCXXXXCRwa.sol b/contracts/interfaces/IERC7984Rwa.sol similarity index 97% rename from contracts/interfaces/draft-IERCXXXXCRwa.sol rename to contracts/interfaces/IERC7984Rwa.sol index 274536f6..c0889397 100644 --- a/contracts/interfaces/draft-IERCXXXXCRwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -7,7 +7,7 @@ import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {IERC7984} from "./IERC7984.sol"; /// @dev Interface for confidential RWA contracts. -interface IERCXXXXCRwa is IERC7984, IERC165, IAccessControl { +interface IERC7984Rwa is IERC7984, IERC165, IAccessControl { /// @dev Emitted when the contract is paused. event Paused(address account); /// @dev Emitted when the contract is unpaused. @@ -66,7 +66,7 @@ interface IERCXXXXCRwa is IERC7984, IERC165, IAccessControl { } /// @dev Interface for confidential RWA compliance. -interface IERCXXXXCRWACompliance { +interface IERC7984RwaCompliance { /// @dev Checks if a transfer follows token compliance. function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 9389df86..5cdccca2 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -7,14 +7,14 @@ import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; -import {IERCXXXXCRwa} from "./../../../interfaces/draft-IERCXXXXCRwa.sol"; +import {IERC7984Rwa} from "./../../../interfaces/IERC7984Rwa.sol"; import {ERC7984} from "./../ERC7984.sol"; import {ERC7984Freezable} from "./ERC7984Freezable.sol"; /** * @dev Extension of {ERC7984} supporting confidential Real World Assets. */ -abstract contract ERC7984Rwa is ERC7984, Pausable, ERC7984Freezable, Multicall, ERC165, AccessControl { +abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, ERC165, AccessControl { bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); /// @dev The caller account is not authorized to perform the operation. @@ -46,7 +46,7 @@ abstract contract ERC7984Rwa is ERC7984, Pausable, ERC7984Freezable, Multicall, /// @inheritdoc ERC165 function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, AccessControl) returns (bool) { - return interfaceId == type(IERCXXXXCRwa).interfaceId || super.supportsInterface(interfaceId); + return interfaceId == type(IERC7984Rwa).interfaceId || super.supportsInterface(interfaceId); } /// @dev Pauses contract. From f58c1f3d05ac3b4493265ce8250d3fbf36431c20 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:56:37 +0200 Subject: [PATCH 009/111] Name confidential --- contracts/interfaces/IERC7984Rwa.sol | 24 ++- .../token/ERC7984/extensions/ERC7984Rwa.sol | 71 +++++--- test/token/extensions/ERC7984Rwa.test.ts | 165 ++++++------------ 3 files changed, 119 insertions(+), 141 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index c0889397..81106c8a 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -40,29 +40,37 @@ interface IERC7984Rwa is IERC7984, IERC165, IAccessControl { ) external; /// @dev Sets confidential amount of token for an account as frozen. function setConfidentialFrozen(address account, euint64 encryptedAmount) external; - /// @dev Receives and executes a batch of function calls on this contract. - function multicall(bytes[] calldata data) external returns (bytes[] memory results); /// @dev Mints confidential amount of tokens to account with proof. - function mint(address to, externalEuint64 encryptedAmount, bytes calldata inputProof) external returns (euint64); + function confidentialMint( + address to, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) external returns (euint64); /// @dev Mints confidential amount of tokens to account. - function mint(address to, euint64 encryptedAmount) external; + function confidentialMint(address to, euint64 encryptedAmount) external; /// @dev Burns confidential amount of tokens from account with proof. - function burn( + function confidentialBurn( address account, externalEuint64 encryptedAmount, bytes calldata inputProof ) external returns (euint64); /// @dev Burns confidential amount of tokens from account. - function burn(address account, euint64 encryptedAmount) external returns (euint64); + 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 forceTransfer( + function forceConfidentialTransferFrom( address from, address to, 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 forceTransfer(address from, address to, euint64 encryptedAmount) external returns (euint64); + function forceConfidentialTransferFrom( + address from, + address to, + euint64 encryptedAmount + ) external returns (euint64); + /// @dev Receives and executes a batch of function calls on this contract. + function multicall(bytes[] calldata data) external returns (bytes[] memory results); } /// @dev Interface for confidential RWA compliance. diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 5cdccca2..070d0e9b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -80,68 +80,93 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, } /// @dev Mints confidential amount of tokens to account with proof. - function mint( + function confidentialMint( address to, externalEuint64 encryptedAmount, bytes calldata inputProof ) public virtual returns (euint64) { - return mint(to, FHE.fromExternal(encryptedAmount, inputProof)); + return _confidentialMint(to, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Mints confidential amount of tokens to account. - function mint(address to, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { - return _mint(to, encryptedAmount); + function confidentialMint(address to, euint64 encryptedAmount) public virtual returns (euint64) { + return _confidentialMint(to, encryptedAmount); } /// @dev Burns confidential amount of tokens from account with proof. - function burn( + function confidentialBurn( address account, externalEuint64 encryptedAmount, bytes calldata inputProof ) public virtual returns (euint64) { - return burn(account, FHE.fromExternal(encryptedAmount, inputProof)); + return _confidentialBurn(account, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Burns confidential amount of tokens from account. - function burn(address account, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { - return _burn(account, encryptedAmount); + function confidentialBurn(address account, euint64 encryptedAmount) public virtual returns (euint64) { + return _confidentialBurn(account, encryptedAmount); } - //TODO: Rename all to confidential /// @dev Forces transfer of confidential amount of tokens from account to account with proof by skipping compliance checks. - function forceTransfer( + function forceConfidentialTransferFrom( address from, address to, externalEuint64 encryptedAmount, bytes calldata inputProof ) public virtual returns (euint64) { - return forceTransfer(from, to, FHE.fromExternal(encryptedAmount, inputProof)); + return _forceConfidentialTransferFrom(from, to, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Forces transfer of confidential amount of tokens from account to account by skipping compliance checks. - function forceTransfer( + function forceConfidentialTransferFrom( address from, address to, euint64 encryptedAmount - ) public virtual onlyAdminOrAgent returns (euint64 transferred) { - euint64 available = confidentialAvailable(from); - transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen & compliance checks - setConfidentialFrozen( - from, - FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) - ); + ) public virtual returns (euint64 transferred) { + return _forceConfidentialTransferFrom(from, to, encryptedAmount); } - /// @dev Adds an agent. + /// @dev Internal function which adds an agent. function _addAgent(address account) internal virtual onlyAdminOrAgent { _grantRole(AGENT_ROLE, account); } - /// @dev Removes an agent. + /// @dev Internal function which removes an agent. function _removeAgent(address account) internal virtual onlyAdminOrAgent { _revokeRole(AGENT_ROLE, account); } + /// @dev Internal function which mints confidential amount of tokens to account. + function _confidentialMint( + address to, + euint64 encryptedAmount + ) internal virtual onlyAdminOrAgent returns (euint64) { + return _mint(to, encryptedAmount); + } + + /// @dev Internal function which burns confidential amount of tokens from account. + function _confidentialBurn( + address account, + euint64 encryptedAmount + ) internal virtual onlyAdminOrAgent returns (euint64) { + return _burn(account, encryptedAmount); + } + + /// @dev Internal function which forces transfer of confidential amount of tokens from account to account by skipping compliance checks. + function _forceConfidentialTransferFrom( + address from, + address to, + euint64 encryptedAmount + ) internal virtual onlyAdminOrAgent returns (euint64 transferred) { + euint64 available = confidentialAvailable(from); + transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen & compliance checks + setConfidentialFrozen( + from, + FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) + ); + } + + /// @dev Internal function which updates confidential balances while performing frozen and compliance checks. function _update( address from, address to, @@ -152,6 +177,10 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, return super._update(from, to, encryptedAmount); } + /** + * @dev Internal function which reverts if `msg.sender` is not authorized as a freezer. + * This freezer role is only granted to admin or agent. + */ function _checkFreezer() internal override onlyAdminOrAgent {} /// @dev Checks if a transfer follows token compliance. diff --git a/test/token/extensions/ERC7984Rwa.test.ts b/test/token/extensions/ERC7984Rwa.test.ts index 0f5ff399..40ac094d 100644 --- a/test/token/extensions/ERC7984Rwa.test.ts +++ b/test/token/extensions/ERC7984Rwa.test.ts @@ -7,10 +7,7 @@ describe('ERC7984Rwa', function () { async function deployFixture() { const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); - await token - .connect(admin) - .addAgent(agent1) - .then(tx => tx.wait()); + await token.connect(admin).addAgent(agent1); token.connect(anyone); return { token, admin, agent1, agent2, recipient, anyone }; } @@ -20,15 +17,9 @@ describe('ERC7984Rwa', function () { const { token, admin, agent1 } = await deployFixture(); for (const manager of [admin, agent1]) { expect(await token.paused()).is.false; - await token - .connect(manager) - .pause() - .then(tx => tx.wait()); + await token.connect(manager).pause(); expect(await token.paused()).is.true; - await token - .connect(manager) - .unpause() - .then(tx => tx.wait()); + await token.connect(manager).unpause(); expect(await token.paused()).is.false; } }); @@ -59,15 +50,9 @@ describe('ERC7984Rwa', function () { const { token, admin, agent1, agent2 } = await deployFixture(); for (const manager of [admin, agent1]) { expect(await token.isAgent(agent2)).is.false; - await token - .connect(manager) - .addAgent(agent2) - .then(tx => tx.wait()); + await token.connect(manager).addAgent(agent2); expect(await token.isAgent(agent2)).is.true; - await token - .connect(manager) - .removeAgent(agent2) - .then(tx => tx.wait()); + await token.connect(manager).removeAgent(agent2); expect(await token.isAgent(agent2)).is.false; } }); @@ -96,16 +81,12 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) - .then(tx => tx.wait()); + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); const balanceHandle = await token.confidentialBalanceOf(recipient); - await token - .connect(manager) - .getHandleAllowance(balanceHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), ).to.eventually.equal(100); @@ -118,10 +99,11 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); + await token.$_setCompliantTransfer(); await expect( token .connect(anyone) - ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone); @@ -136,7 +118,7 @@ describe('ERC7984Rwa', function () { await expect( token .connect(admin) - ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') .withArgs(ethers.ZeroAddress, recipient, encryptedInput.handles[0]); @@ -144,10 +126,7 @@ describe('ERC7984Rwa', function () { it('should not mint if paused', async function () { const { token, admin, recipient } = await deployFixture(); - await token - .connect(admin) - .pause() - .then(tx => tx.wait()); + await token.connect(admin).pause(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) @@ -155,7 +134,7 @@ describe('ERC7984Rwa', function () { await expect( token .connect(admin) - ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ).to.be.revertedWithCustomError(token, 'EnforcedPause'); }); }); @@ -169,28 +148,20 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) - .then(tx => tx.wait()); + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); - await token - .connect(manager) - .getHandleAllowance(balanceBeforeHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(balanceBeforeHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), manager), ).to.eventually.greaterThan(0); await token .connect(manager) - ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof) - .then(tx => tx.wait()); + ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); const balanceHandle = await token.confidentialBalanceOf(recipient); - await token - .connect(manager) - .getHandleAllowance(balanceHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), ).to.eventually.equal(0); @@ -203,10 +174,11 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); + await token.$_setCompliantTransfer(); await expect( token .connect(anyone) - ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone); @@ -221,7 +193,7 @@ describe('ERC7984Rwa', function () { await expect( token .connect(admin) - ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') .withArgs(recipient, ethers.ZeroAddress, encryptedInput.handles[0]); @@ -229,10 +201,7 @@ describe('ERC7984Rwa', function () { it('should not burn if paused', async function () { const { token, admin, recipient } = await deployFixture(); - await token - .connect(admin) - .pause() - .then(tx => tx.wait()); + await token.connect(admin).pause(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) @@ -240,7 +209,7 @@ describe('ERC7984Rwa', function () { await expect( token .connect(admin) - ['burn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), + ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ).to.be.revertedWithCustomError(token, 'EnforcedPause'); }); }); @@ -254,15 +223,14 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)']( + ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], encryptedMintValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); // set frozen (50 available and about to force transfer 25) const encryptedFrozenValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) @@ -274,36 +242,28 @@ describe('ERC7984Rwa', function () { recipient, encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(25) .encrypt(); - await token.$_unsetCompliantTransfer().then(tx => tx.wait()); + await token.$_unsetCompliantTransfer(); expect(await token.compliantTransfer()).to.be.false; await token .connect(manager) - ['forceTransfer(address,address,bytes32,bytes)']( + ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( recipient, anyone, encryptedTransferValueInput.handles[0], encryptedTransferValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const balanceHandle = await token.confidentialBalanceOf(recipient); - await token - .connect(manager) - .getHandleAllowance(balanceHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), ).to.eventually.equal(75); const frozenHandle = await token.confidentialFrozen(recipient); - await token - .connect(manager) - .getHandleAllowance(frozenHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), ).to.eventually.equal(50); // frozen is left unchanged @@ -318,15 +278,14 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)']( + ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], encryptedMintValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); // set frozen (only 20 available but about to force transfer 25) const encryptedFrozenValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) @@ -338,42 +297,31 @@ describe('ERC7984Rwa', function () { recipient, encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(25) .encrypt(); - await token.$_unsetCompliantTransfer().then(tx => tx.wait()); + await token.$_unsetCompliantTransfer(); expect(await token.compliantTransfer()).to.be.false; // should force transfer even if paused - await token - .connect(manager) - .pause() - .then(tx => tx.wait()); + await token.connect(manager).pause(); expect(await token.paused()).to.be.true; await token .connect(manager) - ['forceTransfer(address,address,bytes32,bytes)']( + ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( recipient, anyone, encryptedTransferValueInput.handles[0], encryptedTransferValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const balanceHandle = await token.confidentialBalanceOf(recipient); - await token - .connect(manager) - .getHandleAllowance(balanceHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), ).to.eventually.equal(75); const frozenHandle = await token.confidentialFrozen(recipient); - await token - .connect(manager) - .getHandleAllowance(frozenHandle, manager, true) - .then(tx => tx.wait()); + await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), ).to.eventually.equal(75); // frozen got reset to available balance @@ -388,15 +336,14 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)']( + ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], encryptedMintValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); // set frozen (50 available and about to transfer 25) const encryptedFrozenValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) @@ -408,13 +355,12 @@ describe('ERC7984Rwa', function () { recipient, encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); expect(await token.compliantTransfer()).to.be.true; await expect( token @@ -441,10 +387,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token - .connect(manager) - .pause() - .then(tx => tx.wait()); + await token.connect(manager).pause(); await expect( token .connect(recipient) @@ -482,15 +425,14 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); await token .connect(manager) - ['mint(address,bytes32,bytes)']( + ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], encryptedMintValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); // set frozen (20 available but about to transfer 25) const encryptedFrozenValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) @@ -502,13 +444,12 @@ describe('ERC7984Rwa', function () { recipient, encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, - ) - .then(tx => tx.wait()); + ); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.$_setCompliantTransfer().then(tx => tx.wait()); + await token.$_setCompliantTransfer(); expect(await token.compliantTransfer()).to.be.true; await expect( token From 76b21ae0624dc97b63ce7e4b07ec9c9c07106de2 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:15:42 +0200 Subject: [PATCH 010/111] Move RWA test --- test/token/{ => ERC7984}/extensions/ERC7984Rwa.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/token/{ => ERC7984}/extensions/ERC7984Rwa.test.ts (100%) diff --git a/test/token/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts similarity index 100% rename from test/token/extensions/ERC7984Rwa.test.ts rename to test/token/ERC7984/extensions/ERC7984Rwa.test.ts From 127aff5da6b78dab7543e1f8f4b730c9d07dfa04 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:52:41 +0200 Subject: [PATCH 011/111] Test with & without proof --- contracts/mocks/token/ERC7984RwaMock.sol | 5 + .../ERC7984/extensions/ERC7984Rwa.test.ts | 252 +++++++++++------- 2 files changed, 157 insertions(+), 100 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index d55d8397..cb093b4a 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -16,6 +16,11 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} + function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { + FHE.allowThis(encryptedAmount = FHE.asEuint64(amount)); + FHE.allow(encryptedAmount, msg.sender); + } + function $_setCompliantTransfer() public { compliantTransfer = true; } diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 40ac094d..e84151ae 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -1,5 +1,6 @@ import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; +import { AddressLike, BytesLike } from 'ethers'; import { ethers, fhevm } from 'hardhat'; /* eslint-disable no-unexpected-multiline */ @@ -73,25 +74,39 @@ describe('ERC7984Rwa', function () { }); describe('Mintable', async function () { - it('should mint by admin or agent', async function () { - const { admin, agent1, recipient } = await deployFixture(); - for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(100); - } - }); + for (const withProof of [true, false]) { + it(`should mint by admin or agent ${withProof ? 'with proof' : ''}`, async function () { + const { admin, agent1, recipient } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + await token.$_setCompliantTransfer(); + const amount = 100; + let params = [recipient.address] as unknown as [ + account: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(manager).createEncryptedAmount(amount); + params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); + } + await token + .connect(manager) + [withProof ? 'confidentialMint(address,bytes32,bytes)' : 'confidentialMint(address,bytes32)'](...params); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(100); + } + }); + } it('should not mint if neither admin nor agent', async function () { const { token, recipient, anyone } = await deployFixture(); @@ -140,33 +155,55 @@ describe('ERC7984Rwa', function () { }); describe('Burnable', async function () { - it('should burn by admin or agent', async function () { - const { admin, agent1, recipient } = await deployFixture(); - for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); - const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceBeforeHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), manager), - ).to.eventually.greaterThan(0); - await token - .connect(manager) - ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(0); - } - }); + for (const withProof of [true, false]) { + it(`should burn by admin or agent ${withProof ? 'with proof' : ''}`, async function () { + const { admin, agent1, recipient } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(manager) + ['confidentialMint(address,bytes32,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); + await token.connect(manager).getHandleAllowance(balanceBeforeHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), manager), + ).to.eventually.greaterThan(0); + const amount = 100; + let params = [recipient.address] as unknown as [ + account: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(manager).createEncryptedAmount(amount); + params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); + } + await token + .connect(manager) + [withProof ? 'confidentialBurn(address,bytes32,bytes)' : 'confidentialBurn(address,bytes32)'](...params); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(0); + } + }); + } it('should not burn if neither admin nor agent', async function () { const { token, recipient, anyone } = await deployFixture(); @@ -215,60 +252,75 @@ describe('ERC7984Rwa', function () { }); describe('Force transfer', async function () { - it('should force transfer by admin or agent', async function () { - const { admin, agent1, recipient, anyone } = await deployFixture(); - for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); - const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)']( - recipient, - encryptedMintValueInput.handles[0], - encryptedMintValueInput.inputProof, - ); - // set frozen (50 available and about to force transfer 25) - const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(50) - .encrypt(); - await token - .connect(manager) - ['setConfidentialFrozen(address,bytes32,bytes)']( - recipient, - encryptedFrozenValueInput.handles[0], - encryptedFrozenValueInput.inputProof, - ); - const encryptedTransferValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(25) - .encrypt(); - await token.$_unsetCompliantTransfer(); - expect(await token.compliantTransfer()).to.be.false; - await token - .connect(manager) - ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( - recipient, - anyone, - encryptedTransferValueInput.handles[0], - encryptedTransferValueInput.inputProof, - ); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); - const frozenHandle = await token.confidentialFrozen(recipient); - await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), - ).to.eventually.equal(50); // frozen is left unchanged - } - }); + for (const withProof of [true, false]) { + it(`should force transfer by admin or agent + ${withProof ? 'with proof' : ''}`, async function () { + const { admin, agent1, recipient, anyone } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(manager) + ['confidentialMint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ); + // set frozen (50 available and about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(50) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ); + await token.$_unsetCompliantTransfer(); + expect(await token.compliantTransfer()).to.be.false; + const amount = 25; + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(manager).createEncryptedAmount(amount); + params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); + } + await token + .connect(manager) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), + ).to.eventually.equal(50); // frozen is left unchanged + } + }); + } it('should force transfer even if frozen', async function () { const { admin, agent1, recipient, anyone } = await deployFixture(); From 84af6873252c7a8d9e1f555d8e3974317b1fc5d4 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:03:13 +0200 Subject: [PATCH 012/111] Rwa mock uses freezable --- contracts/mocks/token/ERC7984RwaMock.sol | 24 ---- .../token/ERC7984/extensions/ERC7984Rwa.sol | 2 +- .../ERC7984/extensions/ERC7984Rwa.test.ts | 133 ++++++++++-------- 3 files changed, 74 insertions(+), 85 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index cb093b4a..97533af5 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -41,29 +41,5 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return compliantTransfer; } - // TODO: Remove all below - function confidentialAvailable(address account) public override returns (euint64) { - (ebool success, euint64 unfrozen) = FHESafeMath.tryDecrease( - confidentialBalanceOf(account), - confidentialFrozen(account) - ); - unfrozen = FHE.select(success, unfrozen, FHE.asEuint64(0)); - FHE.allowThis(unfrozen); - return unfrozen; - } - function confidentialFrozen(address account) public view override returns (euint64) { - return _frozenBalances[account]; - } - function setConfidentialFrozen( - address account, - externalEuint64 encryptedAmount, - bytes calldata inputProof - ) public override { - return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); - } - function setConfidentialFrozen(address account, euint64 encryptedAmount) public override { - FHE.allowThis(_frozenBalances[account] = encryptedAmount); - } - function _validateHandleAllowance(bytes32 handle) internal view override onlyAdminOrAgent {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 070d0e9b..5ea2e855 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -160,7 +160,7 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, ) internal virtual onlyAdminOrAgent returns (euint64 transferred) { euint64 available = confidentialAvailable(from); transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen & compliance checks - setConfidentialFrozen( + _setConfidentialFrozen( from, FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) ); diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index e84151ae..24886bf5 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -253,8 +253,7 @@ describe('ERC7984Rwa', function () { describe('Force transfer', async function () { for (const withProof of [true, false]) { - it(`should force transfer by admin or agent - ${withProof ? 'with proof' : ''}`, async function () { + it(`should force transfer by admin or agent ${withProof ? 'with proof' : ''}`, async function () { const { admin, agent1, recipient, anyone } = await deployFixture(); for (const manager of [admin, agent1]) { const { token } = await deployFixture(); @@ -321,64 +320,78 @@ describe('ERC7984Rwa', function () { } }); } - - it('should force transfer even if frozen', async function () { - const { admin, agent1, recipient, anyone } = await deployFixture(); - for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); - const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)']( - recipient, - encryptedMintValueInput.handles[0], - encryptedMintValueInput.inputProof, - ); - // set frozen (only 20 available but about to force transfer 25) - const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(80) - .encrypt(); - await token - .connect(manager) - ['setConfidentialFrozen(address,bytes32,bytes)']( - recipient, - encryptedFrozenValueInput.handles[0], - encryptedFrozenValueInput.inputProof, - ); - const encryptedTransferValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(25) - .encrypt(); - await token.$_unsetCompliantTransfer(); - expect(await token.compliantTransfer()).to.be.false; - // should force transfer even if paused - await token.connect(manager).pause(); - expect(await token.paused()).to.be.true; - await token - .connect(manager) - ['forceConfidentialTransferFrom(address,address,bytes32,bytes)']( - recipient, - anyone, - encryptedTransferValueInput.handles[0], - encryptedTransferValueInput.inputProof, - ); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); - const frozenHandle = await token.confidentialFrozen(recipient); - await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); // frozen got reset to available balance - } - }); + for (const withProof of [true, false]) { + it(`should force transfer even if frozen ${withProof ? 'with proof' : ''}`, async function () { + const { admin, agent1, recipient, anyone } = await deployFixture(); + for (const manager of [admin, agent1]) { + const { token } = await deployFixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(manager) + ['confidentialMint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ); + // set frozen (only 20 available but about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(80) + .encrypt(); + await token + .connect(manager) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, + ); + // should force transfer even if not compliant + await token.$_unsetCompliantTransfer(); + expect(await token.compliantTransfer()).to.be.false; + // should force transfer even if paused + await token.connect(manager).pause(); + expect(await token.paused()).to.be.true; + const amount = 25; + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), manager.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(manager).createEncryptedAmount(amount); + params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); + } + await token + .connect(manager) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), + ).to.eventually.equal(75); // frozen got reset to available balance + } + }); + } }); describe('Transfer', async function () { From b0d5ffa5947ffa0623c413afbc3db5c12f72aff0 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:44:34 +0200 Subject: [PATCH 013/111] Check transferred amounts in tests --- test/helpers/event.ts | 9 ++ .../ERC7984/extensions/ERC7984Rwa.test.ts | 104 +++++++++++++----- 2 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 test/helpers/event.ts diff --git a/test/helpers/event.ts b/test/helpers/event.ts new file mode 100644 index 00000000..99e13ed4 --- /dev/null +++ b/test/helpers/event.ts @@ -0,0 +1,9 @@ +import { EventLog } from 'ethers'; +import { ContractTransactionResponse } from 'ethers'; +import { ethers } from 'ethers'; + +export async function callAndGetResult(txPromise: Promise, eventName: string) { + const receipt = await txPromise.then(tx => tx.wait()); + const logs = receipt?.logs.filter(log => log.address == receipt.to && log.topics[0] == ethers.id(eventName)); + return (logs![0] as EventLog).args; +} diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 24886bf5..7feb30bc 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -1,8 +1,12 @@ +import { callAndGetResult } from '../../../helpers/event'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { AddressLike, BytesLike } from 'ethers'; import { ethers, fhevm } from 'hardhat'; +const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; +const frozenEventSignature = 'TokensFrozen(address,bytes32)'; + /* eslint-disable no-unexpected-multiline */ describe('ERC7984Rwa', function () { async function deployFixture() { @@ -96,14 +100,20 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token - .connect(manager) - [withProof ? 'confidentialMint(address,bytes32,bytes)' : 'confidentialMint(address,bytes32)'](...params); + const [, , transferredHandle] = await callAndGetResult( + token + .connect(manager) + [withProof ? 'confidentialMint(address,bytes32,bytes)' : 'confidentialMint(address,bytes32)'](...params), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(amount); const balanceHandle = await token.confidentialBalanceOf(recipient); await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(100); + ).to.eventually.equal(amount); } }); } @@ -193,9 +203,15 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token - .connect(manager) - [withProof ? 'confidentialBurn(address,bytes32,bytes)' : 'confidentialBurn(address,bytes32)'](...params); + const [, , transferredHandle] = await callAndGetResult( + token + .connect(manager) + [withProof ? 'confidentialBurn(address,bytes32,bytes)' : 'confidentialBurn(address,bytes32)'](...params), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(amount); const balanceHandle = await token.confidentialBalanceOf(recipient); await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( @@ -300,13 +316,21 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token - .connect(manager) - [ - withProof - ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' - : 'forceConfidentialTransferFrom(address,address,bytes32)' - ](...params); + const [from, to, transferredHandle] = await callAndGetResult( + token + .connect(manager) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + transferEventSignature, + ); + expect(from).equal(recipient.address); + expect(to).equal(anyone.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), anyone), + ).to.eventually.equal(amount); const balanceHandle = await token.confidentialBalanceOf(recipient); await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( @@ -372,13 +396,20 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token - .connect(manager) - [ - withProof - ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' - : 'forceConfidentialTransferFrom(address,address,bytes32)' - ](...params); + const [account, frozenAmountHandle] = await callAndGetResult( + token + .connect(manager) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + frozenEventSignature, + ); + expect(account).equal(recipient.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenAmountHandle, await token.getAddress(), recipient), + ).to.eventually.equal(75); const balanceHandle = await token.confidentialBalanceOf(recipient); await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); await expect( @@ -421,13 +452,14 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, ); + const amount = 25; const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) - .add64(25) + .add64(amount) .encrypt(); await token.$_setCompliantTransfer(); expect(await token.compliantTransfer()).to.be.true; - await expect( + const [from, to, transferredHandle] = await callAndGetResult( token .connect(recipient) ['confidentialTransfer(address,bytes32,bytes)']( @@ -435,7 +467,21 @@ describe('ERC7984Rwa', function () { encryptedTransferValueInput.handles[0], encryptedTransferValueInput.inputProof, ), - ).to.emit(token, 'ConfidentialTransfer'); + transferEventSignature, + ); + expect(from).equal(recipient.address); + expect(to).equal(anyone.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), anyone), + ).to.eventually.equal(amount); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(anyone), + await token.getAddress(), + anyone, + ), + ).to.eventually.equal(amount); await expect( fhevm.userDecryptEuint( FhevmType.euint64, @@ -516,7 +562,7 @@ describe('ERC7984Rwa', function () { .encrypt(); await token.$_setCompliantTransfer(); expect(await token.compliantTransfer()).to.be.true; - await expect( + const [, , transferredHandle] = await callAndGetResult( token .connect(recipient) ['confidentialTransfer(address,bytes32,bytes)']( @@ -524,8 +570,11 @@ describe('ERC7984Rwa', function () { encryptedTransferValueInput.handles[0], encryptedTransferValueInput.inputProof, ), - ).to.emit(token, 'ConfidentialTransfer'); - /* TODO: Enable when freezable ready + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), anyone), + ).to.eventually.equal(0); // Balance is unchanged await expect( fhevm.userDecryptEuint( @@ -535,7 +584,6 @@ describe('ERC7984Rwa', function () { recipient, ), ).to.eventually.equal(100); - */ }); }); }); From 6fb7f97dc4f60443bff7206d3d1623486e5b1879 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 14:50:32 +0200 Subject: [PATCH 014/111] Bypass hardhat fhevm behaviour --- test/utils/HandleAccessManager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/HandleAccessManager.test.ts b/test/utils/HandleAccessManager.test.ts index 086065a3..db896f56 100644 --- a/test/utils/HandleAccessManager.test.ts +++ b/test/utils/HandleAccessManager.test.ts @@ -12,7 +12,7 @@ describe('HandleAccessManager', function () { }); it('should not be allowed to reencrypt unallowed handle', async function () { - const handle = await createHandle(this.mock, 100); + const handle = await createHandle(this.mock, 101); await expect(fhevm.userDecryptEuint(FhevmType.euint64, handle, this.mock.target, this.holder)).to.be.rejectedWith( `User ${this.holder.address} is not authorized to user decrypt handle ${handle}`, From 0cb02084b5663c7219fd2a1274f4ae9db53b0970 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:37:50 +0200 Subject: [PATCH 015/111] Add support interface test --- contracts/interfaces/IERC7984Rwa.sol | 7 ++++-- .../token/ERC7984/extensions/ERC7984Rwa.sol | 10 +++++--- test/helpers/interface.ts | 15 ++++++++++++ .../ERC7984/extensions/ERC7984Rwa.test.ts | 23 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/helpers/interface.ts diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 81106c8a..c5ea9e48 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -6,8 +6,8 @@ import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol" import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {IERC7984} from "./IERC7984.sol"; -/// @dev Interface for confidential RWA contracts. -interface IERC7984Rwa is IERC7984, IERC165, IAccessControl { +/// @dev Base interface for confidential RWA contracts. +interface IERC7984RwaBase { /// @dev Emitted when the contract is paused. event Paused(address account); /// @dev Emitted when the contract is unpaused. @@ -73,6 +73,9 @@ interface IERC7984Rwa is IERC7984, IERC165, IAccessControl { function multicall(bytes[] calldata data) external returns (bytes[] memory results); } +/// @dev Full interface for confidential RWA contracts. +interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} + /// @dev Interface for confidential RWA compliance. interface IERC7984RwaCompliance { /// @dev Checks if a transfer follows token compliance. diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 5ea2e855..b8122e49 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -4,10 +4,11 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; -import {IERC7984Rwa} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984} from "./../../../interfaces/IERC7984.sol"; +import {IERC7984RwaBase} from "./../../../interfaces/IERC7984Rwa.sol"; import {ERC7984} from "./../ERC7984.sol"; import {ERC7984Freezable} from "./ERC7984Freezable.sol"; @@ -46,7 +47,10 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, /// @inheritdoc ERC165 function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, AccessControl) returns (bool) { - return interfaceId == type(IERC7984Rwa).interfaceId || super.supportsInterface(interfaceId); + return + interfaceId == type(IERC7984RwaBase).interfaceId || + interfaceId == type(IERC7984).interfaceId || + super.supportsInterface(interfaceId); } /// @dev Pauses contract. diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts new file mode 100644 index 00000000..4b4bf675 --- /dev/null +++ b/test/helpers/interface.ts @@ -0,0 +1,15 @@ +import { Interface } from 'ethers'; +import { ethers } from 'hardhat'; + +export function getFunctions(interfaceFactory: any) { + return (interfaceFactory.createInterface() as Interface).fragments + .filter(f => f.type == 'function') + .map(f => f.format()); +} + +export function getInterfaceId(signatures: string[]) { + return ethers.toBeHex( + signatures.reduce((acc, signature) => acc ^ ethers.toBigInt(ethers.FunctionFragment.from(signature).selector), 0n), + 4, + ); +} diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 7feb30bc..6d39a988 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -1,4 +1,11 @@ +import { + IAccessControl__factory, + IERC165__factory, + IERC7984__factory, + IERC7984RwaBase__factory, +} from '../../../../types'; import { callAndGetResult } from '../../../helpers/event'; +import { getFunctions, getInterfaceId } from '../../../helpers/interface'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { AddressLike, BytesLike } from 'ethers'; @@ -17,6 +24,22 @@ describe('ERC7984Rwa', function () { return { token, admin, agent1, agent2, recipient, anyone }; } + describe('ERC165', async function () { + it('should support interfaces', async function () { + const { token } = await deployFixture(); + const interfaceFactories = [ + IERC7984RwaBase__factory, + IERC7984__factory, + IERC165__factory, + IAccessControl__factory, + ]; + for (const interfaceFactory of interfaceFactories) { + const functions = getFunctions(interfaceFactory); + expect(await token.supportsInterface(getInterfaceId(functions))).is.true; + } + }); + }); + describe('Pausable', async function () { it('should pause & unpause', async function () { const { token, admin, agent1 } = await deployFixture(); From 90ebfa09654acfee38772da306c5876d27f17470 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:49:58 +0200 Subject: [PATCH 016/111] Add should not force transfer if anyone --- .../ERC7984/extensions/ERC7984Rwa.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 6d39a988..fc5aff6e 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -367,6 +367,7 @@ describe('ERC7984Rwa', function () { } }); } + for (const withProof of [true, false]) { it(`should force transfer even if frozen ${withProof ? 'with proof' : ''}`, async function () { const { admin, agent1, recipient, anyone } = await deployFixture(); @@ -446,6 +447,40 @@ describe('ERC7984Rwa', function () { } }); } + + for (const withProof of [true, false]) { + it(`should not force transfer if neither admin nor agent ${withProof ? 'with proof' : ''}`, async function () { + const { token, recipient, anyone } = await deployFixture(); + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + const amount = 100; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), anyone.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(anyone).createEncryptedAmount(amount); + params.push(await token.connect(anyone).createEncryptedAmount.staticCall(amount)); + } + await expect( + token + .connect(anyone) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + ) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone.address); + }); + } }); describe('Transfer', async function () { From c5d07fe9fa22860338d2c07ef045caf3eb81d5d1 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:03:03 +0200 Subject: [PATCH 017/111] Move some modifiers to mock --- contracts/mocks/token/ERC7984RwaMock.sol | 12 +++++++++++ .../token/ERC7984/extensions/ERC7984Rwa.sol | 20 ++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index 97533af5..0ae0200e 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -14,6 +14,18 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { mapping(address account => euint64 encryptedAmount) private _frozenBalances; bool public compliantTransfer; + // TODO: Move modifiers to `ERC7984Rwa` or remove from mock if useless + /// @dev Checks if the sender is an admin. + modifier onlyAdmin() { + require(isAdmin(_msgSender()), UnauthorizedSender(_msgSender())); + _; + } + /// @dev Checks if the sender is an agent. + modifier onlyAgent() { + require(isAgent(_msgSender()), UnauthorizedSender(_msgSender())); + _; + } + constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index b8122e49..7f420904 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -23,28 +23,16 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, /// @dev The transfer does not follow token compliance. error UncompliantTransfer(address from, address to, euint64 encryptedAmount); - constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984(name, symbol, tokenUri) { - _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); - } - - /// @dev Checks if the sender is an admin. - modifier onlyAdmin() { - require(isAdmin(_msgSender()), UnauthorizedSender(_msgSender())); - _; - } - - /// @dev Checks if the sender is an agent. - modifier onlyAgent() { - require(isAgent(_msgSender()), UnauthorizedSender(_msgSender())); - _; - } - /// @dev Checks if the sender is an admin or an agent. modifier onlyAdminOrAgent() { require(isAdmin(_msgSender()) || isAgent(_msgSender()), UnauthorizedSender(_msgSender())); _; } + constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984(name, symbol, tokenUri) { + _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); + } + /// @inheritdoc ERC165 function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, AccessControl) returns (bool) { return From e484066f5d7704bcf2b45da6463d708429168fd3 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:32:23 +0200 Subject: [PATCH 018/111] Update doc --- .changeset/new-crews-boil.md | 2 +- contracts/token/ERC7984/extensions/ERC7984Rwa.sol | 3 ++- contracts/token/README.adoc | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/new-crews-boil.md b/.changeset/new-crews-boil.md index e8c18888..f4702693 100644 --- a/.changeset/new-crews-boil.md +++ b/.changeset/new-crews-boil.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -Add `ERC7984Rwa` extension. +`ERC7984Rwa`: An extension of `ERC7984`, that supports confidential Real World Assets (RWAs). diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 7f420904..7067f5cf 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -13,7 +13,8 @@ import {ERC7984} from "./../ERC7984.sol"; import {ERC7984Freezable} from "./ERC7984Freezable.sol"; /** - * @dev Extension of {ERC7984} supporting confidential Real World Assets. + * @dev Extension of {ERC7984} that supports confidential Real World Assets (RWAs). + * This interface provides compliance checks, transfer controls and enforcement actions. */ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, ERC165, AccessControl { bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); diff --git a/contracts/token/README.adoc b/contracts/token/README.adoc index 25777d53..cbe08e31 100644 --- a/contracts/token/README.adoc +++ b/contracts/token/README.adoc @@ -9,6 +9,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a - {ERC7984ERC20Wrapper}: Extension of {ERC7984} which wraps an `ERC20` into a confidential token. The wrapper allows for free conversion in both directions at a fixed rate. - {ERC7984Freezable}: An extension for {ERC7984}, which allows accounts granted the "freezer" role to freeze and unfreeze tokens. - {ERC7984ObserverAccess}: An extension for {ERC7984}, which allows each account to add an observer who is given access to their transfer and balance amounts. +- {ERC7984Rwa}: Extension of {ERC7984} that supports confidential Real World Assets (RWAs) by providing compliance checks, transfer controls and enforcement actions. - {ERC7984Utils}: A library that provides the on-transfer callback check used by {ERC7984}. == Core @@ -18,6 +19,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a {{ERC7984ERC20Wrapper}} {{ERC7984Freezable}} {{ERC7984ObserverAccess}} +{{ERC7984Rwa}} == Utilities {{ERC7984Utils}} \ No newline at end of file From 64c6c9ba16cb932903b76b5bceb4828bc22736a8 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:46:50 +0200 Subject: [PATCH 019/111] Swap items in doc --- contracts/token/README.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/README.adoc b/contracts/token/README.adoc index bb4b8ec8..724d9acc 100644 --- a/contracts/token/README.adoc +++ b/contracts/token/README.adoc @@ -20,8 +20,8 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a {{ERC7984ERC20Wrapper}} {{ERC7984Freezable}} {{ERC7984ObserverAccess}} -{{ERC7984Rwa}} {{ERC7984Restricted}} +{{ERC7984Rwa}} == Utilities {{ERC7984Utils}} \ No newline at end of file From 1ad9ccde6219f728ebf67a79bf832df5cd271760 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:34:34 +0200 Subject: [PATCH 020/111] Add suggestions --- contracts/interfaces/IERC7984Rwa.sol | 4 +-- .../ERC7984/extensions/ERC7984Rwa.test.ts | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index c5ea9e48..63498c79 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; /// @dev Base interface for confidential RWA contracts. @@ -47,7 +47,7 @@ interface IERC7984RwaBase { bytes calldata inputProof ) external returns (euint64); /// @dev Mints confidential amount of tokens to account. - function confidentialMint(address to, euint64 encryptedAmount) external; + function confidentialMint(address to, euint64 encryptedAmount) external returns (euint64); /// @dev Burns confidential amount of tokens from account with proof. function confidentialBurn( address account, diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index fc5aff6e..f3b47342 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -16,16 +16,16 @@ const frozenEventSignature = 'TokensFrozen(address,bytes32)'; /* eslint-disable no-unexpected-multiline */ describe('ERC7984Rwa', function () { - async function deployFixture() { + const deployFixture = async () => { const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); await token.connect(admin).addAgent(agent1); token.connect(anyone); return { token, admin, agent1, agent2, recipient, anyone }; - } + }; describe('ERC165', async function () { - it('should support interfaces', async function () { + it('should support interface', async function () { const { token } = await deployFixture(); const interfaceFactories = [ IERC7984RwaBase__factory, @@ -38,6 +38,10 @@ describe('ERC7984Rwa', function () { expect(await token.supportsInterface(getInterfaceId(functions))).is.true; } }); + it('should not support interface', async function () { + const { token } = await deployFixture(); + expect(await token.supportsInterface('0xbadbadba')).is.false; + }); }); describe('Pausable', async function () { @@ -56,14 +60,14 @@ describe('ERC7984Rwa', function () { const { token, anyone } = await deployFixture(); await expect(token.connect(anyone).pause()) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); it('should not unpause if neither admin nor agent', async function () { const { token, anyone } = await deployFixture(); await expect(token.connect(anyone).unpause()) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); }); @@ -89,14 +93,14 @@ describe('ERC7984Rwa', function () { const { token, agent1, anyone } = await deployFixture(); await expect(token.connect(anyone).addAgent(agent1)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); it('should not remove agent if neither admin nor agent', async function () { const { token, agent1, anyone } = await deployFixture(); await expect(token.connect(anyone).removeAgent(agent1)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); }); @@ -154,7 +158,7 @@ describe('ERC7984Rwa', function () { ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); it('should not mint if transfer not compliant', async function () { @@ -169,7 +173,7 @@ describe('ERC7984Rwa', function () { ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(ethers.ZeroAddress, recipient, encryptedInput.handles[0]); + .withArgs(ethers.ZeroAddress, recipient.address, encryptedInput.handles[0]); }); it('should not mint if paused', async function () { @@ -257,7 +261,7 @@ describe('ERC7984Rwa', function () { ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone); + .withArgs(anyone.address); }); it('should not mint if transfer not compliant', async function () { @@ -272,7 +276,7 @@ describe('ERC7984Rwa', function () { ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(recipient, ethers.ZeroAddress, encryptedInput.handles[0]); + .withArgs(recipient.address, ethers.ZeroAddress, encryptedInput.handles[0]); }); it('should not burn if paused', async function () { @@ -585,7 +589,7 @@ describe('ERC7984Rwa', function () { ), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(recipient, anyone, encryptedTransferValueInput.handles[0]); + .withArgs(recipient.address, anyone.address, encryptedTransferValueInput.handles[0]); }); it('should not transfer if frozen', async function () { From 628d1434730726cf065e4f3777011a022a63c087 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:01:39 +0200 Subject: [PATCH 021/111] Remove lint annotation --- test/token/ERC7984/extensions/ERC7984Rwa.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index f3b47342..917514d6 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -14,7 +14,6 @@ import { ethers, fhevm } from 'hardhat'; const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const frozenEventSignature = 'TokensFrozen(address,bytes32)'; -/* eslint-disable no-unexpected-multiline */ describe('ERC7984Rwa', function () { const deployFixture = async () => { const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); @@ -649,4 +648,3 @@ describe('ERC7984Rwa', function () { }); }); }); -/* eslint-disable no-unexpected-multiline */ From 0b1d87cbcad5123584a342540158da7da3cede26 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:11:27 +0200 Subject: [PATCH 022/111] Update test name Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- test/token/ERC7984/extensions/ERC7984Rwa.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 917514d6..a7115ba2 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -263,7 +263,7 @@ describe('ERC7984Rwa', function () { .withArgs(anyone.address); }); - it('should not mint if transfer not compliant', async function () { + it('should not burn if transfer not compliant', async function () { const { token, admin, recipient } = await deployFixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) From b6b68270ce37335647b340e7e1d8b72ea155e397 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:03:58 +0200 Subject: [PATCH 023/111] Add restriction to ERC7984Rwa --- contracts/interfaces/IERC7984Restricted.sol | 23 +++++++++ contracts/interfaces/IERC7984Rwa.sol | 9 ++++ .../ERC7984/extensions/ERC7984Restricted.sol | 15 +----- .../token/ERC7984/extensions/ERC7984Rwa.sol | 29 ++++++++++-- .../ERC7984/extensions/ERC7984Rwa.test.ts | 47 +++++++++++++++++++ 5 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 contracts/interfaces/IERC7984Restricted.sol diff --git a/contracts/interfaces/IERC7984Restricted.sol b/contracts/interfaces/IERC7984Restricted.sol new file mode 100644 index 00000000..c129460f --- /dev/null +++ b/contracts/interfaces/IERC7984Restricted.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +/// @dev Interface for contracts that implements user account transfer restrictions. +interface IERC7984Restricted { + enum Restriction { + DEFAULT, // User has no explicit restriction + BLOCKED, // User is explicitly blocked + ALLOWED // User is explicitly allowed + } + + /// @dev Emitted when a user account's restriction is updated. + event UserRestrictionUpdated(address indexed account, Restriction restriction); + + /// @dev The operation failed because the user account is restricted. + error UserRestricted(address account); + + /// @dev Returns the restriction of a user account. + function getRestriction(address account) external view returns (Restriction); + /// @dev Returns whether a user account is allowed to interact with the token. + function isUserAllowed(address account) external view returns (bool); +} diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 63498c79..af72e460 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -5,6 +5,7 @@ import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; +import {IERC7984Restricted} from "./IERC7984Restricted.sol"; /// @dev Base interface for confidential RWA contracts. interface IERC7984RwaBase { @@ -28,6 +29,14 @@ interface IERC7984RwaBase { function pause() external; /// @dev Unpauses contract. function unpause() external; + /// @dev Returns the restriction of a user account. + function getRestriction(address account) external view returns (IERC7984Restricted.Restriction); + /// @dev Blocks an account. + function block(address account) external; + /// @dev Unblocks an account. + function unblock(address account) external; + /// @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. function confidentialFrozen(address account) external view returns (euint64); /// @dev Returns the available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. diff --git a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol index de66f2e7..1f3dbae9 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.27; +import {IERC7984Restricted} from "../../../interfaces/IERC7984Restricted.sol"; import {ERC7984, euint64} from "../ERC7984.sol"; /** @@ -13,21 +14,9 @@ import {ERC7984, euint64} from "../ERC7984.sol"; * a blocklist. Developers can override {isUserAllowed} to check that `restriction == ALLOWED` * to implement an allowlist. */ -abstract contract ERC7984Restricted is ERC7984 { - enum Restriction { - DEFAULT, // User has no explicit restriction - BLOCKED, // User is explicitly blocked - ALLOWED // User is explicitly allowed - } - +abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { mapping(address account => Restriction) private _restrictions; - /// @dev Emitted when a user account's restriction is updated. - event UserRestrictionUpdated(address indexed account, Restriction restriction); - - /// @dev The operation failed because the user account is restricted. - error UserRestricted(address account); - /// @dev Returns the restriction of a user account. function getRestriction(address account) public view virtual returns (Restriction) { return _restrictions[account]; diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 7067f5cf..ffc29341 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -11,12 +11,21 @@ import {IERC7984} from "./../../../interfaces/IERC7984.sol"; import {IERC7984RwaBase} from "./../../../interfaces/IERC7984Rwa.sol"; import {ERC7984} from "./../ERC7984.sol"; import {ERC7984Freezable} from "./ERC7984Freezable.sol"; +import {ERC7984Restricted} from "./ERC7984Restricted.sol"; /** * @dev Extension of {ERC7984} that supports confidential Real World Assets (RWAs). * This interface provides compliance checks, transfer controls and enforcement actions. */ -abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, ERC165, AccessControl { +abstract contract ERC7984Rwa is + ERC7984, + ERC7984Freezable, + ERC7984Restricted, + Pausable, + Multicall, + ERC165, + AccessControl +{ bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); /// @dev The caller account is not authorized to perform the operation. @@ -72,6 +81,16 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, _removeAgent(account); } + /// @dev Blocks an account. + function block(address account) public virtual onlyAdminOrAgent { + _blockUser(account); + } + + /// @dev Unblocks an account. + function unblock(address account) public virtual onlyAdminOrAgent { + _allowUser(account); + } + /// @dev Mints confidential amount of tokens to account with proof. function confidentialMint( address to, @@ -152,21 +171,21 @@ abstract contract ERC7984Rwa is ERC7984, ERC7984Freezable, Pausable, Multicall, euint64 encryptedAmount ) internal virtual onlyAdminOrAgent returns (euint64 transferred) { euint64 available = confidentialAvailable(from); - transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen & compliance checks + transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen, restrictions & compliance checks _setConfidentialFrozen( from, FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) ); } - /// @dev Internal function which updates confidential balances while performing frozen and compliance checks. + /// @dev Internal function which updates confidential balances while performing frozen, restrictions and compliance checks. function _update( address from, address to, euint64 encryptedAmount - ) internal override(ERC7984, ERC7984Freezable) whenNotPaused returns (euint64) { + ) internal override(ERC7984, ERC7984Freezable, ERC7984Restricted) whenNotPaused returns (euint64) { require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); - // frozen check performed through inheritance + // frozen and restrictions checks performed through inheritance return super._update(from, to, encryptedAmount); } diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index a7115ba2..523d8794 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -103,6 +103,28 @@ describe('ERC7984Rwa', function () { }); }); + describe('ERC7984Restricted', async function () { + it('should block & unblock', async function () { + const { token, admin, agent1, recipient } = await deployFixture(); + for (const manager of [admin, agent1]) { + await expect(token.isUserAllowed(recipient)).to.eventually.be.true; + await token.connect(manager).block(recipient); + await expect(token.isUserAllowed(recipient)).to.eventually.be.false; + await token.connect(manager).unblock(recipient); + await expect(token.isUserAllowed(recipient)).to.eventually.be.true; + } + }); + + for (const arg of [true, false]) { + it(`should not ${arg ? 'block' : 'unblock'} if neither admin nor agent`, async function () { + const { token, anyone } = await deployFixture(); + await expect(token.connect(anyone)[arg ? 'block' : 'unblock'](anyone)) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone.address); + }); + } + }); + describe('Mintable', async function () { for (const withProof of [true, false]) { it(`should mint by admin or agent ${withProof ? 'with proof' : ''}`, async function () { @@ -646,5 +668,30 @@ describe('ERC7984Rwa', function () { ), ).to.eventually.equal(100); }); + + for (const arg of [true, false]) { + it(`should not transfer if ${arg ? 'sender' : 'receiver'} blocked `, async function () { + const { token, admin: manager, recipient, anyone } = await deployFixture(); + const account = arg ? recipient : anyone; + await token.$_setCompliantTransfer(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(25) + .encrypt(); + await token.connect(manager).block(account); + + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'UserRestricted') + .withArgs(account); + }); + } }); }); From 3185336e44f27f78acc114863baa9cb9cc034cd8 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:21:23 +0200 Subject: [PATCH 024/111] Move gates --- .../token/ERC7984/extensions/ERC7984Rwa.sol | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index ffc29341..9a3f4c4b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -72,12 +72,12 @@ abstract contract ERC7984Rwa is } /// @dev Adds agent. - function addAgent(address account) public virtual { + function addAgent(address account) public virtual onlyAdminOrAgent { _addAgent(account); } /// @dev Removes agent. - function removeAgent(address account) public virtual { + function removeAgent(address account) public virtual onlyAdminOrAgent { _removeAgent(account); } @@ -96,12 +96,12 @@ abstract contract ERC7984Rwa is address to, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual returns (euint64) { + ) public virtual onlyAdminOrAgent returns (euint64) { return _confidentialMint(to, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Mints confidential amount of tokens to account. - function confidentialMint(address to, euint64 encryptedAmount) public virtual returns (euint64) { + function confidentialMint(address to, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { return _confidentialMint(to, encryptedAmount); } @@ -110,12 +110,15 @@ abstract contract ERC7984Rwa is address account, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual returns (euint64) { + ) public virtual onlyAdminOrAgent returns (euint64) { return _confidentialBurn(account, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Burns confidential amount of tokens from account. - function confidentialBurn(address account, euint64 encryptedAmount) public virtual returns (euint64) { + function confidentialBurn( + address account, + euint64 encryptedAmount + ) public virtual onlyAdminOrAgent returns (euint64) { return _confidentialBurn(account, encryptedAmount); } @@ -125,7 +128,7 @@ abstract contract ERC7984Rwa is address to, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual returns (euint64) { + ) public virtual onlyAdminOrAgent returns (euint64) { return _forceConfidentialTransferFrom(from, to, FHE.fromExternal(encryptedAmount, inputProof)); } @@ -134,33 +137,27 @@ abstract contract ERC7984Rwa is address from, address to, euint64 encryptedAmount - ) public virtual returns (euint64 transferred) { + ) public virtual onlyAdminOrAgent returns (euint64 transferred) { return _forceConfidentialTransferFrom(from, to, encryptedAmount); } /// @dev Internal function which adds an agent. - function _addAgent(address account) internal virtual onlyAdminOrAgent { + function _addAgent(address account) internal virtual { _grantRole(AGENT_ROLE, account); } /// @dev Internal function which removes an agent. - function _removeAgent(address account) internal virtual onlyAdminOrAgent { + function _removeAgent(address account) internal virtual { _revokeRole(AGENT_ROLE, account); } /// @dev Internal function which mints confidential amount of tokens to account. - function _confidentialMint( - address to, - euint64 encryptedAmount - ) internal virtual onlyAdminOrAgent returns (euint64) { + function _confidentialMint(address to, euint64 encryptedAmount) internal virtual returns (euint64) { return _mint(to, encryptedAmount); } /// @dev Internal function which burns confidential amount of tokens from account. - function _confidentialBurn( - address account, - euint64 encryptedAmount - ) internal virtual onlyAdminOrAgent returns (euint64) { + function _confidentialBurn(address account, euint64 encryptedAmount) internal virtual returns (euint64) { return _burn(account, encryptedAmount); } @@ -169,7 +166,7 @@ abstract contract ERC7984Rwa is address from, address to, euint64 encryptedAmount - ) internal virtual onlyAdminOrAgent returns (euint64 transferred) { + ) internal virtual returns (euint64 transferred) { euint64 available = confidentialAvailable(from); transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen, restrictions & compliance checks _setConfidentialFrozen( From b4f8c03ff217d4972b3fb3cf4b8c5fea07bf87f3 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:45:12 +0200 Subject: [PATCH 025/111] Remove ExpectedPause error --- contracts/interfaces/IERC7984Rwa.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index af72e460..8c5e1d7c 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -20,8 +20,6 @@ interface IERC7984RwaBase { error OwnableInvalidOwner(address owner); /// @dev The operation failed because the contract is paused. error EnforcedPause(); - /// @dev The operation failed because the contract is not paused. - error ExpectedPause(); /// @dev Returns true if the contract is paused, and false otherwise. function paused() external view returns (bool); From 28973a2c65796627de7dd0cb92fe33679871bbad Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:04:58 +0200 Subject: [PATCH 026/111] Rename block functions --- contracts/interfaces/IERC7984Rwa.sol | 8 ++++---- contracts/token/ERC7984/extensions/ERC7984Rwa.sol | 8 ++++---- test/token/ERC7984/extensions/ERC7984Rwa.test.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 8c5e1d7c..aad61239 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -29,10 +29,10 @@ interface IERC7984RwaBase { function unpause() external; /// @dev Returns the restriction of a user account. function getRestriction(address account) external view returns (IERC7984Restricted.Restriction); - /// @dev Blocks an account. - function block(address account) external; - /// @dev Unblocks an account. - function unblock(address account) external; + /// @dev Blocks a user account. + function blockUser(address account) external; + /// @dev Unblocks a user account. + function unblockUser(address account) external; /// @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. diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 9a3f4c4b..02d3f2fb 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -81,13 +81,13 @@ abstract contract ERC7984Rwa is _removeAgent(account); } - /// @dev Blocks an account. - function block(address account) public virtual onlyAdminOrAgent { + /// @dev Blocks a user account. + function blockUser(address account) public virtual onlyAdminOrAgent { _blockUser(account); } - /// @dev Unblocks an account. - function unblock(address account) public virtual onlyAdminOrAgent { + /// @dev Unblocks a user account. + function unblockUser(address account) public virtual onlyAdminOrAgent { _allowUser(account); } diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 523d8794..ef50e729 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -108,9 +108,9 @@ describe('ERC7984Rwa', function () { const { token, admin, agent1, recipient } = await deployFixture(); for (const manager of [admin, agent1]) { await expect(token.isUserAllowed(recipient)).to.eventually.be.true; - await token.connect(manager).block(recipient); + await token.connect(manager).blockUser(recipient); await expect(token.isUserAllowed(recipient)).to.eventually.be.false; - await token.connect(manager).unblock(recipient); + await token.connect(manager).unblockUser(recipient); await expect(token.isUserAllowed(recipient)).to.eventually.be.true; } }); @@ -118,7 +118,7 @@ describe('ERC7984Rwa', function () { for (const arg of [true, false]) { it(`should not ${arg ? 'block' : 'unblock'} if neither admin nor agent`, async function () { const { token, anyone } = await deployFixture(); - await expect(token.connect(anyone)[arg ? 'block' : 'unblock'](anyone)) + await expect(token.connect(anyone)[arg ? 'blockUser' : 'unblockUser'](anyone)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); }); @@ -678,7 +678,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.connect(manager).block(account); + await token.connect(manager).blockUser(account); await expect( token From 0facae57fbbe3bb3849503371f3718b52e4864b5 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:07:44 +0200 Subject: [PATCH 027/111] Rename fixture --- .../ERC7984/extensions/ERC7984Rwa.test.ts | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index ef50e729..cc2d0884 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -14,18 +14,18 @@ import { ethers, fhevm } from 'hardhat'; const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const frozenEventSignature = 'TokensFrozen(address,bytes32)'; -describe('ERC7984Rwa', function () { - const deployFixture = async () => { - const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); - const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); - await token.connect(admin).addAgent(agent1); - token.connect(anyone); - return { token, admin, agent1, agent2, recipient, anyone }; - }; +const fixture = async () => { + const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); + const token = await ethers.deployContract('ERC7984RwaMock', ['name', 'symbol', 'uri']); + await token.connect(admin).addAgent(agent1); + token.connect(anyone); + return { token, admin, agent1, agent2, recipient, anyone }; +}; +describe('ERC7984Rwa', function () { describe('ERC165', async function () { it('should support interface', async function () { - const { token } = await deployFixture(); + const { token } = await fixture(); const interfaceFactories = [ IERC7984RwaBase__factory, IERC7984__factory, @@ -38,14 +38,14 @@ describe('ERC7984Rwa', function () { } }); it('should not support interface', async function () { - const { token } = await deployFixture(); + const { token } = await fixture(); expect(await token.supportsInterface('0xbadbadba')).is.false; }); }); describe('Pausable', async function () { it('should pause & unpause', async function () { - const { token, admin, agent1 } = await deployFixture(); + const { token, admin, agent1 } = await fixture(); for (const manager of [admin, agent1]) { expect(await token.paused()).is.false; await token.connect(manager).pause(); @@ -56,14 +56,14 @@ describe('ERC7984Rwa', function () { }); it('should not pause if neither admin nor agent', async function () { - const { token, anyone } = await deployFixture(); + const { token, anyone } = await fixture(); await expect(token.connect(anyone).pause()) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); }); it('should not unpause if neither admin nor agent', async function () { - const { token, anyone } = await deployFixture(); + const { token, anyone } = await fixture(); await expect(token.connect(anyone).unpause()) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); @@ -72,13 +72,13 @@ describe('ERC7984Rwa', function () { describe('Roles', async function () { it('should check admin', async function () { - const { token, admin, anyone } = await deployFixture(); + const { token, admin, anyone } = await fixture(); expect(await token.isAdmin(admin)).is.true; expect(await token.isAdmin(anyone)).is.false; }); it('should check/add/remove agent', async function () { - const { token, admin, agent1, agent2 } = await deployFixture(); + const { token, admin, agent1, agent2 } = await fixture(); for (const manager of [admin, agent1]) { expect(await token.isAgent(agent2)).is.false; await token.connect(manager).addAgent(agent2); @@ -89,14 +89,14 @@ describe('ERC7984Rwa', function () { }); it('should not add agent if neither admin nor agent', async function () { - const { token, agent1, anyone } = await deployFixture(); + const { token, agent1, anyone } = await fixture(); await expect(token.connect(anyone).addAgent(agent1)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); }); it('should not remove agent if neither admin nor agent', async function () { - const { token, agent1, anyone } = await deployFixture(); + const { token, agent1, anyone } = await fixture(); await expect(token.connect(anyone).removeAgent(agent1)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); @@ -105,7 +105,7 @@ describe('ERC7984Rwa', function () { describe('ERC7984Restricted', async function () { it('should block & unblock', async function () { - const { token, admin, agent1, recipient } = await deployFixture(); + const { token, admin, agent1, recipient } = await fixture(); for (const manager of [admin, agent1]) { await expect(token.isUserAllowed(recipient)).to.eventually.be.true; await token.connect(manager).blockUser(recipient); @@ -117,7 +117,7 @@ describe('ERC7984Rwa', function () { for (const arg of [true, false]) { it(`should not ${arg ? 'block' : 'unblock'} if neither admin nor agent`, async function () { - const { token, anyone } = await deployFixture(); + const { token, anyone } = await fixture(); await expect(token.connect(anyone)[arg ? 'blockUser' : 'unblockUser'](anyone)) .to.be.revertedWithCustomError(token, 'UnauthorizedSender') .withArgs(anyone.address); @@ -128,9 +128,9 @@ describe('ERC7984Rwa', function () { describe('Mintable', async function () { for (const withProof of [true, false]) { it(`should mint by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient } = await deployFixture(); + const { admin, agent1, recipient } = await fixture(); for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); + const { token } = await fixture(); await token.$_setCompliantTransfer(); const amount = 100; let params = [recipient.address] as unknown as [ @@ -167,7 +167,7 @@ describe('ERC7984Rwa', function () { } it('should not mint if neither admin nor agent', async function () { - const { token, recipient, anyone } = await deployFixture(); + const { token, recipient, anyone } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) @@ -183,7 +183,7 @@ describe('ERC7984Rwa', function () { }); it('should not mint if transfer not compliant', async function () { - const { token, admin, recipient } = await deployFixture(); + const { token, admin, recipient } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) @@ -198,7 +198,7 @@ describe('ERC7984Rwa', function () { }); it('should not mint if paused', async function () { - const { token, admin, recipient } = await deployFixture(); + const { token, admin, recipient } = await fixture(); await token.connect(admin).pause(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) @@ -215,9 +215,9 @@ describe('ERC7984Rwa', function () { describe('Burnable', async function () { for (const withProof of [true, false]) { it(`should burn by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient } = await deployFixture(); + const { admin, agent1, recipient } = await fixture(); for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); + const { token } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) @@ -270,7 +270,7 @@ describe('ERC7984Rwa', function () { } it('should not burn if neither admin nor agent', async function () { - const { token, recipient, anyone } = await deployFixture(); + const { token, recipient, anyone } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) @@ -286,7 +286,7 @@ describe('ERC7984Rwa', function () { }); it('should not burn if transfer not compliant', async function () { - const { token, admin, recipient } = await deployFixture(); + const { token, admin, recipient } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) @@ -301,7 +301,7 @@ describe('ERC7984Rwa', function () { }); it('should not burn if paused', async function () { - const { token, admin, recipient } = await deployFixture(); + const { token, admin, recipient } = await fixture(); await token.connect(admin).pause(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) @@ -318,9 +318,9 @@ describe('ERC7984Rwa', function () { describe('Force transfer', async function () { for (const withProof of [true, false]) { it(`should force transfer by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient, anyone } = await deployFixture(); + const { admin, agent1, recipient, anyone } = await fixture(); for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); + const { token } = await fixture(); const encryptedMintValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) @@ -395,9 +395,9 @@ describe('ERC7984Rwa', function () { for (const withProof of [true, false]) { it(`should force transfer even if frozen ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient, anyone } = await deployFixture(); + const { admin, agent1, recipient, anyone } = await fixture(); for (const manager of [admin, agent1]) { - const { token } = await deployFixture(); + const { token } = await fixture(); const encryptedMintValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) @@ -475,7 +475,7 @@ describe('ERC7984Rwa', function () { for (const withProof of [true, false]) { it(`should not force transfer if neither admin nor agent ${withProof ? 'with proof' : ''}`, async function () { - const { token, recipient, anyone } = await deployFixture(); + const { token, recipient, anyone } = await fixture(); let params = [recipient.address, anyone.address] as unknown as [ from: AddressLike, to: AddressLike, @@ -510,7 +510,7 @@ describe('ERC7984Rwa', function () { describe('Transfer', async function () { it('should transfer', async function () { - const { token, admin: manager, recipient, anyone } = await deployFixture(); + const { token, admin: manager, recipient, anyone } = await fixture(); const encryptedMintValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) @@ -576,7 +576,7 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if paused', async function () { - const { token, admin: manager, recipient, anyone } = await deployFixture(); + const { token, admin: manager, recipient, anyone } = await fixture(); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) @@ -594,7 +594,7 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if transfer not compliant', async function () { - const { token, recipient, anyone } = await deployFixture(); + const { token, recipient, anyone } = await fixture(); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) @@ -614,7 +614,7 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if frozen', async function () { - const { token, admin: manager, recipient, anyone } = await deployFixture(); + const { token, admin: manager, recipient, anyone } = await fixture(); const encryptedMintValueInput = await fhevm .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) @@ -671,7 +671,7 @@ describe('ERC7984Rwa', function () { for (const arg of [true, false]) { it(`should not transfer if ${arg ? 'sender' : 'receiver'} blocked `, async function () { - const { token, admin: manager, recipient, anyone } = await deployFixture(); + const { token, admin: manager, recipient, anyone } = await fixture(); const account = arg ? recipient : anyone; await token.$_setCompliantTransfer(); const encryptedInput = await fhevm From 2d0ef0ea9c9ba36f030d3364e2e8cbd3b103ab47 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:13:38 +0200 Subject: [PATCH 028/111] Force transfer with all update effects --- .../ERC7984/extensions/ERC7984Freezable.sol | 43 +++++++++- .../ERC7984/extensions/ERC7984Restricted.sol | 22 ++++- .../token/ERC7984/extensions/ERC7984Rwa.sol | 18 ++-- .../extensions/ERC7984Freezable.test.ts | 84 +++++++++++++++++++ .../extensions/ERC7984Restricted.test.ts | 11 +++ .../ERC7984/extensions/ERC7984Rwa.test.ts | 37 +++++++- 6 files changed, 199 insertions(+), 16 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 50fe89d5..66360036 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -21,6 +21,8 @@ import {ERC7984} from "../ERC7984.sol"; abstract contract ERC7984Freezable is ERC7984 { /// @dev Confidential frozen amount of tokens per address. mapping(address account => euint64 encryptedAmount) private _frozenBalances; + /// @dev Skips frozen checks in {_update}. + bool private _skipUpdateCheck; /// @dev Emitted when a confidential amount of token is frozen for an account event TokensFrozen(address indexed account, euint64 encryptedAmount); @@ -59,7 +61,14 @@ abstract contract ERC7984Freezable is ERC7984 { /// @dev Internal function to freeze a confidential amount of tokens for an account. function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual { - _checkFreezer(); + _setConfidentialFrozen(account, encryptedAmount, true); + } + + /// @dev Private function to freeze a confidential amount of tokens for an account + function _setConfidentialFrozen(address account, euint64 encryptedAmount, bool checkFreezer) internal virtual { + if (checkFreezer) { + _checkFreezer(); + } FHE.allowThis(encryptedAmount); FHE.allow(encryptedAmount, account); _frozenBalances[account] = encryptedAmount; @@ -69,15 +78,41 @@ abstract contract ERC7984Freezable is ERC7984 { /// @dev Unimplemented function that must revert if `msg.sender` is not authorized as a freezer. function _checkFreezer() internal virtual; + /// @dev Internal function to skip update check. Check can be restored with {_restoreERC7984FreezableUpdateCheck}. + function _disableERC7984FreezableUpdateCheck() internal virtual { + if (!_skipUpdateCheck) { + _skipUpdateCheck = true; + } + } + + /// @dev Internal function to restore update check previously disabled by {_disableERC7984FreezableUpdateCheck}. + function _restoreERC7984FreezableUpdateCheck() internal virtual { + if (_skipUpdateCheck) { + _skipUpdateCheck = false; + } + } + /** * @dev See {ERC7984-_update}. The `from` account must have sufficient unfrozen balance, * otherwise 0 tokens are transferred. */ function _update(address from, address to, euint64 encryptedAmount) internal virtual override returns (euint64) { + euint64 available; if (from != address(0)) { - euint64 unfrozen = confidentialAvailable(from); - encryptedAmount = FHE.select(FHE.le(encryptedAmount, unfrozen), encryptedAmount, FHE.asEuint64(0)); + available = confidentialAvailable(from); + if (!_skipUpdateCheck) { + encryptedAmount = FHE.select(FHE.le(encryptedAmount, available), encryptedAmount, FHE.asEuint64(0)); + } + } + euint64 transferred = super._update(from, to, encryptedAmount); + if (from != address(0) && _skipUpdateCheck) { + // Reset frozen to balance if transferred more than available + _setConfidentialFrozen( + from, + FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)), + false + ); } - return super._update(from, to, encryptedAmount); + return transferred; } } diff --git a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol index 1f3dbae9..37ca2d93 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol @@ -16,6 +16,8 @@ import {ERC7984, euint64} from "../ERC7984.sol"; */ abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { mapping(address account => Restriction) private _restrictions; + /// @dev Skips restriction checks in {_update}. + bool private _skipUpdateCheck; /// @dev Returns the restriction of a user account. function getRestriction(address account) public view virtual returns (Restriction) { @@ -39,6 +41,20 @@ abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { return getRestriction(account) != Restriction.BLOCKED; // i.e. DEFAULT && ALLOWED } + /// @dev Internal function to skip update check. Check can be restored with {_restoreERC7984RestrictedUpdateCheck}. + function _disableERC7984RestrictedUpdateCheck() internal virtual { + if (!_skipUpdateCheck) { + _skipUpdateCheck = true; + } + } + + /// @dev Internal function to restore update check previously disabled by {_disableERC7984RestrictedUpdateCheck}. + function _restoreERC7984RestrictedUpdateCheck() internal virtual { + if (_skipUpdateCheck) { + _skipUpdateCheck = false; + } + } + /** * @dev See {ERC7984-_update}. Enforces transfer restrictions (excluding minting and burning). * @@ -48,8 +64,10 @@ abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { * * `to` must be allowed to receive tokens (see {isUserAllowed}). */ function _update(address from, address to, euint64 value) internal virtual override returns (euint64) { - if (from != address(0)) _checkRestriction(from); // Not minting - if (to != address(0)) _checkRestriction(to); // Not burning + if (!_skipUpdateCheck) { + if (from != address(0)) _checkRestriction(from); // Not minting + if (to != address(0)) _checkRestriction(to); // Not burning + } return super._update(from, to, value); } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 02d3f2fb..9d6a1821 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -167,22 +167,22 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal virtual returns (euint64 transferred) { - euint64 available = confidentialAvailable(from); - transferred = ERC7984._update(from, to, encryptedAmount); // bypass frozen, restrictions & compliance checks - _setConfidentialFrozen( - from, - FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)) - ); + _disableERC7984FreezableUpdateCheck(); // bypass frozen check + _disableERC7984RestrictedUpdateCheck(); // bypass default restriction check + if (to != address(0)) _checkRestriction(to); // only perform restriction check on `to` + transferred = super._update(from, to, encryptedAmount); // bypass compliance check + _restoreERC7984FreezableUpdateCheck(); + _restoreERC7984RestrictedUpdateCheck(); } - /// @dev Internal function which updates confidential balances while performing frozen, restrictions and compliance checks. + /// @dev Internal function which updates confidential balances while performing frozen, restriction and compliance checks. function _update( address from, address to, euint64 encryptedAmount - ) internal override(ERC7984, ERC7984Freezable, ERC7984Restricted) whenNotPaused returns (euint64) { + ) internal override(ERC7984Freezable, ERC7984Restricted, ERC7984) whenNotPaused returns (euint64) { require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); - // frozen and restrictions checks performed through inheritance + // frozen and restriction checks performed through inheritance return super._update(from, to, encryptedAmount); } diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index 6bef4f42..c9e7cb68 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -1,6 +1,7 @@ import { IACL__factory } from '../../../../types'; import { $ERC7984FreezableMock } from '../../../../types/contracts-exposed/mocks/token/ERC7984FreezableMock.sol/$ERC7984FreezableMock'; import { ACL_ADDRESS } from '../../../helpers/accounts'; +import { callAndGetResult } from '../../../helpers/event'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { AddressLike, BytesLike, EventLog } from 'ethers'; @@ -217,6 +218,89 @@ describe('ERC7984Freezable', function () { ).to.eventually.equal(1000); }); + it('should transfer all if transferring more than available but check disabled', async function () { + const { token, holder, recipient, freezer, anyone } = await deployFixture(); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(500) + .encrypt(); + await token + .connect(freezer) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + await token.$_disableERC7984FreezableUpdateCheck(); + const encryptedInput1 = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(501) + .encrypt(); + const tx = await token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone.address, + encryptedInput1.handles[0], + encryptedInput1.inputProof, + ); + await expect(tx).to.emit(token, 'ConfidentialTransfer'); + const transferEvent = (await tx + .wait() + .then(receipt => receipt!.logs.filter((log: any) => log.address === token.target)[0])) as EventLog; + expect(transferEvent.args[0]).to.equal(recipient.address); + expect(transferEvent.args[1]).to.equal(anyone.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferEvent.args[2], await token.getAddress(), recipient), + ).to.eventually.equal(501); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient.address), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(499); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialFrozen(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(499); // frozen got reset to balance + + // should transfer zero if frozen check is restored + await token.$_restoreERC7984FreezableUpdateCheck(); + const encryptedInput2 = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(499) + .encrypt(); + const [, , transferred] = await callAndGetResult( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone.address, + encryptedInput2.handles[0], + encryptedInput2.inputProof, + ), + 'ConfidentialTransfer(address,address,bytes32)', + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferred, await token.getAddress(), recipient), + ).to.eventually.equal(0); + }); + it('should not set confidential frozen if unauthorized', async function () { const { token, recipient, freezer, anyone } = await deployFixture(); const encryptedInput = await fhevm diff --git a/test/token/ERC7984/extensions/ERC7984Restricted.test.ts b/test/token/ERC7984/extensions/ERC7984Restricted.test.ts index 302c9245..7f3e37af 100644 --- a/test/token/ERC7984/extensions/ERC7984Restricted.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Restricted.test.ts @@ -79,6 +79,17 @@ describe('ERC7984Restricted', function () { await this.token.connect(this.holder).transfer(this.recipient, initialSupply); }); + it('allows when sender and recipient are BLOCKED but restriction checks are disabled', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + await this.token.$_disableERC7984RestrictedUpdateCheck(); + await this.token.connect(this.holder).transfer(this.recipient, initialSupply); + await this.token.$_restoreERC7984RestrictedUpdateCheck(); + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)) + .to.be.revertedWithCustomError(this.token, 'UserRestricted') + .withArgs(this.holder); + }); + it('reverts when sender is BLOCKED', async function () { await this.token.$_blockUser(this.holder); // Sets to BLOCKED diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index cc2d0884..ae9de6fb 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -468,7 +468,7 @@ describe('ERC7984Rwa', function () { await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); await expect( fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); // frozen got reset to available balance + ).to.eventually.equal(75); // frozen got reset to balance } }); } @@ -506,6 +506,41 @@ describe('ERC7984Rwa', function () { .withArgs(anyone.address); }); } + + for (const withProof of [true, false]) { + it(`should not force transfer if receiver blocked ${withProof ? 'with proof' : ''}`, async function () { + const { token, recipient, anyone } = await fixture(); + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + const amount = 100; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), anyone.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(anyone).createEncryptedAmount(amount); + params.push(await token.connect(anyone).createEncryptedAmount.staticCall(amount)); + } + await token.blockUser(anyone); + await expect( + token + .connect(anyone) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + ) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone.address); + }); + } }); describe('Transfer', async function () { From 54517770b6d0dc0fd0717c40acb8af820de853a7 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:23:23 +0200 Subject: [PATCH 029/111] Update set frozen doc --- contracts/token/ERC7984/extensions/ERC7984Freezable.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 66360036..025be19a 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -64,7 +64,7 @@ abstract contract ERC7984Freezable is ERC7984 { _setConfidentialFrozen(account, encryptedAmount, true); } - /// @dev Private function to freeze a confidential amount of tokens for an account + /// @dev Private function to freeze a confidential amount of tokens for an account with optional freezer check. function _setConfidentialFrozen(address account, euint64 encryptedAmount, bool checkFreezer) internal virtual { if (checkFreezer) { _checkFreezer(); From 30650021a9d6e5b3a59d7150ac395d2ac28c1a45 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:23:09 +0200 Subject: [PATCH 030/111] Refactor event checks in freezable tests --- contracts/interfaces/IERC7984Rwa.sol | 2 +- .../extensions/ERC7984Freezable.test.ts | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index aad61239..bbf51585 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 {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index c9e7cb68..72496ed5 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -10,6 +10,7 @@ import { ethers, fhevm } from 'hardhat'; const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; const uri = 'https://example.com/metadata'; +const confidentialTransferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; describe('ERC7984Freezable', function () { async function deployFixture() { @@ -247,7 +248,7 @@ describe('ERC7984Freezable', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(501) .encrypt(); - const tx = await token + const tx = token .connect(recipient) ['confidentialTransfer(address,bytes32,bytes)']( anyone.address, @@ -255,13 +256,11 @@ describe('ERC7984Freezable', function () { encryptedInput1.inputProof, ); await expect(tx).to.emit(token, 'ConfidentialTransfer'); - const transferEvent = (await tx - .wait() - .then(receipt => receipt!.logs.filter((log: any) => log.address === token.target)[0])) as EventLog; - expect(transferEvent.args[0]).to.equal(recipient.address); - expect(transferEvent.args[1]).to.equal(anyone.address); + const [from, to, transferred1] = await callAndGetResult(tx, confidentialTransferEventSignature); + expect(from).to.equal(recipient.address); + expect(to).to.equal(anyone.address); await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferEvent.args[2], await token.getAddress(), recipient), + fhevm.userDecryptEuint(FhevmType.euint64, transferred1, await token.getAddress(), recipient), ).to.eventually.equal(501); await expect( fhevm.userDecryptEuint( @@ -274,7 +273,7 @@ describe('ERC7984Freezable', function () { await expect( fhevm.userDecryptEuint( FhevmType.euint64, - await token.confidentialFrozen(recipient), + await token.confidentialFrozen(recipient.address), await token.getAddress(), recipient, ), @@ -286,7 +285,7 @@ describe('ERC7984Freezable', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(499) .encrypt(); - const [, , transferred] = await callAndGetResult( + const [, , transferred2] = await callAndGetResult( token .connect(recipient) ['confidentialTransfer(address,bytes32,bytes)']( @@ -294,10 +293,10 @@ describe('ERC7984Freezable', function () { encryptedInput2.handles[0], encryptedInput2.inputProof, ), - 'ConfidentialTransfer(address,address,bytes32)', + confidentialTransferEventSignature, ); await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferred, await token.getAddress(), recipient), + fhevm.userDecryptEuint(FhevmType.euint64, transferred2, await token.getAddress(), recipient), ).to.eventually.equal(0); }); From 4a2e41ea08f70fe7d24467923d177b0fdafd675c Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:40:25 +0200 Subject: [PATCH 031/111] Init compliance modules for confidential RWAs --- .changeset/wet-results-doubt.md | 5 + contracts/interfaces/IERC7984Rwa.sol | 20 +++- contracts/mocks/token/ERC7984RwaMock.sol | 2 +- .../token/ERC7984/extensions/ERC7984Rwa.sol | 4 +- .../extensions/ERC7984RwaCompliance.sol | 98 +++++++++++++++++++ .../extensions/ERC7984RwaComplianceModule.sol | 10 ++ 6 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 .changeset/wet-results-doubt.md create mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol create mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md new file mode 100644 index 00000000..e4b5af97 --- /dev/null +++ b/.changeset/wet-results-doubt.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +`ERC7984RwaCompliance`: Support compliance modules for confidential RWAs. diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index bbf51585..48918955 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -86,5 +86,23 @@ interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} /// @dev Interface for confidential RWA compliance. interface IERC7984RwaCompliance { /// @dev Checks if a transfer follows token compliance. - function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); + function isCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); +} + +/// @dev Interface for confidential RWA compliance module. +interface IERC7984RwaComplianceModule { + /// @dev Returns true if module is a certain type, false otherwise. + function isModuleType(uint256 moduleTypeId) external returns (bool); +} + +/// @dev Interface for confidential RWA identity compliance module. +interface IERC7984RwaIdentityComplianceModule is IERC7984RwaComplianceModule { + /// @dev Checks if an identity is authorized. + function isAuthorizedIdentity(address identity) external returns (bool); +} + +/// @dev Interface for confidential RWA transfer compliance module. +interface IERC7984RwaTransferComplianceModule is IERC7984RwaComplianceModule { + /// @dev Checks if an identity is authorized. + function isAuthorizedTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); } diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index 0ae0200e..22f3ea98 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -45,7 +45,7 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return _mint(to, FHE.asEuint64(amount)); } - function _isCompliantTransfer( + function _isCompliant( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 9d6a1821..6b41175d 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -181,7 +181,7 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal override(ERC7984Freezable, ERC7984Restricted, ERC7984) whenNotPaused returns (euint64) { - require(_isCompliantTransfer(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + require(_isCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); // frozen and restriction checks performed through inheritance return super._update(from, to, encryptedAmount); } @@ -193,5 +193,5 @@ abstract contract ERC7984Rwa is function _checkFreezer() internal override onlyAdminOrAgent {} /// @dev Checks if a transfer follows token compliance. - function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + function _isCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol new file mode 100644 index 00000000..84109a15 --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule} from "./../../../interfaces/IERC7984Rwa.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 ERC7984RwaCompliance is ERC7984Rwa { + using EnumerableSet for *; + + uint256 constant IDENTITY_COMPLIANCE_MODULE_TYPE = 1; + uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 2; + EnumerableSet.AddressSet private _identityComplianceModules; + EnumerableSet.AddressSet private _transferComplianceModules; + + /// @dev Emitted when a module is installed. + event ModuleInstalled(uint256 moduleTypeId, address module); + /// @dev Emitted when a module is uninstalled. + event ModuleUninstalled(uint256 moduleTypeId, address module); + + /// @dev The module type is not supported. + error ERC7984RwaUnsupportedModuleType(uint256 moduleTypeId); + /// @dev The provided module doesn't match the provided module type. + error ERC7984RwaMismatchedModuleTypeId(uint256 moduleTypeId, address module); + /// @dev The module is already installed. + error ERC7984RwaAlreadyInstalledModule(uint256 moduleTypeId, address module); + + /** + * @dev Check if a certain module typeId is supported. + * + * Supported module types: + * + * * Identity compliance moduleĂ’ + * * Transfer compliance module + */ + function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { + return moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE || moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE; + } + + function installModule(uint256 moduleTypeId, address module) public virtual onlyAdminOrAgent { + _installModule(moduleTypeId, module); + } + + function _installModule(uint256 moduleTypeId, address module) internal virtual { + require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); + require( + IERC7984RwaComplianceModule(module).isModuleType(moduleTypeId), + ERC7984RwaMismatchedModuleTypeId(moduleTypeId, module) + ); + + if (moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE) { + require(_identityComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { + require(_transferComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); + } + emit ModuleInstalled(moduleTypeId, module); + } + + /// @dev Checks if an identity is authorized. + function _isAuthorizedIdentity(address identity) internal virtual returns (bool) { + address[] memory modules = _identityComplianceModules.values(); + uint256 modulesLength = modules.length; + for (uint256 index = 0; index < modulesLength; index++) { + address module = modules[index]; + if (!IERC7984RwaIdentityComplianceModule(module).isAuthorizedIdentity(identity)) { + return false; + } + } + return true; + } + + /// @dev Checks if a transfer is authorized. + function _isAuthorizedTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool) { + address[] memory modules = _transferComplianceModules.values(); + uint256 modulesLength = modules.length; + for (uint256 index = 0; index < modulesLength; index++) { + address module = modules[index]; + if (!IERC7984RwaTransferComplianceModule(module).isAuthorizedTransfer(from, to, encryptedAmount)) { + return false; + } + } + return true; + } + + /// @dev Checks if a transfer follows token compliance. + function _isCompliant(address from, address to, euint64 encryptedAmount) internal override returns (bool) { + return + _isAuthorizedIdentity(from) && + _isAuthorizedIdentity(to) && + _isAuthorizedTransfer(from, to, encryptedAmount); + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol new file mode 100644 index 00000000..0ee19f30 --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7984RwaComplianceModule} from "./../../../interfaces/IERC7984Rwa.sol"; + +/** + * @dev A contract which allows to build a compliance module for confidential Real World Assets (RWAs). + */ +abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule {} From 3946b30c89958e0c43ce0ac32c7f20638fcc0d04 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:09:44 +0200 Subject: [PATCH 032/111] Add abstract compliance modules --- contracts/interfaces/IERC7984Rwa.sol | 15 ++++++--- contracts/mocks/token/ERC7984RwaMock.sol | 21 ++++++++++-- .../token/ERC7984/extensions/ERC7984Rwa.sol | 6 +++- .../extensions/ERC7984RwaCompliance.sol | 33 +++++++++++-------- .../extensions/ERC7984RwaComplianceModule.sol | 10 ------ .../ERC7984RwaIdentityComplianceModule.sol | 25 ++++++++++++++ .../ERC7984RwaTransferComplianceModule.sol | 26 +++++++++++++++ .../ERC7984/extensions/ERC7984Rwa.test.ts | 28 ++++++++-------- 8 files changed, 119 insertions(+), 45 deletions(-) delete mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol create mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol create mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 48918955..1d31f153 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -7,6 +7,9 @@ import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; import {IERC7984Restricted} from "./IERC7984Restricted.sol"; +uint256 constant IDENTITY_COMPLIANCE_MODULE_TYPE = 1; +uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 2; + /// @dev Base interface for confidential RWA contracts. interface IERC7984RwaBase { /// @dev Emitted when the contract is paused. @@ -85,8 +88,10 @@ interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} /// @dev Interface for confidential RWA compliance. interface IERC7984RwaCompliance { - /// @dev Checks if a transfer follows token compliance. + /// @dev Checks if a transfer follows compliance. function isCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); + /// @dev Checks if a force transfer follows compliance. + function isForceCompliantForce(address from, address to, euint64 encryptedAmount) external returns (bool); } /// @dev Interface for confidential RWA compliance module. @@ -97,12 +102,12 @@ interface IERC7984RwaComplianceModule { /// @dev Interface for confidential RWA identity compliance module. interface IERC7984RwaIdentityComplianceModule is IERC7984RwaComplianceModule { - /// @dev Checks if an identity is authorized. - function isAuthorizedIdentity(address identity) external returns (bool); + /// @dev Checks if an identity is compliant. + function isCompliantIdentity(address identity) external returns (bool); } /// @dev Interface for confidential RWA transfer compliance module. interface IERC7984RwaTransferComplianceModule is IERC7984RwaComplianceModule { - /// @dev Checks if an identity is authorized. - function isAuthorizedTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); + /// @dev Checks if an transfer is compliant. + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (bool); } diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index 22f3ea98..c385f78e 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -13,6 +13,7 @@ import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { mapping(address account => euint64 encryptedAmount) private _frozenBalances; bool public compliantTransfer; + bool public compliantForceTransfer; // TODO: Move modifiers to `ERC7984Rwa` or remove from mock if useless /// @dev Checks if the sender is an admin. @@ -33,14 +34,22 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { FHE.allow(encryptedAmount, msg.sender); } - function $_setCompliantTransfer() public { + function $_setCompliant() public { compliantTransfer = true; } - function $_unsetCompliantTransfer() public { + function $_unsetCompliant() public { compliantTransfer = false; } + function $_setForceCompliant() public { + compliantForceTransfer = true; + } + + function $_unsetForceCompliant() public { + compliantForceTransfer = false; + } + function $_mint(address to, uint64 amount) public returns (euint64 transferred) { return _mint(to, FHE.asEuint64(amount)); } @@ -53,5 +62,13 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return compliantTransfer; } + function _isForceCompliant( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal override returns (bool) { + return compliantForceTransfer; + } + function _validateHandleAllowance(bytes32 handle) internal view override onlyAdminOrAgent {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 6b41175d..a8a28a31 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -167,6 +167,7 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal virtual returns (euint64 transferred) { + require(_isForceCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); _disableERC7984FreezableUpdateCheck(); // bypass frozen check _disableERC7984RestrictedUpdateCheck(); // bypass default restriction check if (to != address(0)) _checkRestriction(to); // only perform restriction check on `to` @@ -192,6 +193,9 @@ abstract contract ERC7984Rwa is */ function _checkFreezer() internal override onlyAdminOrAgent {} - /// @dev Checks if a transfer follows token compliance. + /// @dev Checks if a transfer follows compliance. function _isCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + + /// @dev Checks if a force transfer follows compliance. + function _isForceCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol index 84109a15..dadfd14c 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; import {ERC7984Rwa} from "./ERC7984Rwa.sol"; /** @@ -14,8 +14,6 @@ import {ERC7984Rwa} from "./ERC7984Rwa.sol"; abstract contract ERC7984RwaCompliance is ERC7984Rwa { using EnumerableSet for *; - uint256 constant IDENTITY_COMPLIANCE_MODULE_TYPE = 1; - uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 2; EnumerableSet.AddressSet private _identityComplianceModules; EnumerableSet.AddressSet private _transferComplianceModules; @@ -36,7 +34,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa { * * Supported module types: * - * * Identity compliance moduleĂ’ + * * Identity compliance module * * Transfer compliance module */ function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { @@ -62,37 +60,44 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa { emit ModuleInstalled(moduleTypeId, module); } - /// @dev Checks if an identity is authorized. - function _isAuthorizedIdentity(address identity) internal virtual returns (bool) { + /// @dev Checks if an identity is compliant. + function _isCompliantIdentity(address identity) internal virtual returns (bool) { address[] memory modules = _identityComplianceModules.values(); uint256 modulesLength = modules.length; for (uint256 index = 0; index < modulesLength; index++) { address module = modules[index]; - if (!IERC7984RwaIdentityComplianceModule(module).isAuthorizedIdentity(identity)) { + if (!IERC7984RwaIdentityComplianceModule(module).isCompliantIdentity(identity)) { return false; } } return true; } - /// @dev Checks if a transfer is authorized. - function _isAuthorizedTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool) { + /// @dev Checks if a transfer is compliant. + function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool) { address[] memory modules = _transferComplianceModules.values(); uint256 modulesLength = modules.length; for (uint256 index = 0; index < modulesLength; index++) { address module = modules[index]; - if (!IERC7984RwaTransferComplianceModule(module).isAuthorizedTransfer(from, to, encryptedAmount)) { + if (!IERC7984RwaTransferComplianceModule(module).isCompliantTransfer(from, to, encryptedAmount)) { return false; } } return true; } - /// @dev Checks if a transfer follows token compliance. + /// @dev Checks if a transfer follows compliance. function _isCompliant(address from, address to, euint64 encryptedAmount) internal override returns (bool) { return - _isAuthorizedIdentity(from) && - _isAuthorizedIdentity(to) && - _isAuthorizedTransfer(from, to, encryptedAmount); + _isCompliantIdentity(from) && _isCompliantIdentity(to) && _isCompliantTransfer(from, to, encryptedAmount); + } + + /// @dev Checks if a force transfer follows compliance. + function _isForceCompliant( + address /*from*/, + address to, + euint64 /*encryptedAmount*/ + ) internal override returns (bool) { + return _isCompliantIdentity(to); } } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol deleted file mode 100644 index 0ee19f30..00000000 --- a/contracts/token/ERC7984/extensions/ERC7984RwaComplianceModule.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.27; - -import {IERC7984RwaComplianceModule} from "./../../../interfaces/IERC7984Rwa.sol"; - -/** - * @dev A contract which allows to build a compliance module for confidential Real World Assets (RWAs). - */ -abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule {} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol new file mode 100644 index 00000000..7c1e9abc --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; + +/** + * @dev A contract which allows to build an identity compliance module for confidential Real World Assets (RWAs). + */ +abstract contract ERC7984RwaIdentityComplianceModule is + IERC7984RwaComplianceModule, + IERC7984RwaIdentityComplianceModule +{ + /// @inheritdoc IERC7984RwaComplianceModule + function isModuleType(uint256 moduleTypeId) public pure override returns (bool) { + return moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE; + } + + /// @inheritdoc IERC7984RwaIdentityComplianceModule + function isCompliantIdentity(address identity) public virtual returns (bool) { + return _isCompliantIdentity(identity); + } + + function _isCompliantIdentity(address identity) internal virtual returns (bool); +} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol new file mode 100644 index 00000000..ba8b0283 --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC7984RwaComplianceModule, IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; + +/** + * @dev A contract which allows to build an transfer compliance module for confidential Real World Assets (RWAs). + */ +abstract contract ERC7984RwaTransferComplianceModule is + IERC7984RwaComplianceModule, + IERC7984RwaTransferComplianceModule +{ + /// @inheritdoc IERC7984RwaComplianceModule + function isModuleType(uint256 moduleTypeId) public pure override returns (bool) { + return moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE; + } + + /// @inheritdoc IERC7984RwaTransferComplianceModule + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (bool) { + return _isCompliantTransfer(from, to, encryptedAmount); + } + + function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); +} diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index ae9de6fb..3f3ef38d 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -131,7 +131,7 @@ describe('ERC7984Rwa', function () { const { admin, agent1, recipient } = await fixture(); for (const manager of [admin, agent1]) { const { token } = await fixture(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); const amount = 100; let params = [recipient.address] as unknown as [ account: AddressLike, @@ -172,7 +172,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await expect( token .connect(anyone) @@ -222,7 +222,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -275,7 +275,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await expect( token .connect(anyone) @@ -325,7 +325,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -345,7 +345,7 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, ); - await token.$_unsetCompliantTransfer(); + await token.$_unsetCompliant(); expect(await token.compliantTransfer()).to.be.false; const amount = 25; let params = [recipient.address, anyone.address] as unknown as [ @@ -364,6 +364,7 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } + await token.$_setForceCompliant(); const [from, to, transferredHandle] = await callAndGetResult( token .connect(manager) @@ -402,7 +403,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -423,7 +424,7 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.inputProof, ); // should force transfer even if not compliant - await token.$_unsetCompliantTransfer(); + await token.$_unsetCompliant(); expect(await token.compliantTransfer()).to.be.false; // should force transfer even if paused await token.connect(manager).pause(); @@ -445,6 +446,7 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } + await token.$_setForceCompliant(); const [account, frozenAmountHandle] = await callAndGetResult( token .connect(manager) @@ -550,7 +552,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -575,7 +577,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(amount) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); expect(await token.compliantTransfer()).to.be.true; const [from, to, transferredHandle] = await callAndGetResult( token @@ -654,7 +656,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -678,7 +680,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); expect(await token.compliantTransfer()).to.be.true; const [, , transferredHandle] = await callAndGetResult( token @@ -708,7 +710,7 @@ describe('ERC7984Rwa', function () { it(`should not transfer if ${arg ? 'sender' : 'receiver'} blocked `, async function () { const { token, admin: manager, recipient, anyone } = await fixture(); const account = arg ? recipient : anyone; - await token.$_setCompliantTransfer(); + await token.$_setCompliant(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) From 4debb472ba28db8d5145f3f87d13b1a123fc173b Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:19:34 +0200 Subject: [PATCH 033/111] Compliance implements interface --- contracts/interfaces/IERC7984Rwa.sol | 2 +- contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 1d31f153..f0363a62 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -91,7 +91,7 @@ interface IERC7984RwaCompliance { /// @dev Checks if a transfer follows compliance. function isCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); /// @dev Checks if a force transfer follows compliance. - function isForceCompliantForce(address from, address to, euint64 encryptedAmount) external returns (bool); + function isForceCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); } /// @dev Interface for confidential RWA compliance module. diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol index dadfd14c..24f9ab46 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol @@ -4,14 +4,14 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.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 ERC7984RwaCompliance is ERC7984Rwa { +abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { using EnumerableSet for *; EnumerableSet.AddressSet private _identityComplianceModules; From 86d5250216bd1617376026f80a5e869eb4562b7a Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:15:17 +0200 Subject: [PATCH 034/111] Add post transfer hook --- contracts/interfaces/IERC7984Rwa.sol | 32 ++--- contracts/mocks/token/ERC7984RwaMock.sol | 4 +- .../token/ERC7984/extensions/ERC7984Rwa.sol | 24 +++- .../extensions/ERC7984RwaCompliance.sol | 111 ++++++++++++------ .../ERC7984RwaIdentityComplianceModule.sol | 25 ---- .../ERC7984RwaTransferComplianceModule.sol | 35 ++++-- 6 files changed, 134 insertions(+), 97 deletions(-) delete mode 100644 contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index f0363a62..f0c06628 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -7,8 +7,8 @@ import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; import {IERC7984Restricted} from "./IERC7984Restricted.sol"; -uint256 constant IDENTITY_COMPLIANCE_MODULE_TYPE = 1; -uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 2; +uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 1; +uint256 constant FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE = 2; /// @dev Base interface for confidential RWA contracts. interface IERC7984RwaBase { @@ -88,26 +88,18 @@ interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} /// @dev Interface for confidential RWA compliance. interface IERC7984RwaCompliance { - /// @dev Checks if a transfer follows compliance. - function isCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); - /// @dev Checks if a force transfer follows compliance. - function isForceCompliant(address from, address to, euint64 encryptedAmount) external returns (bool); -} - -/// @dev Interface for confidential RWA compliance module. -interface IERC7984RwaComplianceModule { - /// @dev Returns true if module is a certain type, false otherwise. - function isModuleType(uint256 moduleTypeId) external returns (bool); -} - -/// @dev Interface for confidential RWA identity compliance module. -interface IERC7984RwaIdentityComplianceModule is IERC7984RwaComplianceModule { - /// @dev Checks if an identity is compliant. - function isCompliantIdentity(address identity) external returns (bool); + /// @dev Installs a transfer compliance module. + function installModule(uint256 moduleTypeId, address module) external; + /// @dev Uninstalls a transfer compliance module. + function uninstallModule(uint256 moduleTypeId, address module) external; } /// @dev Interface for confidential RWA transfer compliance module. -interface IERC7984RwaTransferComplianceModule is IERC7984RwaComplianceModule { - /// @dev Checks if an transfer is compliant. +interface IERC7984RwaTransferComplianceModule { + /// @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 (bool); + /// @dev Peforms operation after transfer. + function postTransferHook(address from, address to, euint64 encryptedAmount) external; } diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index c385f78e..f3c93708 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -54,7 +54,7 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return _mint(to, FHE.asEuint64(amount)); } - function _isCompliant( + function _isTransferCompliant( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ @@ -62,7 +62,7 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return compliantTransfer; } - function _isForceCompliant( + function _isForceTransferCompliant( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index a8a28a31..84cd52b1 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -167,11 +167,12 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal virtual returns (euint64 transferred) { - require(_isForceCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + require(_isForceTransferCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); _disableERC7984FreezableUpdateCheck(); // bypass frozen check _disableERC7984RestrictedUpdateCheck(); // bypass default restriction check if (to != address(0)) _checkRestriction(to); // only perform restriction check on `to` transferred = super._update(from, to, encryptedAmount); // bypass compliance check + _postForceTransferHook(from, to, encryptedAmount); _restoreERC7984FreezableUpdateCheck(); _restoreERC7984RestrictedUpdateCheck(); } @@ -181,10 +182,11 @@ abstract contract ERC7984Rwa is address from, address to, euint64 encryptedAmount - ) internal override(ERC7984Freezable, ERC7984Restricted, ERC7984) whenNotPaused returns (euint64) { - require(_isCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + ) internal override(ERC7984Freezable, ERC7984Restricted, ERC7984) whenNotPaused returns (euint64 transferred) { + require(_isTransferCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); // frozen and restriction checks performed through inheritance - return super._update(from, to, encryptedAmount); + transferred = super._update(from, to, encryptedAmount); + _postTransferHook(from, to, encryptedAmount); } /** @@ -194,8 +196,18 @@ abstract contract ERC7984Rwa is function _checkFreezer() internal override onlyAdminOrAgent {} /// @dev Checks if a transfer follows compliance. - function _isCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + function _isTransferCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); /// @dev Checks if a force transfer follows compliance. - function _isForceCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + function _isForceTransferCompliant( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (bool); + + /// @dev Peforms operation after transfer. + function _postTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} + + /// @dev Peforms operation after force transfer. + function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol index 24f9ab46..db779b0e 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaCompliance, IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IERC7984RwaTransferComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; import {ERC7984Rwa} from "./ERC7984Rwa.sol"; /** @@ -14,8 +14,8 @@ import {ERC7984Rwa} from "./ERC7984Rwa.sol"; abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { using EnumerableSet for *; - EnumerableSet.AddressSet private _identityComplianceModules; EnumerableSet.AddressSet private _transferComplianceModules; + EnumerableSet.AddressSet private _forceTransferComplianceModules; /// @dev Emitted when a module is installed. event ModuleInstalled(uint256 moduleTypeId, address module); @@ -24,80 +24,123 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { /// @dev The module type is not supported. error ERC7984RwaUnsupportedModuleType(uint256 moduleTypeId); - /// @dev The provided module doesn't match the provided module type. - error ERC7984RwaMismatchedModuleTypeId(uint256 moduleTypeId, address module); + /// @dev The address is not a transfer compliance module. + error ERC7984RwaNotTransferComplianceModule(address module); /// @dev The module is already installed. error ERC7984RwaAlreadyInstalledModule(uint256 moduleTypeId, address module); + /// @dev The module is already uninstalled. + error ERC7984RwaAlreadyUninstalledModule(uint256 moduleTypeId, address module); /** * @dev Check if a certain module typeId is supported. * * Supported module types: * - * * Identity compliance module * * Transfer compliance module + * * Force transfer compliance module */ function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { - return moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE || moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE; + return moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE || moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE; } + /** + * @inheritdoc IERC7984RwaCompliance + * @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(uint256 moduleTypeId, address module) public virtual onlyAdminOrAgent { _installModule(moduleTypeId, module); } + /// @inheritdoc IERC7984RwaCompliance + function uninstallModule(uint256 moduleTypeId, address module) public virtual onlyAdminOrAgent { + _uninstallModule(moduleTypeId, module); + } + + /// @dev Internal function which installs a transfer compliance module. function _installModule(uint256 moduleTypeId, address module) internal virtual { require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); require( - IERC7984RwaComplianceModule(module).isModuleType(moduleTypeId), - ERC7984RwaMismatchedModuleTypeId(moduleTypeId, module) + IERC7984RwaTransferComplianceModule(module).isModule() == + IERC7984RwaTransferComplianceModule.isModule.selector, + ERC7984RwaNotTransferComplianceModule(module) ); - if (moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE) { - require(_identityComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); - } else if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { + if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { require(_transferComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE) { + require( + _forceTransferComplianceModules.add(module), + ERC7984RwaAlreadyInstalledModule(moduleTypeId, module) + ); } emit ModuleInstalled(moduleTypeId, module); } - /// @dev Checks if an identity is compliant. - function _isCompliantIdentity(address identity) internal virtual returns (bool) { - address[] memory modules = _identityComplianceModules.values(); + /// @dev Internal function which uninstalls a transfer compliance module. + function _uninstallModule(uint256 moduleTypeId, address module) internal virtual { + require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); + if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { + require( + _transferComplianceModules.remove(module), + ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module) + ); + } else if (moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE) { + require( + _forceTransferComplianceModules.remove(module), + ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module) + ); + } + emit ModuleUninstalled(moduleTypeId, module); + } + + /// @dev Checks if a transfer is compliant. + function _isTransferCompliantTransfer( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (bool) { + address[] memory modules = _transferComplianceModules.values(); uint256 modulesLength = modules.length; - for (uint256 index = 0; index < modulesLength; index++) { - address module = modules[index]; - if (!IERC7984RwaIdentityComplianceModule(module).isCompliantIdentity(identity)) { + for (uint256 i = 0; i < modulesLength; i++) { + if (!IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount)) { return false; } } return true; } - /// @dev Checks if a transfer is compliant. - function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool) { - address[] memory modules = _transferComplianceModules.values(); + /// @dev Checks if a force transfer is compliant. + function _isTransferCompliantForceTransfer( + address from, + address to, + euint64 encryptedAmount + ) internal virtual returns (bool) { + address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; - for (uint256 index = 0; index < modulesLength; index++) { - address module = modules[index]; - if (!IERC7984RwaTransferComplianceModule(module).isCompliantTransfer(from, to, encryptedAmount)) { + for (uint256 i = 0; i < modulesLength; i++) { + if (!IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount)) { return false; } } return true; } - /// @dev Checks if a transfer follows compliance. - function _isCompliant(address from, address to, euint64 encryptedAmount) internal override returns (bool) { - return - _isCompliantIdentity(from) && _isCompliantIdentity(to) && _isCompliantTransfer(from, to, encryptedAmount); + /// @dev Peforms operation after transfer. + function _postTransferHook(address from, address to, euint64 encryptedAmount) internal override { + address[] memory modules = _transferComplianceModules.values(); + uint256 modulesLength = modules.length; + for (uint256 i = 0; i < modulesLength; i++) { + IERC7984RwaTransferComplianceModule(modules[i]).postTransferHook(from, to, encryptedAmount); + } } - /// @dev Checks if a force transfer follows compliance. - function _isForceCompliant( - address /*from*/, - address to, - euint64 /*encryptedAmount*/ - ) internal override returns (bool) { - return _isCompliantIdentity(to); + /// @dev Peforms operation after force transfer. + function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal override { + address[] memory modules = _forceTransferComplianceModules.values(); + uint256 modulesLength = modules.length; + for (uint256 i = 0; i < modulesLength; i++) { + IERC7984RwaTransferComplianceModule(modules[i]).postTransferHook(from, to, encryptedAmount); + } } } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol deleted file mode 100644 index 7c1e9abc..00000000 --- a/contracts/token/ERC7984/extensions/ERC7984RwaIdentityComplianceModule.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.27; - -import {IERC7984RwaComplianceModule, IERC7984RwaIdentityComplianceModule, IDENTITY_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; - -/** - * @dev A contract which allows to build an identity compliance module for confidential Real World Assets (RWAs). - */ -abstract contract ERC7984RwaIdentityComplianceModule is - IERC7984RwaComplianceModule, - IERC7984RwaIdentityComplianceModule -{ - /// @inheritdoc IERC7984RwaComplianceModule - function isModuleType(uint256 moduleTypeId) public pure override returns (bool) { - return moduleTypeId == IDENTITY_COMPLIANCE_MODULE_TYPE; - } - - /// @inheritdoc IERC7984RwaIdentityComplianceModule - function isCompliantIdentity(address identity) public virtual returns (bool) { - return _isCompliantIdentity(identity); - } - - function _isCompliantIdentity(address identity) internal virtual returns (bool); -} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol index ba8b0283..a245fc08 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol @@ -3,18 +3,15 @@ pragma solidity ^0.8.27; import {euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {IERC7984RwaComplianceModule, IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; /** - * @dev A contract which allows to build an transfer compliance module for confidential Real World Assets (RWAs). + * @dev A contract which allows to build a transfer compliance module for confidential Real World Assets (RWAs). */ -abstract contract ERC7984RwaTransferComplianceModule is - IERC7984RwaComplianceModule, - IERC7984RwaTransferComplianceModule -{ - /// @inheritdoc IERC7984RwaComplianceModule - function isModuleType(uint256 moduleTypeId) public pure override returns (bool) { - return moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE; +abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule { + /// @inheritdoc IERC7984RwaTransferComplianceModule + function isModule() public pure override returns (bytes4) { + return this.isModule.selector; } /// @inheritdoc IERC7984RwaTransferComplianceModule @@ -22,5 +19,23 @@ abstract contract ERC7984RwaTransferComplianceModule is return _isCompliantTransfer(from, to, encryptedAmount); } - function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + /// @inheritdoc IERC7984RwaTransferComplianceModule + function postTransferHook(address from, address to, euint64 encryptedAmount) public virtual { + _postTransferHook(from, to, encryptedAmount); + } + + /// @dev Internal function which checks if a transfer is compliant. + function _isCompliantTransfer( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal virtual returns (bool) { + // default to non-compliant + return false; + } + + /// @dev Internal function which peforms operation after transfer. + function _postTransferHook(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal virtual { + // default to no-op + } } From 30ca7dfe897662dd7348dec2dc5fe785b5d1d71a Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:35:44 +0200 Subject: [PATCH 035/111] Typo --- contracts/interfaces/IERC7984Rwa.sol | 2 +- contracts/token/ERC7984/extensions/ERC7984Rwa.sol | 4 ++-- contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol | 4 ++-- .../ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index f0c06628..2ce4b4ef 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -100,6 +100,6 @@ interface IERC7984RwaTransferComplianceModule { 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 (bool); - /// @dev Peforms operation after transfer. + /// @dev Performs operation after transfer. function postTransferHook(address from, address to, euint64 encryptedAmount) external; } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 84cd52b1..cddead24 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -205,9 +205,9 @@ abstract contract ERC7984Rwa is euint64 encryptedAmount ) internal virtual returns (bool); - /// @dev Peforms operation after transfer. + /// @dev Performs operation after transfer. function _postTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} - /// @dev Peforms operation after force transfer. + /// @dev Performs operation after force transfer. function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol index db779b0e..d97b8b6b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol @@ -126,7 +126,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { return true; } - /// @dev Peforms operation after transfer. + /// @dev Performs operation after transfer. function _postTransferHook(address from, address to, euint64 encryptedAmount) internal override { address[] memory modules = _transferComplianceModules.values(); uint256 modulesLength = modules.length; @@ -135,7 +135,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { } } - /// @dev Peforms operation after force transfer. + /// @dev Performs operation after force transfer. function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal override { address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol index a245fc08..24a0b0a6 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol @@ -34,7 +34,7 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl return false; } - /// @dev Internal function which peforms operation after transfer. + /// @dev Internal function which Performs operation after transfer. function _postTransferHook(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal virtual { // default to no-op } From 57ab5005c371f9867ea14fe4dd37937d1ca78d67 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 9 Sep 2025 13:02:50 +0200 Subject: [PATCH 036/111] Init investor cap module --- .../ERC7984RwaInvestorCapModule.sol | 54 +++++++++++++++++++ .../ERC7984RwaTransferComplianceModule.sol | 11 +++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol new file mode 100644 index 00000000..4e804444 --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {ERC7984RwaTransferComplianceModule} from "../ERC7984RwaTransferComplianceModule.sol"; + +/** + * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the number of investors. + */ +abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceModule { + using EnumerableSet for *; + + uint256 private _maxInvestor; + EnumerableSet.AddressSet private _investors; + + constructor(address compliance, uint256 maxInvestor) ERC7984RwaTransferComplianceModule(compliance) { + setMaxInvestor(maxInvestor); + } + + /// @dev Sets max number of investors. + function setMaxInvestor(uint256 maxInvestor) public virtual onlyCompliance { + _maxInvestor = maxInvestor; + } + + /// @dev Gets max number of investors. + function getMaxInvestor() public virtual returns (uint256) { + return _maxInvestor; + } + + /// @dev Internal function which checks if a transfer is compliant. + function _isCompliantTransfer( + address /*from*/, + address to, + euint64 /*encryptedAmount*/ + ) internal override returns (bool) { + if ( + to == address(0) || // burning + _investors.contains(to) || // or already investor + _investors.length() < _maxInvestor // or not reached max investors limit + ) { + return true; + } + return false; + } + + /// @dev Internal function which Performs operation after transfer. + function _postTransferHook(address /*from*/, address to, euint64 /*encryptedAmount*/) internal override { + if (!_investors.contains(to)) { + _investors.add(to); + } + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol index 24a0b0a6..120a4e0c 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol @@ -3,12 +3,21 @@ pragma solidity ^0.8.27; import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; /** * @dev A contract which allows to build a transfer compliance module for confidential Real World Assets (RWAs). */ -abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule { +abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule, Ownable { + /// @dev Throws if called by any account other than the compliance. + modifier onlyCompliance() { + _checkOwner(); + _; + } + + constructor(address compliance) Ownable(compliance) {} + /// @inheritdoc IERC7984RwaTransferComplianceModule function isModule() public pure override returns (bytes4) { return this.isModule.selector; From ed06057c48c0d4541b1a368662edf418f81d9023 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:27:46 +0200 Subject: [PATCH 037/111] Move rwa compliance contracts --- .../rwa/ERC7984RwaBalanceCapModule.sol | 53 +++++++++++++++++++ .../{ => rwa}/ERC7984RwaCompliance.sol | 4 +- .../ERC7984RwaInvestorCapModule.sol | 2 +- .../ERC7984RwaTransferComplianceModule.sol | 2 +- 4 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol rename contracts/token/ERC7984/extensions/{ => rwa}/ERC7984RwaCompliance.sol (98%) rename contracts/token/ERC7984/extensions/{ERC7984Rwa => rwa}/ERC7984RwaInvestorCapModule.sol (94%) rename contracts/token/ERC7984/extensions/{ => rwa}/ERC7984RwaTransferComplianceModule.sol (96%) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol new file mode 100644 index 00000000..18f7fe62 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -0,0 +1,53 @@ +// 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 {IERC7984} from "./../../../../interfaces/IERC7984.sol"; +import {FHESafeMath} from "../../../../utils/FHESafeMath.sol"; +import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.sol"; + +/** + * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the balance of an investor. + */ +abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModule { + using EnumerableSet for *; + + euint64 private _maxBalance; + address private _token; + + constructor(address compliance, euint64 maxBalance) ERC7984RwaTransferComplianceModule(compliance) { + setMaxBalance(maxBalance); + } + + /// @dev Sets max balance of an investor. + function setMaxBalance(euint64 maxBalance) public virtual onlyCompliance { + _maxBalance = maxBalance; + } + + /// @dev Gets max balance of an investor. + function getMaxBalance() public 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 (bool) { + if ( + to == address(0) // burning + ) { + return true; + } + (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease( + IERC7984(_token).confidentialBalanceOf(to), + encryptedAmount + ); + ebool isCompliant = FHE.and(increased, FHE.le(futureBalance, _maxBalance)); + isCompliant; + return false; + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol similarity index 98% rename from contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol rename to contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index d97b8b6b..0459550f 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.27; import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; -import {ERC7984Rwa} from "./ERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; +import {ERC7984Rwa} from "../ERC7984Rwa.sol"; /** * @dev Extension of {ERC7984Rwa} that supports compliance modules for confidential Real World Assets (RWAs). diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol similarity index 94% rename from contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol rename to contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 4e804444..48c7fc00 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {ERC7984RwaTransferComplianceModule} from "../ERC7984RwaTransferComplianceModule.sol"; +import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.sol"; /** * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the number of investors. diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol similarity index 96% rename from contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol rename to contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index 120a4e0c..a5daf4a3 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; /** * @dev A contract which allows to build a transfer compliance module for confidential Real World Assets (RWAs). From 4b4d558a819c300306bdfde417f292cd391ff175 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:45:10 +0200 Subject: [PATCH 038/111] Support confidential rwa module --- contracts/interfaces/IERC7984Rwa.sol | 4 +- contracts/mocks/token/ERC7984RwaMock.sol | 16 +++--- .../ERC7984/extensions/ERC7984Freezable.sol | 3 ++ .../token/ERC7984/extensions/ERC7984Rwa.sol | 26 +++++++--- .../rwa/ERC7984RwaBalanceCapModule.sol | 10 ++-- .../extensions/rwa/ERC7984RwaCompliance.sol | 30 +++++++----- .../rwa/ERC7984RwaInvestorCapModule.sol | 13 ++--- .../ERC7984RwaTransferComplianceModule.sol | 8 +-- .../ERC7984/extensions/ERC7984Rwa.test.ts | 49 ++++++++++++------- 9 files changed, 98 insertions(+), 61 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 2ce4b4ef..fa3767e8 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 {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; @@ -99,7 +99,7 @@ interface IERC7984RwaTransferComplianceModule { /// @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 (bool); + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (ebool); /// @dev Performs operation after transfer. function postTransferHook(address from, address to, euint64 encryptedAmount) external; } diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index f3c93708..4ea777c7 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, euint64, externalEuint64, ebool} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; import {Impl} from "@fhevm/solidity/lib/Impl.sol"; import {ERC7984Rwa} from "../../token/ERC7984/extensions/ERC7984Rwa.sol"; import {FHESafeMath} from "../../utils/FHESafeMath.sol"; @@ -12,8 +12,8 @@ import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; // solhint-disable func-name-mixedcase contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { mapping(address account => euint64 encryptedAmount) private _frozenBalances; - bool public compliantTransfer; - bool public compliantForceTransfer; + bool public compliantTransfer = false; + bool public compliantForceTransfer = false; // TODO: Move modifiers to `ERC7984Rwa` or remove from mock if useless /// @dev Checks if the sender is an admin. @@ -58,16 +58,18 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ - ) internal override returns (bool) { - return compliantTransfer; + ) internal override returns (ebool compliant) { + compliant = FHE.asEbool(compliantTransfer); + FHE.allowThis(compliant); } function _isForceTransferCompliant( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ - ) internal override returns (bool) { - return compliantForceTransfer; + ) internal override returns (ebool compliant) { + compliant = FHE.asEbool(compliantForceTransfer); + FHE.allowThis(compliant); } function _validateHandleAllowance(bytes32 handle) internal view override onlyAdminOrAgent {} diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 025be19a..c1a61ab3 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -38,6 +38,9 @@ abstract contract ERC7984Freezable is ERC7984 { confidentialBalanceOf(account), confidentialFrozen(account) ); + if (!FHE.isInitialized(unfrozen)) { + return unfrozen; + } return FHE.select(success, unfrozen, FHE.asEuint64(0)); } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index cddead24..937409d8 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; @@ -30,8 +30,6 @@ abstract contract ERC7984Rwa is /// @dev The caller account is not authorized to perform the operation. error UnauthorizedSender(address account); - /// @dev The transfer does not follow token compliance. - error UncompliantTransfer(address from, address to, euint64 encryptedAmount); /// @dev Checks if the sender is an admin or an agent. modifier onlyAdminOrAgent() { @@ -167,7 +165,14 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal virtual returns (euint64 transferred) { - require(_isForceTransferCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + if (!FHE.isInitialized(encryptedAmount)) { + return encryptedAmount; + } + encryptedAmount = FHE.select( + _isForceTransferCompliant(from, to, encryptedAmount), + encryptedAmount, + FHE.asEuint64(0) + ); _disableERC7984FreezableUpdateCheck(); // bypass frozen check _disableERC7984RestrictedUpdateCheck(); // bypass default restriction check if (to != address(0)) _checkRestriction(to); // only perform restriction check on `to` @@ -183,7 +188,14 @@ abstract contract ERC7984Rwa is address to, euint64 encryptedAmount ) internal override(ERC7984Freezable, ERC7984Restricted, ERC7984) whenNotPaused returns (euint64 transferred) { - require(_isTransferCompliant(from, to, encryptedAmount), UncompliantTransfer(from, to, encryptedAmount)); + if (!FHE.isInitialized(encryptedAmount)) { + return encryptedAmount; + } + encryptedAmount = FHE.select( + _isTransferCompliant(from, to, encryptedAmount), + encryptedAmount, + FHE.asEuint64(0) + ); // frozen and restriction checks performed through inheritance transferred = super._update(from, to, encryptedAmount); _postTransferHook(from, to, encryptedAmount); @@ -196,14 +208,14 @@ abstract contract ERC7984Rwa is function _checkFreezer() internal override onlyAdminOrAgent {} /// @dev Checks if a transfer follows compliance. - function _isTransferCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); + function _isTransferCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (ebool); /// @dev Checks if a force transfer follows compliance. function _isForceTransferCompliant( address from, address to, euint64 encryptedAmount - ) internal virtual returns (bool); + ) internal virtual returns (ebool); /// @dev Performs operation after transfer. function _postTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 18f7fe62..5468f6c7 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -36,18 +36,16 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModu address /*from*/, address to, euint64 encryptedAmount - ) internal override returns (bool) { + ) internal override returns (ebool compliant) { if ( - to == address(0) // burning + !FHE.isInitialized(encryptedAmount) || to == address(0) // if no amount or burning ) { - return true; + return FHE.asEbool(true); } (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease( IERC7984(_token).confidentialBalanceOf(to), encryptedAmount ); - ebool isCompliant = FHE.and(increased, FHE.le(futureBalance, _maxBalance)); - isCompliant; - return false; + compliant = FHE.and(increased, FHE.le(futureBalance, _maxBalance)); } } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index 0459550f..7d52e878 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; import {ERC7984Rwa} from "../ERC7984Rwa.sol"; @@ -99,15 +99,19 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { address from, address to, euint64 encryptedAmount - ) internal virtual returns (bool) { + ) internal virtual returns (ebool compliant) { + if (!FHE.isInitialized(encryptedAmount)) { + return FHE.asEbool(true); + } address[] memory modules = _transferComplianceModules.values(); uint256 modulesLength = modules.length; + compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { - if (!IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount)) { - return false; - } + compliant = FHE.and( + compliant, + IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + ); } - return true; } /// @dev Checks if a force transfer is compliant. @@ -115,15 +119,19 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { address from, address to, euint64 encryptedAmount - ) internal virtual returns (bool) { + ) internal virtual returns (ebool compliant) { + if (!FHE.isInitialized(encryptedAmount)) { + return FHE.asEbool(true); + } address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; + compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { - if (!IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount)) { - return false; - } + compliant = FHE.and( + compliant, + IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + ); } - return true; } /// @dev Performs operation after transfer. diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 48c7fc00..e63fbd18 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.sol"; @@ -33,16 +33,17 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceMod function _isCompliantTransfer( address /*from*/, address to, - euint64 /*encryptedAmount*/ - ) internal override returns (bool) { + euint64 encryptedAmount + ) internal override returns (ebool) { if ( - to == address(0) || // burning + FHE.isInitialized(encryptedAmount) || // no amount + to == address(0) || // or burning _investors.contains(to) || // or already investor _investors.length() < _maxInvestor // or not reached max investors limit ) { - return true; + return FHE.asEbool(true); } - return false; + return FHE.asEbool(false); } /// @dev Internal function which Performs operation after transfer. diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index a5daf4a3..d0d53397 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; -import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; @@ -24,7 +24,7 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl } /// @inheritdoc IERC7984RwaTransferComplianceModule - function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (bool) { + function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { return _isCompliantTransfer(from, to, encryptedAmount); } @@ -38,9 +38,9 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ - ) internal virtual returns (bool) { + ) internal virtual returns (ebool) { // default to non-compliant - return false; + return FHE.asEbool(false); } /// @dev Internal function which Performs operation after transfer. diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index 3f3ef38d..cf540458 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -188,13 +188,16 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) .encrypt(); - await expect( + const [, , transferred] = await callAndGetResult( token .connect(admin) ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(ethers.ZeroAddress, recipient.address, encryptedInput.handles[0]); + transferEventSignature, + ); + await token.getHandleAllowance(transferred, admin.address, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferred, await token.getAddress(), admin), + ).to.eventually.equal(0); }); it('should not mint if paused', async function () { @@ -291,13 +294,19 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) .encrypt(); - await expect( + await token + .connect(admin) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); + const [, , transferredHandle] = await callAndGetResult( token .connect(admin) ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(recipient.address, ethers.ZeroAddress, encryptedInput.handles[0]); + transferEventSignature, + ); + await token.getHandleAllowance(transferredHandle, admin.address, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), admin), + ).to.eventually.equal(0); }); it('should not burn if paused', async function () { @@ -346,7 +355,6 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.inputProof, ); await token.$_unsetCompliant(); - expect(await token.compliantTransfer()).to.be.false; const amount = 25; let params = [recipient.address, anyone.address] as unknown as [ from: AddressLike, @@ -425,7 +433,6 @@ describe('ERC7984Rwa', function () { ); // should force transfer even if not compliant await token.$_unsetCompliant(); - expect(await token.compliantTransfer()).to.be.false; // should force transfer even if paused await token.connect(manager).pause(); expect(await token.paused()).to.be.true; @@ -578,7 +585,6 @@ describe('ERC7984Rwa', function () { .add64(amount) .encrypt(); await token.$_setCompliant(); - expect(await token.compliantTransfer()).to.be.true; const [from, to, transferredHandle] = await callAndGetResult( token .connect(recipient) @@ -631,13 +637,19 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if transfer not compliant', async function () { - const { token, recipient, anyone } = await fixture(); + const { token, admin, recipient, anyone } = await fixture(); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(25) + .encrypt(); + await token + .connect(admin) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - expect(await token.compliantTransfer()).to.be.false; - await expect( + const [, , transferredHandle] = await callAndGetResult( token .connect(recipient) ['confidentialTransfer(address,bytes32,bytes)']( @@ -645,9 +657,11 @@ describe('ERC7984Rwa', function () { encryptedTransferValueInput.handles[0], encryptedTransferValueInput.inputProof, ), - ) - .to.be.revertedWithCustomError(token, 'UncompliantTransfer') - .withArgs(recipient.address, anyone.address, encryptedTransferValueInput.handles[0]); + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(0); }); it('should not transfer if frozen', async function () { @@ -681,7 +695,6 @@ describe('ERC7984Rwa', function () { .add64(25) .encrypt(); await token.$_setCompliant(); - expect(await token.compliantTransfer()).to.be.true; const [, , transferredHandle] = await callAndGetResult( token .connect(recipient) From 4c459487840bdc65b18a67efc3257113eb62baf6 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:05:06 +0200 Subject: [PATCH 039/111] Rename rwa mock functions --- contracts/mocks/token/ERC7984RwaMock.sol | 8 ++--- .../rwa/ERC7984RwaBalanceCapModule.sol | 3 +- .../ERC7984/extensions/ERC7984Rwa.test.ts | 30 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index 4ea777c7..cffb8e1f 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -34,19 +34,19 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { FHE.allow(encryptedAmount, msg.sender); } - function $_setCompliant() public { + function $_setCompliantTransfer() public { compliantTransfer = true; } - function $_unsetCompliant() public { + function $_unsetCompliantTransfer() public { compliantTransfer = false; } - function $_setForceCompliant() public { + function $_setCompliantForceTransfer() public { compliantForceTransfer = true; } - function $_unsetForceCompliant() public { + function $_unsetCompliantForceTransfer() public { compliantForceTransfer = false; } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 5468f6c7..f83ce1d5 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -17,7 +17,8 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModu euint64 private _maxBalance; address private _token; - constructor(address compliance, euint64 maxBalance) ERC7984RwaTransferComplianceModule(compliance) { + constructor(address compliance, address token, euint64 maxBalance) ERC7984RwaTransferComplianceModule(compliance) { + _token = token; setMaxBalance(maxBalance); } diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index cf540458..576aa8f5 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -131,7 +131,7 @@ describe('ERC7984Rwa', function () { const { admin, agent1, recipient } = await fixture(); for (const manager of [admin, agent1]) { const { token } = await fixture(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); const amount = 100; let params = [recipient.address] as unknown as [ account: AddressLike, @@ -172,7 +172,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await expect( token .connect(anyone) @@ -225,7 +225,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -278,7 +278,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await expect( token .connect(anyone) @@ -334,7 +334,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -354,7 +354,7 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.handles[0], encryptedFrozenValueInput.inputProof, ); - await token.$_unsetCompliant(); + await token.$_unsetCompliantTransfer(); const amount = 25; let params = [recipient.address, anyone.address] as unknown as [ from: AddressLike, @@ -372,7 +372,7 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token.$_setForceCompliant(); + await token.$_setCompliantForceTransfer(); const [from, to, transferredHandle] = await callAndGetResult( token .connect(manager) @@ -411,7 +411,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -432,7 +432,7 @@ describe('ERC7984Rwa', function () { encryptedFrozenValueInput.inputProof, ); // should force transfer even if not compliant - await token.$_unsetCompliant(); + await token.$_unsetCompliantTransfer(); // should force transfer even if paused await token.connect(manager).pause(); expect(await token.paused()).to.be.true; @@ -453,7 +453,7 @@ describe('ERC7984Rwa', function () { await token.connect(manager).createEncryptedAmount(amount); params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); } - await token.$_setForceCompliant(); + await token.$_setCompliantForceTransfer(); const [account, frozenAmountHandle] = await callAndGetResult( token .connect(manager) @@ -559,7 +559,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -584,7 +584,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(amount) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); const [from, to, transferredHandle] = await callAndGetResult( token .connect(recipient) @@ -670,7 +670,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), manager.address) .add64(100) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); await token .connect(manager) ['confidentialMint(address,bytes32,bytes)']( @@ -694,7 +694,7 @@ describe('ERC7984Rwa', function () { .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); const [, , transferredHandle] = await callAndGetResult( token .connect(recipient) @@ -723,7 +723,7 @@ describe('ERC7984Rwa', function () { it(`should not transfer if ${arg ? 'sender' : 'receiver'} blocked `, async function () { const { token, admin: manager, recipient, anyone } = await fixture(); const account = arg ? recipient : anyone; - await token.$_setCompliant(); + await token.$_setCompliantTransfer(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) From 9974faeedb5daf2dc3e3d65c3566b3b2e5abed9f Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:12:10 +0200 Subject: [PATCH 040/111] Immutable token in balance cap module --- .../token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index f83ce1d5..2b195313 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -15,7 +15,7 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModu using EnumerableSet for *; euint64 private _maxBalance; - address private _token; + address private immutable _token; constructor(address compliance, address token, euint64 maxBalance) ERC7984RwaTransferComplianceModule(compliance) { _token = token; From 33325810c520fdf24e23518a9cd36012b9c5695d Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:01:56 +0200 Subject: [PATCH 041/111] Switch to always-on/transfer-only compliance modules --- contracts/interfaces/IERC7984Rwa.sol | 6 +- contracts/mocks/token/ERC7984RwaMock.sol | 4 +- .../token/ERC7984/extensions/ERC7984Rwa.sol | 24 ++--- .../extensions/rwa/ERC7984RwaCompliance.sol | 87 +++++++++++-------- .../rwa/ERC7984RwaInvestorCapModule.sol | 4 +- .../ERC7984RwaTransferComplianceModule.sol | 9 +- 6 files changed, 71 insertions(+), 63 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index fa3767e8..d87b0a2d 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -7,8 +7,8 @@ import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; import {IERC7984Restricted} from "./IERC7984Restricted.sol"; -uint256 constant TRANSFER_COMPLIANCE_MODULE_TYPE = 1; -uint256 constant FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE = 2; +uint256 constant ALWAYS_ON_MODULE_TYPE = 1; +uint256 constant TRANSFER_ONLY_MODULE_TYPE = 2; /// @dev Base interface for confidential RWA contracts. interface IERC7984RwaBase { @@ -101,5 +101,5 @@ interface IERC7984RwaTransferComplianceModule { /// @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 postTransferHook(address from, address to, euint64 encryptedAmount) external; + function postTransfer(address from, address to, euint64 encryptedAmount) external; } diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index cffb8e1f..b89c9164 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -54,7 +54,7 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return _mint(to, FHE.asEuint64(amount)); } - function _isTransferCompliant( + function _preCheckTransfer( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ @@ -63,7 +63,7 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { FHE.allowThis(compliant); } - function _isForceTransferCompliant( + function _preCheckForceTransfer( address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 937409d8..50d7b1b7 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -169,7 +169,7 @@ abstract contract ERC7984Rwa is return encryptedAmount; } encryptedAmount = FHE.select( - _isForceTransferCompliant(from, to, encryptedAmount), + _preCheckForceTransfer(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0) ); @@ -177,7 +177,7 @@ abstract contract ERC7984Rwa is _disableERC7984RestrictedUpdateCheck(); // bypass default restriction check if (to != address(0)) _checkRestriction(to); // only perform restriction check on `to` transferred = super._update(from, to, encryptedAmount); // bypass compliance check - _postForceTransferHook(from, to, encryptedAmount); + _postForceTransfer(from, to, encryptedAmount); _restoreERC7984FreezableUpdateCheck(); _restoreERC7984RestrictedUpdateCheck(); } @@ -191,14 +191,10 @@ abstract contract ERC7984Rwa is if (!FHE.isInitialized(encryptedAmount)) { return encryptedAmount; } - encryptedAmount = FHE.select( - _isTransferCompliant(from, to, encryptedAmount), - encryptedAmount, - FHE.asEuint64(0) - ); + encryptedAmount = FHE.select(_preCheckTransfer(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0)); // frozen and restriction checks performed through inheritance transferred = super._update(from, to, encryptedAmount); - _postTransferHook(from, to, encryptedAmount); + _postTransfer(from, to, encryptedAmount); } /** @@ -208,18 +204,14 @@ abstract contract ERC7984Rwa is function _checkFreezer() internal override onlyAdminOrAgent {} /// @dev Checks if a transfer follows compliance. - function _isTransferCompliant(address from, address to, euint64 encryptedAmount) internal virtual returns (ebool); + function _preCheckTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (ebool); /// @dev Checks if a force transfer follows compliance. - function _isForceTransferCompliant( - address from, - address to, - euint64 encryptedAmount - ) internal virtual returns (ebool); + function _preCheckForceTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (ebool); /// @dev Performs operation after transfer. - function _postTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} + function _postTransfer(address from, address to, euint64 encryptedAmount) internal virtual {} /// @dev Performs operation after force transfer. - function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal virtual {} + function _postForceTransfer(address from, address to, euint64 encryptedAmount) internal virtual {} } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index 7d52e878..c66d1b65 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {FHE, ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, TRANSFER_ONLY_MODULE_TYPE, ALWAYS_ON_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; import {ERC7984Rwa} from "../ERC7984Rwa.sol"; /** @@ -14,8 +14,8 @@ import {ERC7984Rwa} from "../ERC7984Rwa.sol"; abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { using EnumerableSet for *; - EnumerableSet.AddressSet private _transferComplianceModules; - EnumerableSet.AddressSet private _forceTransferComplianceModules; + EnumerableSet.AddressSet private _alwaysOnModules; + EnumerableSet.AddressSet private _transferOnlyModules; /// @dev Emitted when a module is installed. event ModuleInstalled(uint256 moduleTypeId, address module); @@ -40,7 +40,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { * * Force transfer compliance module */ function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { - return moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE || moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE; + return moduleTypeId == ALWAYS_ON_MODULE_TYPE || moduleTypeId == TRANSFER_ONLY_MODULE_TYPE; } /** @@ -66,13 +66,10 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { ERC7984RwaNotTransferComplianceModule(module) ); - if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { - require(_transferComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); - } else if (moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE) { - require( - _forceTransferComplianceModules.add(module), - ERC7984RwaAlreadyInstalledModule(moduleTypeId, module) - ); + if (moduleTypeId == ALWAYS_ON_MODULE_TYPE) { + require(_alwaysOnModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == TRANSFER_ONLY_MODULE_TYPE) { + require(_transferOnlyModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); } emit ModuleInstalled(moduleTypeId, module); } @@ -80,22 +77,42 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { /// @dev Internal function which uninstalls a transfer compliance module. function _uninstallModule(uint256 moduleTypeId, address module) internal virtual { require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); - if (moduleTypeId == TRANSFER_COMPLIANCE_MODULE_TYPE) { - require( - _transferComplianceModules.remove(module), - ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module) - ); - } else if (moduleTypeId == FORCE_TRANSFER_COMPLIANCE_MODULE_TYPE) { - require( - _forceTransferComplianceModules.remove(module), - ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module) - ); + if (moduleTypeId == ALWAYS_ON_MODULE_TYPE) { + require(_alwaysOnModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module)); + } else if (moduleTypeId == TRANSFER_ONLY_MODULE_TYPE) { + require(_transferOnlyModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module)); } emit ModuleUninstalled(moduleTypeId, module); } - /// @dev Checks if a transfer is compliant. - function _isTransferCompliantTransfer( + /// @dev Checks if a transfer follows compliance. + function _preCheckTransfer(address from, address to, euint64 encryptedAmount) internal override returns (ebool) { + return + FHE.and(_checkAlwaysBefore(from, to, encryptedAmount), _checkOnlyBeforeTransfer(from, to, encryptedAmount)); + } + + /// @dev Checks if a force transfer follows compliance. + function _preCheckForceTransfer( + address from, + address to, + euint64 encryptedAmount + ) internal override returns (ebool) { + return _checkAlwaysBefore(from, to, encryptedAmount); + } + + /// @dev Peforms operations after transfer. + function _postTransfer(address from, address to, euint64 encryptedAmount) internal override { + _runAlwaysAfter(from, to, encryptedAmount); + _runOnlyAfterTransfer(from, to, encryptedAmount); + } + + /// @dev Peforms operations after force transfer. + function _postForceTransfer(address from, address to, euint64 encryptedAmount) internal override { + _runAlwaysAfter(from, to, encryptedAmount); + } + + /// @dev Checks always-on compliance. + function _checkAlwaysBefore( address from, address to, euint64 encryptedAmount @@ -103,7 +120,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { if (!FHE.isInitialized(encryptedAmount)) { return FHE.asEbool(true); } - address[] memory modules = _transferComplianceModules.values(); + address[] memory modules = _alwaysOnModules.values(); uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { @@ -114,8 +131,8 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { } } - /// @dev Checks if a force transfer is compliant. - function _isTransferCompliantForceTransfer( + /// @dev Checks transfer-only compliance. + function _checkOnlyBeforeTransfer( address from, address to, euint64 encryptedAmount @@ -123,7 +140,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { if (!FHE.isInitialized(encryptedAmount)) { return FHE.asEbool(true); } - address[] memory modules = _forceTransferComplianceModules.values(); + address[] memory modules = _transferOnlyModules.values(); uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { @@ -134,21 +151,21 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { } } - /// @dev Performs operation after transfer. - function _postTransferHook(address from, address to, euint64 encryptedAmount) internal override { - address[] memory modules = _transferComplianceModules.values(); + /// @dev Runs always. + 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++) { - IERC7984RwaTransferComplianceModule(modules[i]).postTransferHook(from, to, encryptedAmount); + IERC7984RwaTransferComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } } - /// @dev Performs operation after force transfer. - function _postForceTransferHook(address from, address to, euint64 encryptedAmount) internal override { - address[] memory modules = _forceTransferComplianceModules.values(); + /// @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++) { - IERC7984RwaTransferComplianceModule(modules[i]).postTransferHook(from, to, encryptedAmount); + IERC7984RwaTransferComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } } } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index e63fbd18..f0cc42f7 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -46,8 +46,8 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceMod return FHE.asEbool(false); } - /// @dev Internal function which Performs operation after transfer. - function _postTransferHook(address /*from*/, address to, euint64 /*encryptedAmount*/) internal override { + /// @dev Internal function which performs operation after transfer. + function _postTransfer(address /*from*/, address to, euint64 /*encryptedAmount*/) internal override { if (!_investors.contains(to)) { _investors.add(to); } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index d0d53397..91870459 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -4,8 +4,7 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IERC7984RwaTransferComplianceModule, TRANSFER_COMPLIANCE_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; - +import {IERC7984RwaTransferComplianceModule} from "./../../../../interfaces/IERC7984Rwa.sol"; /** * @dev A contract which allows to build a transfer compliance module for confidential Real World Assets (RWAs). */ @@ -29,8 +28,8 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl } /// @inheritdoc IERC7984RwaTransferComplianceModule - function postTransferHook(address from, address to, euint64 encryptedAmount) public virtual { - _postTransferHook(from, to, encryptedAmount); + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { + _postTransfer(from, to, encryptedAmount); } /// @dev Internal function which checks if a transfer is compliant. @@ -44,7 +43,7 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl } /// @dev Internal function which Performs operation after transfer. - function _postTransferHook(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal virtual { + function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal virtual { // default to no-op } } From c81b703f95c1396735304cd34e8d58ff09e2aace Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:04:15 +0200 Subject: [PATCH 042/111] Typo --- .../token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index c66d1b65..043337c3 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -100,13 +100,13 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { return _checkAlwaysBefore(from, to, encryptedAmount); } - /// @dev Peforms operations after transfer. + /// @dev Performs operations after transfer. function _postTransfer(address from, address to, euint64 encryptedAmount) internal override { _runAlwaysAfter(from, to, encryptedAmount); _runOnlyAfterTransfer(from, to, encryptedAmount); } - /// @dev Peforms operations after force transfer. + /// @dev Performs operations after force transfer. function _postForceTransfer(address from, address to, euint64 encryptedAmount) internal override { _runAlwaysAfter(from, to, encryptedAmount); } From 7d438c320700e8cfa590830e189c7457e9396477 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:27:14 +0200 Subject: [PATCH 043/111] Use enum for compliance module type --- contracts/interfaces/IERC7984Rwa.sol | 12 +++-- .../extensions/rwa/ERC7984RwaCompliance.sol | 52 +++++++++---------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index d87b0a2d..f90a5c6f 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -7,9 +7,6 @@ import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol"; import {IERC7984} from "./IERC7984.sol"; import {IERC7984Restricted} from "./IERC7984Restricted.sol"; -uint256 constant ALWAYS_ON_MODULE_TYPE = 1; -uint256 constant TRANSFER_ONLY_MODULE_TYPE = 2; - /// @dev Base interface for confidential RWA contracts. interface IERC7984RwaBase { /// @dev Emitted when the contract is paused. @@ -88,10 +85,15 @@ interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} /// @dev Interface for confidential RWA compliance. interface IERC7984RwaCompliance { + enum ComplianceModuleType { + ALWAYS_ON, + TRANSFER_ONLY + } + /// @dev Installs a transfer compliance module. - function installModule(uint256 moduleTypeId, address module) external; + function installModule(ComplianceModuleType moduleType, address module) external; /// @dev Uninstalls a transfer compliance module. - function uninstallModule(uint256 moduleTypeId, address module) external; + function uninstallModule(ComplianceModuleType moduleType, address module) external; } /// @dev Interface for confidential RWA transfer compliance module. diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index 043337c3..0bc16790 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.27; import {FHE, ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule, TRANSFER_ONLY_MODULE_TYPE, ALWAYS_ON_MODULE_TYPE} from "./../../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule} from "./../../../../interfaces/IERC7984Rwa.sol"; import {ERC7984Rwa} from "../ERC7984Rwa.sol"; /** @@ -18,18 +18,18 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { EnumerableSet.AddressSet private _transferOnlyModules; /// @dev Emitted when a module is installed. - event ModuleInstalled(uint256 moduleTypeId, address module); + event ModuleInstalled(ComplianceModuleType moduleType, address module); /// @dev Emitted when a module is uninstalled. - event ModuleUninstalled(uint256 moduleTypeId, address module); + event ModuleUninstalled(ComplianceModuleType moduleType, address module); /// @dev The module type is not supported. - error ERC7984RwaUnsupportedModuleType(uint256 moduleTypeId); + 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(uint256 moduleTypeId, address module); + error ERC7984RwaAlreadyInstalledModule(ComplianceModuleType moduleType, address module); /// @dev The module is already uninstalled. - error ERC7984RwaAlreadyUninstalledModule(uint256 moduleTypeId, address module); + error ERC7984RwaAlreadyUninstalledModule(ComplianceModuleType moduleType, address module); /** * @dev Check if a certain module typeId is supported. @@ -39,8 +39,8 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { * * Transfer compliance module * * Force transfer compliance module */ - function supportsModule(uint256 moduleTypeId) public view virtual returns (bool) { - return moduleTypeId == ALWAYS_ON_MODULE_TYPE || moduleTypeId == TRANSFER_ONLY_MODULE_TYPE; + function supportsModule(ComplianceModuleType moduleType) public view virtual returns (bool) { + return moduleType == ComplianceModuleType.ALWAYS_ON || moduleType == ComplianceModuleType.TRANSFER_ONLY; } /** @@ -48,41 +48,41 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { * @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(uint256 moduleTypeId, address module) public virtual onlyAdminOrAgent { - _installModule(moduleTypeId, module); + function installModule(ComplianceModuleType moduleType, address module) public virtual onlyAdminOrAgent { + _installModule(moduleType, module); } /// @inheritdoc IERC7984RwaCompliance - function uninstallModule(uint256 moduleTypeId, address module) public virtual onlyAdminOrAgent { - _uninstallModule(moduleTypeId, module); + function uninstallModule(ComplianceModuleType moduleType, address module) public virtual onlyAdminOrAgent { + _uninstallModule(moduleType, module); } /// @dev Internal function which installs a transfer compliance module. - function _installModule(uint256 moduleTypeId, address module) internal virtual { - require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); + function _installModule(ComplianceModuleType moduleType, address module) internal virtual { + require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); require( IERC7984RwaTransferComplianceModule(module).isModule() == IERC7984RwaTransferComplianceModule.isModule.selector, ERC7984RwaNotTransferComplianceModule(module) ); - if (moduleTypeId == ALWAYS_ON_MODULE_TYPE) { - require(_alwaysOnModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); - } else if (moduleTypeId == TRANSFER_ONLY_MODULE_TYPE) { - require(_transferOnlyModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleTypeId, module)); + if (moduleType == ComplianceModuleType.ALWAYS_ON) { + require(_alwaysOnModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + } else if (moduleType == ComplianceModuleType.TRANSFER_ONLY) { + require(_transferOnlyModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); } - emit ModuleInstalled(moduleTypeId, module); + emit ModuleInstalled(moduleType, module); } /// @dev Internal function which uninstalls a transfer compliance module. - function _uninstallModule(uint256 moduleTypeId, address module) internal virtual { - require(supportsModule(moduleTypeId), ERC7984RwaUnsupportedModuleType(moduleTypeId)); - if (moduleTypeId == ALWAYS_ON_MODULE_TYPE) { - require(_alwaysOnModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module)); - } else if (moduleTypeId == TRANSFER_ONLY_MODULE_TYPE) { - require(_transferOnlyModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleTypeId, module)); + function _uninstallModule(ComplianceModuleType moduleType, address module) internal virtual { + require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); + if (moduleType == ComplianceModuleType.ALWAYS_ON) { + require(_alwaysOnModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); + } else if (moduleType == ComplianceModuleType.TRANSFER_ONLY) { + require(_transferOnlyModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); } - emit ModuleUninstalled(moduleTypeId, module); + emit ModuleUninstalled(moduleType, module); } /// @dev Checks if a transfer follows compliance. From fea22af80c3f589a8abc38c08627ee9c649ea054 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:18:54 +0200 Subject: [PATCH 044/111] Enable token handles access to modules --- contracts/interfaces/IERC7984Rwa.sol | 6 + .../token/ERC7984RwaBalanceCapModuleMock.sol | 10 + .../token/ERC7984RwaInvestorCapModuleMock.sol | 10 + .../token/ERC7984RwaModularComplianceMock.sol | 15 ++ .../rwa/ERC7984RwaBalanceCapModule.sol | 33 +-- .../extensions/rwa/ERC7984RwaCompliance.sol | 37 ++- .../rwa/ERC7984RwaInvestorCapModule.sol | 14 +- .../ERC7984RwaTransferComplianceModule.sol | 53 +++- .../extensions/ERC7984RwaCompliance.test.ts | 238 ++++++++++++++++++ 9 files changed, 379 insertions(+), 37 deletions(-) create mode 100644 contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol create mode 100644 contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol create mode 100644 contracts/mocks/token/ERC7984RwaModularComplianceMock.sol create mode 100644 test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index f90a5c6f..93b965de 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -21,6 +21,10 @@ interface IERC7984RwaBase { /// @dev The operation failed because the contract is paused. error EnforcedPause(); + /// @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 the contract is paused, and false otherwise. function paused() external view returns (bool); /// @dev Pauses contract. @@ -94,6 +98,8 @@ interface IERC7984RwaCompliance { function installModule(ComplianceModuleType moduleType, address module) external; /// @dev Uninstalls a transfer compliance module. function uninstallModule(ComplianceModuleType moduleType, address module) external; + /// @dev Checks if a compliance module is installed. + function isModuleInstalled(ComplianceModuleType moduleType, address module) external view returns (bool); } /// @dev Interface for confidential RWA transfer compliance module. diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol new file mode 100644 index 00000000..8b2610ca --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ERC7984RwaBalanceCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol"; + +contract ERC7984RwaBalanceCapModuleMock is ERC7984RwaBalanceCapModule, SepoliaConfig { + constructor(address compliance) ERC7984RwaBalanceCapModule(compliance) {} +} diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol new file mode 100644 index 00000000..c2306d60 --- /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 compliance, uint256 maxInvestor) ERC7984RwaInvestorCapModule(compliance, maxInvestor) {} +} diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol new file mode 100644 index 00000000..b3f1409f --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Impl} from "@fhevm/solidity/lib/Impl.sol"; +import {ERC7984Rwa} from "../../token/ERC7984/extensions/ERC7984Rwa.sol"; +import {ERC7984RwaCompliance} from "../../token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol"; +import {FHESafeMath} from "../../utils/FHESafeMath.sol"; +import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; + +contract ERC7984RwaModularComplianceMock is ERC7984RwaCompliance, SepoliaConfig { + constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} +} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 2b195313..4cf59907 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.27; -import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; +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 {IERC7984} from "../../../../interfaces/IERC7984.sol"; import {FHESafeMath} from "../../../../utils/FHESafeMath.sol"; import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.sol"; @@ -14,17 +14,21 @@ import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferCompliance abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModule { using EnumerableSet for *; - euint64 private _maxBalance; address private immutable _token; + euint64 private _maxBalance; - constructor(address compliance, address token, euint64 maxBalance) ERC7984RwaTransferComplianceModule(compliance) { + constructor(address token) ERC7984RwaTransferComplianceModule(token) { _token = token; - setMaxBalance(maxBalance); + } + + /// @dev Sets max balance of an investor with proof. + function setMaxBalance(externalEuint64 maxBalance, bytes calldata inputProof) public virtual onlyTokenAdmin { + FHE.allowThis(_maxBalance = FHE.fromExternal(maxBalance, inputProof)); } /// @dev Sets max balance of an investor. - function setMaxBalance(euint64 maxBalance) public virtual onlyCompliance { - _maxBalance = maxBalance; + function setMaxBalance(euint64 maxBalance) public virtual onlyTokenAdmin { + FHE.allowThis(_maxBalance = maxBalance); } /// @dev Gets max balance of an investor. @@ -38,15 +42,16 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModu address to, euint64 encryptedAmount ) internal override returns (ebool compliant) { - if ( - !FHE.isInitialized(encryptedAmount) || to == address(0) // if no amount or burning - ) { + if (!FHE.isInitialized(encryptedAmount) || to == address(0)) { + // if no amount or burning return FHE.asEbool(true); } - (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease( - IERC7984(_token).confidentialBalanceOf(to), - encryptedAmount - ); + euint64 balance = IERC7984(_token).confidentialBalanceOf(to); + if (FHE.isInitialized(balance)) { + _allowTokenHandleToThis(balance); + } + _allowTokenHandleToThis(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/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol index 0bc16790..f751007f 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol @@ -2,16 +2,17 @@ pragma solidity ^0.8.27; -import {FHE, ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule} from "./../../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule} 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 ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { +abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, HandleAccessManager { using EnumerableSet for *; EnumerableSet.AddressSet private _alwaysOnModules; @@ -30,6 +31,8 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { 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. @@ -57,12 +60,19 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { _uninstallModule(moduleType, module); } + /// @inheritdoc IERC7984RwaCompliance + function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { + return _isModuleInstalled(moduleType, 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(IERC7984RwaTransferComplianceModule.isModule.selector) + ); require( - IERC7984RwaTransferComplianceModule(module).isModule() == - IERC7984RwaTransferComplianceModule.isModule.selector, + success && bytes4(returnData) == IERC7984RwaTransferComplianceModule.isModule.selector, ERC7984RwaNotTransferComplianceModule(module) ); @@ -85,6 +95,13 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { emit ModuleUninstalled(moduleType, module); } + /// @dev Checks if a compliance module is installed. + function _isModuleInstalled(ComplianceModuleType moduleType, address module) internal view virtual returns (bool) { + if (moduleType == ComplianceModuleType.ALWAYS_ON) return _alwaysOnModules.contains(module); + if (moduleType == ComplianceModuleType.TRANSFER_ONLY) return _transferOnlyModules.contains(module); + return false; + } + /// @dev Checks if a transfer follows compliance. function _preCheckTransfer(address from, address to, euint64 encryptedAmount) internal override returns (ebool) { return @@ -151,7 +168,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { } } - /// @dev Runs always. + /// @dev Runs always after. function _runAlwaysAfter(address from, address to, euint64 encryptedAmount) internal virtual { address[] memory modules = _alwaysOnModules.values(); uint256 modulesLength = modules.length; @@ -168,4 +185,12 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance { IERC7984RwaTransferComplianceModule(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/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index f0cc42f7..4aa19a99 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -16,19 +16,24 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceMod EnumerableSet.AddressSet private _investors; constructor(address compliance, uint256 maxInvestor) ERC7984RwaTransferComplianceModule(compliance) { - setMaxInvestor(maxInvestor); + _maxInvestor = maxInvestor; } /// @dev Sets max number of investors. - function setMaxInvestor(uint256 maxInvestor) public virtual onlyCompliance { + function setMaxInvestor(uint256 maxInvestor) public virtual onlyTokenAdmin { _maxInvestor = maxInvestor; } /// @dev Gets max number of investors. - function getMaxInvestor() public virtual returns (uint256) { + function getMaxInvestor() public view virtual returns (uint256) { return _maxInvestor; } + /// @dev Gets current number of investors. + function getCurrentInvestor() public view virtual returns (uint256) { + return _investors.length(); + } + /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( address /*from*/, @@ -36,13 +41,14 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceMod euint64 encryptedAmount ) internal override returns (ebool) { if ( - FHE.isInitialized(encryptedAmount) || // no amount + !FHE.isInitialized(encryptedAmount) || // no amount to == address(0) || // or burning _investors.contains(to) || // or already investor _investors.length() < _maxInvestor // or not reached max investors limit ) { return FHE.asEbool(true); } + return FHE.asEbool(false); } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index 91870459..13f79b01 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -3,19 +3,35 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IERC7984RwaTransferComplianceModule} from "./../../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984Rwa, IERC7984RwaTransferComplianceModule} 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 ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule, Ownable { - /// @dev Throws if called by any account other than the compliance. - modifier onlyCompliance() { - _checkOwner(); +abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule { + address private 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 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)); _; } - constructor(address compliance) Ownable(compliance) {} + constructor(address token) { + _token = token; + } /// @inheritdoc IERC7984RwaTransferComplianceModule function isModule() public pure override returns (bytes4) { @@ -23,8 +39,12 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl } /// @inheritdoc IERC7984RwaTransferComplianceModule - function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { - return _isCompliantTransfer(from, to, encryptedAmount); + function isCompliantTransfer( + address from, + address to, + euint64 encryptedAmount + ) public virtual onlyToken returns (ebool compliant) { + FHE.allow(compliant = _isCompliantTransfer(from, to, encryptedAmount), msg.sender); } /// @inheritdoc IERC7984RwaTransferComplianceModule @@ -37,13 +57,20 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ - ) internal virtual returns (ebool) { - // default to non-compliant - return FHE.asEbool(false); - } + ) 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 within transaction time. + function _allowTokenHandleToThis(euint64 handle) internal virtual { + _allowTokenHandleToThis(handle, false); + } + + /// @dev Allow modules to get access to token handles. + function _allowTokenHandleToThis(euint64 handle, bool persistent) internal virtual { + HandleAccessManager(_token).getHandleAllowance(euint64.unwrap(handle), address(this), persistent); + } } diff --git a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts new file mode 100644 index 00000000..68898b41 --- /dev/null +++ b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts @@ -0,0 +1,238 @@ +import { callAndGetResult } from '../../../helpers/event'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { expect } from 'chai'; +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 fixture = async () => { + const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); + const token = (await ethers.deployContract('ERC7984RwaModularComplianceMock', ['name', 'symbol', 'uri'])).connect( + anyone, + ); + await token.connect(admin).addAgent(agent1); + 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, + 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 or agent', async function () { + const { token, investorCapModule, anyone } = await fixture(); + await expect(token.connect(anyone).installModule(alwaysOnType, investorCapModule)) + .to.be.revertedWithCustomError(token, 'UnauthorizedSender') + .withArgs(anyone.address); + }); + + 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 type of moduleTypes) { + it(`should transfer if compliant to balance cap module with type ${type}`, async function () { + const { token, admin, balanceCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(type, balanceCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await token + .connect(admin) + ['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, balanceCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(transferOnlyType, balanceCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + await token + .connect(admin) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); + await token + .connect(admin) + ['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); + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + // balance is unchanged + ).to.eventually.equal(100); + }); + }); + + for (const type of moduleTypes) { + it(`should transfer if compliant to investor cap module else zero with type ${type}`, async function () { + const { token, admin, investorCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(type, investorCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + for (const investor of [ + recipient.address, // investor#1 + ethers.Wallet.createRandom().address, //investor#2 + ]) { + await token + .connect(admin) + ['confidentialMint(address,bytes32,bytes)'](investor, encryptedMint.handles[0], encryptedMint.inputProof); + } + await expect(investorCapModule.getCurrentInvestor()) + .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); + }); + } +}); From b74c5ba80fe1a679dd3696828d0185817debbbeb Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:56:47 +0200 Subject: [PATCH 045/111] Increase coverage on modular compliance flow --- .../token/ERC7984RwaComplianceModuleMock.sol | 41 +++++++++ .../ERC7984/extensions/ERC7984Freezable.sol | 6 +- .../ERC7984RwaTransferComplianceModule.sol | 2 +- .../extensions/ERC7984RwaCompliance.test.ts | 90 +++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol new file mode 100644 index 00000000..35b64213 --- /dev/null +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -0,0 +1,41 @@ +// 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 {ERC7984RwaTransferComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol"; + +// solhint-disable func-name-mixedcase +contract ERC7984RwaComplianceModuleMock is ERC7984RwaTransferComplianceModule, SepoliaConfig { + bool private _compliant = false; + string private _name; + + event PostTransfer(string name); + event PreTransfer(string name); + + constructor(address compliance, string memory name) ERC7984RwaTransferComplianceModule(compliance) { + _name = name; + } + + function $_setCompliant() public { + _compliant = true; + } + + function $_unsetCompliant() public { + _compliant = false; + } + + function _isCompliantTransfer( + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal override returns (ebool) { + emit PreTransfer(_name); + return FHE.asEbool(_compliant); + } + + function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal override { + emit PostTransfer(_name); + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index c1a61ab3..5794ada3 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -110,9 +110,13 @@ abstract contract ERC7984Freezable is ERC7984 { euint64 transferred = super._update(from, to, encryptedAmount); if (from != address(0) && _skipUpdateCheck) { // Reset frozen to balance if transferred more than available + euint64 frozen = confidentialFrozen(from); + if (!FHE.isInitialized(frozen)) { + frozen = FHE.asEuint64(0); + } _setConfidentialFrozen( from, - FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), confidentialFrozen(from)), + FHE.select(FHE.gt(transferred, available), confidentialBalanceOf(from), frozen), false ); } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index 13f79b01..af6a6ffc 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -48,7 +48,7 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl } /// @inheritdoc IERC7984RwaTransferComplianceModule - function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual onlyToken { _postTransfer(from, to, encryptedAmount); } diff --git a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts index 68898b41..8acd5378 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts @@ -1,5 +1,6 @@ import { callAndGetResult } from '../../../helpers/event'; import { FhevmType } from '@fhevm/hardhat-plugin'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; @@ -7,6 +8,8 @@ const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const alwaysOnType = 0; const transferOnlyType = 1; const moduleTypes = [alwaysOnType, transferOnlyType]; +const alwaysOn = 'always-on'; +const transferOnly = 'transfer-only'; const maxInverstor = 2; const maxBalance = 100; @@ -16,6 +19,14 @@ const fixture = async () => { anyone, ); await token.connect(admin).addAgent(agent1); + const alwaysOnModule = await ethers.deployContract('ERC7984RwaComplianceModuleMock', [ + await token.getAddress(), + alwaysOn, + ]); + const transferOnlyModule = await ethers.deployContract('ERC7984RwaComplianceModuleMock', [ + await token.getAddress(), + transferOnly, + ]); const investorCapModule = await ethers.deployContract('ERC7984RwaInvestorCapModuleMock', [ await token.getAddress(), maxInverstor, @@ -30,6 +41,8 @@ const fixture = async () => { ['setMaxBalance(bytes32,bytes)'](encryptedInput.handles[0], encryptedInput.inputProof); return { token, + alwaysOnModule, + transferOnlyModule, investorCapModule, balanceCapModule, admin, @@ -104,6 +117,82 @@ describe('ERC7984RwaModularCompliance', function () { }); 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, 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(), admin.address) + .add64(amount) + .encrypt(); + // set compliant for initial mint + await alwaysOnModule.$_setCompliant(); + await transferOnlyModule.$_setCompliant(); + await token + .connect(admin) + ['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(), admin.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(admin.address, (await time.latest()) + 1000); + } + const tx = token + .connect(admin) + [ + 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') + .withArgs(alwaysOn) + .to.emit(alwaysOnModule, 'PostTransfer') + .withArgs(alwaysOn); + if (forceTransfer) { + await expect(tx) + .to.not.emit(transferOnlyModule, 'PreTransfer') + .to.not.emit(transferOnlyModule, 'PostTransfer'); + } else { + await expect(tx) + .to.emit(transferOnlyModule, 'PreTransfer') + .withArgs(transferOnly) + .to.emit(transferOnlyModule, 'PostTransfer') + .withArgs(transferOnly); + } + }); + } + } + for (const type of moduleTypes) { it(`should transfer if compliant to balance cap module with type ${type}`, async function () { const { token, admin, balanceCapModule, recipient, anyone } = await fixture(); @@ -233,6 +322,7 @@ describe('ERC7984RwaModularCompliance', function () { recipient, ), ).to.eventually.equal(100); + await expect(investorCapModule.getCurrentInvestor()).to.eventually.equal(3); //TODO: Should be 2 }); } }); From b964c8b2b0ba4b2cf638fc3140b43bab481d8da4 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:32:19 +0200 Subject: [PATCH 046/111] Should not post update investors if not compliant --- .../token/ERC7984RwaInvestorCapModuleMock.sol | 2 +- .../token/ERC7984RwaModularComplianceMock.sol | 4 -- .../rwa/ERC7984RwaBalanceCapModule.sol | 7 +-- .../rwa/ERC7984RwaInvestorCapModule.sol | 58 +++++++++++-------- .../ERC7984RwaTransferComplianceModule.sol | 18 +++--- .../extensions/ERC7984RwaCompliance.test.ts | 25 +++++++- 6 files changed, 71 insertions(+), 43 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol index c2306d60..b3d5631a 100644 --- a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol @@ -6,5 +6,5 @@ import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {ERC7984RwaInvestorCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol"; contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, SepoliaConfig { - constructor(address compliance, uint256 maxInvestor) ERC7984RwaInvestorCapModule(compliance, maxInvestor) {} + constructor(address token, uint64 maxInvestor) ERC7984RwaInvestorCapModule(token, maxInvestor) {} } diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index b3f1409f..ce36df3d 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -3,12 +3,8 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; -import {Impl} from "@fhevm/solidity/lib/Impl.sol"; import {ERC7984Rwa} from "../../token/ERC7984/extensions/ERC7984Rwa.sol"; import {ERC7984RwaCompliance} from "../../token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol"; -import {FHESafeMath} from "../../utils/FHESafeMath.sol"; -import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; contract ERC7984RwaModularComplianceMock is ERC7984RwaCompliance, SepoliaConfig { constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 4cf59907..7317ecb2 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -14,7 +14,6 @@ import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferCompliance abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModule { using EnumerableSet for *; - address private immutable _token; euint64 private _maxBalance; constructor(address token) ERC7984RwaTransferComplianceModule(token) { @@ -47,10 +46,8 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaTransferComplianceModu return FHE.asEbool(true); } euint64 balance = IERC7984(_token).confidentialBalanceOf(to); - if (FHE.isInitialized(balance)) { - _allowTokenHandleToThis(balance); - } - _allowTokenHandleToThis(encryptedAmount); + _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/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 4aa19a99..3558b116 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -3,35 +3,33 @@ 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 {IERC7984} from "../../../../interfaces/IERC7984.sol"; import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.sol"; /** * @dev A transfer compliance module for confidential Real World Assets (RWAs) which limits the number of investors. */ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceModule { - using EnumerableSet for *; + uint64 private _maxInvestor; + euint64 private _investors; - uint256 private _maxInvestor; - EnumerableSet.AddressSet private _investors; - - constructor(address compliance, uint256 maxInvestor) ERC7984RwaTransferComplianceModule(compliance) { + constructor(address token, uint64 maxInvestor) ERC7984RwaTransferComplianceModule(token) { _maxInvestor = maxInvestor; } /// @dev Sets max number of investors. - function setMaxInvestor(uint256 maxInvestor) public virtual onlyTokenAdmin { + function setMaxInvestor(uint64 maxInvestor) public virtual onlyTokenAdmin { _maxInvestor = maxInvestor; } /// @dev Gets max number of investors. - function getMaxInvestor() public view virtual returns (uint256) { + function getMaxInvestor() public view virtual returns (uint64) { return _maxInvestor; } /// @dev Gets current number of investors. - function getCurrentInvestor() public view virtual returns (uint256) { - return _investors.length(); + function getCurrentInvestor() public view virtual returns (euint64) { + return _investors; } /// @dev Internal function which checks if a transfer is compliant. @@ -39,23 +37,35 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaTransferComplianceMod address /*from*/, address to, euint64 encryptedAmount - ) internal override returns (ebool) { - if ( - !FHE.isInitialized(encryptedAmount) || // no amount - to == address(0) || // or burning - _investors.contains(to) || // or already investor - _investors.length() < _maxInvestor // or not reached max investors limit - ) { - return FHE.asEbool(true); - } - - return FHE.asEbool(false); + ) 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.isInitialized(encryptedAmount) // or no amount + ), + 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 { - if (!_investors.contains(to)) { - _investors.add(to); + 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/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol index af6a6ffc..1210a565 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol @@ -9,8 +9,8 @@ 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 ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule { - address private immutable _token; +abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule, HandleAccessManager { + address internal immutable _token; /// @dev The sender is not the token. error SenderNotToken(address account); @@ -64,13 +64,17 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl // default to no-op } - /// @dev Allow modules to get access to token handles within transaction time. - function _allowTokenHandleToThis(euint64 handle) internal virtual { - _allowTokenHandleToThis(handle, false); + /// @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 _allowTokenHandleToThis(euint64 handle, bool persistent) internal virtual { - HandleAccessManager(_token).getHandleAllowance(euint64.unwrap(handle), address(this), persistent); + 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 onlyTokenAdmin {} } diff --git a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts index 8acd5378..286e900b 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts @@ -292,7 +292,17 @@ describe('ERC7984RwaModularCompliance', function () { .connect(admin) ['confidentialMint(address,bytes32,bytes)'](investor, encryptedMint.handles[0], encryptedMint.inputProof); } - await expect(investorCapModule.getCurrentInvestor()) + 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; @@ -322,7 +332,18 @@ describe('ERC7984RwaModularCompliance', function () { recipient, ), ).to.eventually.equal(100); - await expect(investorCapModule.getCurrentInvestor()).to.eventually.equal(3); //TODO: Should be 2 + // 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); }); } }); From 3be8b00f25bf6e12a7ac07b6083f4e24120a940d Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:42:42 +0200 Subject: [PATCH 047/111] Rename to `ModularCompliance` & `ComplianceModule` --- .changeset/wet-results-doubt.md | 2 +- contracts/interfaces/IERC7984Rwa.sol | 6 ++--- .../token/ERC7984RwaComplianceModuleMock.sol | 6 ++--- .../token/ERC7984RwaModularComplianceMock.sol | 4 ++-- .../rwa/ERC7984RwaBalanceCapModule.sol | 6 ++--- ...ule.sol => ERC7984RwaComplianceModule.sol} | 10 ++++----- .../rwa/ERC7984RwaInvestorCapModule.sol | 6 ++--- ...ce.sol => ERC7984RwaModularCompliance.sol} | 22 +++++++++---------- ...ts => ERC7984RwaModularCompliance.test.ts} | 4 ++-- 9 files changed, 33 insertions(+), 33 deletions(-) rename contracts/token/ERC7984/extensions/rwa/{ERC7984RwaTransferComplianceModule.sol => ERC7984RwaComplianceModule.sol} (87%) rename contracts/token/ERC7984/extensions/rwa/{ERC7984RwaCompliance.sol => ERC7984RwaModularCompliance.sol} (89%) rename test/token/ERC7984/extensions/{ERC7984RwaCompliance.test.ts => ERC7984RwaModularCompliance.test.ts} (99%) diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md index e4b5af97..4b731310 100644 --- a/.changeset/wet-results-doubt.md +++ b/.changeset/wet-results-doubt.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -`ERC7984RwaCompliance`: Support compliance modules for confidential RWAs. +`ERC7984RwaModularCompliance`: Support compliance modules for confidential RWAs. diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 93b965de..922b1eb5 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -87,8 +87,8 @@ interface IERC7984RwaBase { /// @dev Full interface for confidential RWA contracts. interface IERC7984Rwa is IERC7984, IERC7984RwaBase, IERC165, IAccessControl {} -/// @dev Interface for confidential RWA compliance. -interface IERC7984RwaCompliance { +/// @dev Interface for confidential RWA with modular compliance. +interface IERC7984RwaModularCompliance { enum ComplianceModuleType { ALWAYS_ON, TRANSFER_ONLY @@ -103,7 +103,7 @@ interface IERC7984RwaCompliance { } /// @dev Interface for confidential RWA transfer compliance module. -interface IERC7984RwaTransferComplianceModule { +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. diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol index 35b64213..955f3260 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -4,17 +4,17 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {ERC7984RwaTransferComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol"; +import {ERC7984RwaComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol"; // solhint-disable func-name-mixedcase -contract ERC7984RwaComplianceModuleMock is ERC7984RwaTransferComplianceModule, SepoliaConfig { +contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, SepoliaConfig { bool private _compliant = false; string private _name; event PostTransfer(string name); event PreTransfer(string name); - constructor(address compliance, string memory name) ERC7984RwaTransferComplianceModule(compliance) { + constructor(address compliance, string memory name) ERC7984RwaComplianceModule(compliance) { _name = name; } diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index ce36df3d..483c7e29 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {ERC7984Rwa} from "../../token/ERC7984/extensions/ERC7984Rwa.sol"; -import {ERC7984RwaCompliance} from "../../token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol"; +import {ERC7984RwaModularCompliance} from "../../token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol"; -contract ERC7984RwaModularComplianceMock is ERC7984RwaCompliance, SepoliaConfig { +contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, SepoliaConfig { constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 7317ecb2..365f202a 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -6,17 +6,17 @@ 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 {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.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 ERC7984RwaTransferComplianceModule { +abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { using EnumerableSet for *; euint64 private _maxBalance; - constructor(address token) ERC7984RwaTransferComplianceModule(token) { + constructor(address token) ERC7984RwaComplianceModule(token) { _token = token; } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol similarity index 87% rename from contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol rename to contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol index 1210a565..577deba7 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaTransferComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -3,13 +3,13 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {IERC7984Rwa, IERC7984RwaTransferComplianceModule} from "../../../../interfaces/IERC7984Rwa.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 ERC7984RwaTransferComplianceModule is IERC7984RwaTransferComplianceModule, HandleAccessManager { +abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule, HandleAccessManager { address internal immutable _token; /// @dev The sender is not the token. @@ -33,12 +33,12 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl _token = token; } - /// @inheritdoc IERC7984RwaTransferComplianceModule + /// @inheritdoc IERC7984RwaComplianceModule function isModule() public pure override returns (bytes4) { return this.isModule.selector; } - /// @inheritdoc IERC7984RwaTransferComplianceModule + /// @inheritdoc IERC7984RwaComplianceModule function isCompliantTransfer( address from, address to, @@ -47,7 +47,7 @@ abstract contract ERC7984RwaTransferComplianceModule is IERC7984RwaTransferCompl FHE.allow(compliant = _isCompliantTransfer(from, to, encryptedAmount), msg.sender); } - /// @inheritdoc IERC7984RwaTransferComplianceModule + /// @inheritdoc IERC7984RwaComplianceModule function postTransfer(address from, address to, euint64 encryptedAmount) public virtual onlyToken { _postTransfer(from, to, encryptedAmount); } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 3558b116..4a5e463e 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -4,16 +4,16 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IERC7984} from "../../../../interfaces/IERC7984.sol"; -import {ERC7984RwaTransferComplianceModule} from "./ERC7984RwaTransferComplianceModule.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 ERC7984RwaTransferComplianceModule { +abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { uint64 private _maxInvestor; euint64 private _investors; - constructor(address token, uint64 maxInvestor) ERC7984RwaTransferComplianceModule(token) { + constructor(address token, uint64 maxInvestor) ERC7984RwaComplianceModule(token) { _maxInvestor = maxInvestor; } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol similarity index 89% rename from contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol rename to contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index f751007f..9c4c9db9 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -4,7 +4,7 @@ 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 {IERC7984RwaCompliance, IERC7984RwaTransferComplianceModule} from "../../../../interfaces/IERC7984Rwa.sol"; +import {IERC7984RwaModularCompliance, IERC7984RwaComplianceModule} from "../../../../interfaces/IERC7984Rwa.sol"; import {HandleAccessManager} from "../../../../utils/HandleAccessManager.sol"; import {ERC7984Rwa} from "../ERC7984Rwa.sol"; @@ -12,7 +12,7 @@ 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 ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, HandleAccessManager { +abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularCompliance, HandleAccessManager { using EnumerableSet for *; EnumerableSet.AddressSet private _alwaysOnModules; @@ -47,7 +47,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han } /** - * @inheritdoc IERC7984RwaCompliance + * @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. */ @@ -55,12 +55,12 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han _installModule(moduleType, module); } - /// @inheritdoc IERC7984RwaCompliance + /// @inheritdoc IERC7984RwaModularCompliance function uninstallModule(ComplianceModuleType moduleType, address module) public virtual onlyAdminOrAgent { _uninstallModule(moduleType, module); } - /// @inheritdoc IERC7984RwaCompliance + /// @inheritdoc IERC7984RwaModularCompliance function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { return _isModuleInstalled(moduleType, module); } @@ -69,10 +69,10 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han function _installModule(ComplianceModuleType moduleType, address module) internal virtual { require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); (bool success, bytes memory returnData) = module.staticcall( - abi.encodePacked(IERC7984RwaTransferComplianceModule.isModule.selector) + abi.encodePacked(IERC7984RwaComplianceModule.isModule.selector) ); require( - success && bytes4(returnData) == IERC7984RwaTransferComplianceModule.isModule.selector, + success && bytes4(returnData) == IERC7984RwaComplianceModule.isModule.selector, ERC7984RwaNotTransferComplianceModule(module) ); @@ -143,7 +143,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han for (uint256 i = 0; i < modulesLength; i++) { compliant = FHE.and( compliant, - IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) ); } } @@ -163,7 +163,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han for (uint256 i = 0; i < modulesLength; i++) { compliant = FHE.and( compliant, - IERC7984RwaTransferComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) ); } } @@ -173,7 +173,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han address[] memory modules = _alwaysOnModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { - IERC7984RwaTransferComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } } @@ -182,7 +182,7 @@ abstract contract ERC7984RwaCompliance is ERC7984Rwa, IERC7984RwaCompliance, Han address[] memory modules = _transferOnlyModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { - IERC7984RwaTransferComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } } diff --git a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts similarity index 99% rename from test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts rename to test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 286e900b..757e0a5f 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -19,11 +19,11 @@ const fixture = async () => { anyone, ); await token.connect(admin).addAgent(agent1); - const alwaysOnModule = await ethers.deployContract('ERC7984RwaComplianceModuleMock', [ + const alwaysOnModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ await token.getAddress(), alwaysOn, ]); - const transferOnlyModule = await ethers.deployContract('ERC7984RwaComplianceModuleMock', [ + const transferOnlyModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ await token.getAddress(), transferOnly, ]); From 1bc4c3cf95120a5afa007faf3b96675e93fd39c6 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:18:37 +0200 Subject: [PATCH 048/111] Add balance cap module tests --- .../token/ERC7984RwaBalanceCapModuleMock.sol | 9 + .../rwa/ERC7984RwaBalanceCapModule.sol | 14 +- .../rwa/ERC7984RwaInvestorCapModule.sol | 5 +- .../ERC7984RwaModularCompliance.test.ts | 208 +++++++++++++----- 4 files changed, 170 insertions(+), 66 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol index 8b2610ca..fbf84e0d 100644 --- a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol @@ -3,8 +3,17 @@ 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/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 365f202a..6a45bf18 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -16,22 +16,27 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { 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 { - FHE.allowThis(_maxBalance = FHE.fromExternal(maxBalance, inputProof)); + 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 virtual returns (euint64) { + function getMaxBalance() public view virtual returns (euint64) { return _maxBalance; } @@ -41,9 +46,8 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { address to, euint64 encryptedAmount ) internal override returns (ebool compliant) { - if (!FHE.isInitialized(encryptedAmount) || to == address(0)) { - // if no amount or burning - return FHE.asEbool(true); + if (to == address(0)) { + return FHE.asEbool(true); // if burning } euint64 balance = IERC7984(_token).confidentialBalanceOf(to); _getTokenHandleAllowance(balance); diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 4a5e463e..dd0160c3 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -43,10 +43,7 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { _getTokenHandleAllowance(encryptedAmount); compliant = FHE.or( FHE.or( - FHE.asEbool( - to == address(0) || // return true if burning - !FHE.isInitialized(encryptedAmount) // or no amount - ), + FHE.asEbool(to == address(0)), // return true if burning FHE.eq(encryptedAmount, FHE.asEuint64(0)) // or zero amount ), FHE.or( diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 757e0a5f..0bb500ed 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -2,6 +2,7 @@ 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)'; @@ -192,6 +193,68 @@ describe('ERC7984RwaModularCompliance', function () { }); } } + }); + + 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 () { @@ -264,66 +327,36 @@ describe('ERC7984RwaModularCompliance', function () { 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, - ), - // balance is unchanged - ).to.eventually.equal(100); }); - }); - for (const type of moduleTypes) { - it(`should transfer if compliant to investor cap module else zero with type ${type}`, async function () { - const { token, admin, investorCapModule, recipient, anyone } = await fixture(); - await token.connect(admin).installModule(type, investorCapModule); + it('should transfer if compliant because burning', async function () { + const { token, admin, balanceCapModule, recipient } = await fixture(); + await token.connect(admin).installModule(alwaysOnType, balanceCapModule); const encryptedMint = await fhevm .createEncryptedInput(await token.getAddress(), admin.address) .add64(100) .encrypt(); - for (const investor of [ - recipient.address, // investor#1 - ethers.Wallet.createRandom().address, //investor#2 - ]) { - await token - .connect(admin) - ['confidentialMint(address,bytes32,bytes)'](investor, encryptedMint.handles[0], encryptedMint.inputProof); - } - await investorCapModule + await token .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); + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedMint.handles[0], encryptedMint.inputProof); const amount = 25; - const encryptedTransferValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), recipient.address) + const encryptedBurnValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), admin.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, + .connect(admin) + ['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(0); + ).to.eventually.equal(25); await expect( fhevm.userDecryptEuint( FhevmType.euint64, @@ -331,19 +364,80 @@ describe('ERC7984RwaModularCompliance', function () { 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); + ).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, investorCapModule, recipient, anyone } = await fixture(); + await token.connect(admin).installModule(type, investorCapModule); + const encryptedMint = await fhevm + .createEncryptedInput(await token.getAddress(), admin.address) + .add64(100) + .encrypt(); + for (const investor of [ + recipient.address, // investor#1 + ethers.Wallet.createRandom().address, //investor#2 + ]) { + await token + .connect(admin) + ['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); + }); + } + }); }); From 1336085c9a0ba2aaa07af50e148121ebb1f69dfb Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:38:34 +0200 Subject: [PATCH 049/111] Add max investor tests --- .../rwa/ERC7984RwaInvestorCapModule.sol | 3 +++ .../ERC7984RwaModularCompliance.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index dd0160c3..099f3cd9 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -13,6 +13,8 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { uint64 private _maxInvestor; euint64 private _investors; + event MaxInvestorSet(uint64 maxInvestor); + constructor(address token, uint64 maxInvestor) ERC7984RwaComplianceModule(token) { _maxInvestor = maxInvestor; } @@ -20,6 +22,7 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { /// @dev Sets max number of investors. function setMaxInvestor(uint64 maxInvestor) public virtual onlyTokenAdmin { _maxInvestor = maxInvestor; + emit MaxInvestorSet(maxInvestor); } /// @dev Gets max number of investors. diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 0bb500ed..d8281db8 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -439,5 +439,25 @@ describe('ERC7984RwaModularCompliance', function () { ).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); + }); }); }); From 4f04222ec405e5ba888a1913709c52aa0a296b36 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:35:50 +0200 Subject: [PATCH 050/111] Use agent for operations --- contracts/mocks/token/ERC7984RwaMock.sol | 14 +- .../token/ERC7984/extensions/ERC7984Rwa.sol | 61 +- .../ERC7984/extensions/ERC7984Rwa.test.ts | 600 +++++++++--------- 3 files changed, 319 insertions(+), 356 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaMock.sol b/contracts/mocks/token/ERC7984RwaMock.sol index 0ae0200e..8267808f 100644 --- a/contracts/mocks/token/ERC7984RwaMock.sol +++ b/contracts/mocks/token/ERC7984RwaMock.sol @@ -14,18 +14,6 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { mapping(address account => euint64 encryptedAmount) private _frozenBalances; bool public compliantTransfer; - // TODO: Move modifiers to `ERC7984Rwa` or remove from mock if useless - /// @dev Checks if the sender is an admin. - modifier onlyAdmin() { - require(isAdmin(_msgSender()), UnauthorizedSender(_msgSender())); - _; - } - /// @dev Checks if the sender is an agent. - modifier onlyAgent() { - require(isAgent(_msgSender()), UnauthorizedSender(_msgSender())); - _; - } - constructor(string memory name, string memory symbol, string memory tokenUri) ERC7984Rwa(name, symbol, tokenUri) {} function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { @@ -53,5 +41,5 @@ contract ERC7984RwaMock is ERC7984Rwa, HandleAccessManager, SepoliaConfig { return compliantTransfer; } - function _validateHandleAllowance(bytes32 handle) internal view override onlyAdminOrAgent {} + function _validateHandleAllowance(bytes32 handle) internal view override onlyAgent {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index 99c3ad09..1e70fefe 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -28,14 +28,18 @@ abstract contract ERC7984Rwa is { bytes32 public constant AGENT_ROLE = keccak256("AGENT_ROLE"); - /// @dev The caller account is not authorized to perform the operation. - error UnauthorizedSender(address account); /// @dev The transfer does not follow token compliance. error UncompliantTransfer(address from, address to, euint64 encryptedAmount); - /// @dev Checks if the sender is an admin or an agent. - modifier onlyAdminOrAgent() { - require(isAdmin(_msgSender()) || isAgent(_msgSender()), UnauthorizedSender(_msgSender())); + /// @dev Checks if the sender is an admin. + modifier onlyAdmin() { + _checkRole(DEFAULT_ADMIN_ROLE); + _; + } + + /// @dev Checks if the sender is an agent. + modifier onlyAgent() { + _checkRole(AGENT_ROLE); _; } @@ -52,12 +56,12 @@ abstract contract ERC7984Rwa is } /// @dev Pauses contract. - function pause() public virtual onlyAdminOrAgent { + function pause() public virtual onlyAgent { _pause(); } /// @dev Unpauses contract. - function unpause() public virtual onlyAdminOrAgent { + function unpause() public virtual onlyAgent { _unpause(); } @@ -72,27 +76,27 @@ abstract contract ERC7984Rwa is } /// @dev Adds agent. - function addAgent(address account) public virtual onlyAdminOrAgent { - _addAgent(account); + function addAgent(address account) public virtual onlyAdmin { + _grantRole(AGENT_ROLE, account); } /// @dev Removes agent. - function removeAgent(address account) public virtual onlyAdminOrAgent { - _removeAgent(account); + function removeAgent(address account) public virtual onlyAdmin { + _revokeRole(AGENT_ROLE, account); } /// @dev Blocks a user account. - function blockUser(address account) public virtual onlyAdminOrAgent { + function blockUser(address account) public virtual onlyAgent { _blockUser(account); } /// @dev Unblocks a user account. - function unblockUser(address account) public virtual onlyAdminOrAgent { + function unblockUser(address account) public virtual onlyAgent { _allowUser(account); } /// @dev Sets confidential frozen with proof. - function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual onlyAdminOrAgent { + function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual onlyAgent { require( FHE.isAllowed(encryptedAmount, account), ERC7984UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) @@ -105,7 +109,7 @@ abstract contract ERC7984Rwa is address account, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual onlyAdminOrAgent { + ) public virtual onlyAgent { _setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); } @@ -114,12 +118,12 @@ abstract contract ERC7984Rwa is address to, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual onlyAdminOrAgent returns (euint64) { + ) public virtual onlyAgent returns (euint64) { return _confidentialMint(to, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Mints confidential amount of tokens to account. - function confidentialMint(address to, euint64 encryptedAmount) public virtual onlyAdminOrAgent returns (euint64) { + function confidentialMint(address to, euint64 encryptedAmount) public virtual onlyAgent returns (euint64) { return _confidentialMint(to, encryptedAmount); } @@ -128,15 +132,12 @@ abstract contract ERC7984Rwa is address account, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual onlyAdminOrAgent returns (euint64) { + ) public virtual onlyAgent returns (euint64) { return _confidentialBurn(account, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Burns confidential amount of tokens from account. - function confidentialBurn( - address account, - euint64 encryptedAmount - ) public virtual onlyAdminOrAgent returns (euint64) { + function confidentialBurn(address account, euint64 encryptedAmount) public virtual onlyAgent returns (euint64) { return _confidentialBurn(account, encryptedAmount); } @@ -146,7 +147,7 @@ abstract contract ERC7984Rwa is address to, externalEuint64 encryptedAmount, bytes calldata inputProof - ) public virtual onlyAdminOrAgent returns (euint64) { + ) public virtual onlyAgent returns (euint64) { return _forceConfidentialTransferFrom(from, to, FHE.fromExternal(encryptedAmount, inputProof)); } @@ -155,20 +156,10 @@ abstract contract ERC7984Rwa is address from, address to, euint64 encryptedAmount - ) public virtual onlyAdminOrAgent returns (euint64 transferred) { + ) public virtual onlyAgent returns (euint64 transferred) { return _forceConfidentialTransferFrom(from, to, encryptedAmount); } - /// @dev Internal function which adds an agent. - function _addAgent(address account) internal virtual { - _grantRole(AGENT_ROLE, account); - } - - /// @dev Internal function which removes an agent. - function _removeAgent(address account) internal virtual { - _revokeRole(AGENT_ROLE, account); - } - /// @dev Internal function which mints confidential amount of tokens to account. function _confidentialMint(address to, euint64 encryptedAmount) internal virtual returns (euint64) { return _mint(to, encryptedAmount); @@ -208,7 +199,7 @@ abstract contract ERC7984Rwa is * @dev Internal function which reverts if `msg.sender` is not authorized as a freezer. * This freezer role is only granted to admin or agent. */ - function _checkFreezer() internal override onlyAdminOrAgent {} + function _checkFreezer() internal override onlyAgent {} /// @dev Checks if a transfer follows token compliance. function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal virtual returns (bool); diff --git a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts index ae9de6fb..8a022b94 100644 --- a/test/token/ERC7984/extensions/ERC7984Rwa.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Rwa.test.ts @@ -13,6 +13,8 @@ import { ethers, fhevm } from 'hardhat'; const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const frozenEventSignature = 'TokensFrozen(address,bytes32)'; +const adminRole = ethers.ZeroHash; +const agentRole = ethers.id('AGENT_ROLE'); const fixture = async () => { const [admin, agent1, agent2, recipient, anyone] = await ethers.getSigners(); @@ -45,28 +47,26 @@ describe('ERC7984Rwa', function () { describe('Pausable', async function () { it('should pause & unpause', async function () { - const { token, admin, agent1 } = await fixture(); - for (const manager of [admin, agent1]) { - expect(await token.paused()).is.false; - await token.connect(manager).pause(); - expect(await token.paused()).is.true; - await token.connect(manager).unpause(); - expect(await token.paused()).is.false; - } + const { token, agent1 } = await fixture(); + expect(await token.paused()).is.false; + await token.connect(agent1).pause(); + expect(await token.paused()).is.true; + await token.connect(agent1).unpause(); + expect(await token.paused()).is.false; }); - it('should not pause if neither admin nor agent', async function () { + it('should not pause if not agent', async function () { const { token, anyone } = await fixture(); await expect(token.connect(anyone).pause()) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); - it('should not unpause if neither admin nor agent', async function () { + it('should not unpause if not agent', async function () { const { token, anyone } = await fixture(); await expect(token.connect(anyone).unpause()) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); }); @@ -78,95 +78,89 @@ describe('ERC7984Rwa', function () { }); it('should check/add/remove agent', async function () { - const { token, admin, agent1, agent2 } = await fixture(); - for (const manager of [admin, agent1]) { - expect(await token.isAgent(agent2)).is.false; - await token.connect(manager).addAgent(agent2); - expect(await token.isAgent(agent2)).is.true; - await token.connect(manager).removeAgent(agent2); - expect(await token.isAgent(agent2)).is.false; - } + const { token, admin, agent2 } = await fixture(); + expect(await token.isAgent(agent2)).is.false; + await token.connect(admin).addAgent(agent2); + expect(await token.isAgent(agent2)).is.true; + await token.connect(admin).removeAgent(agent2); + expect(await token.isAgent(agent2)).is.false; }); - it('should not add agent if neither admin nor agent', async function () { + it('should not add agent if not admin', async function () { const { token, agent1, anyone } = await fixture(); await expect(token.connect(anyone).addAgent(agent1)) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, adminRole); }); - it('should not remove agent if neither admin nor agent', async function () { + it('should not remove agent if not admin', async function () { const { token, agent1, anyone } = await fixture(); await expect(token.connect(anyone).removeAgent(agent1)) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, adminRole); }); }); describe('ERC7984Restricted', async function () { it('should block & unblock', async function () { - const { token, admin, agent1, recipient } = await fixture(); - for (const manager of [admin, agent1]) { - await expect(token.isUserAllowed(recipient)).to.eventually.be.true; - await token.connect(manager).blockUser(recipient); - await expect(token.isUserAllowed(recipient)).to.eventually.be.false; - await token.connect(manager).unblockUser(recipient); - await expect(token.isUserAllowed(recipient)).to.eventually.be.true; - } + const { token, agent1, recipient } = await fixture(); + await expect(token.isUserAllowed(recipient)).to.eventually.be.true; + await token.connect(agent1).blockUser(recipient); + await expect(token.isUserAllowed(recipient)).to.eventually.be.false; + await token.connect(agent1).unblockUser(recipient); + await expect(token.isUserAllowed(recipient)).to.eventually.be.true; }); for (const arg of [true, false]) { - it(`should not ${arg ? 'block' : 'unblock'} if neither admin nor agent`, async function () { + it(`should not ${arg ? 'block' : 'unblock'} if not agent`, async function () { const { token, anyone } = await fixture(); await expect(token.connect(anyone)[arg ? 'blockUser' : 'unblockUser'](anyone)) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); } }); describe('Mintable', async function () { for (const withProof of [true, false]) { - it(`should mint by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient } = await fixture(); - for (const manager of [admin, agent1]) { - const { token } = await fixture(); - await token.$_setCompliantTransfer(); - const amount = 100; - let params = [recipient.address] as unknown as [ - account: AddressLike, - encryptedAmount: BytesLike, - inputProof: BytesLike, - ]; - if (withProof) { - const { handles, inputProof } = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(amount) - .encrypt(); - params.push(handles[0], inputProof); - } else { - await token.connect(manager).createEncryptedAmount(amount); - params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); - } - const [, , transferredHandle] = await callAndGetResult( - token - .connect(manager) - [withProof ? 'confidentialMint(address,bytes32,bytes)' : 'confidentialMint(address,bytes32)'](...params), - transferEventSignature, - ); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), - ).to.eventually.equal(amount); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(amount); + it(`should mint by agent ${withProof ? 'with proof' : ''}`, async function () { + const { agent1, recipient } = await fixture(); + const { token } = await fixture(); + await token.$_setCompliantTransfer(); + const amount = 100; + let params = [recipient.address] as unknown as [ + account: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(agent1).createEncryptedAmount(amount); + params.push(await token.connect(agent1).createEncryptedAmount.staticCall(amount)); } + const [, , transferredHandle] = await callAndGetResult( + token + .connect(agent1) + [withProof ? 'confidentialMint(address,bytes32,bytes)' : 'confidentialMint(address,bytes32)'](...params), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(amount); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(agent1).getHandleAllowance(balanceHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), agent1), + ).to.eventually.equal(amount); }); } - it('should not mint if neither admin nor agent', async function () { + it('should not mint if not agent', async function () { const { token, recipient, anyone } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) @@ -178,19 +172,19 @@ describe('ERC7984Rwa', function () { .connect(anyone) ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); it('should not mint if transfer not compliant', async function () { - const { token, admin, recipient } = await fixture(); + const { token, agent1, recipient } = await fixture(); const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), admin.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await expect( token - .connect(admin) + .connect(agent1) ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') @@ -198,15 +192,15 @@ describe('ERC7984Rwa', function () { }); it('should not mint if paused', async function () { - const { token, admin, recipient } = await fixture(); - await token.connect(admin).pause(); + const { token, agent1, recipient } = await fixture(); + await token.connect(agent1).pause(); const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), admin.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await expect( token - .connect(admin) + .connect(agent1) ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ).to.be.revertedWithCustomError(token, 'EnforcedPause'); }); @@ -214,62 +208,56 @@ describe('ERC7984Rwa', function () { describe('Burnable', async function () { for (const withProof of [true, false]) { - it(`should burn by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient } = await fixture(); - for (const manager of [admin, agent1]) { - const { token } = await fixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) + it(`should burn agent ${withProof ? 'with proof' : ''}`, async function () { + const { agent1, recipient } = await fixture(); + const { token } = await fixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); + const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); + await token.connect(agent1).getHandleAllowance(balanceBeforeHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), agent1), + ).to.eventually.greaterThan(0); + const amount = 100; + let params = [recipient.address] as unknown as [ + account: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - const balanceBeforeHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceBeforeHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceBeforeHandle, await token.getAddress(), manager), - ).to.eventually.greaterThan(0); - const amount = 100; - let params = [recipient.address] as unknown as [ - account: AddressLike, - encryptedAmount: BytesLike, - inputProof: BytesLike, - ]; - if (withProof) { - const { handles, inputProof } = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(amount) - .encrypt(); - params.push(handles[0], inputProof); - } else { - await token.connect(manager).createEncryptedAmount(amount); - params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); - } - const [, , transferredHandle] = await callAndGetResult( - token - .connect(manager) - [withProof ? 'confidentialBurn(address,bytes32,bytes)' : 'confidentialBurn(address,bytes32)'](...params), - transferEventSignature, - ); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), - ).to.eventually.equal(amount); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(0); + params.push(handles[0], inputProof); + } else { + await token.connect(agent1).createEncryptedAmount(amount); + params.push(await token.connect(agent1).createEncryptedAmount.staticCall(amount)); } + const [, , transferredHandle] = await callAndGetResult( + token + .connect(agent1) + [withProof ? 'confidentialBurn(address,bytes32,bytes)' : 'confidentialBurn(address,bytes32)'](...params), + transferEventSignature, + ); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), + ).to.eventually.equal(amount); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(agent1).getHandleAllowance(balanceHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), agent1), + ).to.eventually.equal(0); }); } - it('should not burn if neither admin nor agent', async function () { + it('should not burn if not agent', async function () { const { token, recipient, anyone } = await fixture(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) @@ -281,19 +269,19 @@ describe('ERC7984Rwa', function () { .connect(anyone) ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); it('should not burn if transfer not compliant', async function () { - const { token, admin, recipient } = await fixture(); + const { token, agent1, recipient } = await fixture(); const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), admin.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await expect( token - .connect(admin) + .connect(agent1) ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ) .to.be.revertedWithCustomError(token, 'UncompliantTransfer') @@ -301,15 +289,15 @@ describe('ERC7984Rwa', function () { }); it('should not burn if paused', async function () { - const { token, admin, recipient } = await fixture(); - await token.connect(admin).pause(); + const { token, agent1, recipient } = await fixture(); + await token.connect(agent1).pause(); const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), admin.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await expect( token - .connect(admin) + .connect(agent1) ['confidentialBurn(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof), ).to.be.revertedWithCustomError(token, 'EnforcedPause'); }); @@ -318,163 +306,159 @@ describe('ERC7984Rwa', function () { describe('Force transfer', async function () { for (const withProof of [true, false]) { it(`should force transfer by admin or agent ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient, anyone } = await fixture(); - for (const manager of [admin, agent1]) { - const { token } = await fixture(); - const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)']( - recipient, - encryptedMintValueInput.handles[0], - encryptedMintValueInput.inputProof, - ); - // set frozen (50 available and about to force transfer 25) - const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(50) - .encrypt(); - await token - .connect(manager) - ['setConfidentialFrozen(address,bytes32,bytes)']( - recipient, - encryptedFrozenValueInput.handles[0], - encryptedFrozenValueInput.inputProof, - ); - await token.$_unsetCompliantTransfer(); - expect(await token.compliantTransfer()).to.be.false; - const amount = 25; - let params = [recipient.address, anyone.address] as unknown as [ - from: AddressLike, - to: AddressLike, - encryptedAmount: BytesLike, - inputProof: BytesLike, - ]; - if (withProof) { - const { handles, inputProof } = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(amount) - .encrypt(); - params.push(handles[0], inputProof); - } else { - await token.connect(manager).createEncryptedAmount(amount); - params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); - } - const [from, to, transferredHandle] = await callAndGetResult( - token - .connect(manager) - [ - withProof - ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' - : 'forceConfidentialTransferFrom(address,address,bytes32)' - ](...params), - transferEventSignature, + const { agent1, recipient, anyone } = await fixture(); + const { token } = await fixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ); + // set frozen (50 available and about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(50) + .encrypt(); + await token + .connect(agent1) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, ); - expect(from).equal(recipient.address); - expect(to).equal(anyone.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), anyone), - ).to.eventually.equal(amount); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); - const frozenHandle = await token.confidentialFrozen(recipient); - await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), - ).to.eventually.equal(50); // frozen is left unchanged + await token.$_unsetCompliantTransfer(); + expect(await token.compliantTransfer()).to.be.false; + const amount = 25; + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(agent1).createEncryptedAmount(amount); + params.push(await token.connect(agent1).createEncryptedAmount.staticCall(amount)); } + const [from, to, transferredHandle] = await callAndGetResult( + token + .connect(agent1) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + transferEventSignature, + ); + expect(from).equal(recipient.address); + expect(to).equal(anyone.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), anyone), + ).to.eventually.equal(amount); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(agent1).getHandleAllowance(balanceHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), agent1), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token.connect(agent1).getHandleAllowance(frozenHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), agent1), + ).to.eventually.equal(50); // frozen is left unchanged }); } for (const withProof of [true, false]) { it(`should force transfer even if frozen ${withProof ? 'with proof' : ''}`, async function () { - const { admin, agent1, recipient, anyone } = await fixture(); - for (const manager of [admin, agent1]) { - const { token } = await fixture(); - const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(100) - .encrypt(); - await token.$_setCompliantTransfer(); - await token - .connect(manager) - ['confidentialMint(address,bytes32,bytes)']( - recipient, - encryptedMintValueInput.handles[0], - encryptedMintValueInput.inputProof, - ); - // set frozen (only 20 available but about to force transfer 25) - const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(80) - .encrypt(); - await token - .connect(manager) - ['setConfidentialFrozen(address,bytes32,bytes)']( - recipient, - encryptedFrozenValueInput.handles[0], - encryptedFrozenValueInput.inputProof, - ); - // should force transfer even if not compliant - await token.$_unsetCompliantTransfer(); - expect(await token.compliantTransfer()).to.be.false; - // should force transfer even if paused - await token.connect(manager).pause(); - expect(await token.paused()).to.be.true; - const amount = 25; - let params = [recipient.address, anyone.address] as unknown as [ - from: AddressLike, - to: AddressLike, - encryptedAmount: BytesLike, - inputProof: BytesLike, - ]; - if (withProof) { - const { handles, inputProof } = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) - .add64(amount) - .encrypt(); - params.push(handles[0], inputProof); - } else { - await token.connect(manager).createEncryptedAmount(amount); - params.push(await token.connect(manager).createEncryptedAmount.staticCall(amount)); - } - const [account, frozenAmountHandle] = await callAndGetResult( - token - .connect(manager) - [ - withProof - ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' - : 'forceConfidentialTransferFrom(address,address,bytes32)' - ](...params), - frozenEventSignature, + const { agent1, recipient, anyone } = await fixture(); + const { token } = await fixture(); + const encryptedMintValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(100) + .encrypt(); + await token.$_setCompliantTransfer(); + await token + .connect(agent1) + ['confidentialMint(address,bytes32,bytes)']( + recipient, + encryptedMintValueInput.handles[0], + encryptedMintValueInput.inputProof, + ); + // set frozen (only 20 available but about to force transfer 25) + const encryptedFrozenValueInput = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(80) + .encrypt(); + await token + .connect(agent1) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient, + encryptedFrozenValueInput.handles[0], + encryptedFrozenValueInput.inputProof, ); - expect(account).equal(recipient.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenAmountHandle, await token.getAddress(), recipient), - ).to.eventually.equal(75); - const balanceHandle = await token.confidentialBalanceOf(recipient); - await token.connect(manager).getHandleAllowance(balanceHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); - const frozenHandle = await token.confidentialFrozen(recipient); - await token.connect(manager).getHandleAllowance(frozenHandle, manager, true); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), manager), - ).to.eventually.equal(75); // frozen got reset to balance + // should force transfer even if not compliant + await token.$_unsetCompliantTransfer(); + expect(await token.compliantTransfer()).to.be.false; + // should force transfer even if paused + await token.connect(agent1).pause(); + expect(await token.paused()).to.be.true; + const amount = 25; + let params = [recipient.address, anyone.address] as unknown as [ + from: AddressLike, + to: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), agent1.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(agent1).createEncryptedAmount(amount); + params.push(await token.connect(agent1).createEncryptedAmount.staticCall(amount)); } + const [account, frozenAmountHandle] = await callAndGetResult( + token + .connect(agent1) + [ + withProof + ? 'forceConfidentialTransferFrom(address,address,bytes32,bytes)' + : 'forceConfidentialTransferFrom(address,address,bytes32)' + ](...params), + frozenEventSignature, + ); + expect(account).equal(recipient.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenAmountHandle, await token.getAddress(), recipient), + ).to.eventually.equal(75); + const balanceHandle = await token.confidentialBalanceOf(recipient); + await token.connect(agent1).getHandleAllowance(balanceHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), agent1), + ).to.eventually.equal(75); + const frozenHandle = await token.confidentialFrozen(recipient); + await token.connect(agent1).getHandleAllowance(frozenHandle, agent1, true); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), agent1), + ).to.eventually.equal(75); // frozen got reset to balance }); } for (const withProof of [true, false]) { - it(`should not force transfer if neither admin nor agent ${withProof ? 'with proof' : ''}`, async function () { + it(`should not force transfer if not agent ${withProof ? 'with proof' : ''}`, async function () { const { token, recipient, anyone } = await fixture(); let params = [recipient.address, anyone.address] as unknown as [ from: AddressLike, @@ -502,14 +486,14 @@ describe('ERC7984Rwa', function () { : 'forceConfidentialTransferFrom(address,address,bytes32)' ](...params), ) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); } for (const withProof of [true, false]) { it(`should not force transfer if receiver blocked ${withProof ? 'with proof' : ''}`, async function () { - const { token, recipient, anyone } = await fixture(); + const { token, agent1, recipient, anyone } = await fixture(); let params = [recipient.address, anyone.address] as unknown as [ from: AddressLike, to: AddressLike, @@ -527,7 +511,7 @@ describe('ERC7984Rwa', function () { await token.connect(anyone).createEncryptedAmount(amount); params.push(await token.connect(anyone).createEncryptedAmount.staticCall(amount)); } - await token.blockUser(anyone); + await token.connect(agent1).blockUser(anyone); await expect( token .connect(anyone) @@ -537,22 +521,22 @@ describe('ERC7984Rwa', function () { : 'forceConfidentialTransferFrom(address,address,bytes32)' ](...params), ) - .to.be.revertedWithCustomError(token, 'UnauthorizedSender') - .withArgs(anyone.address); + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, agentRole); }); } }); describe('Transfer', async function () { it('should transfer', async function () { - const { token, admin: manager, recipient, anyone } = await fixture(); + const { token, agent1, recipient, anyone } = await fixture(); const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await token.$_setCompliantTransfer(); await token - .connect(manager) + .connect(agent1) ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], @@ -560,11 +544,11 @@ describe('ERC7984Rwa', function () { ); // set frozen (50 available and about to transfer 25) const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(50) .encrypt(); await token - .connect(manager) + .connect(agent1) ['setConfidentialFrozen(address,bytes32,bytes)']( recipient, encryptedFrozenValueInput.handles[0], @@ -611,12 +595,12 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if paused', async function () { - const { token, admin: manager, recipient, anyone } = await fixture(); + const { token, agent1, recipient, anyone } = await fixture(); const encryptedTransferValueInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.connect(manager).pause(); + await token.connect(agent1).pause(); await expect( token .connect(recipient) @@ -649,14 +633,14 @@ describe('ERC7984Rwa', function () { }); it('should not transfer if frozen', async function () { - const { token, admin: manager, recipient, anyone } = await fixture(); + const { token, agent1, recipient, anyone } = await fixture(); const encryptedMintValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(100) .encrypt(); await token.$_setCompliantTransfer(); await token - .connect(manager) + .connect(agent1) ['confidentialMint(address,bytes32,bytes)']( recipient, encryptedMintValueInput.handles[0], @@ -664,11 +648,11 @@ describe('ERC7984Rwa', function () { ); // set frozen (20 available but about to transfer 25) const encryptedFrozenValueInput = await fhevm - .createEncryptedInput(await token.getAddress(), manager.address) + .createEncryptedInput(await token.getAddress(), agent1.address) .add64(80) .encrypt(); await token - .connect(manager) + .connect(agent1) ['setConfidentialFrozen(address,bytes32,bytes)']( recipient, encryptedFrozenValueInput.handles[0], @@ -706,14 +690,14 @@ describe('ERC7984Rwa', function () { for (const arg of [true, false]) { it(`should not transfer if ${arg ? 'sender' : 'receiver'} blocked `, async function () { - const { token, admin: manager, recipient, anyone } = await fixture(); + const { token, agent1, recipient, anyone } = await fixture(); const account = arg ? recipient : anyone; await token.$_setCompliantTransfer(); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(25) .encrypt(); - await token.connect(manager).blockUser(account); + await token.connect(agent1).blockUser(account); await expect( token From 53ec92670a56858c76fa1811201cab15d5d71be9 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:56:41 +0200 Subject: [PATCH 051/111] Restore restricted and freezable --- contracts/interfaces/IERC7984Restricted.sol | 23 -------------- .../ERC7984/extensions/ERC7984Freezable.sol | 9 +++--- .../ERC7984/extensions/ERC7984Restricted.sol | 31 ++++++++----------- 3 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 contracts/interfaces/IERC7984Restricted.sol diff --git a/contracts/interfaces/IERC7984Restricted.sol b/contracts/interfaces/IERC7984Restricted.sol deleted file mode 100644 index c129460f..00000000 --- a/contracts/interfaces/IERC7984Restricted.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.24; - -/// @dev Interface for contracts that implements user account transfer restrictions. -interface IERC7984Restricted { - enum Restriction { - DEFAULT, // User has no explicit restriction - BLOCKED, // User is explicitly blocked - ALLOWED // User is explicitly allowed - } - - /// @dev Emitted when a user account's restriction is updated. - event UserRestrictionUpdated(address indexed account, Restriction restriction); - - /// @dev The operation failed because the user account is restricted. - error UserRestricted(address account); - - /// @dev Returns the restriction of a user account. - function getRestriction(address account) external view returns (Restriction); - /// @dev Returns whether a user account is allowed to interact with the token. - function isUserAllowed(address account) external view returns (bool); -} diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index ec86c2be..52cbfd76 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -36,9 +36,6 @@ abstract contract ERC7984Freezable is ERC7984 { confidentialBalanceOf(account), confidentialFrozen(account) ); - if (!FHE.isInitialized(unfrozen)) { - return unfrozen; - } return FHE.select(success, unfrozen, FHE.asEuint64(0)); } @@ -51,8 +48,12 @@ abstract contract ERC7984Freezable is ERC7984 { } /** - * @dev See {ERC7984-_update}. The `from` account must have sufficient unfrozen balance, + * @dev See {ERC7984-_update}. + * + * The `from` account must have sufficient unfrozen balance, * otherwise 0 tokens are transferred. + * The default freezing behavior can be changed (for a pass-through for instance) by overriding + * {confidentialAvailable}. */ function _update(address from, address to, euint64 encryptedAmount) internal virtual override returns (euint64) { if (from != address(0)) { diff --git a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol index 86e5c4d3..886be4b2 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Restricted.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Restricted.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.27; -import {IERC7984Restricted} from "../../../interfaces/IERC7984Restricted.sol"; import {ERC7984, euint64} from "../ERC7984.sol"; /** @@ -14,10 +13,20 @@ import {ERC7984, euint64} from "../ERC7984.sol"; * a blocklist. Developers can override {isUserAllowed} to check that `restriction == ALLOWED` * to implement an allowlist. */ -abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { +abstract contract ERC7984Restricted is ERC7984 { + enum Restriction { + DEFAULT, // User has no explicit restriction + BLOCKED, // User is explicitly blocked + ALLOWED // User is explicitly allowed + } + mapping(address account => Restriction) private _restrictions; - /// @dev Skips restriction checks in {_update}. - bool private _skipUpdateCheck; + + /// @dev Emitted when a user account's restriction is updated. + event UserRestrictionUpdated(address indexed account, Restriction restriction); + + /// @dev The operation failed because the user account is restricted. + error UserRestricted(address account); /// @dev Returns the restriction of a user account. function getRestriction(address account) public view virtual returns (Restriction) { @@ -41,20 +50,6 @@ abstract contract ERC7984Restricted is ERC7984, IERC7984Restricted { return getRestriction(account) != Restriction.BLOCKED; // i.e. DEFAULT && ALLOWED } - /// @dev Internal function to skip update check. Check can be restored with {_restoreERC7984RestrictedUpdateCheck}. - function _disableERC7984RestrictedUpdateCheck() internal virtual { - if (!_skipUpdateCheck) { - _skipUpdateCheck = true; - } - } - - /// @dev Internal function to restore update check previously disabled by {_disableERC7984RestrictedUpdateCheck}. - function _restoreERC7984RestrictedUpdateCheck() internal virtual { - if (_skipUpdateCheck) { - _skipUpdateCheck = false; - } - } - /** * @dev See {ERC7984-_update}. Enforces transfer restrictions (excluding minting and burning). * From d3240e1d0470b2500d9474fea7a0e66b7f14b736 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:40:35 +0200 Subject: [PATCH 052/111] Update styling --- contracts/interfaces/IERC7984Rwa.sol | 8 ++--- .../token/ERC7984RwaComplianceModuleMock.sol | 13 +++----- .../rwa/ERC7984RwaModularCompliance.sol | 32 ++++++++++--------- .../ERC7984RwaModularCompliance.test.ts | 16 ++-------- 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 6faf5b85..35e730f8 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -71,16 +71,16 @@ interface IERC7984Rwa is IERC7984, IERC165 { /// @dev Interface for confidential RWA with modular compliance. interface IERC7984RwaModularCompliance { enum ComplianceModuleType { - ALWAYS_ON, - TRANSFER_ONLY + 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 Checks if a compliance module is installed. - function isModuleInstalled(ComplianceModuleType moduleType, address module) external view returns (bool); } /// @dev Interface for confidential RWA transfer compliance module. diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol index 955f3260..73d5d163 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -9,14 +9,11 @@ import {ERC7984RwaComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC // solhint-disable func-name-mixedcase contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, SepoliaConfig { bool private _compliant = false; - string private _name; - event PostTransfer(string name); - event PreTransfer(string name); + event PostTransfer(); + event PreTransfer(); - constructor(address compliance, string memory name) ERC7984RwaComplianceModule(compliance) { - _name = name; - } + constructor(address compliance) ERC7984RwaComplianceModule(compliance) {} function $_setCompliant() public { _compliant = true; @@ -31,11 +28,11 @@ contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, Se address /*to*/, euint64 /*encryptedAmount*/ ) internal override returns (ebool) { - emit PreTransfer(_name); + emit PreTransfer(); return FHE.asEbool(_compliant); } function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal override { - emit PostTransfer(_name); + emit PostTransfer(); } } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index f342fce8..79fe7717 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -43,7 +43,12 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC * * Force transfer compliance module */ function supportsModule(ComplianceModuleType moduleType) public view virtual returns (bool) { - return moduleType == ComplianceModuleType.ALWAYS_ON || moduleType == ComplianceModuleType.TRANSFER_ONLY; + return moduleType == ComplianceModuleType.AlwaysOn || moduleType == ComplianceModuleType.TransferOnly; + } + + /// @inheritdoc IERC7984RwaModularCompliance + function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { + return _isModuleInstalled(moduleType, module); } /** @@ -60,9 +65,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC _uninstallModule(moduleType, module); } - /// @inheritdoc IERC7984RwaModularCompliance - function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { - return _isModuleInstalled(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. @@ -76,9 +85,9 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC ERC7984RwaNotTransferComplianceModule(module) ); - if (moduleType == ComplianceModuleType.ALWAYS_ON) { + if (moduleType == ComplianceModuleType.AlwaysOn) { require(_alwaysOnModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); - } else if (moduleType == ComplianceModuleType.TRANSFER_ONLY) { + } else if (moduleType == ComplianceModuleType.TransferOnly) { require(_transferOnlyModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); } emit ModuleInstalled(moduleType, module); @@ -87,21 +96,14 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @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.ALWAYS_ON) { + if (moduleType == ComplianceModuleType.AlwaysOn) { require(_alwaysOnModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); - } else if (moduleType == ComplianceModuleType.TRANSFER_ONLY) { + } else if (moduleType == ComplianceModuleType.TransferOnly) { require(_transferOnlyModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); } emit ModuleUninstalled(moduleType, module); } - /// @dev Checks if a compliance module is installed. - function _isModuleInstalled(ComplianceModuleType moduleType, address module) internal view virtual returns (bool) { - if (moduleType == ComplianceModuleType.ALWAYS_ON) return _alwaysOnModules.contains(module); - if (moduleType == ComplianceModuleType.TRANSFER_ONLY) return _transferOnlyModules.contains(module); - return false; - } - /** * @dev Updates confidential balances. It transfers zero if it does not follow * transfer compliance. Runs hooks after the transfer. diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index f13ac7aa..52ffa2e9 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -9,8 +9,6 @@ const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const alwaysOnType = 0; const transferOnlyType = 1; const moduleTypes = [alwaysOnType, transferOnlyType]; -const alwaysOn = 'always-on'; -const transferOnly = 'transfer-only'; const maxInverstor = 2; const maxBalance = 100; const adminRole = ethers.ZeroHash; @@ -23,11 +21,9 @@ const fixture = async () => { await token.connect(admin).addAgent(agent1); const alwaysOnModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ await token.getAddress(), - alwaysOn, ]); const transferOnlyModule = await ethers.deployContract('ERC7984RwaModularComplianceModuleMock', [ await token.getAddress(), - transferOnly, ]); const investorCapModule = await ethers.deployContract('ERC7984RwaInvestorCapModuleMock', [ await token.getAddress(), @@ -175,21 +171,13 @@ describe('ERC7984RwaModularCompliance', function () { await expect( fhevm.userDecryptEuint(FhevmType.euint64, transferredHandle, await token.getAddress(), recipient), ).to.eventually.equal(compliant ? amount : 0); - await expect(tx) - .to.emit(alwaysOnModule, 'PreTransfer') - .withArgs(alwaysOn) - .to.emit(alwaysOnModule, 'PostTransfer') - .withArgs(alwaysOn); + 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') - .withArgs(transferOnly) - .to.emit(transferOnlyModule, 'PostTransfer') - .withArgs(transferOnly); + await expect(tx).to.emit(transferOnlyModule, 'PreTransfer').to.emit(transferOnlyModule, 'PostTransfer'); } }); } From 830aecee2a791d8795bff702dc442c36034ddb66 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:47:02 -0600 Subject: [PATCH 053/111] run checks even on 0 transfer --- .../rwa/ERC7984RwaModularCompliance.sol | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 79fe7717..6e29af47 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -113,20 +113,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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) - ) + euint64 amountToTransfer = FHE.select( + FHE.and(_checkAlwaysBefore(from, to, encryptedAmount), _checkOnlyBeforeTransfer(from, to, encryptedAmount)), + encryptedAmount, + FHE.asEuint64(0) ); - _runAlwaysAfter(from, to, transferred); - _runOnlyAfterTransfer(from, to, transferred); + transferred = super._update(from, to, amountToTransfer); + _onTransferCompliance(from, to, transferred); } /** @@ -138,12 +131,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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)) + euint64 amountToTransfer = FHE.select( + _checkAlwaysBefore(from, to, encryptedAmount), + encryptedAmount, + FHE.asEuint64(0) ); - _runAlwaysAfter(from, to, transferred); + transferred = super._forceUpdate(from, to, amountToTransfer); + _onTransferCompliance(from, to, transferred); } /// @dev Checks always-on compliance. @@ -152,9 +146,6 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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); @@ -172,9 +163,6 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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); @@ -186,25 +174,21 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } } - /// @dev Runs always after. - function _runAlwaysAfter(address from, address to, euint64 encryptedAmount) internal virtual { + function _onTransferCompliance(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; + modules = _transferOnlyModules.values(); + 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}. + /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow compliance modules to access any handle. function _validateHandleAllowance(bytes32) internal view override { require( _alwaysOnModules.contains(msg.sender) || _transferOnlyModules.contains(msg.sender), From e132aacfeae149b3db7125f5e14ba3727c407d74 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:56:16 -0600 Subject: [PATCH 054/111] fix compilation --- contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol | 4 ++-- contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol | 4 ++-- contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol | 4 ++-- contracts/mocks/token/ERC7984RwaModularComplianceMock.sol | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol index fbf84e0d..be6de97f 100644 --- a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.24; -import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ZamaEthereumConfig} 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 { +contract ERC7984RwaBalanceCapModuleMock is ERC7984RwaBalanceCapModule, ZamaEthereumConfig { event AmountEncrypted(euint64 amount); constructor(address compliance) ERC7984RwaBalanceCapModule(compliance) {} diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol index 73d5d163..33545659 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -2,12 +2,12 @@ pragma solidity ^0.8.24; -import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ZamaEthereumConfig} 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 { +contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { bool private _compliant = false; event PostTransfer(); diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol index b3d5631a..21bbb668 100644 --- a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.24; -import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {ERC7984RwaInvestorCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol"; -contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, SepoliaConfig { +contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, ZamaEthereumConfig { constructor(address token, uint64 maxInvestor) ERC7984RwaInvestorCapModule(token, maxInvestor) {} } diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index b1ec993a..c1e3b626 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -2,12 +2,12 @@ pragma solidity ^0.8.24; -import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {ZamaEthereumConfig} 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 { +contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ZamaEthereumConfig { constructor( string memory name, string memory symbol, From 7c078bd2804bfb0521c947e3388c86edede4fdf6 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:20:11 -0600 Subject: [PATCH 055/111] add comments and rename modules --- contracts/interfaces/IERC7984Rwa.sol | 4 +- .../rwa/ERC7984RwaModularCompliance.sol | 48 ++++++++++++------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 70166186..baedd5a9 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -71,8 +71,8 @@ interface IERC7984Rwa is IERC7984 { /// @dev Interface for confidential RWA with modular compliance. interface IERC7984RwaModularCompliance { enum ComplianceModuleType { - AlwaysOn, - TransferOnly + Default, + ForceTransfer } /// @dev Checks if a compliance module is installed. diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 6e29af47..526a2280 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -11,12 +11,21 @@ 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: Only force transfer compliance modules are called before force transfers--all compliance modules are called + * after force transfers. Normal transfers call all compliance modules (including force transfer compliance modules). */ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularCompliance, HandleAccessManager { using EnumerableSet for *; - EnumerableSet.AddressSet private _alwaysOnModules; - EnumerableSet.AddressSet private _transferOnlyModules; + EnumerableSet.AddressSet private _forceTransferComplianceModules; + EnumerableSet.AddressSet private _complianceModules; /// @dev Emitted when a module is installed. event ModuleInstalled(ComplianceModuleType moduleType, address module); @@ -43,7 +52,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC * * Force transfer compliance module */ function supportsModule(ComplianceModuleType moduleType) public view virtual returns (bool) { - return moduleType == ComplianceModuleType.AlwaysOn || moduleType == ComplianceModuleType.TransferOnly; + return moduleType == ComplianceModuleType.Default || moduleType == ComplianceModuleType.ForceTransfer; } /// @inheritdoc IERC7984RwaModularCompliance @@ -70,8 +79,8 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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); + if (moduleType == ComplianceModuleType.ForceTransfer) return _forceTransferComplianceModules.contains(module); + if (moduleType == ComplianceModuleType.Default) return _complianceModules.contains(module); } /// @dev Internal function which installs a transfer compliance module. @@ -85,10 +94,10 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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)); + if (moduleType == ComplianceModuleType.ForceTransfer) { + require(_forceTransferComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + } else if (moduleType == ComplianceModuleType.Default) { + require(_complianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); } emit ModuleInstalled(moduleType, module); } @@ -96,10 +105,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @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)); + if (moduleType == ComplianceModuleType.ForceTransfer) { + require( + _forceTransferComplianceModules.remove(module), + ERC7984RwaAlreadyUninstalledModule(moduleType, module) + ); + } else if (moduleType == ComplianceModuleType.Default) { + require(_complianceModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); } emit ModuleUninstalled(moduleType, module); } @@ -146,7 +158,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC address to, euint64 encryptedAmount ) internal virtual returns (ebool compliant) { - address[] memory modules = _alwaysOnModules.values(); + address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { @@ -163,7 +175,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC address to, euint64 encryptedAmount ) internal virtual returns (ebool compliant) { - address[] memory modules = _transferOnlyModules.values(); + address[] memory modules = _complianceModules.values(); uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { @@ -175,13 +187,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } function _onTransferCompliance(address from, address to, euint64 encryptedAmount) internal virtual { - address[] memory modules = _alwaysOnModules.values(); + address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } - modules = _transferOnlyModules.values(); + modules = _complianceModules.values(); modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); @@ -191,7 +203,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow compliance modules to access any handle. function _validateHandleAllowance(bytes32) internal view override { require( - _alwaysOnModules.contains(msg.sender) || _transferOnlyModules.contains(msg.sender), + _forceTransferComplianceModules.contains(msg.sender) || _complianceModules.contains(msg.sender), SenderNotComplianceModule(msg.sender) ); } From 15174b73ec6bcb324de87c1324a6356b0fb6c437 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:16:22 -0600 Subject: [PATCH 056/111] create identity module --- .../rwa/ERC7984RwaComplianceModule.sol | 78 ++++--------------- .../rwa/ERC7984RwaIdentityModule.sol | 30 +++++++ 2 files changed, 45 insertions(+), 63 deletions(-) create mode 100644 contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol index 988b2f93..b9b7b27a 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -4,51 +4,11 @@ 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; - } - +abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule { /// @inheritdoc IERC7984RwaComplianceModule function isModule() public pure override returns (bytes4) { return this.isModule.selector; @@ -59,38 +19,30 @@ abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule, Han address from, address to, euint64 encryptedAmount - ) public virtual onlyToken returns (ebool compliant) { - FHE.allow(compliant = _isCompliantTransfer(from, to, encryptedAmount), msg.sender); + ) public virtual returns (ebool compliant) { + FHE.allowTransient(compliant = _isCompliantTransfer(msg.sender, from, to, encryptedAmount), msg.sender); } /// @inheritdoc IERC7984RwaComplianceModule - function postTransfer(address from, address to, euint64 encryptedAmount) public virtual onlyToken { - _postTransfer(from, to, encryptedAmount); + function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { + _postTransfer(msg.sender, from, to, encryptedAmount); } /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( - address /*from*/, - address /*to*/, - euint64 /*encryptedAmount*/ + address token, + 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 { + 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(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/ERC7984RwaIdentityModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol new file mode 100644 index 00000000..0f6fa095 --- /dev/null +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol @@ -0,0 +1,30 @@ +// 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"; + +interface IIdentityRegistry { + function isVerified(address user) external view returns (bool); +} + +interface IToken { + function identityRegistry() external view returns (IIdentityRegistry); +} + +contract ERC7984IdentityComplianceModule is ERC7984RwaComplianceModule { + error AddressNotVerified(address user); + + function _isCompliantTransfer( + address token, + address, + address to, + euint64 + ) internal virtual override returns (ebool) { + require(IToken(token).identityRegistry().isVerified(to), AddressNotVerified(to)); + } +} From f659ac35c20da7cdab7a46b43588163b0a49cc54 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:39:58 -0600 Subject: [PATCH 057/111] revert unnecessary change and ensure token has access to transfer amount --- .../rwa/ERC7984RwaBalanceCapModule.sol | 51 +++++++++---------- .../rwa/ERC7984RwaComplianceModule.sol | 31 ++++++++--- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 6a45bf18..70ac02ef 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -14,45 +14,42 @@ import {ERC7984RwaComplianceModule} from "./ERC7984RwaComplianceModule.sol"; abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { using EnumerableSet for *; - euint64 private _maxBalance; + mapping(address => euint64) private _maxBalances; - event MaxBalanceSet(euint64 newMaxBalance); + event MaxBalanceSet(address token, euint64 newMaxBalance); - constructor(address token) ERC7984RwaComplianceModule(token) { - _token = token; - } + error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); /// @dev Sets max balance of an investor with proof. - function setMaxBalance(externalEuint64 maxBalance, bytes calldata inputProof) public virtual onlyTokenAdmin { + function setMaxBalance( + address token, + externalEuint64 maxBalance, + bytes calldata inputProof + ) public virtual onlyTokenAgent(token) { 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); + FHE.allowThis(_maxBalances[token] = maxBalance_); + emit MaxBalanceSet(token, maxBalance_); } /// @dev Gets max balance of an investor. - function getMaxBalance() public view virtual returns (euint64) { - return _maxBalance; + function getMaxBalance(address token) public view virtual returns (euint64) { + return _maxBalances[token]; } /// @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 + function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal returns (ebool) { + if (to == address(0) || from == to) { + return FHE.asEbool(true); // if burning or self-transfer } - euint64 balance = IERC7984(_token).confidentialBalanceOf(to); - _getTokenHandleAllowance(balance); - _getTokenHandleAllowance(encryptedAmount); + + require( + FHE.isAllowed(encryptedAmount, msg.sender), + UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) + ); + + euint64 balance = IERC7984(msg.sender).confidentialBalanceOf(to); + _getTokenHandleAllowance(msg.sender, balance); (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease(balance, encryptedAmount); - compliant = FHE.and(increased, FHE.le(futureBalance, _maxBalance)); + return FHE.and(increased, FHE.le(futureBalance, getMaxBalance(msg.sender))); } } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol index b9b7b27a..a720d4c1 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -4,23 +4,30 @@ 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 { + /// @dev Thrown when the sender is not authorized to call the given function. + error NotAuthorized(address account); + + modifier onlyTokenAgent(address token) { + require(IERC7984Rwa(token).isAgent(msg.sender), NotAuthorized(msg.sender)); + _; + } + /// @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 returns (ebool compliant) { - FHE.allowTransient(compliant = _isCompliantTransfer(msg.sender, from, to, encryptedAmount), msg.sender); + 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 IERC7984RwaComplianceModule @@ -45,4 +52,16 @@ abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule { ) 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); + } + } } From f9a28d586aaa86024704c22268ccab1a98c00500 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:06:52 -0600 Subject: [PATCH 058/111] revamp `ERC7984RwaInvestorCapModule` --- .../rwa/ERC7984RwaBalanceCapModule.sol | 2 - .../rwa/ERC7984RwaComplianceModule.sol | 2 + .../rwa/ERC7984RwaInvestorCapModule.sol | 91 +++++++++++-------- 3 files changed, 56 insertions(+), 39 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 70ac02ef..5e5ebc70 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -18,8 +18,6 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { event MaxBalanceSet(address token, euint64 newMaxBalance); - error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); - /// @dev Sets max balance of an investor with proof. function setMaxBalance( address token, diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol index a720d4c1..5c1207f7 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -10,6 +10,8 @@ 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 { + error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); + /// @dev Thrown when the sender is not authorized to call the given function. error NotAuthorized(address account); diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index 099f3cd9..e52a19c3 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -10,62 +10,79 @@ 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; + mapping(address => uint64) private _maxInvestorCounts; + mapping(address => euint64) private _investorCounts; - event MaxInvestorSet(uint64 maxInvestor); + event MaxInvestorSet(address indexed token, uint64 maxInvestor); - constructor(address token, uint64 maxInvestor) ERC7984RwaComplianceModule(token) { - _maxInvestor = maxInvestor; + /// @dev Sets max number of investors for the given token `token` to `maxInvestor`. + function setMaxInvestor(address token, uint64 maxInvestorCount) public virtual onlyTokenAgent(token) { + _maxInvestorCounts[token] = maxInvestorCount; + emit MaxInvestorSet(token, maxInvestorCount); } - /// @dev Sets max number of investors. - function setMaxInvestor(uint64 maxInvestor) public virtual onlyTokenAdmin { - _maxInvestor = maxInvestor; - emit MaxInvestorSet(maxInvestor); + /// @dev Gets max number of investors for the given token `token`. + function maxInvestorCounts(address token) public view virtual returns (uint64) { + return _maxInvestorCounts[token]; } - /// @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 Gets current number of investors for the given token `token`. + function investorCounts(address token) public view virtual returns (euint64) { + return _investorCounts[token]; } /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( - address /*from*/, + address token, + address from, address to, euint64 encryptedAmount ) internal override returns (ebool compliant) { - euint64 balance = IERC7984(_token).confidentialBalanceOf(to); - _getTokenHandleAllowance(balance); - _getTokenHandleAllowance(encryptedAmount); + if (to == address(0) || to == from || euint64.unwrap(encryptedAmount) == 0) { + return FHE.asEbool(true); + } + + euint64 toBalance = IERC7984(token).confidentialBalanceOf(to); + euint64 fromBalance = IERC7984(token).confidentialBalanceOf(to); + + _getTokenHandleAllowance(token, fromBalance); + _getTokenHandleAllowance(token, toBalance); + + require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); + require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, token)); + require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, token)); + compliant = FHE.or( + FHE.eq(encryptedAmount, FHE.asEuint64(0)), // or zero amount 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 + FHE.gt(toBalance, FHE.asEuint64(0)), // or already investor + FHE.lt(investorCounts(token), maxInvestorCounts(token)) // 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); + function _postTransfer(address token, address from, address to, euint64 encryptedAmount) internal override { + euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); + euint64 toBalance = IERC7984(token).confidentialBalanceOf(to); + + _getTokenHandleAllowance(token, fromBalance); + _getTokenHandleAllowance(token, toBalance); + + require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); + require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, msg.sender)); + require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, msg.sender)); + + euint64 newInvestorCount = FHE.add( + investorCounts(token), + FHE.asEuint64(FHE.gt(encryptedAmount, euint64.wrap(0))) + ); + newInvestorCount = FHE.sub( + newInvestorCount, + FHE.asEuint64(FHE.and(FHE.gt(fromBalance, euint64.wrap(0)), FHE.gt(toBalance, euint64.wrap(0)))) + ); + + _investorCounts[token] = newInvestorCount; + FHE.allowThis(newInvestorCount); } } From 4420c8bc480a7bb429b735856fd04b70c9a567b6 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:11:31 -0600 Subject: [PATCH 059/111] compiles --- .../token/ERC7984RwaBalanceCapModuleMock.sol | 12 +----------- .../token/ERC7984RwaComplianceModuleMock.sol | 10 +++++++--- .../token/ERC7984RwaInvestorCapModuleMock.sol | 4 +--- .../rwa/ERC7984RwaBalanceCapModule.sol | 18 ++++++++++-------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol index be6de97f..ec851063 100644 --- a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol @@ -6,14 +6,4 @@ import {ZamaEthereumConfig} 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, ZamaEthereumConfig { - 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); - } -} +contract ERC7984RwaBalanceCapModuleMock is ERC7984RwaBalanceCapModule, ZamaEthereumConfig {} diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol index 33545659..9240c0a9 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -13,8 +13,6 @@ contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, Za event PostTransfer(); event PreTransfer(); - constructor(address compliance) ERC7984RwaComplianceModule(compliance) {} - function $_setCompliant() public { _compliant = true; } @@ -24,6 +22,7 @@ contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, Za } function _isCompliantTransfer( + address /*token*/, address /*from*/, address /*to*/, euint64 /*encryptedAmount*/ @@ -32,7 +31,12 @@ contract ERC7984RwaModularComplianceModuleMock is ERC7984RwaComplianceModule, Za return FHE.asEbool(_compliant); } - function _postTransfer(address /*from*/, address /*to*/, euint64 /*encryptedAmount*/) internal override { + function _postTransfer( + address /*token*/, + address /*from*/, + address /*to*/, + euint64 /*encryptedAmount*/ + ) internal override { emit PostTransfer(); } } diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol index 21bbb668..a0ded83b 100644 --- a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol @@ -5,6 +5,4 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {ERC7984RwaInvestorCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol"; -contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, ZamaEthereumConfig { - constructor(address token, uint64 maxInvestor) ERC7984RwaInvestorCapModule(token, maxInvestor) {} -} +contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, ZamaEthereumConfig {} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 5e5ebc70..173bef53 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -35,19 +35,21 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { } /// @dev Internal function which checks if a transfer is compliant. - function _isCompliantTransfer(address from, address to, euint64 encryptedAmount) internal returns (ebool) { + function _isCompliantTransfer( + address token, + address from, + address to, + euint64 encryptedAmount + ) internal override returns (ebool) { if (to == address(0) || from == to) { return FHE.asEbool(true); // if burning or self-transfer } - require( - FHE.isAllowed(encryptedAmount, msg.sender), - UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) - ); + require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); - euint64 balance = IERC7984(msg.sender).confidentialBalanceOf(to); - _getTokenHandleAllowance(msg.sender, balance); + euint64 balance = IERC7984(token).confidentialBalanceOf(to); + _getTokenHandleAllowance(token, balance); (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease(balance, encryptedAmount); - return FHE.and(increased, FHE.le(futureBalance, getMaxBalance(msg.sender))); + return FHE.and(increased, FHE.le(futureBalance, getMaxBalance(token))); } } From 546bea2a45c05e10ce1070f943c7a8a4d79c6b53 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:17:21 -0600 Subject: [PATCH 060/111] fix `_postTransfer` hook --- .../rwa/ERC7984RwaInvestorCapModule.sol | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index e52a19c3..a4d9ba84 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -42,21 +42,21 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { return FHE.asEbool(true); } + euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); euint64 toBalance = IERC7984(token).confidentialBalanceOf(to); - euint64 fromBalance = IERC7984(token).confidentialBalanceOf(to); _getTokenHandleAllowance(token, fromBalance); _getTokenHandleAllowance(token, toBalance); - require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, token)); require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, token)); + require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); compliant = FHE.or( - FHE.eq(encryptedAmount, FHE.asEuint64(0)), // or zero amount + FHE.eq(encryptedAmount, FHE.asEuint64(0)), // zero transfer FHE.or( - FHE.gt(toBalance, FHE.asEuint64(0)), // or already investor - FHE.lt(investorCounts(token), maxInvestorCounts(token)) // or not reached max investors limit + FHE.gt(toBalance, FHE.asEuint64(0)), // already investor + FHE.lt(investorCounts(token), maxInvestorCounts(token)) // room for another investor ) ); } @@ -73,14 +73,18 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, msg.sender)); require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, msg.sender)); - euint64 newInvestorCount = FHE.add( - investorCounts(token), - FHE.asEuint64(FHE.gt(encryptedAmount, euint64.wrap(0))) - ); - newInvestorCount = FHE.sub( - newInvestorCount, - FHE.asEuint64(FHE.and(FHE.gt(fromBalance, euint64.wrap(0)), FHE.gt(toBalance, euint64.wrap(0)))) - ); + ebool transferNotZero = FHE.ne(encryptedAmount, euint64.wrap(0)); + euint64 newInvestorCount = investorCounts(token); + + if (to != address(0)) { + ebool addInvestor = FHE.and(transferNotZero, FHE.eq(toBalance, encryptedAmount)); + newInvestorCount = FHE.add(newInvestorCount, FHE.asEuint64(addInvestor)); + } + + if (from != address(0)) { + ebool subInvestor = FHE.and(transferNotZero, FHE.eq(fromBalance, euint64.wrap(0))); + newInvestorCount = FHE.sub(newInvestorCount, FHE.asEuint64(subInvestor)); + } _investorCounts[token] = newInvestorCount; FHE.allowThis(newInvestorCount); From 980e6ec26b5ff7798498eb2a7221dc25790f7172 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:41:25 -0600 Subject: [PATCH 061/111] nit --- .../extensions/rwa/ERC7984RwaBalanceCapModule.sol | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 173bef53..88c91edc 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -30,7 +30,7 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { } /// @dev Gets max balance of an investor. - function getMaxBalance(address token) public view virtual returns (euint64) { + function maxBalances(address token) public view virtual returns (euint64) { return _maxBalances[token]; } @@ -45,11 +45,13 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { return FHE.asEbool(true); // if burning or self-transfer } - require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); - euint64 balance = IERC7984(token).confidentialBalanceOf(to); _getTokenHandleAllowance(token, balance); + + require(FHE.isAllowed(balance, token), UnauthorizedUseOfEncryptedAmount(balance, token)); + require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); + (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease(balance, encryptedAmount); - return FHE.and(increased, FHE.le(futureBalance, getMaxBalance(token))); + return FHE.and(increased, FHE.le(futureBalance, maxBalances(token))); } } From 729249517642bc415a00d4990297cc310da1d1c6 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:02:58 -0600 Subject: [PATCH 062/111] add `onInstall` and `onUninstall` hooks to compliance modules --- contracts/interfaces/IERC7984Rwa.sol | 32 +++++++++++++++++-- .../rwa/ERC7984RwaBalanceCapModule.sol | 16 ++++++++-- .../rwa/ERC7984RwaComplianceModule.sol | 4 +++ .../rwa/ERC7984RwaInvestorCapModule.sol | 16 ++++++++-- .../rwa/ERC7984RwaModularCompliance.sol | 31 ++++++++++++++---- 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index baedd5a9..a792ff04 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -9,50 +9,67 @@ 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 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 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, @@ -60,6 +77,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, @@ -77,18 +95,28 @@ interface IERC7984RwaModularCompliance { /// @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; + function installModule(ComplianceModuleType moduleType, address module, bytes calldata initData) external; + /// @dev Uninstalls a transfer compliance module. - function uninstallModule(ComplianceModuleType moduleType, address module) external; + function uninstallModule(ComplianceModuleType moduleType, address module, bytes calldata deinitData) 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; + + /// @dev Performs operation after installation. + function onInstall(bytes calldata initData) external; + + /// @dev Performs operation after uninstallation. + function onUninstall(bytes calldata deinitData) external; } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol index 88c91edc..c96f3cfd 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol @@ -18,6 +18,13 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { event MaxBalanceSet(address token, euint64 newMaxBalance); + function onInstall(bytes calldata initData) public override { + euint64 maxBalance = abi.decode(initData, (euint64)); + _setMaxBalance(msg.sender, maxBalance); + + super.onInstall(initData); + } + /// @dev Sets max balance of an investor with proof. function setMaxBalance( address token, @@ -25,8 +32,8 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { bytes calldata inputProof ) public virtual onlyTokenAgent(token) { euint64 maxBalance_ = FHE.fromExternal(maxBalance, inputProof); - FHE.allowThis(_maxBalances[token] = maxBalance_); - emit MaxBalanceSet(token, maxBalance_); + + _setMaxBalance(token, maxBalance_); } /// @dev Gets max balance of an investor. @@ -34,6 +41,11 @@ abstract contract ERC7984RwaBalanceCapModule is ERC7984RwaComplianceModule { return _maxBalances[token]; } + function _setMaxBalance(address token, euint64 maxBalance) internal { + FHE.allowThis(_maxBalances[token] = maxBalance); + emit MaxBalanceSet(token, maxBalance); + } + /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( address token, diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol index 5c1207f7..50ffc598 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol @@ -37,6 +37,10 @@ abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule { _postTransfer(msg.sender, from, to, encryptedAmount); } + function onInstall(bytes calldata initData) public virtual {} + + function onUninstall(bytes calldata deinitData) public virtual {} + /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( address token, diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol index a4d9ba84..158a397e 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol @@ -15,10 +15,15 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { event MaxInvestorSet(address indexed token, uint64 maxInvestor); + function onInstall(bytes calldata initData) public override { + uint64 maxInvestorCount = abi.decode(initData, (uint64)); + _setMaxInvestorCount(msg.sender, maxInvestorCount); + super.onInstall(initData); + } + /// @dev Sets max number of investors for the given token `token` to `maxInvestor`. - function setMaxInvestor(address token, uint64 maxInvestorCount) public virtual onlyTokenAgent(token) { - _maxInvestorCounts[token] = maxInvestorCount; - emit MaxInvestorSet(token, maxInvestorCount); + function setMaxInvestorCount(address token, uint64 maxInvestorCount) public virtual onlyTokenAgent(token) { + _setMaxInvestorCount(token, maxInvestorCount); } /// @dev Gets max number of investors for the given token `token`. @@ -31,6 +36,11 @@ abstract contract ERC7984RwaInvestorCapModule is ERC7984RwaComplianceModule { return _investorCounts[token]; } + function _setMaxInvestorCount(address token, uint64 maxInvestorCount) internal { + _maxInvestorCounts[token] = maxInvestorCount; + emit MaxInvestorSet(token, maxInvestorCount); + } + /// @dev Internal function which checks if a transfer is compliant. function _isCompliantTransfer( address token, diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 526a2280..f33902b1 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -65,13 +65,21 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC * @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); + function installModule( + ComplianceModuleType moduleType, + address module, + bytes memory initData + ) public virtual onlyAdmin { + _installModule(moduleType, module, initData); } /// @inheritdoc IERC7984RwaModularCompliance - function uninstallModule(ComplianceModuleType moduleType, address module) public virtual onlyAdmin { - _uninstallModule(moduleType, module); + function uninstallModule( + ComplianceModuleType moduleType, + address module, + bytes memory deinitData + ) public virtual onlyAdmin { + _uninstallModule(moduleType, module, deinitData); } /// @dev Checks if a compliance module is installed. @@ -84,7 +92,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } /// @dev Internal function which installs a transfer compliance module. - function _installModule(ComplianceModuleType moduleType, address module) internal virtual { + function _installModule(ComplianceModuleType moduleType, address module, bytes memory initData) internal virtual { require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); (bool success, bytes memory returnData) = module.staticcall( abi.encodePacked(IERC7984RwaComplianceModule.isModule.selector) @@ -99,11 +107,18 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } else if (moduleType == ComplianceModuleType.Default) { require(_complianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); } + + IERC7984RwaComplianceModule(module).onInstall(initData); + emit ModuleInstalled(moduleType, module); } /// @dev Internal function which uninstalls a transfer compliance module. - function _uninstallModule(ComplianceModuleType moduleType, address module) internal virtual { + function _uninstallModule( + ComplianceModuleType moduleType, + address module, + bytes memory deinitData + ) internal virtual { require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); if (moduleType == ComplianceModuleType.ForceTransfer) { require( @@ -113,6 +128,10 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } else if (moduleType == ComplianceModuleType.Default) { require(_complianceModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); } + + // ignore success purposely to avoid modules that revert on uninstall + module.call(abi.encodeCall(IERC7984RwaComplianceModule.onUninstall, (deinitData))); + emit ModuleUninstalled(moduleType, module); } From d6fb493f26bafb4437a19bec940f7754e6eb306d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:33:18 -0600 Subject: [PATCH 063/111] create mock compliance module --- .../mocks/token/ComplianceModuleMock.sol | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 contracts/mocks/token/ComplianceModuleMock.sol diff --git a/contracts/mocks/token/ComplianceModuleMock.sol b/contracts/mocks/token/ComplianceModuleMock.sol new file mode 100644 index 00000000..8a785617 --- /dev/null +++ b/contracts/mocks/token/ComplianceModuleMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ZamaEthereumConfig} 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"; + +contract ComplianceModuleMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { + bool public isCompliant = false; + + event PostTransfer(); + event PreTransfer(); + + function setIsCompliant(bool isCompliant_) public { + isCompliant = isCompliant_; + } + + function _isCompliantTransfer(address, address, address, euint64) internal override returns (ebool) { + emit PreTransfer(); + return FHE.asEbool(isCompliant); + } + + function _postTransfer(address, address, address, euint64) internal override { + emit PostTransfer(); + } +} From a0b1ffbc9fc9f1842ad24b7c3a9fc74a5b4c74bc Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:36:13 -0600 Subject: [PATCH 064/111] remove compliance module --- .../token/ERC7984RwaBalanceCapModuleMock.sol | 9 -- .../token/ERC7984RwaComplianceModuleMock.sol | 29 ++--- .../token/ERC7984RwaInvestorCapModuleMock.sol | 8 -- .../rwa/ERC7984RwaBalanceCapModule.sol | 69 ------------ .../rwa/ERC7984RwaIdentityModule.sol | 30 ------ .../rwa/ERC7984RwaInvestorCapModule.sol | 102 ------------------ 6 files changed, 7 insertions(+), 240 deletions(-) delete mode 100644 contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol delete mode 100644 contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol delete mode 100644 contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol delete mode 100644 contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol delete mode 100644 contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol diff --git a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol deleted file mode 100644 index ec851063..00000000 --- a/contracts/mocks/token/ERC7984RwaBalanceCapModuleMock.sol +++ /dev/null @@ -1,9 +0,0 @@ -// 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 {ERC7984RwaBalanceCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol"; - -contract ERC7984RwaBalanceCapModuleMock is ERC7984RwaBalanceCapModule, ZamaEthereumConfig {} diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol index 9240c0a9..1a603ddc 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol @@ -6,37 +6,22 @@ import {ZamaEthereumConfig} 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, ZamaEthereumConfig { - bool private _compliant = false; +contract ComplianceModuleMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { + bool public isCompliant = false; event PostTransfer(); event PreTransfer(); - function $_setCompliant() public { - _compliant = true; + function setIsCompliant(bool isCompliant_) public { + isCompliant = isCompliant_; } - function $_unsetCompliant() public { - _compliant = false; - } - - function _isCompliantTransfer( - address /*token*/, - address /*from*/, - address /*to*/, - euint64 /*encryptedAmount*/ - ) internal override returns (ebool) { + function _isCompliantTransfer(address, address, address, euint64) internal override returns (ebool) { emit PreTransfer(); - return FHE.asEbool(_compliant); + return FHE.asEbool(isCompliant); } - function _postTransfer( - address /*token*/, - address /*from*/, - address /*to*/, - euint64 /*encryptedAmount*/ - ) internal override { + function _postTransfer(address, address, address, euint64) internal override { emit PostTransfer(); } } diff --git a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol b/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol deleted file mode 100644 index a0ded83b..00000000 --- a/contracts/mocks/token/ERC7984RwaInvestorCapModuleMock.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.24; - -import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {ERC7984RwaInvestorCapModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol"; - -contract ERC7984RwaInvestorCapModuleMock is ERC7984RwaInvestorCapModule, ZamaEthereumConfig {} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol deleted file mode 100644 index c96f3cfd..00000000 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaBalanceCapModule.sol +++ /dev/null @@ -1,69 +0,0 @@ -// 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 *; - - mapping(address => euint64) private _maxBalances; - - event MaxBalanceSet(address token, euint64 newMaxBalance); - - function onInstall(bytes calldata initData) public override { - euint64 maxBalance = abi.decode(initData, (euint64)); - _setMaxBalance(msg.sender, maxBalance); - - super.onInstall(initData); - } - - /// @dev Sets max balance of an investor with proof. - function setMaxBalance( - address token, - externalEuint64 maxBalance, - bytes calldata inputProof - ) public virtual onlyTokenAgent(token) { - euint64 maxBalance_ = FHE.fromExternal(maxBalance, inputProof); - - _setMaxBalance(token, maxBalance_); - } - - /// @dev Gets max balance of an investor. - function maxBalances(address token) public view virtual returns (euint64) { - return _maxBalances[token]; - } - - function _setMaxBalance(address token, euint64 maxBalance) internal { - FHE.allowThis(_maxBalances[token] = maxBalance); - emit MaxBalanceSet(token, maxBalance); - } - - /// @dev Internal function which checks if a transfer is compliant. - function _isCompliantTransfer( - address token, - address from, - address to, - euint64 encryptedAmount - ) internal override returns (ebool) { - if (to == address(0) || from == to) { - return FHE.asEbool(true); // if burning or self-transfer - } - - euint64 balance = IERC7984(token).confidentialBalanceOf(to); - _getTokenHandleAllowance(token, balance); - - require(FHE.isAllowed(balance, token), UnauthorizedUseOfEncryptedAmount(balance, token)); - require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); - - (ebool increased, euint64 futureBalance) = FHESafeMath.tryIncrease(balance, encryptedAmount); - return FHE.and(increased, FHE.le(futureBalance, maxBalances(token))); - } -} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol deleted file mode 100644 index 0f6fa095..00000000 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaIdentityModule.sol +++ /dev/null @@ -1,30 +0,0 @@ -// 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"; - -interface IIdentityRegistry { - function isVerified(address user) external view returns (bool); -} - -interface IToken { - function identityRegistry() external view returns (IIdentityRegistry); -} - -contract ERC7984IdentityComplianceModule is ERC7984RwaComplianceModule { - error AddressNotVerified(address user); - - function _isCompliantTransfer( - address token, - address, - address to, - euint64 - ) internal virtual override returns (ebool) { - require(IToken(token).identityRegistry().isVerified(to), AddressNotVerified(to)); - } -} diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol deleted file mode 100644 index 158a397e..00000000 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaInvestorCapModule.sol +++ /dev/null @@ -1,102 +0,0 @@ -// 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 { - mapping(address => uint64) private _maxInvestorCounts; - mapping(address => euint64) private _investorCounts; - - event MaxInvestorSet(address indexed token, uint64 maxInvestor); - - function onInstall(bytes calldata initData) public override { - uint64 maxInvestorCount = abi.decode(initData, (uint64)); - _setMaxInvestorCount(msg.sender, maxInvestorCount); - super.onInstall(initData); - } - - /// @dev Sets max number of investors for the given token `token` to `maxInvestor`. - function setMaxInvestorCount(address token, uint64 maxInvestorCount) public virtual onlyTokenAgent(token) { - _setMaxInvestorCount(token, maxInvestorCount); - } - - /// @dev Gets max number of investors for the given token `token`. - function maxInvestorCounts(address token) public view virtual returns (uint64) { - return _maxInvestorCounts[token]; - } - - /// @dev Gets current number of investors for the given token `token`. - function investorCounts(address token) public view virtual returns (euint64) { - return _investorCounts[token]; - } - - function _setMaxInvestorCount(address token, uint64 maxInvestorCount) internal { - _maxInvestorCounts[token] = maxInvestorCount; - emit MaxInvestorSet(token, maxInvestorCount); - } - - /// @dev Internal function which checks if a transfer is compliant. - function _isCompliantTransfer( - address token, - address from, - address to, - euint64 encryptedAmount - ) internal override returns (ebool compliant) { - if (to == address(0) || to == from || euint64.unwrap(encryptedAmount) == 0) { - return FHE.asEbool(true); - } - - euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); - euint64 toBalance = IERC7984(token).confidentialBalanceOf(to); - - _getTokenHandleAllowance(token, fromBalance); - _getTokenHandleAllowance(token, toBalance); - - require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, token)); - require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, token)); - require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); - - compliant = FHE.or( - FHE.eq(encryptedAmount, FHE.asEuint64(0)), // zero transfer - FHE.or( - FHE.gt(toBalance, FHE.asEuint64(0)), // already investor - FHE.lt(investorCounts(token), maxInvestorCounts(token)) // room for another investor - ) - ); - } - - /// @dev Internal function which performs operation after transfer. - function _postTransfer(address token, address from, address to, euint64 encryptedAmount) internal override { - euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); - euint64 toBalance = IERC7984(token).confidentialBalanceOf(to); - - _getTokenHandleAllowance(token, fromBalance); - _getTokenHandleAllowance(token, toBalance); - - require(FHE.isAllowed(encryptedAmount, token), UnauthorizedUseOfEncryptedAmount(encryptedAmount, token)); - require(FHE.isAllowed(fromBalance, token), UnauthorizedUseOfEncryptedAmount(fromBalance, msg.sender)); - require(FHE.isAllowed(toBalance, token), UnauthorizedUseOfEncryptedAmount(toBalance, msg.sender)); - - ebool transferNotZero = FHE.ne(encryptedAmount, euint64.wrap(0)); - euint64 newInvestorCount = investorCounts(token); - - if (to != address(0)) { - ebool addInvestor = FHE.and(transferNotZero, FHE.eq(toBalance, encryptedAmount)); - newInvestorCount = FHE.add(newInvestorCount, FHE.asEuint64(addInvestor)); - } - - if (from != address(0)) { - ebool subInvestor = FHE.and(transferNotZero, FHE.eq(fromBalance, euint64.wrap(0))); - newInvestorCount = FHE.sub(newInvestorCount, FHE.asEuint64(subInvestor)); - } - - _investorCounts[token] = newInvestorCount; - FHE.allowThis(newInvestorCount); - } -} From 65b883025916d80e9e8a322ad445508530702a97 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:36:32 -0600 Subject: [PATCH 065/111] remove compliance module mock --- .../mocks/token/ComplianceModuleMock.sol | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 contracts/mocks/token/ComplianceModuleMock.sol diff --git a/contracts/mocks/token/ComplianceModuleMock.sol b/contracts/mocks/token/ComplianceModuleMock.sol deleted file mode 100644 index 8a785617..00000000 --- a/contracts/mocks/token/ComplianceModuleMock.sol +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ZamaEthereumConfig} 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"; - -contract ComplianceModuleMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { - bool public isCompliant = false; - - event PostTransfer(); - event PreTransfer(); - - function setIsCompliant(bool isCompliant_) public { - isCompliant = isCompliant_; - } - - function _isCompliantTransfer(address, address, address, euint64) internal override returns (ebool) { - emit PreTransfer(); - return FHE.asEbool(isCompliant); - } - - function _postTransfer(address, address, address, euint64) internal override { - emit PostTransfer(); - } -} From 1a89821737aa02b48a65984aea2ebdf11a117b84 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:39:02 -0600 Subject: [PATCH 066/111] rename file --- ...ianceModuleMock.sol => ComplianceModuleConfidentialMock.sol} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename contracts/mocks/token/{ERC7984RwaComplianceModuleMock.sol => ComplianceModuleConfidentialMock.sol} (89%) diff --git a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol similarity index 89% rename from contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol rename to contracts/mocks/token/ComplianceModuleConfidentialMock.sol index 1a603ddc..2c49d5f6 100644 --- a/contracts/mocks/token/ERC7984RwaComplianceModuleMock.sol +++ b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol @@ -6,7 +6,7 @@ import {ZamaEthereumConfig} 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"; -contract ComplianceModuleMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { +contract ComplianceModuleConfidentialMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { bool public isCompliant = false; event PostTransfer(); From 3df5aae194348b34d0d9f2aba6b74851c53a6f17 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:18:43 -0600 Subject: [PATCH 067/111] update tests --- .../ComplianceModuleConfidentialMock.sol | 24 +- contracts/mocks/token/ERC7984Mock.sol | 7 + .../token/ERC7984RwaModularComplianceMock.sol | 18 +- .../rwa/ERC7984RwaModularCompliance.sol | 8 +- .../ERC7984RwaModularCompliance.test.ts | 486 ++++++------------ 5 files changed, 196 insertions(+), 347 deletions(-) diff --git a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol index 2c49d5f6..938189bc 100644 --- a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol +++ b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol @@ -7,15 +7,37 @@ import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {ERC7984RwaComplianceModule} from "../../token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol"; contract ComplianceModuleConfidentialMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { - bool public isCompliant = false; + 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, address, address, euint64) internal override returns (ebool) { emit PreTransfer(); return FHE.asEbool(isCompliant); diff --git a/contracts/mocks/token/ERC7984Mock.sol b/contracts/mocks/token/ERC7984Mock.sol index 63f75192..57559130 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 index c1e3b626..72b45971 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -6,12 +6,26 @@ import {ZamaEthereumConfig} 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"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; +import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; -contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ZamaEthereumConfig { +contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984Mock { constructor( string memory name, string memory symbol, string memory tokenUri, address admin - ) ERC7984Rwa(admin) ERC7984(name, symbol, tokenUri) {} + ) ERC7984Rwa(admin) ERC7984Mock(name, symbol, tokenUri) {} + + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC7984Rwa, ERC7984) 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/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index f33902b1..ea10357d 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -102,11 +102,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC ERC7984RwaNotTransferComplianceModule(module) ); + EnumerableSet.AddressSet storage modules; if (moduleType == ComplianceModuleType.ForceTransfer) { - require(_forceTransferComplianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); - } else if (moduleType == ComplianceModuleType.Default) { - require(_complianceModules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + modules = _forceTransferComplianceModules; + } else { + modules = _complianceModules; } + require(modules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); IERC7984RwaComplianceModule(module).onInstall(initData); diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 52ffa2e9..7b95043a 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -1,48 +1,30 @@ +import { $ERC7984RwaModularCompliance } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol/$ERC7984RwaModularCompliance'; 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'; +enum ModuleType { + Default, + ForceTransfer, +} + 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 [admin, agent1, agent2, holder, recipient, anyone] = await ethers.getSigners(); const token = ( - await ethers.deployContract('ERC7984RwaModularComplianceMock', ['name', 'symbol', 'uri', admin.address]) - ).connect(anyone); + await ethers.deployContract('$ERC7984RwaModularComplianceMock', ['name', 'symbol', 'uri', admin]) + ).connect(anyone) as $ERC7984RwaModularCompliance; 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); + const complianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + return { token, - alwaysOnModule, - transferOnlyModule, - investorCapModule, - balanceCapModule, + holder, + complianceModule, admin, agent1, agent2, @@ -52,64 +34,152 @@ const fixture = async () => { }; 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; - }); - } + 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('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; + describe('support module', async function () { + for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + it(`should support module type ${ModuleType[type]}`, async function () { + await expect(this.token.supportsModule(type)).to.eventually.be.true; + }); + + it('should not support other module types', async function () { + await expect(this.token.supportsModule(3)).to.be.reverted; }); } + }); + + describe('install module', async function () { + it('should emit event', async function () { + await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x')) + .to.emit(this.token, 'ModuleInstalled') + .withArgs(ModuleType.Default, this.complianceModule); + }); - 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); + it('should call `onInstall` on the module', async function () { + await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0xffff')) + .to.emit(this.complianceModule, 'OnInstall') + .withArgs('0xffff'); }); - 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 [ModuleType.Default, ModuleType.ForceTransfer]) { + it(`should add ${ModuleType[type]} module to modules list`, async function () { + await this.token.$_installModule(type, this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(type, this.complianceModule)).to.eventually.be.true; }); } - 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()); + it('should gate to admin', async function () { + await expect(this.token.connect(this.anyone).installModule(ModuleType.Default, this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') + .withArgs(this.anyone, adminRole); + }); + + it('should run module check', async function () { + const notModule = '0x0000000000000000000000000000000000000001'; + await expect(this.token.connect(this.admin).installModule(ModuleType.Default, notModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNotTransferComplianceModule') + .withArgs(notModule); + }); + + it('should not install module if already installed', async function () { + await this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x'); + await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyInstalledModule') + .withArgs(ModuleType.Default, this.complianceModule); + }); + }); + + describe('uninstall module', async function () { + beforeEach(async function () { + for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + await this.token.$_installModule(type, this.complianceModule, '0x'); + } + }); + + it('should emit event', async function () { + await expect(this.token.$_uninstallModule(ModuleType.Default, this.complianceModule, '0x')) + .to.emit(this.token, 'ModuleUninstalled') + .withArgs(ModuleType.Default, this.complianceModule); + }); + + it('should fail if module not installed', async function () { + const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + + await expect(this.token.$_uninstallModule(ModuleType.Default, newComplianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyUninstalledModule') + .withArgs(ModuleType.Default, newComplianceModule); + }); + + it('should call `onUninstall` on the module', async function () { + await expect(this.token.$_uninstallModule(ModuleType.Default, this.complianceModule, '0xffff')) + .to.emit(this.complianceModule, 'OnUninstall') + .withArgs('0xffff'); + }); + + for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + it(`should remove module of type ${ModuleType[type]} from modules list`, async function () { + await this.token.$_uninstallModule(type, this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(type, 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(ModuleType.Default, this.complianceModule, '0x'); + }); + + it('should gate to admin', async function () { + await expect(this.token.connect(this.anyone).uninstallModule(ModuleType.Default, this.complianceModule, '0x')) + .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') + .withArgs(this.anyone, adminRole); + }); }); - 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('check compliance on transfer', async function () { + beforeEach(async function () { + await this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x'); + await this.token['$_mint(address,uint64)'](this.holder, 1000); + }); + + it('should call pre-transfer hook', 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 hook', 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); }); } }); @@ -183,270 +253,4 @@ describe('ERC7984RwaModularCompliance', function () { } } }); - - 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); - }); - }); }); From 53f02318b22ae12e34d931d61be69ef612a52b57 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:47:18 -0600 Subject: [PATCH 068/111] tests passing --- test/helpers/interface.ts | 3 + test/token/ERC7984/ERC7984.test.ts | 2 +- .../ERC7984RwaModularCompliance.test.ts | 174 ++++++++++-------- 3 files changed, 102 insertions(+), 77 deletions(-) diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index caaccf9a..19eb7be9 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -34,6 +34,9 @@ export const SIGNATURES = { 'forceConfidentialTransferFrom(address,address,bytes32,bytes)', 'forceConfidentialTransferFrom(address,address,bytes32)', 'canTransact(address)', + 'isAdmin(address)', + 'isAgent(address)', + 'isAdminOrAgent(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 index 7b95043a..0ad99ced 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -157,18 +157,22 @@ describe('ERC7984RwaModularCompliance', function () { beforeEach(async function () { await this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x'); await this.token['$_mint(address,uint64)'](this.holder, 1000); + + const forceTransferModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + await this.token.$_installModule(ModuleType.ForceTransfer, forceTransferModule, '0x'); + this.forceTransferModule = forceTransferModule; }); - it('should call pre-transfer hook', async function () { - await expect( - this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n), - ).to.emit(this.complianceModule, 'PreTransfer'); + 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') + .to.emit(this.forceTransferModule, 'PreTransfer'); }); - it('should call post-transfer hook', async function () { - await expect( - this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n), - ).to.emit(this.complianceModule, 'PostTransfer'); + 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') + .to.emit(this.forceTransferModule, 'PostTransfer'); }); for (const approve of [true, false]) { @@ -182,75 +186,93 @@ describe('ERC7984RwaModularCompliance', function () { ).to.eventually.equal(approve ? 100 : 0); }); } - }); - 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, + describe('force transfer', function () { + it('should only call pre-transfer hook on force transfer module', 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.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'); - } - }); - } - } + ) + .to.emit(this.forceTransferModule, 'PreTransfer') + .to.not.emit(this.complianceModule, 'PreTransfer'); + }); + + it('should call post-transfer hook on all compliance modules', 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.forceTransferModule, 'PostTransfer') + .to.emit(this.complianceModule, 'PostTransfer'); + }); + + it('should pass compliance if default module fails', 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); + }); + + it('should fail compliance if force transfer module does not pass', async function () { + await this.forceTransferModule.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(0); + }); + }); }); }); From 81aa09a17ba3c6011e7567ffe276a6cce4508922 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:48:20 -0600 Subject: [PATCH 069/111] fix lint --- contracts/mocks/token/ERC7984RwaModularComplianceMock.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index 72b45971..02f8d44b 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -3,11 +3,11 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} 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"; -import {ERC7984Mock} from "./ERC7984Mock.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/rwa/ERC7984RwaModularCompliance.sol"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984Mock { constructor( From 1700b8e54349d5209a16631d39793a041b39b3c3 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:52:15 -0600 Subject: [PATCH 070/111] rename default compliance module to standard --- contracts/interfaces/IERC7984Rwa.sol | 2 +- .../rwa/ERC7984RwaModularCompliance.sol | 6 +-- .../ERC7984RwaModularCompliance.test.ts | 42 +++++++++---------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index a792ff04..704eb969 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -89,7 +89,7 @@ interface IERC7984Rwa is IERC7984 { /// @dev Interface for confidential RWA with modular compliance. interface IERC7984RwaModularCompliance { enum ComplianceModuleType { - Default, + Standard, ForceTransfer } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index ea10357d..23f27850 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -52,7 +52,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC * * Force transfer compliance module */ function supportsModule(ComplianceModuleType moduleType) public view virtual returns (bool) { - return moduleType == ComplianceModuleType.Default || moduleType == ComplianceModuleType.ForceTransfer; + return moduleType == ComplianceModuleType.Standard || moduleType == ComplianceModuleType.ForceTransfer; } /// @inheritdoc IERC7984RwaModularCompliance @@ -88,7 +88,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC address module ) internal view virtual returns (bool installed) { if (moduleType == ComplianceModuleType.ForceTransfer) return _forceTransferComplianceModules.contains(module); - if (moduleType == ComplianceModuleType.Default) return _complianceModules.contains(module); + if (moduleType == ComplianceModuleType.Standard) return _complianceModules.contains(module); } /// @dev Internal function which installs a transfer compliance module. @@ -127,7 +127,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC _forceTransferComplianceModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module) ); - } else if (moduleType == ComplianceModuleType.Default) { + } else if (moduleType == ComplianceModuleType.Standard) { require(_complianceModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); } diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 0ad99ced..96e0de4d 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -6,7 +6,7 @@ import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; enum ModuleType { - Default, + Standard, ForceTransfer, } @@ -55,7 +55,7 @@ describe('ERC7984RwaModularCompliance', function () { }); describe('support module', async function () { - for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { it(`should support module type ${ModuleType[type]}`, async function () { await expect(this.token.supportsModule(type)).to.eventually.be.true; }); @@ -68,18 +68,18 @@ describe('ERC7984RwaModularCompliance', function () { describe('install module', async function () { it('should emit event', async function () { - await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x')) + await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x')) .to.emit(this.token, 'ModuleInstalled') - .withArgs(ModuleType.Default, this.complianceModule); + .withArgs(ModuleType.Standard, this.complianceModule); }); it('should call `onInstall` on the module', async function () { - await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0xffff')) + await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0xffff')) .to.emit(this.complianceModule, 'OnInstall') .withArgs('0xffff'); }); - for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { it(`should add ${ModuleType[type]} module to modules list`, async function () { await this.token.$_installModule(type, this.complianceModule, '0x'); await expect(this.token.isModuleInstalled(type, this.complianceModule)).to.eventually.be.true; @@ -87,54 +87,54 @@ describe('ERC7984RwaModularCompliance', function () { } it('should gate to admin', async function () { - await expect(this.token.connect(this.anyone).installModule(ModuleType.Default, this.complianceModule, '0x')) + await expect(this.token.connect(this.anyone).installModule(ModuleType.Standard, this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') .withArgs(this.anyone, adminRole); }); it('should run module check', async function () { const notModule = '0x0000000000000000000000000000000000000001'; - await expect(this.token.connect(this.admin).installModule(ModuleType.Default, notModule, '0x')) + await expect(this.token.connect(this.admin).installModule(ModuleType.Standard, notModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNotTransferComplianceModule') .withArgs(notModule); }); it('should not install module if already installed', async function () { - await this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x'); - await expect(this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x')) + await this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x'); + await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyInstalledModule') - .withArgs(ModuleType.Default, this.complianceModule); + .withArgs(ModuleType.Standard, this.complianceModule); }); }); describe('uninstall module', async function () { beforeEach(async function () { - for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { await this.token.$_installModule(type, this.complianceModule, '0x'); } }); it('should emit event', async function () { - await expect(this.token.$_uninstallModule(ModuleType.Default, this.complianceModule, '0x')) + await expect(this.token.$_uninstallModule(ModuleType.Standard, this.complianceModule, '0x')) .to.emit(this.token, 'ModuleUninstalled') - .withArgs(ModuleType.Default, this.complianceModule); + .withArgs(ModuleType.Standard, this.complianceModule); }); it('should fail if module not installed', async function () { const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); - await expect(this.token.$_uninstallModule(ModuleType.Default, newComplianceModule, '0x')) + await expect(this.token.$_uninstallModule(ModuleType.Standard, newComplianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyUninstalledModule') - .withArgs(ModuleType.Default, newComplianceModule); + .withArgs(ModuleType.Standard, newComplianceModule); }); it('should call `onUninstall` on the module', async function () { - await expect(this.token.$_uninstallModule(ModuleType.Default, this.complianceModule, '0xffff')) + await expect(this.token.$_uninstallModule(ModuleType.Standard, this.complianceModule, '0xffff')) .to.emit(this.complianceModule, 'OnUninstall') .withArgs('0xffff'); }); - for (const type of [ModuleType.Default, ModuleType.ForceTransfer]) { + for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { it(`should remove module of type ${ModuleType[type]} from modules list`, async function () { await this.token.$_uninstallModule(type, this.complianceModule, '0x'); await expect(this.token.isModuleInstalled(type, this.complianceModule)).to.eventually.be.false; @@ -143,11 +143,11 @@ describe('ERC7984RwaModularCompliance', function () { it("should not revert if module's `onUninstall` reverts", async function () { await this.complianceModule.setRevertOnUninstall(true); - await this.token.$_uninstallModule(ModuleType.Default, this.complianceModule, '0x'); + await this.token.$_uninstallModule(ModuleType.Standard, this.complianceModule, '0x'); }); it('should gate to admin', async function () { - await expect(this.token.connect(this.anyone).uninstallModule(ModuleType.Default, this.complianceModule, '0x')) + await expect(this.token.connect(this.anyone).uninstallModule(ModuleType.Standard, this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') .withArgs(this.anyone, adminRole); }); @@ -155,7 +155,7 @@ describe('ERC7984RwaModularCompliance', function () { describe('check compliance on transfer', async function () { beforeEach(async function () { - await this.token.$_installModule(ModuleType.Default, this.complianceModule, '0x'); + await this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x'); await this.token['$_mint(address,uint64)'](this.holder, 1000); const forceTransferModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); From d24636710234d7cd76847041045e590741375db2 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:30:31 -0600 Subject: [PATCH 071/111] nit --- .../ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 23f27850..85f40ca0 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -115,7 +115,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC emit ModuleInstalled(moduleType, module); } - /// @dev Internal function which uninstalls a transfer compliance module. + /// @dev Internal function which uninstalls a compliance module. function _uninstallModule( ComplianceModuleType moduleType, address module, From d36c031f1110562dd29611343cabd5e825262f27 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:25:24 -0600 Subject: [PATCH 072/111] add coverage --- .../token/ComplianceModuleConfidentialMock.sol | 15 ++++++++++++--- .../ERC7984RwaModularCompliance.test.ts | 7 ++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol index 938189bc..17a3f656 100644 --- a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol +++ b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol @@ -4,7 +4,8 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} 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"; +import {IERC7984} from "./../../interfaces/IERC7984.sol"; +import {ERC7984RwaComplianceModule} from "./../../token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol"; contract ComplianceModuleConfidentialMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { bool public isCompliant = true; @@ -38,12 +39,20 @@ contract ComplianceModuleConfidentialMock is ERC7984RwaComplianceModule, ZamaEth revertOnUninstall = revertOnUninstall_; } - function _isCompliantTransfer(address, address, address, euint64) internal override returns (ebool) { + 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, address, address, euint64) internal override { + function _postTransfer(address token, address from, address to, euint64 amount) internal override { emit PostTransfer(); + super._postTransfer(token, from, to, amount); } } diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 96e0de4d..9fb2ca55 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -90,11 +90,13 @@ describe('ERC7984RwaModularCompliance', function () { await expect(this.token.connect(this.anyone).installModule(ModuleType.Standard, this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') .withArgs(this.anyone, adminRole); + + await this.token.connect(this.admin).installModule(ModuleType.Standard, this.complianceModule, '0x'); }); it('should run module check', async function () { const notModule = '0x0000000000000000000000000000000000000001'; - await expect(this.token.connect(this.admin).installModule(ModuleType.Standard, notModule, '0x')) + await expect(this.token.$_installModule(ModuleType.Standard, notModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNotTransferComplianceModule') .withArgs(notModule); }); @@ -150,6 +152,9 @@ describe('ERC7984RwaModularCompliance', function () { await expect(this.token.connect(this.anyone).uninstallModule(ModuleType.Standard, this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') .withArgs(this.anyone, adminRole); + + await this.token.connect(this.admin).uninstallModule(ModuleType.Standard, this.complianceModule, '0x'); + await expect(this.token.isModuleInstalled(ModuleType.Standard, this.complianceModule)).to.eventually.be.false; }); }); From 46c18a0886f25e9472c8117ca0054516b8e83a86 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:34:14 -0600 Subject: [PATCH 073/111] optimize --- .../extensions/rwa/ERC7984RwaModularCompliance.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 85f40ca0..f11a72ff 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -122,14 +122,14 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC bytes memory deinitData ) internal virtual { require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); + + EnumerableSet.AddressSet storage modules; if (moduleType == ComplianceModuleType.ForceTransfer) { - require( - _forceTransferComplianceModules.remove(module), - ERC7984RwaAlreadyUninstalledModule(moduleType, module) - ); - } else if (moduleType == ComplianceModuleType.Standard) { - require(_complianceModules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); + modules = _forceTransferComplianceModules; + } else { + modules = _complianceModules; } + require(modules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); // ignore success purposely to avoid modules that revert on uninstall module.call(abi.encodeCall(IERC7984RwaComplianceModule.onUninstall, (deinitData))); From 978567b071897e9abe38cac7adb50950516e956c Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:16:00 -0600 Subject: [PATCH 074/111] remove compliance module from token dir --- .../ComplianceModuleConfidential.sol} | 13 ++++++----- .../IComplianceModuleConfidential.sol | 23 +++++++++++++++++++ .../ComplianceModuleConfidentialMock.sol | 4 ++-- 3 files changed, 32 insertions(+), 8 deletions(-) rename contracts/{token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol => finance/compliance/ComplianceModuleConfidential.sol} (83%) create mode 100644 contracts/interfaces/IComplianceModuleConfidential.sol diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol b/contracts/finance/compliance/ComplianceModuleConfidential.sol similarity index 83% rename from contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol rename to contracts/finance/compliance/ComplianceModuleConfidential.sol index 50ffc598..1ef0d990 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol +++ b/contracts/finance/compliance/ComplianceModuleConfidential.sol @@ -3,13 +3,14 @@ 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"; +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 ERC7984RwaComplianceModule is IERC7984RwaComplianceModule { +abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential { error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); /// @dev Thrown when the sender is not authorized to call the given function. @@ -20,19 +21,19 @@ abstract contract ERC7984RwaComplianceModule is IERC7984RwaComplianceModule { _; } - /// @inheritdoc IERC7984RwaComplianceModule + /// @inheritdoc IComplianceModuleConfidential function isModule() public pure override returns (bytes4) { return this.isModule.selector; } - /// @inheritdoc IERC7984RwaComplianceModule + /// @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 IERC7984RwaComplianceModule + /// @inheritdoc IComplianceModuleConfidential function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { _postTransfer(msg.sender, from, to, encryptedAmount); } diff --git a/contracts/interfaces/IComplianceModuleConfidential.sol b/contracts/interfaces/IComplianceModuleConfidential.sol new file mode 100644 index 00000000..4699e927 --- /dev/null +++ b/contracts/interfaces/IComplianceModuleConfidential.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {euint64, ebool} from "@fhevm/solidity/lib/FHE.sol"; + +/// @dev Interface for confidential RWA transfer compliance module. +interface IComplianceModuleConfidential { + /// @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; + + /// @dev Performs operation after installation. + function onInstall(bytes calldata initData) external; + + /// @dev Performs operation after uninstallation. + function onUninstall(bytes calldata deinitData) external; +} diff --git a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol index 17a3f656..cedc748e 100644 --- a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol +++ b/contracts/mocks/token/ComplianceModuleConfidentialMock.sol @@ -4,10 +4,10 @@ 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"; -import {ERC7984RwaComplianceModule} from "./../../token/ERC7984/extensions/rwa/ERC7984RwaComplianceModule.sol"; -contract ComplianceModuleConfidentialMock is ERC7984RwaComplianceModule, ZamaEthereumConfig { +contract ComplianceModuleConfidentialMock is ComplianceModuleConfidential, ZamaEthereumConfig { bool public isCompliant = true; bool public revertOnUninstall = false; From 1a5ccfa036200bf6798218e25875a77ea987ae9d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:25:13 -0600 Subject: [PATCH 075/111] fix test --- .../extensions/ERC7984RwaModularCompliance.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 9fb2ca55..66e14c0e 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -59,11 +59,11 @@ describe('ERC7984RwaModularCompliance', function () { it(`should support module type ${ModuleType[type]}`, async function () { await expect(this.token.supportsModule(type)).to.eventually.be.true; }); - - it('should not support other module types', async function () { - await expect(this.token.supportsModule(3)).to.be.reverted; - }); } + + it('should not support other module types', async function () { + await expect(this.token.supportsModule(3)).to.be.reverted; + }); }); describe('install module', async function () { From e8e0368b206d889f54122c1b0473d188a39abf80 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:36:22 -0600 Subject: [PATCH 076/111] fix tests --- .../ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index f11a72ff..69b348bc 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -222,10 +222,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow compliance modules to access any handle. - function _validateHandleAllowance(bytes32) internal view override { - require( - _forceTransferComplianceModules.contains(msg.sender) || _complianceModules.contains(msg.sender), - SenderNotComplianceModule(msg.sender) - ); + function _validateHandleAllowance(bytes32) internal view override returns (bool) { + return _forceTransferComplianceModules.contains(msg.sender) || _complianceModules.contains(msg.sender); } } From 974c5a6f4b5540d3fdd99576a8cc2cea5d80cd7d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:53:41 -0600 Subject: [PATCH 077/111] override supports interface on `ERC7984RwaModularCompliance` --- contracts/mocks/token/ERC7984RwaModularComplianceMock.sol | 4 +++- .../ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index 02f8d44b..a15124c9 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -17,7 +17,9 @@ contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984 address admin ) ERC7984Rwa(admin) ERC7984Mock(name, symbol, tokenUri) {} - function supportsInterface(bytes4 interfaceId) public view virtual override(ERC7984Rwa, ERC7984) returns (bool) { + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC7984, ERC7984RwaModularCompliance) returns (bool) { return super.supportsInterface(interfaceId); } diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 69b348bc..369a14ef 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -82,6 +82,11 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC _uninstallModule(moduleType, module, deinitData); } + /// @inheritdoc ERC7984Rwa + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC7984RwaModularCompliance).interfaceId || super.supportsInterface(interfaceId); + } + /// @dev Checks if a compliance module is installed. function _isModuleInstalled( ComplianceModuleType moduleType, From 3b19d07a11af7af8a0662231bdc07f3ee5fdbe3c Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 4 Feb 2026 16:05:36 -0600 Subject: [PATCH 078/111] fix slither --- .../token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol index 369a14ef..d07c717a 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol @@ -137,6 +137,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC require(modules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); // ignore success purposely to avoid modules that revert on uninstall + // slither-disable-next-line unchecked-lowlevel module.call(abi.encodeCall(IERC7984RwaComplianceModule.onUninstall, (deinitData))); emit ModuleUninstalled(moduleType, module); From 6bf6fc6de9da5cf49dfae78f34530ea34c99691a Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:21:42 -0500 Subject: [PATCH 079/111] add only admin modifier to abstract module --- .../finance/compliance/ComplianceModuleConfidential.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/finance/compliance/ComplianceModuleConfidential.sol b/contracts/finance/compliance/ComplianceModuleConfidential.sol index 1ef0d990..180db0a9 100644 --- a/contracts/finance/compliance/ComplianceModuleConfidential.sol +++ b/contracts/finance/compliance/ComplianceModuleConfidential.sol @@ -16,6 +16,13 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential /// @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)); _; From f19df16944bb1d5e4e0b8a638b94b6855980e5e7 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:26:04 -0800 Subject: [PATCH 080/111] move modular compliance file --- contracts/mocks/token/ERC7984RwaModularComplianceMock.sol | 2 +- .../extensions/{rwa => }/ERC7984RwaModularCompliance.sol | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename contracts/token/ERC7984/extensions/{rwa => }/ERC7984RwaModularCompliance.sol (98%) diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol index a15124c9..b0a1d337 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol @@ -6,7 +6,7 @@ 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/rwa/ERC7984RwaModularCompliance.sol"; +import {ERC7984RwaModularCompliance} from "./../../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol"; import {ERC7984Mock} from "./ERC7984Mock.sol"; contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984Mock { diff --git a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol similarity index 98% rename from contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol rename to contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index d07c717a..da9eb2a0 100644 --- a/contracts/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -4,9 +4,9 @@ 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"; +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). From f146dd68bc7811b10bd9aaffff1f5660165b6d11 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:16:38 -0800 Subject: [PATCH 081/111] allow compliance modules to read transfer amount --- .../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index da9eb2a0..05071179 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -189,6 +189,8 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { + // TODO: Can the encrypted amount ever be 0? Applies to all allowances for compliance. + FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) @@ -206,6 +208,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { + FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) @@ -217,12 +220,14 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC address[] memory modules = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { + FHE.allowTransient(encryptedAmount, modules[i]); IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } modules = _complianceModules.values(); modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { + FHE.allowTransient(encryptedAmount, modules[i]); IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); } } From 1566cab1535a21837149bd3d62085db479fdbe09 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:17:07 -0800 Subject: [PATCH 082/111] up --- .../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 05071179..af1cc012 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -190,6 +190,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { // TODO: Can the encrypted amount ever be 0? Applies to all allowances for compliance. + // Should we optimistically be granting allowances at all? FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, From 39e7bd0e8fa701efbeb5048a868a1838d2989240 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:27:33 -0500 Subject: [PATCH 083/111] Remove `IERC7984RwaComplianceModule` interface. Instead use `IComplianceModuleConfidential` --- contracts/interfaces/IERC7984Rwa.sol | 18 ---------------- .../ERC7984RwaModularCompliance.sol | 21 ++++++++++--------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 704eb969..720211b2 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -102,21 +102,3 @@ interface IERC7984RwaModularCompliance { /// @dev Uninstalls a transfer compliance module. function uninstallModule(ComplianceModuleType moduleType, address module, bytes calldata deinitData) 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; - - /// @dev Performs operation after installation. - function onInstall(bytes calldata initData) external; - - /// @dev Performs operation after uninstallation. - function onUninstall(bytes calldata deinitData) external; -} diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index af1cc012..1aed5aeb 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -4,8 +4,9 @@ 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 {IComplianceModuleConfidential} from "./../../../interfaces/IComplianceModuleConfidential.sol"; +import {IERC7984RwaModularCompliance} from "./../../../interfaces/IERC7984Rwa.sol"; +import {HandleAccessManager} from "./../../../utils/HandleAccessManager.sol"; import {ERC7984Rwa} from "./ERC7984Rwa.sol"; /** @@ -100,10 +101,10 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC function _installModule(ComplianceModuleType moduleType, address module, bytes memory initData) internal virtual { require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); (bool success, bytes memory returnData) = module.staticcall( - abi.encodePacked(IERC7984RwaComplianceModule.isModule.selector) + abi.encodePacked(IComplianceModuleConfidential.isModule.selector) ); require( - success && bytes4(returnData) == IERC7984RwaComplianceModule.isModule.selector, + success && bytes4(returnData) == IComplianceModuleConfidential.isModule.selector, ERC7984RwaNotTransferComplianceModule(module) ); @@ -115,7 +116,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } require(modules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); - IERC7984RwaComplianceModule(module).onInstall(initData); + IComplianceModuleConfidential(module).onInstall(initData); emit ModuleInstalled(moduleType, module); } @@ -138,7 +139,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC // ignore success purposely to avoid modules that revert on uninstall // slither-disable-next-line unchecked-lowlevel - module.call(abi.encodeCall(IERC7984RwaComplianceModule.onUninstall, (deinitData))); + module.call(abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); emit ModuleUninstalled(moduleType, module); } @@ -194,7 +195,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, - IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + IComplianceModuleConfidential(modules[i]).isCompliantTransfer(from, to, encryptedAmount) ); } } @@ -212,7 +213,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, - IERC7984RwaComplianceModule(modules[i]).isCompliantTransfer(from, to, encryptedAmount) + IComplianceModuleConfidential(modules[i]).isCompliantTransfer(from, to, encryptedAmount) ); } } @@ -222,14 +223,14 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { FHE.allowTransient(encryptedAmount, modules[i]); - IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + IComplianceModuleConfidential(modules[i]).postTransfer(from, to, encryptedAmount); } modules = _complianceModules.values(); modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { FHE.allowTransient(encryptedAmount, modules[i]); - IERC7984RwaComplianceModule(modules[i]).postTransfer(from, to, encryptedAmount); + IComplianceModuleConfidential(modules[i]).postTransfer(from, to, encryptedAmount); } } From a1b1dd0a5eff4fd2b77dec421285f50e2a3a1630 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:45:31 -0500 Subject: [PATCH 084/111] review feedback --- .../extensions/ERC7984RwaModularCompliance.sol | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 1aed5aeb..a5b72517 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -41,8 +41,6 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC 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. @@ -108,12 +106,9 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC ERC7984RwaNotTransferComplianceModule(module) ); - EnumerableSet.AddressSet storage modules; - if (moduleType == ComplianceModuleType.ForceTransfer) { - modules = _forceTransferComplianceModules; - } else { - modules = _complianceModules; - } + EnumerableSet.AddressSet storage modules = moduleType == ComplianceModuleType.ForceTransfer + ? _forceTransferComplianceModules + : _complianceModules; require(modules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); IComplianceModuleConfidential(module).onInstall(initData); From 62f22cc7d58b8142fd3f1865b5a3d3530bc135fd Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:15:20 -0500 Subject: [PATCH 085/111] remove `isAdminOrAgent` function --- contracts/interfaces/IERC7984Rwa.sol | 3 --- contracts/token/ERC7984/extensions/ERC7984Rwa.sol | 5 ----- .../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol | 4 ++-- test/helpers/interface.ts | 1 - 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 720211b2..c8e644de 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -16,9 +16,6 @@ interface IERC7984Rwa is IERC7984 { /// @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 canTransact(address account) external view returns (bool); diff --git a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol index c371cc50..84f1b57b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Rwa.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Rwa.sol @@ -65,11 +65,6 @@ abstract contract ERC7984Rwa is IERC7984Rwa, ERC7984Freezable, ERC7984Restricted 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/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index a5b72517..3132e613 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.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"; @@ -133,8 +134,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC require(modules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); // ignore success purposely to avoid modules that revert on uninstall - // slither-disable-next-line unchecked-lowlevel - module.call(abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); + LowLevelCall.callNoReturn(module, abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); emit ModuleUninstalled(moduleType, module); } diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index 19eb7be9..416a4577 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -36,7 +36,6 @@ export const SIGNATURES = { 'canTransact(address)', 'isAdmin(address)', 'isAgent(address)', - 'isAdminOrAgent(address)', 'pause()', 'paused()', 'setConfidentialFrozen(address,bytes32,bytes)', From bb28fe84d6efad00c7d91cfe0100823dc59fea1f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:37:51 -0400 Subject: [PATCH 086/111] rename compliance functions --- .../ERC7984RwaModularCompliance.sol | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 3132e613..c455b475 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -149,12 +149,15 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { euint64 amountToTransfer = FHE.select( - FHE.and(_checkAlwaysBefore(from, to, encryptedAmount), _checkOnlyBeforeTransfer(from, to, encryptedAmount)), + FHE.and( + _checkForceTransferCompliance(from, to, encryptedAmount), + _checkStandardCompliance(from, to, encryptedAmount) + ), encryptedAmount, FHE.asEuint64(0) ); transferred = super._update(from, to, amountToTransfer); - _onTransferCompliance(from, to, transferred); + _postTransferCompliance(from, to, transferred); } /** @@ -167,16 +170,16 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { euint64 amountToTransfer = FHE.select( - _checkAlwaysBefore(from, to, encryptedAmount), + _checkForceTransferCompliance(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0) ); transferred = super._forceUpdate(from, to, amountToTransfer); - _onTransferCompliance(from, to, transferred); + _postTransferCompliance(from, to, transferred); } - /// @dev Checks always-on compliance. - function _checkAlwaysBefore( + /// @dev Checks force transfer compliance modules (applied to both normal and force transfers). + function _checkForceTransferCompliance( address from, address to, euint64 encryptedAmount @@ -195,8 +198,8 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } } - /// @dev Checks transfer-only compliance. - function _checkOnlyBeforeTransfer( + /// @dev Checks standard compliance modules (applied to normal transfers only). + function _checkStandardCompliance( address from, address to, euint64 encryptedAmount @@ -213,7 +216,8 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } } - function _onTransferCompliance(address from, address to, euint64 encryptedAmount) internal virtual { + /// @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 = _forceTransferComplianceModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { From 666f6216cda153f957300fffa8b1a656bc1c263b Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:15:47 -0400 Subject: [PATCH 087/111] max number of compliance modules --- .../ERC7984RwaModularCompliance.sol | 27 ++++++--- test/helpers/interface.ts | 5 ++ .../ERC7984RwaModularCompliance.test.ts | 55 +++++++++++-------- 3 files changed, 55 insertions(+), 32 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index c455b475..92de7a6f 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -36,12 +36,14 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @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 address is not a valid compliance module. + error ERC7984RwaInvalidModule(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); + error ERC7984RwaDuplicateModule(ComplianceModuleType moduleType, address module); + /// @dev The module is not installed. + error ERC7984RwaNonexistentModule(ComplianceModuleType moduleType, address module); + /// @dev The maximum number of modules has been exceeded. + error ERC7984RwaExceededMaxModules(); /** * @dev Check if a certain module typeId is supported. @@ -82,6 +84,11 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC _uninstallModule(moduleType, module, deinitData); } + /// @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); @@ -98,19 +105,23 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @dev Internal function which installs a transfer compliance module. function _installModule(ComplianceModuleType moduleType, address module, bytes memory initData) internal virtual { + require( + _forceTransferComplianceModules.length() + _complianceModules.length() < maxComplianceModules(), + ERC7984RwaExceededMaxModules() + ); require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); (bool success, bytes memory returnData) = module.staticcall( abi.encodePacked(IComplianceModuleConfidential.isModule.selector) ); require( success && bytes4(returnData) == IComplianceModuleConfidential.isModule.selector, - ERC7984RwaNotTransferComplianceModule(module) + ERC7984RwaInvalidModule(module) ); EnumerableSet.AddressSet storage modules = moduleType == ComplianceModuleType.ForceTransfer ? _forceTransferComplianceModules : _complianceModules; - require(modules.add(module), ERC7984RwaAlreadyInstalledModule(moduleType, module)); + require(modules.add(module), ERC7984RwaDuplicateModule(moduleType, module)); IComplianceModuleConfidential(module).onInstall(initData); @@ -131,7 +142,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } else { modules = _complianceModules; } - require(modules.remove(module), ERC7984RwaAlreadyUninstalledModule(moduleType, module)); + require(modules.remove(module), ERC7984RwaNonexistentModule(moduleType, module)); // ignore success purposely to avoid modules that revert on uninstall LowLevelCall.callNoReturn(module, abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index 416a4577..f0129215 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(uint8,address)', + 'installModule(uint8,address,bytes)', + 'uninstallModule(uint8,address,bytes)', + ], ERC7984RWA: [ 'blockUser(address)', 'confidentialAvailable(address)', diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index 66e14c0e..be270686 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -1,5 +1,6 @@ import { $ERC7984RwaModularCompliance } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol/$ERC7984RwaModularCompliance'; import { callAndGetResult } from '../../../helpers/event'; +import { INTERFACE_IDS, INVALID_ID } from '../../../helpers/interface'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { time } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; @@ -10,29 +11,8 @@ enum ModuleType { ForceTransfer, } -const transferEventSignature = 'ConfidentialTransfer(address,address,bytes32)'; const adminRole = ethers.ZeroHash; -const fixture = async () => { - 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'); - - return { - token, - holder, - complianceModule, - admin, - agent1, - agent2, - recipient, - anyone, - }; -}; - describe('ERC7984RwaModularCompliance', function () { beforeEach(async function () { const [admin, agent1, agent2, holder, recipient, anyone] = await ethers.getSigners(); @@ -54,6 +34,18 @@ describe('ERC7984RwaModularCompliance', function () { }); }); + 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('support module', async function () { for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { it(`should support module type ${ModuleType[type]}`, async function () { @@ -97,14 +89,29 @@ describe('ERC7984RwaModularCompliance', function () { it('should run module check', async function () { const notModule = '0x0000000000000000000000000000000000000001'; await expect(this.token.$_installModule(ModuleType.Standard, notModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNotTransferComplianceModule') + .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(i % 2 === 0 ? ModuleType.Standard : ModuleType.ForceTransfer, module, '0x'); + } + + const extraModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + await expect(this.token.$_installModule(ModuleType.Standard, extraModule, '0x')).to.be.revertedWithCustomError( + this.token, + 'ERC7984RwaExceededMaxModules', + ); + }); + it('should not install module if already installed', async function () { await this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x'); await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyInstalledModule') + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaDuplicateModule') .withArgs(ModuleType.Standard, this.complianceModule); }); }); @@ -126,7 +133,7 @@ describe('ERC7984RwaModularCompliance', function () { const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); await expect(this.token.$_uninstallModule(ModuleType.Standard, newComplianceModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'ERC7984RwaAlreadyUninstalledModule') + .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNonexistentModule') .withArgs(ModuleType.Standard, newComplianceModule); }); From 38bce200f752b4aab6500f3395823bf9a1df2178 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:10:27 -0400 Subject: [PATCH 088/111] switch to use 165 for module verification --- .../ComplianceModuleConfidential.sol | 19 ++++++++++--------- .../IComplianceModuleConfidential.sol | 6 ++---- .../ERC7984RwaModularCompliance.sol | 6 ++---- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/contracts/finance/compliance/ComplianceModuleConfidential.sol b/contracts/finance/compliance/ComplianceModuleConfidential.sol index 180db0a9..50e19c8f 100644 --- a/contracts/finance/compliance/ComplianceModuleConfidential.sol +++ b/contracts/finance/compliance/ComplianceModuleConfidential.sol @@ -3,14 +3,15 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {IComplianceModuleConfidential} from "../../interfaces/IComplianceModuleConfidential.sol"; -import {IERC7984Rwa} from "../../interfaces/IERC7984Rwa.sol"; -import {HandleAccessManager} from "../../utils/HandleAccessManager.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 { +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. @@ -28,11 +29,6 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential _; } - /// @inheritdoc IComplianceModuleConfidential - function isModule() public pure override returns (bytes4) { - return this.isModule.selector; - } - /// @inheritdoc IComplianceModuleConfidential function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { ebool compliant = _isCompliantTransfer(msg.sender, from, to, encryptedAmount); @@ -49,6 +45,11 @@ abstract contract ComplianceModuleConfidential is 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. function _isCompliantTransfer( address token, diff --git a/contracts/interfaces/IComplianceModuleConfidential.sol b/contracts/interfaces/IComplianceModuleConfidential.sol index 4699e927..0dc2531f 100644 --- a/contracts/interfaces/IComplianceModuleConfidential.sol +++ b/contracts/interfaces/IComplianceModuleConfidential.sol @@ -3,12 +3,10 @@ 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 { - /// @dev Returns magic number if it is a module. - function isModule() external returns (bytes4); - +interface IComplianceModuleConfidential is IERC165 { /// @dev Checks if a transfer is compliant. Should be non-mutating. function isCompliantTransfer(address from, address to, euint64 encryptedAmount) external returns (ebool); diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 92de7a6f..e1b90ca6 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -3,6 +3,7 @@ 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"; @@ -110,11 +111,8 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC ERC7984RwaExceededMaxModules() ); require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); - (bool success, bytes memory returnData) = module.staticcall( - abi.encodePacked(IComplianceModuleConfidential.isModule.selector) - ); require( - success && bytes4(returnData) == IComplianceModuleConfidential.isModule.selector, + ERC165Checker.supportsInterface(module, type(IComplianceModuleConfidential).interfaceId), ERC7984RwaInvalidModule(module) ); From fbee7ae2d54a1e1bb2f50b6c6e143e8768a00a0f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:26:27 -0400 Subject: [PATCH 089/111] remove todo block --- .../token/ERC7984/extensions/ERC7984RwaModularCompliance.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index e1b90ca6..3c2b2b1c 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -197,9 +197,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { - // TODO: Can the encrypted amount ever be 0? Applies to all allowances for compliance. - // Should we optimistically be granting allowances at all? - FHE.allowTransient(encryptedAmount, modules[i]); + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, IComplianceModuleConfidential(modules[i]).isCompliantTransfer(from, to, encryptedAmount) From fb75f9be585efa84ac86c2e502fcb5193a1c1734 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:12:40 -0400 Subject: [PATCH 090/111] remove force transfer modules --- contracts/interfaces/IERC7984Rwa.sol | 11 +- .../ERC7984RwaModularCompliance.sol | 140 ++++-------------- test/helpers/interface.ts | 6 +- .../ERC7984RwaModularCompliance.test.ts | 138 +++++------------ 4 files changed, 74 insertions(+), 221 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index c8e644de..0f1793d0 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -85,17 +85,12 @@ interface IERC7984Rwa is IERC7984 { /// @dev Interface for confidential RWA with modular compliance. interface IERC7984RwaModularCompliance { - enum ComplianceModuleType { - Standard, - ForceTransfer - } - /// @dev Checks if a compliance module is installed. - function isModuleInstalled(ComplianceModuleType moduleType, address module) external view returns (bool); + function isModuleInstalled(address module) external view returns (bool); /// @dev Installs a transfer compliance module. - function installModule(ComplianceModuleType moduleType, address module, bytes calldata initData) external; + function installModule(address module, bytes calldata initData) external; /// @dev Uninstalls a transfer compliance module. - function uninstallModule(ComplianceModuleType moduleType, address module, bytes calldata deinitData) external; + function uninstallModule(address module, bytes calldata deinitData) external; } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 3c2b2b1c..6d91960f 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -21,46 +21,30 @@ import {ERC7984Rwa} from "./ERC7984Rwa.sol"; * amount and may do accounting as necessary. Compliance modules may revert on either call, which will propagate * and revert the entire transaction. * - * NOTE: Only force transfer compliance modules are called before force transfers--all compliance modules are called - * after force transfers. Normal transfers call all compliance modules (including force transfer compliance modules). + * 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 _forceTransferComplianceModules; EnumerableSet.AddressSet private _complianceModules; /// @dev Emitted when a module is installed. - event ModuleInstalled(ComplianceModuleType moduleType, address module); + event ModuleInstalled(address module); /// @dev Emitted when a module is uninstalled. - event ModuleUninstalled(ComplianceModuleType moduleType, address module); + event ModuleUninstalled(address module); - /// @dev The module type is not supported. - error ERC7984RwaUnsupportedModuleType(ComplianceModuleType moduleType); /// @dev The address is not a valid compliance module. error ERC7984RwaInvalidModule(address module); /// @dev The module is already installed. - error ERC7984RwaDuplicateModule(ComplianceModuleType moduleType, address module); + error ERC7984RwaDuplicateModule(address module); /// @dev The module is not installed. - error ERC7984RwaNonexistentModule(ComplianceModuleType moduleType, address module); + error ERC7984RwaNonexistentModule(address module); /// @dev The maximum number of modules has been exceeded. error ERC7984RwaExceededMaxModules(); - /** - * @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.Standard || moduleType == ComplianceModuleType.ForceTransfer; - } - /// @inheritdoc IERC7984RwaModularCompliance - function isModuleInstalled(ComplianceModuleType moduleType, address module) public view virtual returns (bool) { - return _isModuleInstalled(moduleType, module); + function isModuleInstalled(address module) public view virtual returns (bool) { + return _complianceModules.contains(module); } /** @@ -68,21 +52,13 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC * @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, - bytes memory initData - ) public virtual onlyAdmin { - _installModule(moduleType, module, initData); + function installModule(address module, bytes memory initData) public virtual onlyAdmin { + _installModule(module, initData); } /// @inheritdoc IERC7984RwaModularCompliance - function uninstallModule( - ComplianceModuleType moduleType, - address module, - bytes memory deinitData - ) public virtual onlyAdmin { - _uninstallModule(moduleType, module, deinitData); + function uninstallModule(address module, bytes memory deinitData) public virtual onlyAdmin { + _uninstallModule(module, deinitData); } /// @dev Returns the maximum number of modules that can be installed. @@ -95,57 +71,28 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC return interfaceId == type(IERC7984RwaModularCompliance).interfaceId || super.supportsInterface(interfaceId); } - /// @dev Checks if a compliance module is installed. - function _isModuleInstalled( - ComplianceModuleType moduleType, - address module - ) internal view virtual returns (bool installed) { - if (moduleType == ComplianceModuleType.ForceTransfer) return _forceTransferComplianceModules.contains(module); - if (moduleType == ComplianceModuleType.Standard) return _complianceModules.contains(module); - } - /// @dev Internal function which installs a transfer compliance module. - function _installModule(ComplianceModuleType moduleType, address module, bytes memory initData) internal virtual { - require( - _forceTransferComplianceModules.length() + _complianceModules.length() < maxComplianceModules(), - ERC7984RwaExceededMaxModules() - ); - require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); + function _installModule(address module, bytes memory initData) internal virtual { + require(_complianceModules.length() < maxComplianceModules(), ERC7984RwaExceededMaxModules()); require( ERC165Checker.supportsInterface(module, type(IComplianceModuleConfidential).interfaceId), ERC7984RwaInvalidModule(module) ); - - EnumerableSet.AddressSet storage modules = moduleType == ComplianceModuleType.ForceTransfer - ? _forceTransferComplianceModules - : _complianceModules; - require(modules.add(module), ERC7984RwaDuplicateModule(moduleType, module)); + require(_complianceModules.add(module), ERC7984RwaDuplicateModule(module)); IComplianceModuleConfidential(module).onInstall(initData); - emit ModuleInstalled(moduleType, module); + emit ModuleInstalled(module); } /// @dev Internal function which uninstalls a compliance module. - function _uninstallModule( - ComplianceModuleType moduleType, - address module, - bytes memory deinitData - ) internal virtual { - require(supportsModule(moduleType), ERC7984RwaUnsupportedModuleType(moduleType)); - - EnumerableSet.AddressSet storage modules; - if (moduleType == ComplianceModuleType.ForceTransfer) { - modules = _forceTransferComplianceModules; - } else { - modules = _complianceModules; - } - require(modules.remove(module), ERC7984RwaNonexistentModule(moduleType, 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(moduleType, module); + emit ModuleUninstalled(module); } /** @@ -158,10 +105,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { euint64 amountToTransfer = FHE.select( - FHE.and( - _checkForceTransferCompliance(from, to, encryptedAmount), - _checkStandardCompliance(from, to, encryptedAmount) - ), + _checkCompliance(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0) ); @@ -170,43 +114,20 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC } /** - * @dev Forces the update of confidential balances. It transfers zero if it does not - * follow force transfer compliance. Runs hooks after the force transfer. + * @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) { - euint64 amountToTransfer = FHE.select( - _checkForceTransferCompliance(from, to, encryptedAmount), - encryptedAmount, - FHE.asEuint64(0) - ); - transferred = super._forceUpdate(from, to, amountToTransfer); + transferred = super._forceUpdate(from, to, encryptedAmount); _postTransferCompliance(from, to, transferred); } - /// @dev Checks force transfer compliance modules (applied to both normal and force transfers). - function _checkForceTransferCompliance( - address from, - address to, - euint64 encryptedAmount - ) internal virtual returns (ebool compliant) { - address[] memory modules = _forceTransferComplianceModules.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 Checks standard compliance modules (applied to normal transfers only). - function _checkStandardCompliance( + /// @dev Checks all compliance modules. + function _checkCompliance( address from, address to, euint64 encryptedAmount @@ -215,7 +136,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC uint256 modulesLength = modules.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; i++) { - FHE.allowTransient(encryptedAmount, modules[i]); + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules[i]); compliant = FHE.and( compliant, IComplianceModuleConfidential(modules[i]).isCompliantTransfer(from, to, encryptedAmount) @@ -225,23 +146,16 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC /// @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 = _forceTransferComplianceModules.values(); + address[] memory modules = _complianceModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { FHE.allowTransient(encryptedAmount, modules[i]); IComplianceModuleConfidential(modules[i]).postTransfer(from, to, encryptedAmount); } - - modules = _complianceModules.values(); - modulesLength = modules.length; - for (uint256 i = 0; i < modulesLength; i++) { - 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 _forceTransferComplianceModules.contains(msg.sender) || _complianceModules.contains(msg.sender); + return _complianceModules.contains(msg.sender); } } diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index f0129215..3d5c76be 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -24,9 +24,9 @@ export const SIGNATURES = { ], ERC7984ERC20Wrapper: ['underlying()', 'unwrap(address,address,bytes32,bytes)', 'wrap(address,uint256)'], ERC7984RWAModularCompliance: [ - 'isModuleInstalled(uint8,address)', - 'installModule(uint8,address,bytes)', - 'uninstallModule(uint8,address,bytes)', + 'isModuleInstalled(address)', + 'installModule(address,bytes)', + 'uninstallModule(address,bytes)', ], ERC7984RWA: [ 'blockUser(address)', diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts index be270686..9758a554 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts @@ -1,16 +1,9 @@ import { $ERC7984RwaModularCompliance } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol/$ERC7984RwaModularCompliance'; -import { callAndGetResult } from '../../../helpers/event'; import { INTERFACE_IDS, INVALID_ID } from '../../../helpers/interface'; import { FhevmType } from '@fhevm/hardhat-plugin'; -import { time } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; -enum ModuleType { - Standard, - ForceTransfer, -} - const adminRole = ethers.ZeroHash; describe('ERC7984RwaModularCompliance', function () { @@ -46,49 +39,35 @@ describe('ERC7984RwaModularCompliance', function () { }); }); - describe('support module', async function () { - for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { - it(`should support module type ${ModuleType[type]}`, async function () { - await expect(this.token.supportsModule(type)).to.eventually.be.true; - }); - } - - it('should not support other module types', async function () { - await expect(this.token.supportsModule(3)).to.be.reverted; - }); - }); - describe('install module', async function () { it('should emit event', async function () { - await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x')) + await expect(this.token.$_installModule(this.complianceModule, '0x')) .to.emit(this.token, 'ModuleInstalled') - .withArgs(ModuleType.Standard, this.complianceModule); + .withArgs(this.complianceModule); }); it('should call `onInstall` on the module', async function () { - await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0xffff')) + await expect(this.token.$_installModule(this.complianceModule, '0xffff')) .to.emit(this.complianceModule, 'OnInstall') .withArgs('0xffff'); }); - for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { - it(`should add ${ModuleType[type]} module to modules list`, async function () { - await this.token.$_installModule(type, this.complianceModule, '0x'); - await expect(this.token.isModuleInstalled(type, this.complianceModule)).to.eventually.be.true; - }); - } + 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(ModuleType.Standard, this.complianceModule, '0x')) + 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(ModuleType.Standard, this.complianceModule, '0x'); + 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(ModuleType.Standard, notModule, '0x')) + await expect(this.token.$_installModule(notModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaInvalidModule') .withArgs(notModule); }); @@ -98,93 +77,85 @@ describe('ERC7984RwaModularCompliance', function () { for (let i = 0; i < max; i++) { const module = await ethers.deployContract('$ComplianceModuleConfidentialMock'); - await this.token.$_installModule(i % 2 === 0 ? ModuleType.Standard : ModuleType.ForceTransfer, module, '0x'); + await this.token.$_installModule(module, '0x'); } const extraModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); - await expect(this.token.$_installModule(ModuleType.Standard, extraModule, '0x')).to.be.revertedWithCustomError( + 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(ModuleType.Standard, this.complianceModule, '0x'); - await expect(this.token.$_installModule(ModuleType.Standard, this.complianceModule, '0x')) + await this.token.$_installModule(this.complianceModule, '0x'); + await expect(this.token.$_installModule(this.complianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaDuplicateModule') - .withArgs(ModuleType.Standard, this.complianceModule); + .withArgs(this.complianceModule); }); }); describe('uninstall module', async function () { beforeEach(async function () { - for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { - await this.token.$_installModule(type, this.complianceModule, '0x'); - } + await this.token.$_installModule(this.complianceModule, '0x'); }); it('should emit event', async function () { - await expect(this.token.$_uninstallModule(ModuleType.Standard, this.complianceModule, '0x')) + await expect(this.token.$_uninstallModule(this.complianceModule, '0x')) .to.emit(this.token, 'ModuleUninstalled') - .withArgs(ModuleType.Standard, this.complianceModule); + .withArgs(this.complianceModule); }); it('should fail if module not installed', async function () { const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); - await expect(this.token.$_uninstallModule(ModuleType.Standard, newComplianceModule, '0x')) + await expect(this.token.$_uninstallModule(newComplianceModule, '0x')) .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNonexistentModule') - .withArgs(ModuleType.Standard, newComplianceModule); + .withArgs(newComplianceModule); }); it('should call `onUninstall` on the module', async function () { - await expect(this.token.$_uninstallModule(ModuleType.Standard, this.complianceModule, '0xffff')) + await expect(this.token.$_uninstallModule(this.complianceModule, '0xffff')) .to.emit(this.complianceModule, 'OnUninstall') .withArgs('0xffff'); }); - for (const type of [ModuleType.Standard, ModuleType.ForceTransfer]) { - it(`should remove module of type ${ModuleType[type]} from modules list`, async function () { - await this.token.$_uninstallModule(type, this.complianceModule, '0x'); - await expect(this.token.isModuleInstalled(type, this.complianceModule)).to.eventually.be.false; - }); - } + 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(ModuleType.Standard, this.complianceModule, '0x'); + await this.token.$_uninstallModule(this.complianceModule, '0x'); }); it('should gate to admin', async function () { - await expect(this.token.connect(this.anyone).uninstallModule(ModuleType.Standard, this.complianceModule, '0x')) + 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(ModuleType.Standard, this.complianceModule, '0x'); - await expect(this.token.isModuleInstalled(ModuleType.Standard, this.complianceModule)).to.eventually.be.false; + 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(ModuleType.Standard, this.complianceModule, '0x'); + await this.token.$_installModule(this.complianceModule, '0x'); await this.token['$_mint(address,uint64)'](this.holder, 1000); - - const forceTransferModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); - await this.token.$_installModule(ModuleType.ForceTransfer, forceTransferModule, '0x'); - this.forceTransferModule = forceTransferModule; }); 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') - .to.emit(this.forceTransferModule, 'PreTransfer'); + 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') - .to.emit(this.forceTransferModule, 'PostTransfer'); + await expect( + this.token.connect(this.holder)['confidentialTransfer(address,uint64)'](this.recipient, 100n), + ).to.emit(this.complianceModule, 'PostTransfer'); }); for (const approve of [true, false]) { @@ -200,7 +171,7 @@ describe('ERC7984RwaModularCompliance', function () { } describe('force transfer', function () { - it('should only call pre-transfer hook on force transfer module', async function () { + it('should not call pre-transfer hook', async function () { const encryptedAmount = await fhevm .createEncryptedInput(this.token.target, this.agent1.address) .add64(100n) @@ -215,12 +186,10 @@ describe('ERC7984RwaModularCompliance', function () { encryptedAmount.handles[0], encryptedAmount.inputProof, ), - ) - .to.emit(this.forceTransferModule, 'PreTransfer') - .to.not.emit(this.complianceModule, 'PreTransfer'); + ).to.not.emit(this.complianceModule, 'PreTransfer'); }); - it('should call post-transfer hook on all compliance modules', async function () { + it('should call post-transfer hook', async function () { const encryptedAmount = await fhevm .createEncryptedInput(this.token.target, this.agent1.address) .add64(100n) @@ -235,12 +204,10 @@ describe('ERC7984RwaModularCompliance', function () { encryptedAmount.handles[0], encryptedAmount.inputProof, ), - ) - .to.emit(this.forceTransferModule, 'PostTransfer') - .to.emit(this.complianceModule, 'PostTransfer'); + ).to.emit(this.complianceModule, 'PostTransfer'); }); - it('should pass compliance if default module fails', async function () { + it('should pass compliance even if module denies', async function () { await this.complianceModule.setIsCompliant(false); const encryptedAmount = await fhevm @@ -262,29 +229,6 @@ describe('ERC7984RwaModularCompliance', function () { fhevm.userDecryptEuint(FhevmType.euint64, recipientBalance, this.token.target, this.recipient), ).to.eventually.equal(100); }); - - it('should fail compliance if force transfer module does not pass', async function () { - await this.forceTransferModule.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(0); - }); }); }); }); From 60bf89ee0a995c248fd5dd070d916cc2f4f4ae81 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:22:43 -0400 Subject: [PATCH 091/111] update docs and add modules getter --- .../compliance/ComplianceModuleConfidential.sol | 12 ++++++++++-- .../interfaces/IComplianceModuleConfidential.sol | 9 ++++++--- .../extensions/ERC7984RwaModularCompliance.sol | 7 ++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/contracts/finance/compliance/ComplianceModuleConfidential.sol b/contracts/finance/compliance/ComplianceModuleConfidential.sol index 50e19c8f..ee3fb49b 100644 --- a/contracts/finance/compliance/ComplianceModuleConfidential.sol +++ b/contracts/finance/compliance/ComplianceModuleConfidential.sol @@ -41,8 +41,10 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential, _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 @@ -50,7 +52,10 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential, return interfaceId == type(IComplianceModuleConfidential).interfaceId || super.supportsInterface(interfaceId); } - /// @dev Internal function which checks if a transfer is compliant. + /** + * @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, @@ -58,7 +63,10 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential, euint64 encryptedAmount ) internal virtual returns (ebool); - /// @dev Internal function which performs operation after transfer. + /** + * @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*/, diff --git a/contracts/interfaces/IComplianceModuleConfidential.sol b/contracts/interfaces/IComplianceModuleConfidential.sol index 0dc2531f..8731978f 100644 --- a/contracts/interfaces/IComplianceModuleConfidential.sol +++ b/contracts/interfaces/IComplianceModuleConfidential.sol @@ -7,15 +7,18 @@ 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. + /** + * @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 operation after installation. + /// @dev Performs operations after installation. function onInstall(bytes calldata initData) external; - /// @dev Performs operation after uninstallation. + /// @dev Performs operations after uninstallation. function onUninstall(bytes calldata deinitData) external; } diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol index 6d91960f..6ef629dd 100644 --- a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol +++ b/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol @@ -61,6 +61,11 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC _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; @@ -149,7 +154,7 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC address[] memory modules = _complianceModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; i++) { - FHE.allowTransient(encryptedAmount, modules[i]); + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules[i]); IComplianceModuleConfidential(modules[i]).postTransfer(from, to, encryptedAmount); } } From 162f51139aaef13bc335d124fc7cb0d3fe39790e Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:40:08 -0400 Subject: [PATCH 092/111] rename ERC7984RwaModularCompliance to ERC7984Hooked --- .../{ERC7984RwaModularCompliance.sol => ERC7984Hooked.sol} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename contracts/token/ERC7984/extensions/{ERC7984RwaModularCompliance.sol => ERC7984Hooked.sol} (100%) diff --git a/contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol similarity index 100% rename from contracts/token/ERC7984/extensions/ERC7984RwaModularCompliance.sol rename to contracts/token/ERC7984/extensions/ERC7984Hooked.sol From c03df4a1ff45517d8563577848ff1e2001eba218 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:18:17 -0400 Subject: [PATCH 093/111] Move from modular compliance to hooked --- .changeset/wet-results-doubt.md | 2 +- ...Confidential.sol => ERC7984HookModule.sol} | 24 ++--- ...onfidential.sol => IERC7984HookModule.sol} | 8 +- contracts/interfaces/IERC7984Rwa.sol | 12 --- ...tialMock.sol => ERC7984HookModuleMock.sol} | 6 +- ...mplianceMock.sol => ERC7984HookedMock.sol} | 10 +- .../ERC7984/extensions/ERC7984Hooked.sol | 100 +++++++++-------- test/helpers/interface.ts | 6 +- ...mpliance.test.ts => ERC7984Hooked.test.ts} | 102 +++++++++--------- 9 files changed, 124 insertions(+), 146 deletions(-) rename contracts/finance/compliance/{ComplianceModuleConfidential.sol => ERC7984HookModule.sol} (74%) rename contracts/interfaces/{IComplianceModuleConfidential.sol => IERC7984HookModule.sol} (64%) rename contracts/mocks/token/{ComplianceModuleConfidentialMock.sol => ERC7984HookModuleMock.sol} (83%) rename contracts/mocks/token/{ERC7984RwaModularComplianceMock.sol => ERC7984HookedMock.sol} (63%) rename test/token/ERC7984/extensions/{ERC7984RwaModularCompliance.test.ts => ERC7984Hooked.test.ts} (63%) diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md index 4b731310..eed96183 100644 --- a/.changeset/wet-results-doubt.md +++ b/.changeset/wet-results-doubt.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -`ERC7984RwaModularCompliance`: Support compliance modules for confidential RWAs. +`ERC7984Hooked`: Support hook modules for ERC-7984 tokens. diff --git a/contracts/finance/compliance/ComplianceModuleConfidential.sol b/contracts/finance/compliance/ERC7984HookModule.sol similarity index 74% rename from contracts/finance/compliance/ComplianceModuleConfidential.sol rename to contracts/finance/compliance/ERC7984HookModule.sol index ee3fb49b..6328bb2b 100644 --- a/contracts/finance/compliance/ComplianceModuleConfidential.sol +++ b/contracts/finance/compliance/ERC7984HookModule.sol @@ -4,14 +4,14 @@ 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 {IERC7984HookModule} from "./../../interfaces/IERC7984HookModule.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). + * @dev A contract which allows to build an ERC-7984 hook module. */ -abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential, ERC165 { +abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); /// @dev Thrown when the sender is not authorized to call the given function. @@ -29,34 +29,34 @@ abstract contract ComplianceModuleConfidential is IComplianceModuleConfidential, _; } - /// @inheritdoc IComplianceModuleConfidential - function isCompliantTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { - ebool compliant = _isCompliantTransfer(msg.sender, from, to, encryptedAmount); + /// @inheritdoc IERC7984HookModule + function beforeTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { + ebool compliant = _beforeTransfer(msg.sender, from, to, encryptedAmount); FHE.allowTransient(compliant, msg.sender); return compliant; } - /// @inheritdoc IComplianceModuleConfidential + /// @inheritdoc IERC7984HookModule function postTransfer(address from, address to, euint64 encryptedAmount) public virtual { _postTransfer(msg.sender, from, to, encryptedAmount); } - /// @inheritdoc IComplianceModuleConfidential + /// @inheritdoc IERC7984HookModule function onInstall(bytes calldata initData) public virtual {} - /// @inheritdoc IComplianceModuleConfidential + /// @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(IComplianceModuleConfidential).interfaceId || super.supportsInterface(interfaceId); + return interfaceId == type(IERC7984HookModule).interfaceId || super.supportsInterface(interfaceId); } /** - * @dev Internal function which checks if a transfer is compliant. Transient access is already granted to the module + * @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}. */ - function _isCompliantTransfer( + function _beforeTransfer( address token, address from, address to, diff --git a/contracts/interfaces/IComplianceModuleConfidential.sol b/contracts/interfaces/IERC7984HookModule.sol similarity index 64% rename from contracts/interfaces/IComplianceModuleConfidential.sol rename to contracts/interfaces/IERC7984HookModule.sol index 8731978f..bfd9a143 100644 --- a/contracts/interfaces/IComplianceModuleConfidential.sol +++ b/contracts/interfaces/IERC7984HookModule.sol @@ -5,13 +5,13 @@ 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 Interface for an ERC-7984 hook module. +interface IERC7984HookModule is IERC165 { /** - * @dev Checks if a transfer is compliant. Should be non-mutating. Transient access is already granted + * @dev Hook that runs before a transfer. 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); + function beforeTransfer(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/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index 0f1793d0..e979741f 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -82,15 +82,3 @@ 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/ERC7984HookModuleMock.sol similarity index 83% rename from contracts/mocks/token/ComplianceModuleConfidentialMock.sol rename to contracts/mocks/token/ERC7984HookModuleMock.sol index cedc748e..12be44c7 100644 --- a/contracts/mocks/token/ComplianceModuleConfidentialMock.sol +++ b/contracts/mocks/token/ERC7984HookModuleMock.sol @@ -4,10 +4,10 @@ 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 {ERC7984HookModule} from "./../../finance/compliance/ERC7984HookModule.sol"; import {IERC7984} from "./../../interfaces/IERC7984.sol"; -contract ComplianceModuleConfidentialMock is ComplianceModuleConfidential, ZamaEthereumConfig { +contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { bool public isCompliant = true; bool public revertOnUninstall = false; @@ -39,7 +39,7 @@ contract ComplianceModuleConfidentialMock is ComplianceModuleConfidential, ZamaE revertOnUninstall = revertOnUninstall_; } - function _isCompliantTransfer(address token, address from, address, euint64) internal override returns (ebool) { + function _beforeTransfer(address token, address from, address, euint64) internal override returns (ebool) { euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); if (euint64.unwrap(fromBalance) != 0) { diff --git a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol b/contracts/mocks/token/ERC7984HookedMock.sol similarity index 63% rename from contracts/mocks/token/ERC7984RwaModularComplianceMock.sol rename to contracts/mocks/token/ERC7984HookedMock.sol index b0a1d337..c5d1fc0e 100644 --- a/contracts/mocks/token/ERC7984RwaModularComplianceMock.sol +++ b/contracts/mocks/token/ERC7984HookedMock.sol @@ -5,11 +5,11 @@ 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 {ERC7984Hooked} from "./../../token/ERC7984/extensions/ERC7984Hooked.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 { +contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock { constructor( string memory name, string memory symbol, @@ -17,9 +17,7 @@ contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984 address admin ) ERC7984Rwa(admin) ERC7984Mock(name, symbol, tokenUri) {} - function supportsInterface( - bytes4 interfaceId - ) public view virtual override(ERC7984, ERC7984RwaModularCompliance) returns (bool) { + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC7984, ERC7984Hooked) returns (bool) { return super.supportsInterface(interfaceId); } @@ -27,7 +25,7 @@ contract ERC7984RwaModularComplianceMock is ERC7984RwaModularCompliance, ERC7984 address from, address to, euint64 amount - ) internal virtual override(ERC7984Mock, ERC7984RwaModularCompliance) returns (euint64) { + ) internal virtual override(ERC7984Mock, ERC7984Hooked) returns (euint64) { return super._update(from, to, amount); } } diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index 6ef629dd..e059e67a 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -6,103 +6,102 @@ 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 {IERC7984HookModule} from "./../../../interfaces/IERC7984HookModule.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). + * @dev Extension of {ERC7984Rwa} that supports hook 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 + * Modules are called before transfers 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. Compliance modules may revert on either call, which will propagate + * amount and may do accounting as necessary. 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. + * NOTE: Force transfers bypass the module checks before the transfer. All transfers call modules after the transfer. */ -abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularCompliance, HandleAccessManager { +abstract contract ERC7984Hooked is ERC7984Rwa, HandleAccessManager { using EnumerableSet for *; - EnumerableSet.AddressSet private _complianceModules; + EnumerableSet.AddressSet private _modules; /// @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 address is not a valid module. + error ERC7984HookedInvalidModule(address module); /// @dev The module is already installed. - error ERC7984RwaDuplicateModule(address module); + error ERC7984HookedDuplicateModule(address module); /// @dev The module is not installed. - error ERC7984RwaNonexistentModule(address module); + error ERC7984HookedNonexistentModule(address module); /// @dev The maximum number of modules has been exceeded. - error ERC7984RwaExceededMaxModules(); + error ERC7984HookedExceededMaxModules(); - /// @inheritdoc IERC7984RwaModularCompliance + /// @dev Checks if a module is installed. function isModuleInstalled(address module) public view virtual returns (bool) { - return _complianceModules.contains(module); + return _modules.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. + * @dev Installs a hook module. + * + * Consider gas footprint of the module before adding it since all modules will perform + * all steps (pre-check, check, post-hook) in a single transaction. */ function installModule(address module, bytes memory initData) public virtual onlyAdmin { _installModule(module, initData); } - /// @inheritdoc IERC7984RwaModularCompliance + /// @dev Uninstalls a hook module. 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(); + function modules() public view virtual returns (address[] memory) { + return _modules.values(); } /// @dev Returns the maximum number of modules that can be installed. - function maxComplianceModules() public view virtual returns (uint256) { + function maxModules() 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); + return interfaceId == 0x2e07e769 || super.supportsInterface(interfaceId); } - /// @dev Internal function which installs a transfer compliance module. + /// @dev Internal function which installs a hook module. function _installModule(address module, bytes memory initData) internal virtual { - require(_complianceModules.length() < maxComplianceModules(), ERC7984RwaExceededMaxModules()); + require(_modules.length() < maxModules(), ERC7984HookedExceededMaxModules()); require( - ERC165Checker.supportsInterface(module, type(IComplianceModuleConfidential).interfaceId), - ERC7984RwaInvalidModule(module) + ERC165Checker.supportsInterface(module, type(IERC7984HookModule).interfaceId), + ERC7984HookedInvalidModule(module) ); - require(_complianceModules.add(module), ERC7984RwaDuplicateModule(module)); + require(_modules.add(module), ERC7984HookedDuplicateModule(module)); - IComplianceModuleConfidential(module).onInstall(initData); + IERC7984HookModule(module).onInstall(initData); emit ModuleInstalled(module); } - /// @dev Internal function which uninstalls a compliance module. + /// @dev Internal function which uninstalls a module. function _uninstallModule(address module, bytes memory deinitData) internal virtual { - require(_complianceModules.remove(module), ERC7984RwaNonexistentModule(module)); + require(_modules.remove(module), ERC7984HookedNonexistentModule(module)); - // ignore success purposely to avoid modules that revert on uninstall - LowLevelCall.callNoReturn(module, abi.encodeCall(IComplianceModuleConfidential.onUninstall, (deinitData))); + LowLevelCall.callNoReturn(module, abi.encodeCall(IERC7984HookModule.onUninstall, (deinitData))); emit ModuleUninstalled(module); } /** - * @dev Updates confidential balances. It transfers zero if it does not follow - * transfer compliance. Runs hooks after the transfer. + * @dev Updates confidential balances. It transfers zero if it does not pass + * module checks. Runs hooks after the transfer. */ function _update( address from, @@ -110,16 +109,16 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { euint64 amountToTransfer = FHE.select( - _checkCompliance(from, to, encryptedAmount), + _runBeforeTransferHooks(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0) ); transferred = super._update(from, to, amountToTransfer); - _postTransferCompliance(from, to, transferred); + _runAfterTransferHooks(from, to, transferred); } /** - * @dev Forces the update of confidential balances. Bypasses compliance checks + * @dev Forces the update of confidential balances. Bypasses module checks * before the transfer. Runs hooks after the force transfer. */ function _forceUpdate( @@ -128,39 +127,36 @@ abstract contract ERC7984RwaModularCompliance is ERC7984Rwa, IERC7984RwaModularC euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { transferred = super._forceUpdate(from, to, encryptedAmount); - _postTransferCompliance(from, to, transferred); + _runAfterTransferHooks(from, to, transferred); } - /// @dev Checks all compliance modules. - function _checkCompliance( + /// @dev Runs the before-transfer hooks for all modules. + function _runBeforeTransferHooks( address from, address to, euint64 encryptedAmount ) internal virtual returns (ebool compliant) { - address[] memory modules = _complianceModules.values(); + address[] memory modules = _modules.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) - ); + compliant = FHE.and(compliant, IERC7984HookModule(modules[i]).beforeTransfer(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(); + /// @dev Runs the after-transfer hooks for all modules. This runs after all transfers (including force transfers). + function _runAfterTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { + address[] memory modules = _modules.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); + IERC7984HookModule(modules[i]).postTransfer(from, to, encryptedAmount); } } - /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow compliance modules to access any handle. + /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow modules to access any handle. function _validateHandleAllowance(bytes32) internal view override returns (bool) { - return _complianceModules.contains(msg.sender); + return _modules.contains(msg.sender); } } diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index 3d5c76be..b500a93d 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -23,11 +23,7 @@ export const SIGNATURES = { 'symbol()', ], ERC7984ERC20Wrapper: ['underlying()', 'unwrap(address,address,bytes32,bytes)', 'wrap(address,uint256)'], - ERC7984RWAModularCompliance: [ - 'isModuleInstalled(address)', - 'installModule(address,bytes)', - 'uninstallModule(address,bytes)', - ], + ERC7984Hooked: ['isModuleInstalled(address)', 'installModule(address,bytes)', 'uninstallModule(address,bytes)'], ERC7984RWA: [ 'blockUser(address)', 'confidentialAvailable(address)', diff --git a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts similarity index 63% rename from test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts rename to test/token/ERC7984/extensions/ERC7984Hooked.test.ts index 9758a554..3885c1d6 100644 --- a/test/token/ERC7984/extensions/ERC7984RwaModularCompliance.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -1,4 +1,4 @@ -import { $ERC7984RwaModularCompliance } from '../../../../types/contracts-exposed/token/ERC7984/extensions/rwa/ERC7984RwaModularCompliance.sol/$ERC7984RwaModularCompliance'; +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'; @@ -6,18 +6,18 @@ import { ethers, fhevm } from 'hardhat'; const adminRole = ethers.ZeroHash; -describe('ERC7984RwaModularCompliance', function () { +describe('ERC7984Hooked', 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; + const token = (await ethers.deployContract('$ERC7984HookedMock', ['name', 'symbol', 'uri', admin])).connect( + anyone, + ) as $ERC7984Hooked; await token.connect(admin).addAgent(agent1); - const complianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + const hookModule = await ethers.deployContract('$ERC7984HookModuleMock'); Object.assign(this, { token, - complianceModule, + hookModule, admin, agent1, agent2, @@ -31,7 +31,7 @@ describe('ERC7984RwaModularCompliance', 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; + await expect(this.token.supportsInterface(INTERFACE_IDS.ERC7984Hooked)).to.eventually.be.true; }); it('should not support interface', async function () { @@ -41,126 +41,126 @@ describe('ERC7984RwaModularCompliance', function () { describe('install module', async function () { it('should emit event', async function () { - await expect(this.token.$_installModule(this.complianceModule, '0x')) + await expect(this.token.$_installModule(this.hookModule, '0x')) .to.emit(this.token, 'ModuleInstalled') - .withArgs(this.complianceModule); + .withArgs(this.hookModule); }); it('should call `onInstall` on the module', async function () { - await expect(this.token.$_installModule(this.complianceModule, '0xffff')) - .to.emit(this.complianceModule, 'OnInstall') + 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.complianceModule, '0x'); - await expect(this.token.isModuleInstalled(this.complianceModule)).to.eventually.be.true; + await this.token.$_installModule(this.hookModule, '0x'); + await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.true; }); it('should gate to admin', async function () { - await expect(this.token.connect(this.anyone).installModule(this.complianceModule, '0x')) + await expect(this.token.connect(this.anyone).installModule(this.hookModule, '0x')) .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') .withArgs(this.anyone, adminRole); - await this.token.connect(this.admin).installModule(this.complianceModule, '0x'); + 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, 'ERC7984RwaInvalidModule') + .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.maxComplianceModules()); + const max = Number(await this.token.maxModules()); for (let i = 0; i < max; i++) { - const module = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + const module = await ethers.deployContract('$ERC7984HookModuleMock'); await this.token.$_installModule(module, '0x'); } - const extraModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + const extraModule = await ethers.deployContract('$ERC7984HookModuleMock'); await expect(this.token.$_installModule(extraModule, '0x')).to.be.revertedWithCustomError( this.token, - 'ERC7984RwaExceededMaxModules', + 'ERC7984HookedExceededMaxModules', ); }); 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); + 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.complianceModule, '0x'); + await this.token.$_installModule(this.hookModule, '0x'); }); it('should emit event', async function () { - await expect(this.token.$_uninstallModule(this.complianceModule, '0x')) + await expect(this.token.$_uninstallModule(this.hookModule, '0x')) .to.emit(this.token, 'ModuleUninstalled') - .withArgs(this.complianceModule); + .withArgs(this.hookModule); }); it('should fail if module not installed', async function () { - const newComplianceModule = await ethers.deployContract('$ComplianceModuleConfidentialMock'); + const newModule = await ethers.deployContract('$ERC7984HookModuleMock'); - await expect(this.token.$_uninstallModule(newComplianceModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'ERC7984RwaNonexistentModule') - .withArgs(newComplianceModule); + 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.complianceModule, '0xffff')) - .to.emit(this.complianceModule, 'OnUninstall') + 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.complianceModule, '0x'); - await expect(this.token.isModuleInstalled(this.complianceModule)).to.eventually.be.false; + 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.complianceModule.setRevertOnUninstall(true); - await this.token.$_uninstallModule(this.complianceModule, '0x'); + await this.hookModule.setRevertOnUninstall(true); + await this.token.$_uninstallModule(this.hookModule, '0x'); }); it('should gate to admin', async function () { - await expect(this.token.connect(this.anyone).uninstallModule(this.complianceModule, '0x')) + await expect(this.token.connect(this.anyone).uninstallModule(this.hookModule, '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; + await this.token.connect(this.admin).uninstallModule(this.hookModule, '0x'); + await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.false; }); }); - describe('check compliance on transfer', async function () { + describe('hooks on transfer', async function () { beforeEach(async function () { - await this.token.$_installModule(this.complianceModule, '0x'); + 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.complianceModule, 'PreTransfer'); + ).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.complianceModule, 'PostTransfer'); + ).to.emit(this.hookModule, 'PostTransfer'); }); for (const approve of [true, false]) { - it(`should react correctly to compliance ${approve ? 'approval' : 'denial'}`, async function () { - await this.complianceModule.setIsCompliant(approve); + 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); @@ -186,7 +186,7 @@ describe('ERC7984RwaModularCompliance', function () { encryptedAmount.handles[0], encryptedAmount.inputProof, ), - ).to.not.emit(this.complianceModule, 'PreTransfer'); + ).to.not.emit(this.hookModule, 'PreTransfer'); }); it('should call post-transfer hook', async function () { @@ -204,11 +204,11 @@ describe('ERC7984RwaModularCompliance', function () { encryptedAmount.handles[0], encryptedAmount.inputProof, ), - ).to.emit(this.complianceModule, 'PostTransfer'); + ).to.emit(this.hookModule, 'PostTransfer'); }); - it('should pass compliance even if module denies', async function () { - await this.complianceModule.setIsCompliant(false); + it('should bypass hooks even if module denies', async function () { + await this.hookModule.setIsCompliant(false); const encryptedAmount = await fhevm .createEncryptedInput(this.token.target, this.agent1.address) From 3d3f9aa2024d97bb520638abd9cce1373b089b45 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:28:03 -0400 Subject: [PATCH 094/111] hooked is extension of ERC7984 --- contracts/mocks/token/ERC7984HookedMock.sol | 8 +- .../ERC7984/extensions/ERC7984Hooked.sol | 49 +++++------ .../ERC7984/extensions/ERC7984Hooked.test.ts | 81 ++----------------- 3 files changed, 31 insertions(+), 107 deletions(-) diff --git a/contracts/mocks/token/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984HookedMock.sol index c5d1fc0e..65c3e36e 100644 --- a/contracts/mocks/token/ERC7984HookedMock.sol +++ b/contracts/mocks/token/ERC7984HookedMock.sol @@ -4,18 +4,18 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC7984} from "./../../token/ERC7984/ERC7984.sol"; import {ERC7984Hooked} from "./../../token/ERC7984/extensions/ERC7984Hooked.sol"; -import {ERC7984Rwa} from "./../../token/ERC7984/extensions/ERC7984Rwa.sol"; import {ERC7984Mock} from "./ERC7984Mock.sol"; -contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock { +contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock, Ownable { constructor( string memory name, string memory symbol, string memory tokenUri, address admin - ) ERC7984Rwa(admin) ERC7984Mock(name, symbol, tokenUri) {} + ) ERC7984Mock(name, symbol, tokenUri) Ownable(admin) {} function supportsInterface(bytes4 interfaceId) public view virtual override(ERC7984, ERC7984Hooked) returns (bool) { return super.supportsInterface(interfaceId); @@ -28,4 +28,6 @@ contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock { ) internal virtual override(ERC7984Mock, ERC7984Hooked) returns (euint64) { return super._update(from, to, amount); } + + function _authorizeModuleChange() internal virtual override onlyOwner {} } diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index e059e67a..e77bea92 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -8,21 +8,18 @@ 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 {ERC7984Rwa} from "./ERC7984Rwa.sol"; +import {ERC7984} from "./../ERC7984.sol"; /** - * @dev Extension of {ERC7984Rwa} that supports hook modules for confidential Real World Assets (RWAs). - * Inspired by ERC-7579 modules. + * @dev Extension of {ERC7984} that supports hook modules. Inspired by ERC-7579 modules. * * Modules are called before transfers 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: Force transfers bypass the module checks before the transfer. All transfers call modules after the transfer. */ -abstract contract ERC7984Hooked is ERC7984Rwa, HandleAccessManager { +abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { using EnumerableSet for *; EnumerableSet.AddressSet private _modules; @@ -52,12 +49,14 @@ abstract contract ERC7984Hooked is ERC7984Rwa, HandleAccessManager { * Consider gas footprint of the module before adding it since all modules will perform * all steps (pre-check, check, post-hook) in a single transaction. */ - function installModule(address module, bytes memory initData) public virtual onlyAdmin { + function installModule(address module, bytes memory initData) public virtual { + _authorizeModuleChange(); _installModule(module, initData); } /// @dev Uninstalls a hook module. - function uninstallModule(address module, bytes memory deinitData) public virtual onlyAdmin { + function uninstallModule(address module, bytes memory deinitData) public virtual { + _authorizeModuleChange(); _uninstallModule(module, deinitData); } @@ -71,11 +70,14 @@ abstract contract ERC7984Hooked is ERC7984Rwa, HandleAccessManager { return 15; } - /// @inheritdoc ERC7984Rwa + /// @inheritdoc ERC7984 function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == 0x2e07e769 || super.supportsInterface(interfaceId); } + /// @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()); @@ -117,41 +119,28 @@ abstract contract ERC7984Hooked is ERC7984Rwa, HandleAccessManager { _runAfterTransferHooks(from, to, transferred); } - /** - * @dev Forces the update of confidential balances. Bypasses module 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); - _runAfterTransferHooks(from, to, transferred); - } - /// @dev Runs the before-transfer hooks for all modules. function _runBeforeTransferHooks( address from, address to, euint64 encryptedAmount ) internal virtual returns (ebool compliant) { - address[] memory modules = _modules.values(); - uint256 modulesLength = modules.length; + address[] memory modules_ = modules(); + 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]).beforeTransfer(from, to, encryptedAmount)); + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules_[i]); + compliant = FHE.and(compliant, IERC7984HookModule(modules_[i]).beforeTransfer(from, to, encryptedAmount)); } } /// @dev Runs the after-transfer hooks for all modules. This runs after all transfers (including force transfers). function _runAfterTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { - address[] memory modules = _modules.values(); - uint256 modulesLength = modules.length; + address[] memory modules_ = modules(); + 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); + if (FHE.isInitialized(encryptedAmount)) FHE.allowTransient(encryptedAmount, modules_[i]); + IERC7984HookModule(modules_[i]).postTransfer(from, to, encryptedAmount); } } diff --git a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts index 3885c1d6..08b69a86 100644 --- a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -4,23 +4,18 @@ import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; -const adminRole = ethers.ZeroHash; - describe('ERC7984Hooked', function () { beforeEach(async function () { - const [admin, agent1, agent2, holder, recipient, anyone] = await ethers.getSigners(); + const [admin, holder, recipient, anyone] = await ethers.getSigners(); const token = (await ethers.deployContract('$ERC7984HookedMock', ['name', 'symbol', 'uri', admin])).connect( anyone, ) as $ERC7984Hooked; - await token.connect(admin).addAgent(agent1); const hookModule = await ethers.deployContract('$ERC7984HookModuleMock'); Object.assign(this, { token, hookModule, admin, - agent1, - agent2, recipient, holder, anyone, @@ -30,7 +25,6 @@ describe('ERC7984Hooked', function () { 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.ERC7984Hooked)).to.eventually.be.true; }); @@ -57,10 +51,10 @@ describe('ERC7984Hooked', function () { await expect(this.token.isModuleInstalled(this.hookModule)).to.eventually.be.true; }); - it('should gate to admin', async function () { + it('should gate to owner', async function () { await expect(this.token.connect(this.anyone).installModule(this.hookModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') - .withArgs(this.anyone, adminRole); + .to.be.revertedWithCustomError(this.token, 'OwnableUnauthorizedAccount') + .withArgs(this.anyone); await this.token.connect(this.admin).installModule(this.hookModule, '0x'); }); @@ -130,10 +124,10 @@ describe('ERC7984Hooked', function () { await this.token.$_uninstallModule(this.hookModule, '0x'); }); - it('should gate to admin', async function () { + it('should gate to owner', async function () { await expect(this.token.connect(this.anyone).uninstallModule(this.hookModule, '0x')) - .to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount') - .withArgs(this.anyone, adminRole); + .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; @@ -169,66 +163,5 @@ describe('ERC7984Hooked', function () { ).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.hookModule, '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.hookModule, 'PostTransfer'); - }); - - it('should bypass hooks even if module denies', async function () { - await this.hookModule.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); - }); - }); }); }); From 10351a00631b8ba3008d72bbf6bc6c755a4b6b5d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:29:06 -0400 Subject: [PATCH 095/111] Apply suggestion from @arr00 --- .changeset/wet-results-doubt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wet-results-doubt.md b/.changeset/wet-results-doubt.md index eed96183..682d4763 100644 --- a/.changeset/wet-results-doubt.md +++ b/.changeset/wet-results-doubt.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -`ERC7984Hooked`: Support hook modules for ERC-7984 tokens. +`ERC7984Hooked`: Call external hooks before and after transfer of confidential tokens. From 8613b2bf5b993bc902c516c4f2ec380e3ad39ba8 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:30:14 -0400 Subject: [PATCH 096/111] Apply suggestion from @arr00 --- contracts/finance/compliance/ERC7984HookModule.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/finance/compliance/ERC7984HookModule.sol b/contracts/finance/compliance/ERC7984HookModule.sol index 6328bb2b..470e38f4 100644 --- a/contracts/finance/compliance/ERC7984HookModule.sol +++ b/contracts/finance/compliance/ERC7984HookModule.sol @@ -9,7 +9,7 @@ import {IERC7984Rwa} from "./../../interfaces/IERC7984Rwa.sol"; import {HandleAccessManager} from "./../../utils/HandleAccessManager.sol"; /** - * @dev A contract which allows to build an ERC-7984 hook module. + * @dev An abstract base contract for building ERC-7984 hook modules. Compatible with {ERC7984Hooked}. */ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); From 29fd6221a3bc8d479d6b68eb7dd1d9a425eec71f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:36:24 -0400 Subject: [PATCH 097/111] standardize pre/post --- contracts/finance/compliance/ERC7984HookModule.sol | 6 +++--- contracts/interfaces/IERC7984HookModule.sol | 2 +- contracts/mocks/token/ERC7984HookModuleMock.sol | 2 +- .../token/ERC7984/extensions/ERC7984Hooked.sol | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/finance/compliance/ERC7984HookModule.sol b/contracts/finance/compliance/ERC7984HookModule.sol index 470e38f4..54ef2485 100644 --- a/contracts/finance/compliance/ERC7984HookModule.sol +++ b/contracts/finance/compliance/ERC7984HookModule.sol @@ -30,8 +30,8 @@ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { } /// @inheritdoc IERC7984HookModule - function beforeTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { - ebool compliant = _beforeTransfer(msg.sender, from, to, encryptedAmount); + function preTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { + ebool compliant = _preTransfer(msg.sender, from, to, encryptedAmount); FHE.allowTransient(compliant, msg.sender); return compliant; } @@ -56,7 +56,7 @@ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { * @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}. */ - function _beforeTransfer( + function _preTransfer( address token, address from, address to, diff --git a/contracts/interfaces/IERC7984HookModule.sol b/contracts/interfaces/IERC7984HookModule.sol index bfd9a143..5f15ea72 100644 --- a/contracts/interfaces/IERC7984HookModule.sol +++ b/contracts/interfaces/IERC7984HookModule.sol @@ -11,7 +11,7 @@ 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 beforeTransfer(address from, address to, euint64 encryptedAmount) external returns (ebool); + 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; diff --git a/contracts/mocks/token/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984HookModuleMock.sol index 12be44c7..8eadba59 100644 --- a/contracts/mocks/token/ERC7984HookModuleMock.sol +++ b/contracts/mocks/token/ERC7984HookModuleMock.sol @@ -39,7 +39,7 @@ contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { revertOnUninstall = revertOnUninstall_; } - function _beforeTransfer(address token, address from, address, euint64) internal override returns (ebool) { + function _preTransfer(address token, address from, address, euint64) internal override returns (ebool) { euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); if (euint64.unwrap(fromBalance) != 0) { diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index e77bea92..8df609c4 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -111,16 +111,16 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { euint64 encryptedAmount ) internal virtual override returns (euint64 transferred) { euint64 amountToTransfer = FHE.select( - _runBeforeTransferHooks(from, to, encryptedAmount), + _runPreTransferHooks(from, to, encryptedAmount), encryptedAmount, FHE.asEuint64(0) ); transferred = super._update(from, to, amountToTransfer); - _runAfterTransferHooks(from, to, transferred); + _runPostTransferHooks(from, to, transferred); } - /// @dev Runs the before-transfer hooks for all modules. - function _runBeforeTransferHooks( + /// @dev Runs the pre-transfer hooks for all modules. + function _runPreTransferHooks( address from, address to, euint64 encryptedAmount @@ -130,12 +130,12 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { 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]).beforeTransfer(from, to, encryptedAmount)); + compliant = FHE.and(compliant, IERC7984HookModule(modules_[i]).preTransfer(from, to, encryptedAmount)); } } - /// @dev Runs the after-transfer hooks for all modules. This runs after all transfers (including force transfers). - function _runAfterTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { + /// @dev Runs the post-transfer hooks for all modules. + function _runPostTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { address[] memory modules_ = modules(); uint256 modulesLength = modules_.length; for (uint256 i = 0; i < modulesLength; i++) { From cc56a4b425d271b686965e6f36f97392e2271d2d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:58:05 -0400 Subject: [PATCH 098/111] up --- .../token/ERC7984/extensions/ERC7984Hooked.sol | 17 ++++++++--------- test/helpers/interface.ts | 1 - .../ERC7984/extensions/ERC7984Hooked.test.ts | 5 +++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index 8df609c4..196b9b4b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -13,11 +13,13 @@ import {ERC7984} from "./../ERC7984.sol"; /** * @dev Extension of {ERC7984} that supports hook modules. Inspired by ERC-7579 modules. * - * Modules are called before transfers and after transfers. Before the transfer, 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 *; @@ -70,11 +72,6 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { return 15; } - /// @inheritdoc ERC7984 - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == 0x2e07e769 || super.supportsInterface(interfaceId); - } - /// @dev Authorization logic for installing and uninstalling modules. Must be implemented by the concrete contract. function _authorizeModuleChange() internal virtual; @@ -102,8 +99,10 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { } /** - * @dev Updates confidential balances. It transfers zero if it does not pass - * module checks. Runs hooks after the transfer. + * @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, @@ -144,7 +143,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { } } - /// @dev See {HandleAccessManager-_validateHandleAllowance}. Allow modules to access any handle. + /// @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/test/helpers/interface.ts b/test/helpers/interface.ts index b500a93d..416a4577 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -23,7 +23,6 @@ export const SIGNATURES = { 'symbol()', ], ERC7984ERC20Wrapper: ['underlying()', 'unwrap(address,address,bytes32,bytes)', 'wrap(address,uint256)'], - ERC7984Hooked: ['isModuleInstalled(address)', 'installModule(address,bytes)', 'uninstallModule(address,bytes)'], ERC7984RWA: [ 'blockUser(address)', 'confidentialAvailable(address)', diff --git a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts index 08b69a86..16bb713d 100644 --- a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -49,9 +49,10 @@ describe('ERC7984Hooked', function () { 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()).to.eventually.deep.equal([this.hookModule]); }); - it('should gate to owner', async function () { + 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); @@ -124,7 +125,7 @@ describe('ERC7984Hooked', function () { await this.token.$_uninstallModule(this.hookModule, '0x'); }); - it('should gate to owner', async function () { + 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); From dcfcbf441554869a538589554375ee8a05af7760 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:01:42 -0400 Subject: [PATCH 099/111] fix tests --- contracts/mocks/token/ERC7984HookedMock.sol | 4 ---- test/token/ERC7984/extensions/ERC7984Hooked.test.ts | 13 +------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/contracts/mocks/token/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984HookedMock.sol index 65c3e36e..05b8bb95 100644 --- a/contracts/mocks/token/ERC7984HookedMock.sol +++ b/contracts/mocks/token/ERC7984HookedMock.sol @@ -17,10 +17,6 @@ contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock, Ownable { address admin ) ERC7984Mock(name, symbol, tokenUri) Ownable(admin) {} - function supportsInterface(bytes4 interfaceId) public view virtual override(ERC7984, ERC7984Hooked) returns (bool) { - return super.supportsInterface(interfaceId); - } - function _update( address from, address to, diff --git a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts index 16bb713d..b4ac34ac 100644 --- a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -22,17 +22,6 @@ describe('ERC7984Hooked', function () { }); }); - 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.ERC7984Hooked)).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.hookModule, '0x')) @@ -49,7 +38,7 @@ describe('ERC7984Hooked', function () { 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()).to.eventually.deep.equal([this.hookModule]); + await expect(this.token.modules()).to.eventually.deep.equal([this.hookModule.target]); }); it('should gate via `_authorizeModuleChange`', async function () { From a70d5fdd1c7ceb754f604cb53412c54d7f0adf84 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:08:42 -0400 Subject: [PATCH 100/111] move hook module file --- .../mocks/token/ERC7984HookModuleMock.sol | 2 +- .../ERC7984/utils}/ERC7984HookModule.sol | 20 ++----------------- 2 files changed, 3 insertions(+), 19 deletions(-) rename contracts/{finance/compliance => token/ERC7984/utils}/ERC7984HookModule.sol (79%) diff --git a/contracts/mocks/token/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984HookModuleMock.sol index 8eadba59..792ca29e 100644 --- a/contracts/mocks/token/ERC7984HookModuleMock.sol +++ b/contracts/mocks/token/ERC7984HookModuleMock.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {ERC7984HookModule} from "./../../finance/compliance/ERC7984HookModule.sol"; +import {ERC7984HookModule} from "./../../token/ERC7984/utils/ERC7984HookModule.sol"; import {IERC7984} from "./../../interfaces/IERC7984.sol"; contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { diff --git a/contracts/finance/compliance/ERC7984HookModule.sol b/contracts/token/ERC7984/utils/ERC7984HookModule.sol similarity index 79% rename from contracts/finance/compliance/ERC7984HookModule.sol rename to contracts/token/ERC7984/utils/ERC7984HookModule.sol index 54ef2485..d24d93c0 100644 --- a/contracts/finance/compliance/ERC7984HookModule.sol +++ b/contracts/token/ERC7984/utils/ERC7984HookModule.sol @@ -4,9 +4,8 @@ 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 {IERC7984Rwa} from "./../../interfaces/IERC7984Rwa.sol"; -import {HandleAccessManager} from "./../../utils/HandleAccessManager.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}. @@ -14,21 +13,6 @@ import {HandleAccessManager} from "./../../utils/HandleAccessManager.sol"; abstract contract ERC7984HookModule is IERC7984HookModule, 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 IERC7984HookModule function preTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { ebool compliant = _preTransfer(msg.sender, from, to, encryptedAmount); From f836b6dd72b48d0e13eb4233dc942ee2ca552ea3 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:21:01 -0400 Subject: [PATCH 101/111] pr feedback --- contracts/interfaces/IERC7984HookModule.sol | 6 +++++- contracts/mocks/token/ERC7984HookModuleMock.sol | 4 ++-- contracts/mocks/token/ERC7984HookedMock.sol | 3 +-- contracts/token/ERC7984/utils/ERC7984HookModule.sol | 2 -- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/contracts/interfaces/IERC7984HookModule.sol b/contracts/interfaces/IERC7984HookModule.sol index 5f15ea72..d07e3e6a 100644 --- a/contracts/interfaces/IERC7984HookModule.sol +++ b/contracts/interfaces/IERC7984HookModule.sol @@ -19,6 +19,10 @@ interface IERC7984HookModule is IERC165 { /// @dev Performs operations after installation. function onInstall(bytes calldata initData) external; - /// @dev Performs operations after uninstallation. + /** + * @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/mocks/token/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984HookModuleMock.sol index 792ca29e..967d5670 100644 --- a/contracts/mocks/token/ERC7984HookModuleMock.sol +++ b/contracts/mocks/token/ERC7984HookModuleMock.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.24; import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, ebool, euint64} from "@fhevm/solidity/lib/FHE.sol"; -import {ERC7984HookModule} from "./../../token/ERC7984/utils/ERC7984HookModule.sol"; import {IERC7984} from "./../../interfaces/IERC7984.sol"; +import {ERC7984HookModule} from "./../../token/ERC7984/utils/ERC7984HookModule.sol"; contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { bool public isCompliant = true; @@ -42,7 +42,7 @@ contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { function _preTransfer(address token, address from, address, euint64) internal override returns (ebool) { euint64 fromBalance = IERC7984(token).confidentialBalanceOf(from); - if (euint64.unwrap(fromBalance) != 0) { + if (FHE.isInitialized(fromBalance)) { _getTokenHandleAllowance(token, fromBalance); assert(FHE.isAllowed(fromBalance, address(this))); } diff --git a/contracts/mocks/token/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984HookedMock.sol index 05b8bb95..0fa9d2d0 100644 --- a/contracts/mocks/token/ERC7984HookedMock.sol +++ b/contracts/mocks/token/ERC7984HookedMock.sol @@ -2,8 +2,7 @@ pragma solidity ^0.8.24; -import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC7984} from "./../../token/ERC7984/ERC7984.sol"; import {ERC7984Hooked} from "./../../token/ERC7984/extensions/ERC7984Hooked.sol"; diff --git a/contracts/token/ERC7984/utils/ERC7984HookModule.sol b/contracts/token/ERC7984/utils/ERC7984HookModule.sol index d24d93c0..510ac8ed 100644 --- a/contracts/token/ERC7984/utils/ERC7984HookModule.sol +++ b/contracts/token/ERC7984/utils/ERC7984HookModule.sol @@ -11,8 +11,6 @@ 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 { - error UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address sender); - /// @inheritdoc IERC7984HookModule function preTransfer(address from, address to, euint64 encryptedAmount) public virtual returns (ebool) { ebool compliant = _preTransfer(msg.sender, from, to, encryptedAmount); From 4c54896a230ac404d2280d81bc09653b00218a92 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:13:14 -0400 Subject: [PATCH 102/111] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto GarcĂ­a --- contracts/interfaces/IERC7984Rwa.sol | 2 +- contracts/token/ERC7984/extensions/ERC7984Hooked.sol | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/contracts/interfaces/IERC7984Rwa.sol b/contracts/interfaces/IERC7984Rwa.sol index e979741f..b15d3f87 100644 --- a/contracts/interfaces/IERC7984Rwa.sol +++ b/contracts/interfaces/IERC7984Rwa.sol @@ -2,7 +2,7 @@ // OpenZeppelin Confidential Contracts (last updated v0.3.0) (interfaces/IERC7984Rwa.sol) pragma solidity ^0.8.24; -import {ebool, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IERC7984} from "./IERC7984.sol"; /// @dev Interface for confidential RWA contracts. diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index 196b9b4b..ef9f5504 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -51,14 +51,12 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { * Consider gas footprint of the module before adding it since all modules will perform * all steps (pre-check, check, post-hook) in a single transaction. */ - function installModule(address module, bytes memory initData) public virtual { - _authorizeModuleChange(); + function installModule(address module, bytes memory initData) public virtual onlyModuleChange { _installModule(module, initData); } /// @dev Uninstalls a hook module. - function uninstallModule(address module, bytes memory deinitData) public virtual { - _authorizeModuleChange(); + function uninstallModule(address module, bytes memory deinitData) public virtual onlyModuleChange { _uninstallModule(module, deinitData); } @@ -99,7 +97,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { } /** - * @dev See {ERC7984._update}. + * @dev See {ERC7984-_update}. * * Modified to run pre and post transfer hooks. Zero tokens are transferred if a module does not approve * the transfer. From a1bc7cb6689cc01fcbc718004f3e86560cda1673 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:23:12 -0400 Subject: [PATCH 103/111] add in modifier --- contracts/token/ERC7984/extensions/ERC7984Hooked.sol | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index ef9f5504..dc418f66 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -40,6 +40,11 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { /// @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); @@ -49,14 +54,14 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { * @dev Installs a hook module. * * Consider gas footprint of the module before adding it since all modules will perform - * all steps (pre-check, check, post-hook) in a single transaction. + * both steps (pre-hook, post-hook) on all transfers. */ - function installModule(address module, bytes memory initData) public virtual onlyModuleChange { + 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 onlyModuleChange { + function uninstallModule(address module, bytes memory deinitData) public virtual onlyAuthorizedModuleChange { _uninstallModule(module, deinitData); } From 7ca76c01fde93271770cea46c3f78b12c6cb6098 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:34:59 -0400 Subject: [PATCH 104/111] add to docs --- contracts/token/README.adoc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 8d7ebce601e70616d9c7c2d4853c62430066c80d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:28:00 -0400 Subject: [PATCH 105/111] Update contracts/mocks/token/ERC7984HookedMock.sol Co-authored-by: James Toussaint <33313130+james-toussaint@users.noreply.github.com> --- contracts/mocks/token/ERC7984HookedMock.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/mocks/token/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984HookedMock.sol index 0fa9d2d0..965a43de 100644 --- a/contracts/mocks/token/ERC7984HookedMock.sol +++ b/contracts/mocks/token/ERC7984HookedMock.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.24; import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {ERC7984} from "./../../token/ERC7984/ERC7984.sol"; import {ERC7984Hooked} from "./../../token/ERC7984/extensions/ERC7984Hooked.sol"; import {ERC7984Mock} from "./ERC7984Mock.sol"; From f30be07b6b8756528cd2e48a0470be0d9babea8a Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:29:38 -0400 Subject: [PATCH 106/111] Update contracts/token/ERC7984/extensions/ERC7984Hooked.sol Co-authored-by: James Toussaint <33313130+james-toussaint@users.noreply.github.com> --- contracts/token/ERC7984/extensions/ERC7984Hooked.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index dc418f66..0f36fd9a 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -130,7 +130,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { address[] memory modules_ = modules(); uint256 modulesLength = modules_.length; compliant = FHE.asEbool(true); - for (uint256 i = 0; i < modulesLength; i++) { + 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)); } From 24882cadfc1d3dabf9660152f64e330aa36f30ea Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:23:03 -0400 Subject: [PATCH 107/111] add test for base module --- .../token/ERC7984/utils/ERC7984HookModule.sol | 15 ++++++ test/helpers/interface.ts | 6 +++ .../{ => extensions}/ERC7984Votes.test.ts | 0 .../ERC7984/utils/ERC7984HookModule.test.ts | 53 +++++++++++++++++++ 4 files changed, 74 insertions(+) rename test/token/ERC7984/{ => extensions}/ERC7984Votes.test.ts (100%) create mode 100644 test/token/ERC7984/utils/ERC7984HookModule.test.ts diff --git a/contracts/token/ERC7984/utils/ERC7984HookModule.sol b/contracts/token/ERC7984/utils/ERC7984HookModule.sol index 510ac8ed..985094c9 100644 --- a/contracts/token/ERC7984/utils/ERC7984HookModule.sol +++ b/contracts/token/ERC7984/utils/ERC7984HookModule.sol @@ -11,8 +11,15 @@ 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; @@ -20,6 +27,10 @@ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { /// @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); } @@ -37,6 +48,8 @@ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { /** * @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, @@ -48,6 +61,8 @@ abstract contract ERC7984HookModule is IERC7984HookModule, ERC165 { /** * @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*/, diff --git a/test/helpers/interface.ts b/test/helpers/interface.ts index 416a4577..c0eabe8a 100644 --- a/test/helpers/interface.ts +++ b/test/helpers/interface.ts @@ -43,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/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'); + }); + }); +}); From 752e3d33c719ec4db1755d41b0ce7b2252cef29f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:37:26 -0400 Subject: [PATCH 108/111] move mocks --- .../mocks/token/{ => ERC7984/extensions}/ERC7984HookedMock.sol | 0 .../mocks/token/{ => ERC7984/utils}/ERC7984HookModuleMock.sol | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename contracts/mocks/token/{ => ERC7984/extensions}/ERC7984HookedMock.sol (100%) rename contracts/mocks/token/{ => ERC7984/utils}/ERC7984HookModuleMock.sol (100%) diff --git a/contracts/mocks/token/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol similarity index 100% rename from contracts/mocks/token/ERC7984HookedMock.sol rename to contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol diff --git a/contracts/mocks/token/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol similarity index 100% rename from contracts/mocks/token/ERC7984HookModuleMock.sol rename to contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol From 6771ff5c61111adab77864293d3bcca1a240141c Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:44:17 -0400 Subject: [PATCH 109/111] fix mock imports --- .../mocks/token/ERC7984/extensions/ERC7984HookedMock.sol | 4 ++-- contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol b/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol index 965a43de..4d7a9ee9 100644 --- a/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol +++ b/contracts/mocks/token/ERC7984/extensions/ERC7984HookedMock.sol @@ -4,8 +4,8 @@ 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"; +import {ERC7984Hooked} from "../../../../token/ERC7984/extensions/ERC7984Hooked.sol"; +import {ERC7984Mock} from "../../ERC7984Mock.sol"; contract ERC7984HookedMock is ERC7984Hooked, ERC7984Mock, Ownable { constructor( diff --git a/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol b/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol index 967d5670..d62513ce 100644 --- a/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol +++ b/contracts/mocks/token/ERC7984/utils/ERC7984HookModuleMock.sol @@ -4,8 +4,8 @@ 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"; +import {IERC7984} from "../../../../interfaces/IERC7984.sol"; +import {ERC7984HookModule} from "../../../../token/ERC7984/utils/ERC7984HookModule.sol"; contract ERC7984HookModuleMock is ERC7984HookModule, ZamaEthereumConfig { bool public isCompliant = true; From 9d95fa43bad19aebe470c9598df7840c87f4ecf8 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:14:33 -0400 Subject: [PATCH 110/111] return slice of modules instead of full list --- .../token/ERC7984/extensions/ERC7984Hooked.sol | 14 +++++++++----- .../token/ERC7984/extensions/ERC7984Hooked.test.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index 0f36fd9a..166b73d4 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -65,9 +65,13 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { _uninstallModule(module, deinitData); } - /// @dev Returns the list of modules installed on the token. - function modules() public view virtual returns (address[] memory) { - return _modules.values(); + /** + * @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. @@ -127,7 +131,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { address to, euint64 encryptedAmount ) internal virtual returns (ebool compliant) { - address[] memory modules_ = modules(); + address[] memory modules_ = modules(0, type(uint256).max); uint256 modulesLength = modules_.length; compliant = FHE.asEbool(true); for (uint256 i = 0; i < modulesLength; ++i) { @@ -138,7 +142,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { /// @dev Runs the post-transfer hooks for all modules. function _runPostTransferHooks(address from, address to, euint64 encryptedAmount) internal virtual { - address[] memory modules_ = modules(); + 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]); diff --git a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts index b4ac34ac..6598613f 100644 --- a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -38,7 +38,7 @@ describe('ERC7984Hooked', function () { 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()).to.eventually.deep.equal([this.hookModule.target]); + await expect(this.token.modules(0, ethers.MaxInt256)).to.eventually.deep.equal([this.hookModule.target]); }); it('should gate via `_authorizeModuleChange`', async function () { From 19244c39a3aec4a5d9a58f523a5df958d945550b Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:41:51 -0400 Subject: [PATCH 111/111] prefix install and uninstall events --- contracts/token/ERC7984/extensions/ERC7984Hooked.sol | 8 ++++---- test/token/ERC7984/extensions/ERC7984Hooked.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol index 166b73d4..f3118e35 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Hooked.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Hooked.sol @@ -27,9 +27,9 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { EnumerableSet.AddressSet private _modules; /// @dev Emitted when a module is installed. - event ModuleInstalled(address module); + event ERC7984HookedModuleInstalled(address module); /// @dev Emitted when a module is uninstalled. - event ModuleUninstalled(address module); + event ERC7984HookedModuleUninstalled(address module); /// @dev The address is not a valid module. error ERC7984HookedInvalidModule(address module); @@ -93,7 +93,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { IERC7984HookModule(module).onInstall(initData); - emit ModuleInstalled(module); + emit ERC7984HookedModuleInstalled(module); } /// @dev Internal function which uninstalls a module. @@ -102,7 +102,7 @@ abstract contract ERC7984Hooked is ERC7984, HandleAccessManager { LowLevelCall.callNoReturn(module, abi.encodeCall(IERC7984HookModule.onUninstall, (deinitData))); - emit ModuleUninstalled(module); + emit ERC7984HookedModuleUninstalled(module); } /** diff --git a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts index 6598613f..38802b26 100644 --- a/test/token/ERC7984/extensions/ERC7984Hooked.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Hooked.test.ts @@ -25,7 +25,7 @@ describe('ERC7984Hooked', function () { describe('install module', async function () { it('should emit event', async function () { await expect(this.token.$_installModule(this.hookModule, '0x')) - .to.emit(this.token, 'ModuleInstalled') + .to.emit(this.token, 'ERC7984HookedModuleInstalled') .withArgs(this.hookModule); }); @@ -86,7 +86,7 @@ describe('ERC7984Hooked', function () { it('should emit event', async function () { await expect(this.token.$_uninstallModule(this.hookModule, '0x')) - .to.emit(this.token, 'ModuleUninstalled') + .to.emit(this.token, 'ERC7984HookedModuleUninstalled') .withArgs(this.hookModule); });