diff --git a/AGENTS.md b/AGENTS.md index b151852..315ea72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Modular compliance-rule library for CMTAT / ERC-3643 security tokens. Each rule - `openzeppelin-contracts` v5.6.1 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context` - `openzeppelin-contracts-upgradeable` v5.6.1 - `CMTAT` v3.0.0 — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces -- `RuleEngine` v3.0.0-rc2 — `IRule`, `RulesManagementModule` +- `RuleEngine` v3.0.0-rc3 — `IRule`, `RulesManagementModule` - `forge-std` — Foundry test utilities Remappings are in `remappings.txt`; aliases used in source: `OZ/`, `CMTAT/`, `RuleEngine/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68490c9..5f3af1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,38 @@ Custom changelog tag: `Dependencies`, `Documentation`, `Testing` - Update surya doc by running the 3 scripts in [./doc/script](./doc/script) - Update changelog -## v0.3.0 - + + +## v0.4.0 + +### Added + +- `RuleConditionalTransferLightMultiToken` and `RuleConditionalTransferLightMultiTokenOwnable2Step` — multi-token conditional transfer rules with token-scoped approvals keyed by `(token, from, to, value)`. + +### Changed + +- Update contract version in `VersionModule` to `0.4.0`. +- Ownable2Step rule deployments now explicitly advertise ERC-165 `IERC165` (`0x01ffc9a7`), ERC-173 (`0x7f5828d0`), and Ownable2Step (`0x9ab669ef`) interface IDs. + +### Dependencies + +- Update RuleEngine to `v3.0.0-rc3`. + +### Documentation + +- Added technical documentation: `doc/technical/RuleConditionalTransferLightMultiToken.md`. +- Updated README operation-rule sections and tables to include `RuleConditionalTransferLightMultiToken`. + +### Testing + +- Added `RuleConditionalTransferLightMultiToken` tests proving approvals are token-scoped and cannot be consumed cross-token. +- Added explicit RuleEngine integration tests for `RuleConditionalTransferLightMultiToken` documenting caller-context behavior in shared RuleEngine topology. +- Added `Ownable2StepERC165Support` test covering all Ownable2Step rule deployments. +- Extended `Ownable2StepERC165Support` with negative assertions to ensure Ownable2Step rule deployments do not advertise unrelated interfaces (`IAccessControl`, `0xdeadbeef`). + +## v0.3.0 - 2026-04-16 + +Commit: `91c21c1191e84ff938892267ec443b0d1bb9efb0` ### Security diff --git a/CLAUDE.md b/CLAUDE.md index b151852..315ea72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Modular compliance-rule library for CMTAT / ERC-3643 security tokens. Each rule - `openzeppelin-contracts` v5.6.1 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context` - `openzeppelin-contracts-upgradeable` v5.6.1 - `CMTAT` v3.0.0 — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces -- `RuleEngine` v3.0.0-rc2 — `IRule`, `RulesManagementModule` +- `RuleEngine` v3.0.0-rc3 — `IRule`, `RulesManagementModule` - `forge-std` — Foundry test utilities Remappings are in `remappings.txt`; aliases used in source: `OZ/`, `CMTAT/`, `RuleEngine/`. diff --git a/README.md b/README.md index 097c66c..88b09ac 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ forge test | Component | Compatible Versions | | ---------------- | ----------------------------------------- | -| **Rules v0.1.0** | CMTAT ≥ v3.0.0
RuleEngine v3.0.0-rc2 | +| **Rules v0.1.0** | CMTAT ≥ v3.0.0
RuleEngine v3.0.0-rc3 | Each Rule implements the interface `IRuleEngine` defined in CMTAT. @@ -306,7 +306,7 @@ Available validation rules: `RuleWhitelist`, `RuleWhitelistWrapper`, `RuleSpende Operation rules modify blockchain state during transfer execution. Their `transferred()` function is state-mutating: it consumes or updates stored data as part of the transfer flow. -Available operation rules: `RuleConditionalTransferLight`. +Available operation rules: `RuleConditionalTransferLight`, `RuleConditionalTransferLightMultiToken`. A full-featured variant, `RuleConditionalTransfer`, is maintained as a separate experimental repository at [CMTA/RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer). @@ -362,6 +362,7 @@ Several rules are available in multiple access-control variants. Use the simples | RuleSpenderWhitelist | Read-Only | | | | This rule blocks `transferFrom` when the spender is not in the whitelist. Direct transfers are always allowed. | | RuleERC2980 | Read-Only | | | | ERC-2980 Swiss Compliant rule combining a whitelist (recipient-only) and a frozenlist (blocks sender, recipient, and spender for `transferFrom`). Frozenlist takes priority over whitelist. | | RuleConditionalTransferLight | Read-Write | | | | This rule requires that transfers have to be approved by an operator before being executed. Each approval is consumed once and the same transfer can be approved multiple times. | +| RuleConditionalTransferLightMultiToken | Read-Write | | | | Multi-token variant of ConditionalTransferLight. Approvals are token-scoped with key `(token, from, to, value)` so one token cannot consume another token's approvals. | | [RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer) (external) | Read-Write | | |
(experimental rule) | Full-featured approval-based transfer rule implementing Swiss law *Vinkulierung*. Supports automatic approval after three months, automatic transfer execution, and a conditional whitelist for address pairs that bypass approval. Maintained in a separate repository. | | [RuleSelf](https://github.com/rya-sge/ruleself) (community) | — | | — |
(community project) | Use [Self](https://self.xyz), a zero-knowledge identity solution to determine which is allowed to interact with the token.
Community-maintained rule project. Not developed or maintained by CMTA. | @@ -382,6 +383,7 @@ Detailed technical documentation for each rule is available in [`doc/technical/` | RuleSpenderWhitelist | [RuleSpenderWhitelist.md](./doc/technical/RuleSpenderWhitelist.md) | | RuleERC2980 | [RuleERC2980.md](./doc/technical/RuleERC2980.md) | | RuleConditionalTransferLight | [RuleConditionalTransferLight.md](./doc/technical/RuleConditionalTransferLight.md) | +| RuleConditionalTransferLightMultiToken | [RuleConditionalTransferLightMultiToken.md](./doc/technical/RuleConditionalTransferLightMultiToken.md) | ### Operational Notes @@ -422,13 +424,22 @@ Detailed technical documentation for each rule is available in [`doc/technical/` - `RuleConditionalTransferLight`: `transferred()` is restricted to the single token bound via `bindToken`; second bind reverts with `RuleConditionalTransferLight_TokenAlreadyBound` until `unbindToken`. - `RuleConditionalTransferLight`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`. +#### RuleConditionalTransferLightMultiToken + +- `RuleConditionalTransferLightMultiToken`: approvals are keyed by `(token, from, to, value)` and are not nonce-based. +- `RuleConditionalTransferLightMultiToken`: operator functions are token-scoped (`approveTransfer(token, ...)`, `cancelTransferApproval(token, ...)`, `approvedCount(token, ...)`, `approveAndTransferIfAllowed(token, ...)`). +- `RuleConditionalTransferLightMultiToken`: execution is restricted to bound tokens; only the calling bound token can consume approvals for its own key space. +- `RuleConditionalTransferLightMultiToken`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`. +- `RuleConditionalTransferLightMultiToken`: with a shared `RuleEngine`, the caller seen by the rule is the engine address (not the underlying token). In that topology, token-scoped approvals are not visible unless approvals are keyed to the engine address, which is not per-token scoping. +- **Warning**: `RuleConditionalTransferLightMultiToken` supports several tokens when integrated directly with each token contract. It must not be used for per-token approval isolation through a shared `RuleEngine`. + #### General notes - All validation rules: read-only rules still implement `transferred()` for ERC-3643 and RuleEngine compatibility, but do not change state. - All AccessControl variants: use `onlyRole(ROLE)` in `_authorize*()` and mark internal helpers `virtual`. - All AccessControl variants: use `AccessControlEnumerable`, so role members can be enumerated with `getRoleMember` / `getRoleMemberCount`; default admin is treated as having all roles via `hasRole`, but may not appear in role member lists unless explicitly granted. - All meta-tx-enabled rules: `forwarderIrrevocable` is accepted as-is (including `address(0)`) and is not validated against ERC-165 because some forwarders do not implement it. -- All rules: implement `IERC3643Version` via `VersionModule` and expose `version()` returning `"0.3.0"`. +- All rules: implement `IERC3643Version` via `VersionModule` and expose `version()` returning `"0.4.0"`. ### Read-only (validation) rule @@ -579,7 +590,7 @@ The operator calls `setIdentityRegistry(registry)`. The issuer attempts a transf ### Read-Write (Operation) rule -For the moment, there is only one operation rule available: ConditionalTransferLight. +There are two operation rules available: `RuleConditionalTransferLight` and `RuleConditionalTransferLightMultiToken`. #### Conditional transfer (light) @@ -591,6 +602,14 @@ This rule requires that transfers must be approved by an operator before being e An operator calls `approveTransfer(from, to, value)`. The compliance manager binds exactly one token with `bindToken(token)`; attempting to bind a second token reverts. The token calls `detectTransferRestriction` (passes) and later `transferred` to consume the approval. Without approval, `detectTransferRestriction` returns code 46 and the transfer is rejected. The operator can revoke with `cancelTransferApproval`. To migrate to a different token, the compliance manager must first call `unbindToken` before binding the new one. +#### Conditional transfer (light, multi-token) + +This variant scopes approvals by token address. It hashes `(token, from, to, value)` and supports multiple bound tokens in a single rule instance. Each successful transfer consumes one approval in the calling token namespace. Mints (`from == address(0)`) and burns (`to == address(0)`) remain exempt. + +**Usage scenario** + +An operator calls `approveTransfer(tokenA, from, to, value)` for `tokenA`. A transfer on `tokenA` succeeds and consumes the approval. The same `(from, to, value)` transfer on `tokenB` is still rejected until separately approved with `approveTransfer(tokenB, from, to, value)`. + ## Access Control The module `AccessControlModuleStandalone` implements RBAC access control by inheriting from OpenZeppelin's `AccessControlEnumerable`. @@ -618,8 +637,8 @@ See also [docs.openzeppelin.com - AccessControl](https://docs.openzeppelin.com/c | `ADDRESS_LIST_REMOVE_ROLE` | `0x1b94c92b564251ed6b49246d9a82eb7a486b6490f3b3a3bf3b28d2e99801f3ec` | `removeAddress`, `removeAddresses` (RuleWhitelist, RuleBlacklist) | | `SANCTIONLIST_ROLE` | `0x30842281ac34bdc7d568c7ab276f84ba6fc1a1de1ae858b0afd35e716fb0650d` | `setSanctionListOracle`, `clearSanctionListOracle` (RuleSanctionsList) | | `RULES_MANAGEMENT_ROLE` | `0xea5f4eb72290e50c32abd6c23e45de3d8300b3286e1cbc2e293114b92e034e5e` | `setRules`, `clearRules`, `addRule`, `removeRule` (RuleWhitelistWrapper) | -| `OPERATOR_ROLE` | `0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | `approveTransfer`, `cancelTransferApproval` (RuleConditionalTransferLight) | -| `COMPLIANCE_MANAGER_ROLE` | `0xe5c50d0927e06141e032cb9a67e1d7092dc85c0b0825191f7e1cede600028568` | `bindToken`, `unbindToken` (RuleConditionalTransferLight) | +| `OPERATOR_ROLE` | `0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | `approveTransfer`, `cancelTransferApproval` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken) | +| `COMPLIANCE_MANAGER_ROLE` | `0xe5c50d0927e06141e032cb9a67e1d7092dc85c0b0825191f7e1cede600028568` | `bindToken`, `unbindToken` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken) | | `WHITELIST_ADD_ROLE` | `0x77c0b4c0975a0b0417d8ce295502737b95fee8923755fed0cce952907a1861ed` | `addWhitelistAddress`, `addWhitelistAddresses` (RuleERC2980) | | `WHITELIST_REMOVE_ROLE` | `0xf4d11a530c5b90f459c6ab1e335d3d77156b8ff3093308e4fca6d100ee87ade9` | `removeWhitelistAddress`, `removeWhitelistAddresses` (RuleERC2980) | | `FROZENLIST_ADD_ROLE` | `0xc52c49807a071974b9260f4b553ee09bd9fd85f687d8d4cc3232de7104ff7835` | `addFrozenlistAddress`, `addFrozenlistAddresses` (RuleERC2980) | @@ -637,9 +656,11 @@ For simpler ownership-based control, `Ownable2Step` variants (two-step ownership - `RuleMaxTotalSupplyOwnable2Step` - `RuleERC2980Ownable2Step` - `RuleConditionalTransferLightOwnable2Step` +- `RuleConditionalTransferLightMultiTokenOwnable2Step` `RuleConditionalTransferLightOwnable2Step` now grants approval and execution permissions exclusively to the owner. All `Ownable2Step` variants enforce access using OpenZeppelin's `onlyOwner` modifier. +All `Ownable2Step` variants also advertise ERC-165 support for `IERC165` (`0x01ffc9a7`), ERC-173 ownership (`0x7f5828d0`), and Ownable2Step handover (`0x9ab669ef`). ### Address List @@ -678,7 +699,7 @@ Here are the settings for [Hardhat](https://hardhat.org) and [Foundry](https://g - CMTAT [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0) - - RuleEngine [v3.0.0-rc2](https://github.com/CMTA/RuleEngine/releases/tag/v3.0.0-rc2) + - RuleEngine [v3.0.0-rc3](https://github.com/CMTA/RuleEngine/releases/tag/v3.0.0-rc3) ### Toolchain installation diff --git a/doc/specification/RulesSpecificationv0.3.0.pdf b/doc/specification/RulesSpecificationv0.3.0.pdf new file mode 100644 index 0000000..53754ab Binary files /dev/null and b/doc/specification/RulesSpecificationv0.3.0.pdf differ diff --git a/doc/specification/RulesSpecificationv0.2.0.pdf b/doc/specification/archive/RulesSpecificationv0.2.0.pdf similarity index 100% rename from doc/specification/RulesSpecificationv0.2.0.pdf rename to doc/specification/archive/RulesSpecificationv0.2.0.pdf diff --git a/doc/specification/cover_page.odg b/doc/specification/cover_page.odg index 855eff6..4645f13 100644 Binary files a/doc/specification/cover_page.odg and b/doc/specification/cover_page.odg differ diff --git a/doc/specification/cover_page.pdf b/doc/specification/cover_page.pdf index 0b3519c..8dd7fe2 100644 Binary files a/doc/specification/cover_page.pdf and b/doc/specification/cover_page.pdf differ diff --git a/doc/technical/RuleConditionalTransferLightMultiToken.md b/doc/technical/RuleConditionalTransferLightMultiToken.md new file mode 100644 index 0000000..c7cbcd2 --- /dev/null +++ b/doc/technical/RuleConditionalTransferLightMultiToken.md @@ -0,0 +1,53 @@ +# Rule Conditional Transfer Light MultiToken + +[TOC] + +`RuleConditionalTransferLightMultiToken` is an operation rule that requires explicit operator approval before each transfer, with approvals scoped per token. + +Approval key: + +- `keccak256(token, from, to, value)` + +This prevents approval reuse across tokens when the rule receives token-specific caller context (for example, direct token callbacks to the rule). + +## Restriction codes + +| Constant | Code | Meaning | +| --- | --- | --- | +| `CODE_TRANSFER_REQUEST_NOT_APPROVED` | 46 | No approval exists for this `(token, from, to, value)` tuple | + +## Access control + +| Role | Description | +| --- | --- | +| `DEFAULT_ADMIN_ROLE` | Manages all roles (AccessControl variant) | +| `OPERATOR_ROLE` | Approve/cancel approvals and call `approveAndTransferIfAllowed` | +| `COMPLIANCE_MANAGER_ROLE` | Bind/unbind token contracts | + +## Methods + +### `approveTransfer(address token, address from, address to, uint256 value)` + +Approves one transfer for a specific token key. + +### `cancelTransferApproval(address token, address from, address to, uint256 value)` + +Removes one approval for a specific token key. Reverts if none exists. + +### `approvedCount(address token, address from, address to, uint256 value) -> uint256` + +Returns the remaining count for a specific token key. + +### `approveAndTransferIfAllowed(address token, address from, address to, uint256 value) -> bool` + +Approves and executes `safeTransferFrom` on the specified token, requiring allowance for this rule as spender. + +### `transferred(...)` + +Only bound tokens can call transfer execution hooks. Approval consumption uses the caller token (`msg.sender`) as the token key. + +## Notes + +- Mints and burns are exempt from approval consumption (`from == address(0)` or `to == address(0)`). +- This rule is ERC-20 operation-focused, like `RuleConditionalTransferLight`. +- In a shared `RuleEngine` topology, rule calls are made by the `RuleEngine` address, so `msg.sender` is the engine (not the token). In that case, token-scoped approval keys are not observable through current ERC-3643 / RuleEngine function signatures. diff --git a/lib/RuleEngine b/lib/RuleEngine index ec4a24a..5827604 160000 --- a/lib/RuleEngine +++ b/lib/RuleEngine @@ -1 +1 @@ -Subproject commit ec4a24a96ca30e2ef8f79a06e49846a431e9b4b1 +Subproject commit 5827604be4e38f65c055a929c7b62462a20f4bbd diff --git a/src/modules/Ownable2StepERC165Module.sol b/src/modules/Ownable2StepERC165Module.sol new file mode 100644 index 0000000..96b808d --- /dev/null +++ b/src/modules/Ownable2StepERC165Module.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {OwnableInterfaceId} from "RuleEngine/modules/library/OwnableInterfaceId.sol"; +import {Ownable2StepInterfaceId} from "RuleEngine/modules/library/Ownable2StepInterfaceId.sol"; + +/** + * @title Ownable2StepERC165Module + * @notice Shared ERC-165 advertisement for Ownable2Step deployments. + */ +abstract contract Ownable2StepERC165Module is ERC165 { + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == OwnableInterfaceId.IERC173_INTERFACE_ID + || interfaceId == Ownable2StepInterfaceId.IOWNABLE2STEP_INTERFACE_ID + || ERC165.supportsInterface(interfaceId); + } +} diff --git a/src/modules/VersionModule.sol b/src/modules/VersionModule.sol index 8da4a78..c4922ae 100644 --- a/src/modules/VersionModule.sol +++ b/src/modules/VersionModule.sol @@ -8,7 +8,7 @@ import {IERC3643Version} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol * @notice Exposes the contract version as required by ERC-3643. */ abstract contract VersionModule is IERC3643Version { - string private constant VERSION = "0.3.0"; + string private constant VERSION = "0.4.0"; /*////////////////////////////////////////////////////////////// PUBLIC FUNCTIONS diff --git a/src/rules/operation/RuleConditionalTransferLight.sol b/src/rules/operation/RuleConditionalTransferLight.sol index 787427a..df88d51 100644 --- a/src/rules/operation/RuleConditionalTransferLight.sol +++ b/src/rules/operation/RuleConditionalTransferLight.sol @@ -52,4 +52,7 @@ contract RuleConditionalTransferLight is AccessControlModuleStandalone, RuleCond function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {} function _onlyComplianceManager() internal virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {} + + function _authorizeComplianceBindingChange(address) internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) + {} } diff --git a/src/rules/operation/RuleConditionalTransferLightMultiToken.sol b/src/rules/operation/RuleConditionalTransferLightMultiToken.sol new file mode 100644 index 0000000..ce30493 --- /dev/null +++ b/src/rules/operation/RuleConditionalTransferLightMultiToken.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol"; +import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol"; +import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol"; +import {IERC7551Compliance} from "CMTAT/interfaces/tokenization/draft-IERC7551.sol"; +import {IERC3643ComplianceFull} from "../../mocks/IERC3643ComplianceFull.sol"; +import {AccessControlModuleStandalone} from "../../modules/AccessControlModuleStandalone.sol"; +import {RuleConditionalTransferLightMultiTokenBase} from "./abstract/RuleConditionalTransferLightMultiTokenBase.sol"; + +contract RuleConditionalTransferLightMultiToken is + AccessControlModuleStandalone, + RuleConditionalTransferLightMultiTokenBase +{ + constructor(address admin) AccessControlModuleStandalone(admin) {} + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(AccessControlEnumerable, IERC165) + returns (bool) + { + return interfaceId == RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID + || interfaceId == ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID + || interfaceId == RuleInterfaceId.IRULE_INTERFACE_ID + || interfaceId == type(IERC7551Compliance).interfaceId + || interfaceId == type(IERC3643ComplianceFull).interfaceId + || AccessControlEnumerable.supportsInterface(interfaceId); + } + + function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {} + + function _onlyComplianceManager() internal virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {} +} diff --git a/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol b/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol new file mode 100644 index 0000000..fd12100 --- /dev/null +++ b/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol"; +import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol"; +import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol"; +import {IERC7551Compliance} from "CMTAT/interfaces/tokenization/draft-IERC7551.sol"; +import {IERC3643ComplianceFull} from "../../mocks/IERC3643ComplianceFull.sol"; +import {RuleConditionalTransferLightMultiTokenBase} from "./abstract/RuleConditionalTransferLightMultiTokenBase.sol"; +import {Ownable2StepERC165Module} from "../../modules/Ownable2StepERC165Module.sol"; + +contract RuleConditionalTransferLightMultiTokenOwnable2Step is + RuleConditionalTransferLightMultiTokenBase, + Ownable2Step, + Ownable2StepERC165Module +{ + constructor(address owner) Ownable(owner) {} + + function supportsInterface(bytes4 interfaceId) + public + view + override(Ownable2StepERC165Module, IERC165) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || interfaceId == RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID + || interfaceId == ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID + || interfaceId == RuleInterfaceId.IRULE_INTERFACE_ID + || interfaceId == type(IERC7551Compliance).interfaceId + || interfaceId == type(IERC3643ComplianceFull).interfaceId; + } + + function _authorizeTransferApproval() internal view virtual override onlyOwner {} + + function _onlyComplianceManager() internal virtual override onlyOwner {} +} diff --git a/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol b/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol index 0ba8e65..d19b61a 100644 --- a/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol +++ b/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol @@ -3,19 +3,20 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol"; import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol"; import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol"; import {IERC7551Compliance} from "CMTAT/interfaces/tokenization/draft-IERC7551.sol"; import {IERC3643ComplianceFull} from "../../mocks/IERC3643ComplianceFull.sol"; import {RuleConditionalTransferLightBase} from "./abstract/RuleConditionalTransferLightBase.sol"; +import {Ownable2StepERC165Module} from "../../modules/Ownable2StepERC165Module.sol"; /** * @title RuleConditionalTransferLightOwnable2Step * @notice Ownable2Step variant of RuleConditionalTransferLight. */ -contract RuleConditionalTransferLightOwnable2Step is RuleConditionalTransferLightBase, Ownable2Step { +contract RuleConditionalTransferLightOwnable2Step is RuleConditionalTransferLightBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -26,8 +27,13 @@ contract RuleConditionalTransferLightOwnable2Step is RuleConditionalTransferLigh PUBLIC FUNCTIONS //////////////////////////////////////////////////////////////*/ - function supportsInterface(bytes4 interfaceId) public view override returns (bool) { - return interfaceId == type(IERC165).interfaceId + function supportsInterface(bytes4 interfaceId) + public + view + override(Ownable2StepERC165Module, IERC165) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) || interfaceId == RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID || interfaceId == ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID || interfaceId == RuleInterfaceId.IRULE_INTERFACE_ID @@ -42,4 +48,6 @@ contract RuleConditionalTransferLightOwnable2Step is RuleConditionalTransferLigh function _authorizeTransferApproval() internal view virtual override onlyOwner {} function _onlyComplianceManager() internal virtual override onlyOwner {} + + function _authorizeComplianceBindingChange(address) internal view virtual override onlyOwner {} } diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol b/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol index 7b2bfca..69e19db 100644 --- a/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol +++ b/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol @@ -173,4 +173,5 @@ abstract contract RuleConditionalTransferLightBase is function _authorizeTransferExecution() internal view override { require(isTokenBound(_msgSender()), RuleConditionalTransferLight_TransferExecutorUnauthorized(_msgSender())); } + } diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol new file mode 100644 index 0000000..0e656ab --- /dev/null +++ b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol"; +import {IERC1404, IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol"; +import {IERC3643ComplianceRead, IERC3643IComplianceContract} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol"; +import {IERC7551Compliance} from "CMTAT/interfaces/tokenization/draft-IERC7551.sol"; +import {IRule} from "RuleEngine/interfaces/IRule.sol"; +import {ERC3643ComplianceModule} from "RuleEngine/modules/ERC3643ComplianceModule.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {VersionModule} from "../../../modules/VersionModule.sol"; +import {RuleConditionalTransferLightMultiTokenInvariantStorage} from "./RuleConditionalTransferLightMultiTokenInvariantStorage.sol"; +import {ITransferContext} from "../../interfaces/ITransferContext.sol"; + +abstract contract RuleConditionalTransferLightMultiTokenBase is + VersionModule, + ERC3643ComplianceModule, + RuleConditionalTransferLightMultiTokenInvariantStorage, + IRule +{ + using SafeERC20 for IERC20; + + mapping(bytes32 => uint256) public approvalCounts; + + modifier onlyTransferApprover() { + _authorizeTransferApproval(); + _; + } + + modifier onlyTransferExecutor() { + _authorizeTransferExecution(); + _; + } + + function _authorizeTransferApproval() internal view virtual; + + function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override(IRule) returns (bool) { + return restrictionCode == CODE_TRANSFER_REQUEST_NOT_APPROVED; + } + + function messageForTransferRestriction(uint8 restrictionCode) + external + pure + override(IERC1404) + returns (string memory) + { + if (restrictionCode == CODE_TRANSFER_REQUEST_NOT_APPROVED) { + return TEXT_TRANSFER_REQUEST_NOT_APPROVED; + } + return TEXT_CODE_NOT_FOUND; + } + + function created(address to, uint256 value) external onlyBoundToken { + _transferred(_msgSender(), address(0), to, value); + } + + function destroyed(address from, uint256 value) external onlyBoundToken { + _transferred(_msgSender(), from, address(0), value); + } + + function approveTransfer(address token, address from, address to, uint256 value) public onlyTransferApprover { + _approveTransfer(token, from, to, value); + } + + function cancelTransferApproval(address token, address from, address to, uint256 value) public onlyTransferApprover { + _cancelTransferApproval(token, from, to, value); + } + + function approvedCount(address token, address from, address to, uint256 value) public view returns (uint256) { + bytes32 transferHash = _transferHash(token, from, to, value); + return approvalCounts[transferHash]; + } + + function approveAndTransferIfAllowed(address token, address from, address to, uint256 value) + public + onlyTransferApprover + returns (bool) + { + require(isTokenBound(token), RuleConditionalTransferLightMultiToken_InvalidToken()); + + _approveTransfer(token, from, to, value); + + uint256 allowed = IERC20(token).allowance(from, address(this)); + require( + allowed >= value, + RuleConditionalTransferLightMultiToken_InsufficientAllowance(token, from, allowed, value) + ); + + IERC20(token).safeTransferFrom(from, to, value); + return true; + } + + function transferred(address from, address to, uint256 value) + public + override(IERC3643IComplianceContract) + onlyTransferExecutor + { + _transferred(_msgSender(), from, to, value); + } + + function transferred( + address, + /* spender */ + address from, + address to, + uint256 value + ) + public + override(IRuleEngine) + onlyTransferExecutor + { + _transferred(_msgSender(), from, to, value); + } + + function transferred(ITransferContext.FungibleTransferContext calldata ctx) external onlyTransferExecutor { + _transferred(_msgSender(), ctx.from, ctx.to, ctx.value); + } + + function detectTransferRestriction(address from, address to, uint256 value) + public + view + override(IERC1404) + returns (uint8) + { + if (from == address(0) || to == address(0)) { + return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK); + } + + address token = _msgSender(); + if (!isTokenBound(token)) { + return CODE_TRANSFER_REQUEST_NOT_APPROVED; + } + + bytes32 transferHash = _transferHash(token, from, to, value); + if (approvalCounts[transferHash] == 0) { + return CODE_TRANSFER_REQUEST_NOT_APPROVED; + } + + return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK); + } + + function detectTransferRestrictionFrom( + address, + /* spender */ + address from, + address to, + uint256 value + ) + public + view + override(IERC1404Extend) + returns (uint8) + { + return detectTransferRestriction(from, to, value); + } + + function canTransfer(address from, address to, uint256 value) + public + view + override(IERC3643ComplianceRead) + returns (bool) + { + return detectTransferRestriction(from, to, value) == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK); + } + + function canTransferFrom(address spender, address from, address to, uint256 value) + public + view + override(IERC7551Compliance) + returns (bool) + { + return detectTransferRestrictionFrom(spender, from, to, value) + == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK); + } + + function _authorizeTransferExecution() internal view virtual { + require( + isTokenBound(_msgSender()), + RuleConditionalTransferLightMultiToken_TransferExecutorUnauthorized(_msgSender()) + ); + } + + function _authorizeComplianceBindingChange(address) internal virtual override { + _onlyComplianceManager(); + } + + function _approveTransfer(address token, address from, address to, uint256 value) internal virtual { + require(isTokenBound(token), RuleConditionalTransferLightMultiToken_InvalidToken()); + bytes32 transferHash = _transferHash(token, from, to, value); + approvalCounts[transferHash] += 1; + emit TransferApproved(token, from, to, value, approvalCounts[transferHash]); + } + + function _cancelTransferApproval(address token, address from, address to, uint256 value) internal virtual { + require(isTokenBound(token), RuleConditionalTransferLightMultiToken_InvalidToken()); + bytes32 transferHash = _transferHash(token, from, to, value); + uint256 count = approvalCounts[transferHash]; + + require(count != 0, RuleConditionalTransferLightMultiToken_TransferApprovalNotFound()); + + approvalCounts[transferHash] = count - 1; + emit TransferApprovalCancelled(token, from, to, value, approvalCounts[transferHash]); + } + + function _transferred(address token, address from, address to, uint256 value) internal virtual { + if (from == address(0) || to == address(0)) { + return; + } + + bytes32 transferHash = _transferHash(token, from, to, value); + uint256 count = approvalCounts[transferHash]; + + require(count != 0, RuleConditionalTransferLightMultiToken_TransferNotApproved()); + + approvalCounts[transferHash] = count - 1; + emit TransferExecuted(token, from, to, value, approvalCounts[transferHash]); + } + + function _transferHash(address token, address from, address to, uint256 value) + internal + pure + virtual + returns (bytes32 hash) + { + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, shl(96, token)) + mstore(add(ptr, 0x20), shl(96, from)) + mstore(add(ptr, 0x40), shl(96, to)) + mstore(add(ptr, 0x60), value) + hash := keccak256(ptr, 0x80) + } + } +} diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol new file mode 100644 index 0000000..0466287 --- /dev/null +++ b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {RuleSharedInvariantStorage} from "../../validation/abstract/invariant/RuleSharedInvariantStorage.sol"; + +abstract contract RuleConditionalTransferLightMultiTokenInvariantStorage is RuleSharedInvariantStorage { + /* ============ Role ============ */ + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + /* ============ State variables ============ */ + string constant TEXT_TRANSFER_REQUEST_NOT_APPROVED = "ConditionalTransferLightMultiToken: The request is not approved"; + uint8 public constant CODE_TRANSFER_REQUEST_NOT_APPROVED = 46; + + /* ============ Events ============ */ + event TransferApproved(address indexed token, address indexed from, address indexed to, uint256 value, uint256 count); + event TransferExecuted(address indexed token, address indexed from, address indexed to, uint256 value, uint256 remaining); + event TransferApprovalCancelled( + address indexed token, address indexed from, address indexed to, uint256 value, uint256 remaining + ); + + /* ============ Custom error ============ */ + error RuleConditionalTransferLightMultiToken_TransferExecutorUnauthorized(address account); + error RuleConditionalTransferLightMultiToken_InsufficientAllowance( + address token, address owner, uint256 allowance, uint256 required + ); + error RuleConditionalTransferLightMultiToken_InvalidToken(); + error RuleConditionalTransferLightMultiToken_TransferNotApproved(); + error RuleConditionalTransferLightMultiToken_TransferApprovalNotFound(); +} diff --git a/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol b/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol index f299e82..3b4f7da 100644 --- a/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; import {RuleBlacklistBase} from "../abstract/base/RuleBlacklistBase.sol"; import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; @@ -11,7 +12,7 @@ import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; * @title RuleBlacklistOwnable2Step * @notice Ownable2Step variant of RuleBlacklist with owner-based authorization hooks. */ -contract RuleBlacklistOwnable2Step is RuleBlacklistBase, Ownable2Step { +contract RuleBlacklistOwnable2Step is RuleBlacklistBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -26,6 +27,17 @@ contract RuleBlacklistOwnable2Step is RuleBlacklistBase, Ownable2Step { function _authorizeAddressListRemove() internal view virtual override onlyOwner {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleBlacklistBase, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleBlacklistBase.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol b/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol index 072adbe..e56490b 100644 --- a/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol +++ b/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; import {RuleERC2980Base} from "../abstract/base/RuleERC2980Base.sol"; /** @@ -11,7 +12,7 @@ import {RuleERC2980Base} from "../abstract/base/RuleERC2980Base.sol"; * @notice Ownable2Step variant of RuleERC2980 with owner-based authorization hooks. * @dev All whitelist and frozenlist management functions are restricted to the contract owner. */ -contract RuleERC2980Ownable2Step is RuleERC2980Base, Ownable2Step { +contract RuleERC2980Ownable2Step is RuleERC2980Base, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -38,6 +39,17 @@ contract RuleERC2980Ownable2Step is RuleERC2980Base, Ownable2Step { function _authorizeFrozenlistRemove() internal view virtual override onlyOwner {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleERC2980Base, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleERC2980Base.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol b/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol index c46ff38..3dc4def 100644 --- a/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol @@ -3,13 +3,15 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; +import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol"; import {RuleIdentityRegistryBase} from "../abstract/base/RuleIdentityRegistryBase.sol"; /** * @title RuleIdentityRegistryOwnable2Step * @notice Ownable2Step variant of RuleIdentityRegistry. */ -contract RuleIdentityRegistryOwnable2Step is RuleIdentityRegistryBase, Ownable2Step { +contract RuleIdentityRegistryOwnable2Step is RuleIdentityRegistryBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -21,4 +23,15 @@ contract RuleIdentityRegistryOwnable2Step is RuleIdentityRegistryBase, Ownable2S //////////////////////////////////////////////////////////////*/ function _authorizeIdentityRegistryManager() internal view virtual override onlyOwner {} + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleTransferValidation, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleTransferValidation.supportsInterface(interfaceId); + } } diff --git a/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol b/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol index 46a78d4..4cd9e77 100644 --- a/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol @@ -3,13 +3,15 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; +import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol"; import {RuleMaxTotalSupplyBase} from "../abstract/base/RuleMaxTotalSupplyBase.sol"; /** * @title RuleMaxTotalSupplyOwnable2Step * @notice Ownable2Step variant of RuleMaxTotalSupply. */ -contract RuleMaxTotalSupplyOwnable2Step is RuleMaxTotalSupplyBase, Ownable2Step { +contract RuleMaxTotalSupplyOwnable2Step is RuleMaxTotalSupplyBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -24,4 +26,15 @@ contract RuleMaxTotalSupplyOwnable2Step is RuleMaxTotalSupplyBase, Ownable2Step //////////////////////////////////////////////////////////////*/ function _authorizeMaxTotalSupplyManager() internal view virtual override onlyOwner {} + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleTransferValidation, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleTransferValidation.supportsInterface(interfaceId); + } } diff --git a/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol b/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol index 47b40fe..69744d4 100644 --- a/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; import {ERC2771Context} from "../../../modules/MetaTxModuleStandalone.sol"; +import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol"; import {RuleSanctionsListBase} from "../abstract/base/RuleSanctionsListBase.sol"; import {ISanctionsList} from "../../interfaces/ISanctionsList.sol"; @@ -12,7 +14,7 @@ import {ISanctionsList} from "../../interfaces/ISanctionsList.sol"; * @title RuleSanctionsListOwnable2Step * @notice Ownable2Step variant of RuleSanctionsList. */ -contract RuleSanctionsListOwnable2Step is RuleSanctionsListBase, Ownable2Step { +contract RuleSanctionsListOwnable2Step is RuleSanctionsListBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -28,6 +30,17 @@ contract RuleSanctionsListOwnable2Step is RuleSanctionsListBase, Ownable2Step { function _authorizeSanctionListManager() internal view virtual override onlyOwner {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleTransferValidation, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleTransferValidation.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol b/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol index f9c2de8..8948e83 100644 --- a/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; +import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol"; import {RuleSpenderWhitelistBase} from "../abstract/base/RuleSpenderWhitelistBase.sol"; import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; @@ -11,7 +13,7 @@ import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; * @title RuleSpenderWhitelistOwnable2Step * @notice Ownable2Step deployment variant of spender whitelist rule. */ -contract RuleSpenderWhitelistOwnable2Step is RuleSpenderWhitelistBase, Ownable2Step { +contract RuleSpenderWhitelistOwnable2Step is RuleSpenderWhitelistBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -29,6 +31,17 @@ contract RuleSpenderWhitelistOwnable2Step is RuleSpenderWhitelistBase, Ownable2S function _authorizeAddressListRemove() internal view virtual override onlyOwner {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleTransferValidation, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleTransferValidation.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol b/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol index d2f8aaf..0e85c82 100644 --- a/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; import {RuleWhitelistBase} from "../abstract/base/RuleWhitelistBase.sol"; import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; @@ -11,7 +12,7 @@ import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol"; * @title RuleWhitelistOwnable2Step * @notice Ownable2Step variant of RuleWhitelist with owner-based authorization hooks. */ -contract RuleWhitelistOwnable2Step is RuleWhitelistBase, Ownable2Step { +contract RuleWhitelistOwnable2Step is RuleWhitelistBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -37,6 +38,17 @@ contract RuleWhitelistOwnable2Step is RuleWhitelistBase, Ownable2Step { function _authorizeCheckSpenderManager() internal view virtual override onlyOwner {} + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleWhitelistBase, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleWhitelistBase.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleWhitelistWrapper.sol b/src/rules/validation/deployment/RuleWhitelistWrapper.sol index 99297e1..595eed0 100644 --- a/src/rules/validation/deployment/RuleWhitelistWrapper.sol +++ b/src/rules/validation/deployment/RuleWhitelistWrapper.sol @@ -64,6 +64,8 @@ contract RuleWhitelistWrapper is RuleWhitelistWrapperBase, AccessControlModuleSt */ function _onlyRulesManager() internal virtual override onlyRole(RULES_MANAGEMENT_ROLE) {} + function _onlyRulesLimitManager() internal virtual override onlyRole(RULES_MANAGEMENT_ROLE) {} + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol b/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol index d5f7bad..d16380a 100644 --- a/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol +++ b/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol @@ -6,13 +6,14 @@ pragma solidity ^0.8.20; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol"; /* ==== Abstract contracts === */ import {RuleWhitelistWrapperBase} from "../abstract/base/RuleWhitelistWrapperBase.sol"; /** * @title Wrapper to call several different whitelist rules (Ownable2Step) */ -contract RuleWhitelistWrapperOwnable2Step is RuleWhitelistWrapperBase, Ownable2Step { +contract RuleWhitelistWrapperOwnable2Step is RuleWhitelistWrapperBase, Ownable2Step, Ownable2StepERC165Module { /*////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////*/ @@ -36,6 +37,19 @@ contract RuleWhitelistWrapperOwnable2Step is RuleWhitelistWrapperBase, Ownable2S */ function _onlyRulesManager() internal view virtual override onlyOwner {} + function _onlyRulesLimitManager() internal view virtual override onlyOwner {} + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(RuleWhitelistWrapperBase, Ownable2StepERC165Module) + returns (bool) + { + return Ownable2StepERC165Module.supportsInterface(interfaceId) + || RuleWhitelistWrapperBase.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ diff --git a/test/Ownable2Step/Ownable2StepERC165Support.t.sol b/test/Ownable2Step/Ownable2StepERC165Support.t.sol new file mode 100644 index 0000000..fd3908d --- /dev/null +++ b/test/Ownable2Step/Ownable2StepERC165Support.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {OwnableInterfaceId} from "RuleEngine/modules/library/OwnableInterfaceId.sol"; +import {Ownable2StepInterfaceId} from "RuleEngine/modules/library/Ownable2StepInterfaceId.sol"; + +import {RuleBlacklistOwnable2Step} from "src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol"; +import {RuleWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol"; +import {RuleWhitelistWrapperOwnable2Step} from "src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol"; +import {RuleSpenderWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol"; +import {RuleERC2980Ownable2Step} from "src/rules/validation/deployment/RuleERC2980Ownable2Step.sol"; +import {RuleSanctionsListOwnable2Step} from "src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol"; +import {RuleIdentityRegistryOwnable2Step} from "src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol"; +import {RuleMaxTotalSupplyOwnable2Step} from "src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol"; +import {RuleConditionalTransferLightOwnable2Step} from "src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol"; +import { + RuleConditionalTransferLightMultiTokenOwnable2Step +} from "src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol"; +import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol"; + +contract Ownable2StepERC165SupportTest is Test { + address internal constant OWNER = address(0xA11CE); + + function testAllOwnable2StepRulesAdvertiseOwnableInterfaces() public { + RuleBlacklistOwnable2Step blacklist = new RuleBlacklistOwnable2Step(OWNER, address(0)); + RuleWhitelistOwnable2Step whitelist = new RuleWhitelistOwnable2Step(OWNER, address(0), false, false); + RuleWhitelistWrapperOwnable2Step wrapper = new RuleWhitelistWrapperOwnable2Step(OWNER, address(0), false); + RuleSpenderWhitelistOwnable2Step spenderWhitelist = new RuleSpenderWhitelistOwnable2Step(OWNER, address(0)); + RuleERC2980Ownable2Step erc2980 = new RuleERC2980Ownable2Step(OWNER, address(0), false); + RuleSanctionsListOwnable2Step sanctions = + new RuleSanctionsListOwnable2Step(OWNER, address(0), ISanctionsList(address(0))); + RuleIdentityRegistryOwnable2Step identity = new RuleIdentityRegistryOwnable2Step(OWNER, address(0)); + RuleMaxTotalSupplyOwnable2Step maxSupply = new RuleMaxTotalSupplyOwnable2Step(OWNER, address(1), 1); + RuleConditionalTransferLightOwnable2Step conditional = new RuleConditionalTransferLightOwnable2Step(OWNER); + RuleConditionalTransferLightMultiTokenOwnable2Step conditionalMulti = + new RuleConditionalTransferLightMultiTokenOwnable2Step(OWNER); + + _assertOwnable2StepInterfaces(address(blacklist)); + _assertOwnable2StepInterfaces(address(whitelist)); + _assertOwnable2StepInterfaces(address(wrapper)); + _assertOwnable2StepInterfaces(address(spenderWhitelist)); + _assertOwnable2StepInterfaces(address(erc2980)); + _assertOwnable2StepInterfaces(address(sanctions)); + _assertOwnable2StepInterfaces(address(identity)); + _assertOwnable2StepInterfaces(address(maxSupply)); + _assertOwnable2StepInterfaces(address(conditional)); + _assertOwnable2StepInterfaces(address(conditionalMulti)); + } + + function _assertOwnable2StepInterfaces(address target) internal view { + IERC165 i = IERC165(target); + assertTrue(i.supportsInterface(type(IERC165).interfaceId)); + assertTrue(i.supportsInterface(OwnableInterfaceId.IERC173_INTERFACE_ID)); + assertTrue(i.supportsInterface(Ownable2StepInterfaceId.IOWNABLE2STEP_INTERFACE_ID)); + assertFalse(i.supportsInterface(type(IAccessControl).interfaceId)); + assertFalse(i.supportsInterface(bytes4(0xdeadbeef))); + } +} diff --git a/test/RuleConditionalTransferLightMultiToken/CMTATIntegrationDirectMultiToken.t.sol b/test/RuleConditionalTransferLightMultiToken/CMTATIntegrationDirectMultiToken.t.sol new file mode 100644 index 0000000..5019fca --- /dev/null +++ b/test/RuleConditionalTransferLightMultiToken/CMTATIntegrationDirectMultiToken.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {HelperContract} from "../HelperContract.sol"; +import {CMTATDeployment} from "RuleEngine/../test/utils/CMTATDeployment.sol"; +import {CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol"; +import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol"; +import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol"; + +contract CMTATIntegrationDirectMultiToken is Test, HelperContract { + CMTATStandalone private tokenA; + CMTATStandalone private tokenB; + RuleConditionalTransferLightMultiToken private rule; + + function setUp() public { + CMTATDeployment deploymentA = new CMTATDeployment(); + CMTATDeployment deploymentB = new CMTATDeployment(); + + tokenA = deploymentA.cmtat(); + tokenB = deploymentB.cmtat(); + + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule = new RuleConditionalTransferLightMultiToken(DEFAULT_ADMIN_ADDRESS); + + vm.startPrank(DEFAULT_ADMIN_ADDRESS); + rule.bindToken(address(tokenA)); + rule.bindToken(address(tokenB)); + tokenA.setRuleEngine(IRuleEngine(address(rule))); + tokenB.setRuleEngine(IRuleEngine(address(rule))); + + tokenA.mint(ADDRESS1, 100); + tokenB.mint(ADDRESS1, 100); + vm.stopPrank(); + } + + function testDetectRestriction_IsTokenScoped() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(address(tokenA), ADDRESS1, ADDRESS2, 10); + + resUint8 = tokenA.detectTransferRestriction(ADDRESS1, ADDRESS2, 10); + assertEq(resUint8, TRANSFER_OK); + + resUint8 = tokenB.detectTransferRestriction(ADDRESS1, ADDRESS2, 10); + assertEq(resUint8, CODE_TRANSFER_REQUEST_NOT_APPROVED); + } + + function testTransferConsumption_IsTokenScoped() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(address(tokenA), ADDRESS1, ADDRESS2, 10); + + vm.prank(ADDRESS1); + tokenA.transfer(ADDRESS2, 10); + + assertEq(rule.approvedCount(address(tokenA), ADDRESS1, ADDRESS2, 10), 0); + assertEq(rule.approvedCount(address(tokenB), ADDRESS1, ADDRESS2, 10), 0); + + vm.prank(ADDRESS1); + vm.expectRevert(); + tokenB.transfer(ADDRESS2, 10); + } +} diff --git a/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiToken.t.sol b/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiToken.t.sol new file mode 100644 index 0000000..9cbd950 --- /dev/null +++ b/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiToken.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {HelperContract} from "../HelperContract.sol"; +import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol"; +import {MockERC20WithTransferContext} from "src/mocks/MockERC20WithTransferContext.sol"; + +contract RuleConditionalTransferLightMultiTokenTest is Test, HelperContract { + RuleConditionalTransferLightMultiToken private rule; + MockERC20WithTransferContext private tokenA; + MockERC20WithTransferContext private tokenB; + + function setUp() public { + tokenA = new MockERC20WithTransferContext("Token A", "TKNA"); + tokenB = new MockERC20WithTransferContext("Token B", "TKNB"); + + rule = new RuleConditionalTransferLightMultiToken(DEFAULT_ADMIN_ADDRESS); + + vm.startPrank(DEFAULT_ADMIN_ADDRESS); + rule.bindToken(address(tokenA)); + rule.bindToken(address(tokenB)); + vm.stopPrank(); + + tokenA.setRule(address(rule)); + tokenB.setRule(address(rule)); + + tokenA.mint(ADDRESS1, 100); + tokenB.mint(ADDRESS1, 100); + } + + function testApprovalForTokenADoesNotAuthorizeTokenB() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(address(tokenA), ADDRESS1, ADDRESS2, 10); + + vm.prank(ADDRESS1); + tokenA.transfer(ADDRESS2, 10); + + assertEq(tokenA.balanceOf(ADDRESS1), 90); + assertEq(tokenA.balanceOf(ADDRESS2), 10); + + vm.expectRevert(); + vm.prank(ADDRESS1); + tokenB.transfer(ADDRESS2, 10); + } + + function testApproveAndTransferIfAllowedUsesTokenScopedApproval() public { + vm.prank(ADDRESS1); + tokenA.approve(address(rule), 10); + + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveAndTransferIfAllowed(address(tokenA), ADDRESS1, ADDRESS2, 10); + + assertEq(tokenA.balanceOf(ADDRESS1), 90); + assertEq(tokenA.balanceOf(ADDRESS2), 10); + assertEq(rule.approvedCount(address(tokenA), ADDRESS1, ADDRESS2, 10), 0); + assertEq(rule.approvedCount(address(tokenB), ADDRESS1, ADDRESS2, 10), 0); + } + + function testApproveTransferRevertsForUnboundToken() public { + vm.expectRevert(); + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(ADDRESS3, ADDRESS1, ADDRESS2, 10); + } +} diff --git a/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiTokenRuleEngineIntegration.t.sol b/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiTokenRuleEngineIntegration.t.sol new file mode 100644 index 0000000..e652b66 --- /dev/null +++ b/test/RuleConditionalTransferLightMultiToken/RuleConditionalTransferLightMultiTokenRuleEngineIntegration.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {HelperContract} from "../HelperContract.sol"; +import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol"; +import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol"; + +contract RuleConditionalTransferLightMultiTokenRuleEngineIntegrationTest is Test, HelperContract { + RuleConditionalTransferLightMultiToken private rule; + RuleEngine private sharedRuleEngine; + + function setUp() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + sharedRuleEngine = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS); + + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule = new RuleConditionalTransferLightMultiToken(DEFAULT_ADMIN_ADDRESS); + + // In RuleEngine path, rule sees only msg.sender == RuleEngine. + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.bindToken(address(sharedRuleEngine)); + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.bindToken(ADDRESS1); + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.bindToken(ADDRESS2); + + vm.prank(DEFAULT_ADMIN_ADDRESS); + sharedRuleEngine.addRule(rule); + } + + function testTokenScopedApprovalIsNotVisibleThroughSharedRuleEngineCallerContext() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(ADDRESS1, ADDRESS2, ADDRESS3, 10); + + resUint8 = sharedRuleEngine.detectTransferRestriction(ADDRESS2, ADDRESS3, 10); + assertEq(resUint8, CODE_TRANSFER_REQUEST_NOT_APPROVED); + } + + function testRuleEngineScopedApprovalIsConsumableButNotTokenScoped() public { + vm.prank(DEFAULT_ADMIN_ADDRESS); + rule.approveTransfer(address(sharedRuleEngine), ADDRESS2, ADDRESS3, 10); + + resUint8 = sharedRuleEngine.detectTransferRestriction(ADDRESS2, ADDRESS3, 10); + assertEq(resUint8, TRANSFER_OK); + + vm.prank(address(sharedRuleEngine)); + rule.transferred(ADDRESS2, ADDRESS3, 10); + + resUint8 = sharedRuleEngine.detectTransferRestriction(ADDRESS2, ADDRESS3, 10); + assertEq(resUint8, CODE_TRANSFER_REQUEST_NOT_APPROVED); + } +} diff --git a/test/Version.t.sol b/test/Version.t.sol index 9bc8030..54580f4 100644 --- a/test/Version.t.sol +++ b/test/Version.t.sol @@ -13,7 +13,7 @@ import {RuleERC2980} from "src/rules/validation/deployment/RuleERC2980.sol"; import {RuleConditionalTransferLight} from "src/rules/operation/RuleConditionalTransferLight.sol"; contract VersionTest is Test, HelperContract { - string constant EXPECTED_VERSION = "0.3.0"; + string constant EXPECTED_VERSION = "0.4.0"; function testVersionRuleWhitelist() public { RuleWhitelist rule = new RuleWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, true, false);