diff --git a/.gitignore b/.gitignore
index 1bb896c..faaf0d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ docOut/
node_modules/
/history
/ERCSpecification
+FEEDBACK.md
*.dbg.json
# Codex
@@ -27,3 +28,5 @@ node_modules/
#drawio
*.bkp
*.dtmp
+
+
diff --git a/AGENTS.md b/AGENTS.md
index b151852..3b4c845 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,6 +28,8 @@ Modular compliance-rule library for CMTAT / ERC-3643 security tokens. Each rule
| `RuleERC2980Ownable2Step` | Ownable2Step variant of RuleERC2980 |
| `RuleConditionalTransferLight` | Require operator approval before each transfer; bound to exactly one token at a time (`bindToken` reverts if a token is already bound; use `unbindToken` first to migrate) |
| `RuleConditionalTransferLightOwnable2Step` | Owner-only approval and execution for conditional transfers |
+| `RuleMintAllowance` | Enforce a per-minter mint quota; each mint reduces the minter's allowance; operator can set/increase/decrease allowances; bind to RuleEngine address before use |
+| `RuleMintAllowanceOwnable2Step` | Ownable2Step variant of RuleMintAllowance |
| `AccessControlModuleStandalone` | Base RBAC module; admin implicitly holds all roles |
| `MetaTxModuleStandalone` | ERC-2771 meta-transaction support |
| `VersionModule` | Implements `IERC3643Version`; returns the contract version string |
@@ -36,7 +38,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/`.
@@ -60,6 +62,7 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
| RuleIdentityRegistry | 55–57 |
| RuleERC2980 | 60–63 |
| RuleSpenderWhitelist | 66 |
+| RuleMintAllowance | 70 |
## Conventions
- Each rule has an `InvariantStorage` abstract contract holding its constants, custom errors, and events.
@@ -76,3 +79,4 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
- `AGENTS.md` and `CLAUDE.md` are identical — always update both together.
- Always update README.md with the latest change
- New rule or features implemented: create/update technical documentation in `doc/technical`, update README, create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
+- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68490c9..fd1ebf3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,7 +43,58 @@ Custom changelog tag: `Dependencies`, `Documentation`, `Testing`
- Update surya doc by running the 3 scripts in [./doc/script](./doc/script)
- Update changelog
-## v0.3.0 -
+
+
+## Unreleased
+
+### Added
+
+- `RuleMintAllowance` and `RuleMintAllowanceOwnable2Step` — operation rule enforcing a per-minter mint quota managed by an operator. Each mint reduces the minter's allowance; the operator can set an absolute quota or increment/decrement it. Regular transfers and burns are not restricted. Restriction code 70 (`CODE_MINTER_ALLOWANCE_EXCEEDED`).
+
+### Changed
+
+- `RuleMintAllowance` now enforces single-target binding like `RuleConditionalTransferLight`: a second `bindToken` call reverts with `RuleMintAllowance_TokenAlreadyBound` until the current RuleEngine/token is unbound.
+
+### Documentation
+
+- Added technical documentation: `doc/technical/RuleMintAllowance.md`.
+- Updated restriction code table, rule index, role summary, and Ownable2Step list in README.
+- Documented that `RuleMintAllowance` does not work with pure ERC-3643 3-arg mint callbacks; it requires the spender-aware CMTAT/RuleEngine path.
+
+### Testing
+
+- Added unit tests (`test/RuleMintAllowance/RuleMintAllowance.t.sol`, `test/RuleMintAllowance/RuleMintAllowanceOwnable2Step.t.sol`) and CMTAT integration test (`test/RuleMintAllowance/CMTATIntegration.t.sol`) — 54 tests, including batch mint rollback and ERC-165 advertised-interface coverage, >98% line coverage on `RuleMintAllowanceBase`.
+
+## 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..ede166d 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/`.
@@ -76,3 +76,4 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
- `AGENTS.md` and `CLAUDE.md` are identical — always update both together.
- Always update README.md with the latest change
- New rule or features implemented: create/update technical documentation in `doc/technical`, update README, create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
+- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit.
diff --git a/README.md b/README.md
index 097c66c..56371e2 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.
@@ -163,7 +163,9 @@ Here is the list of codes used by the different rules
| | CODE_ADDRESS_TO_NOT_WHITELISTED | 63 |
| | Reserved slot | 64-65 |
| RuleSpenderWhitelist | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 66 |
-| | Reserved slot | 67-70 |
+| | Reserved slot | 67-69 |
+| RuleMintAllowance | CODE_MINTER_ALLOWANCE_EXCEEDED | 70 |
+| | Reserved slot | 71-74 |
Note:
@@ -306,7 +308,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 +364,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 +385,8 @@ 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) |
+| RuleMintAllowance | [RuleMintAllowance.md](./doc/technical/RuleMintAllowance.md) |
### Operational Notes
@@ -422,13 +427,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 +593,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 +605,24 @@ 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.
+#### Mint allowance
+
+This rule enforces a per-minter mint quota for one bound RuleEngine/token at a time. An operator sets the number of tokens each minter address is allowed to mint via `setMintAllowance(minter, amount)`. Every successful mint reduces the minter's remaining quota. The operator can adjust quotas at any time with `increaseMintAllowance` / `decreaseMintAllowance`. Regular transfers and burns are not restricted.
+
+Compatibility warning: `RuleMintAllowance` does not enforce quotas for a token that only calls the standard ERC-3643 3-arg compliance functions. It requires the CMTAT/RuleEngine spender-aware path so the minter address is passed as `spender`.
+
+**Usage scenario**
+
+The compliance manager binds the rule to the RuleEngine with `bindToken(ruleEngine)`. Attempting to bind a second RuleEngine/token reverts until the current binding is removed with `unbindToken`. The operator assigns `setMintAllowance(alice, 100_000e18)`. Alice's mints deduct from her quota through `transferred(alice, address(0), recipient, amount)`; once exhausted, further mints revert with code 70 until the operator increases the quota.
+
+#### 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 +650,9 @@ 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 / RuleMintAllowance) |
+| `ALLOWANCE_OPERATOR_ROLE` | `0x86a2482724302deea267bc1ca14032806c318aeaf8d1e0d445a6fb7e7c997beb` | `setMintAllowance`, `increaseMintAllowance`, `decreaseMintAllowance` (RuleMintAllowance) |
| `WHITELIST_ADD_ROLE` | `0x77c0b4c0975a0b0417d8ce295502737b95fee8923755fed0cce952907a1861ed` | `addWhitelistAddress`, `addWhitelistAddresses` (RuleERC2980) |
| `WHITELIST_REMOVE_ROLE` | `0xf4d11a530c5b90f459c6ab1e335d3d77156b8ff3093308e4fca6d100ee87ade9` | `removeWhitelistAddress`, `removeWhitelistAddresses` (RuleERC2980) |
| `FROZENLIST_ADD_ROLE` | `0xc52c49807a071974b9260f4b553ee09bd9fd85f687d8d4cc3232de7104ff7835` | `addFrozenlistAddress`, `addFrozenlistAddresses` (RuleERC2980) |
@@ -637,9 +670,12 @@ For simpler ownership-based control, `Ownable2Step` variants (two-step ownership
- `RuleMaxTotalSupplyOwnable2Step`
- `RuleERC2980Ownable2Step`
- `RuleConditionalTransferLightOwnable2Step`
+- `RuleConditionalTransferLightMultiTokenOwnable2Step`
+- `RuleMintAllowanceOwnable2Step`
`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 +714,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
@@ -747,6 +783,7 @@ forge test --match-contract --match-test
```
- For `RuleConditionalTransferLight` fuzz/integration tests, note that mint and burn paths (`from == address(0)` or `to == address(0)`) are intentionally exempt from approval consumption.
+- `RuleMintAllowance` integration tests cover single mints, cumulative `batchMint` allowance consumption, rollback on over-allowance batch mint, and advertised ERC-165 interface IDs.
- Ownable2Step variants also include dedicated tests for ownership transfer and manager-only functions (IdentityRegistry, MaxTotalSupply, SanctionsList).
- Coverage-focused tests also target deployment wrappers and operation-rule overloads (`created`, `destroyed`, spender-aware `transferred`) to improve line/function coverage in `src/rules/operation` and `src/rules/validation/deployment`.
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/doc/technical/RuleMintAllowance.md b/doc/technical/RuleMintAllowance.md
new file mode 100644
index 0000000..9621eb7
--- /dev/null
+++ b/doc/technical/RuleMintAllowance.md
@@ -0,0 +1,84 @@
+# Rule Mint Allowance
+
+[TOC]
+
+This rule enforces a per-minter mint quota. An operator assigns each minter address a maximum number of tokens it is allowed to mint. Every successful mint reduces the minter's remaining allowance. The operator can adjust the allowance at any time by setting an absolute value or by incrementing/decrementing the current one.
+
+It is an **operation rule**: it modifies state during the transfer call (deducting from the minter's allowance), unlike validation rules which are read-only.
+
+Regular transfers and burns are **not restricted** by this rule — it only acts on minting operations (`from == address(0)`).
+
+`detectTransferRestriction(from, to, value)` (the 3-arg form without a spender) always returns `TRANSFER_OK` because the minter's identity is not available in that call path. Use `detectTransferRestrictionFrom(minter, address(0), to, amount)` to query a minter's allowance.
+
+> Compatibility warning: this rule does **not** enforce mint allowances for a token that only calls the standard ERC-3643 3-arg compliance functions. It requires the spender-aware RuleEngine/CMTAT v3.3+ path (`detectTransferRestrictionFrom` and `transferred(spender, from, to, value)`) so the minter address is available.
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_MINTER_ALLOWANCE_EXCEEDED` | 70 | Minter's remaining allowance is less than the requested mint amount |
+
+## Access Control
+
+| Role | Description |
+| --- | --- |
+| `DEFAULT_ADMIN_ROLE` | Manages all roles; implicitly holds all roles below |
+| `ALLOWANCE_OPERATOR_ROLE` | May set, increase, and decrease per-minter allowances |
+| `COMPLIANCE_MANAGER_ROLE` | May bind and unbind the rule to a RuleEngine (`bindToken`, `unbindToken`) |
+
+The state-modifying `transferred()` functions are restricted to the bound entity only. In the standard CMTAT + RuleEngine deployment, bind the rule to the **RuleEngine address** (not the token address), because the RuleEngine is the direct caller of the rule's `transferred()`. The rule targets exactly one bound entity at a time; attempting to bind a second RuleEngine/token reverts with `RuleMintAllowance_TokenAlreadyBound`. To migrate, call `unbindToken` first.
+
+## Methods
+
+### `setMintAllowance(address minter, uint256 amount)`
+
+Sets the `minter`'s allowance to an absolute `amount`, overwriting any previous value. Restricted to `ALLOWANCE_OPERATOR_ROLE`. Emits `MintAllowanceSet`.
+
+### `increaseMintAllowance(address minter, uint256 amount)`
+
+Adds `amount` to `minter`'s current allowance. Restricted to `ALLOWANCE_OPERATOR_ROLE`. Emits `MintAllowanceIncreased`.
+
+### `decreaseMintAllowance(address minter, uint256 amount)`
+
+Subtracts `amount` from `minter`'s current allowance. Reverts with `RuleMintAllowance_DecreaseBelowZero` if `amount` exceeds the current allowance. Restricted to `ALLOWANCE_OPERATOR_ROLE`. Emits `MintAllowanceDecreased`.
+
+### `mintAllowance(address minter) → uint256`
+
+Returns the remaining mint allowance for `minter`. Default is `0`.
+
+### `bindToken(address token)` / `unbindToken(address token)`
+
+Binds or unbinds the caller address. Only the bound address is authorised to call `transferred`. In practice, bind the RuleEngine address. Restricted to `COMPLIANCE_MANAGER_ROLE`. A second `bindToken` call reverts until the current binding is removed.
+
+## Workflow
+
+1. Deploy `RuleMintAllowance` (or `RuleMintAllowanceOwnable2Step`).
+2. Add the rule to the RuleEngine with `ruleEngine.addRule(ruleMintAllowance)`.
+3. Bind the rule to the RuleEngine: `ruleMintAllowance.bindToken(address(ruleEngine))`.
+4. Set the CMTAT's RuleEngine: `cmtat.setRuleEngine(ruleEngine)`.
+5. For each minter, call `setMintAllowance(minterAddress, quota)`.
+6. Minters can now mint tokens up to their assigned quota.
+
+## Allowance deduction
+
+When CMTAT v3.3+ calls `ruleEngine.transferred(minter, address(0), recipient, amount)`, the rule receives `transferred(minter, address(0), recipient, amount)` and deducts `amount` from `mintAllowance[minter]`. If `amount > mintAllowance[minter]`, the call reverts with `RuleMintAllowance_AllowanceExceeded`.
+
+## Multiple minters
+
+Each minter has an independent allowance within the single bound RuleEngine/token. Multiple minters can share a single `RuleMintAllowance` instance, but that instance intentionally targets only one bound caller at a time so allowance state is not shared across multiple RuleEngines/tokens.
+
+## Notes
+
+### 3-arg `transferred` path
+
+When CMTAT calls `ruleEngine.transferred(from, to, value)` without a spender (CMTAT v3.2 and earlier, a pure ERC-3643 token, or when spender is `address(0)`), the rule receives the 3-arg call and performs **no deduction**. The minter's quota is only consumed via the 4-arg path where the minter's address is passed as `spender`.
+
+Because of this, `RuleMintAllowance` must not be used as a standalone compliance contract for a pure ERC-3643 token. It is intended for the CMTAT/RuleEngine spender-aware integration path.
+
+### Burns not restricted
+
+Burns (`to == address(0)`) are not tracked by this rule. Minters do not recover allowance when tokens are burned.
+
+## Usage scenario
+
+An issuer deploys `RuleMintAllowance` and grants `ALLOWANCE_OPERATOR_ROLE` to a compliance officer. The officer assigns `setMintAllowance(alice, 100_000e18)` — Alice may mint up to 100 000 tokens. Each `cmtat.mint(recipient, amount)` call by Alice reduces her quota. Once exhausted, further mints by Alice revert. The officer can call `increaseMintAllowance(alice, 50_000e18)` to extend Alice's quota or `setMintAllowance(alice, 0)` to revoke it entirely.
diff --git a/lib/CMTAT b/lib/CMTAT
index 49544f4..580d477 160000
--- a/lib/CMTAT
+++ b/lib/CMTAT
@@ -1 +1 @@
-Subproject commit 49544f4de1993008acfc9e848d0bf03bd31d8579
+Subproject commit 580d4776e4cbb857b2da7d83fd79144ae7e47557
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/script/DeployCMTATWithBlacklist.s.sol b/script/DeployCMTATWithBlacklist.s.sol
index 67b819a..5d446bc 100644
--- a/script/DeployCMTATWithBlacklist.s.sol
+++ b/script/DeployCMTATWithBlacklist.s.sol
@@ -2,13 +2,13 @@
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
contract DeployCMTATWithBlacklist is Script {
- function deploy(address admin, address forwarder) public returns (CMTATStandalone token, RuleBlacklist rule) {
+ function deploy(address admin, address forwarder) public returns (CMTATStandardStandalone token, RuleBlacklist rule) {
ICMTATConstructor.ERC20Attributes memory erc20Attributes =
ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
@@ -21,7 +21,7 @@ contract DeployCMTATWithBlacklist is Script {
);
ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
- token = new CMTATStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
+ token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
rule = new RuleBlacklist(admin, address(0));
token.setRuleEngine(IRuleEngine(address(rule)));
@@ -32,7 +32,7 @@ contract DeployCMTATWithBlacklist is Script {
}
}
- function run() external returns (CMTATStandalone token, RuleBlacklist rule) {
+ function run() external returns (CMTATStandardStandalone token, RuleBlacklist rule) {
vm.startBroadcast();
(token, rule) = deploy(msg.sender, address(0));
vm.stopBroadcast();
diff --git a/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol b/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
index 6579cf6..3515894 100644
--- a/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
+++ b/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
@@ -2,7 +2,7 @@
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
@@ -16,7 +16,7 @@ import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
* a blacklist (RuleBlacklist) and a sanctions screening (RuleSanctionsList).
*
* Deployment order:
- * 1. CMTATStandalone — token contract (deployer as temporary admin)
+ * 1. CMTATStandardStandalone — token contract (deployer as temporary admin)
* 2. RuleBlacklist — blocks blacklisted senders / recipients
* 3. RuleSanctionsList — blocks sanctioned addresses via Chainalysis oracle
* 4. RuleEngine — aggregates both rules; token bound at construction
@@ -27,7 +27,7 @@ contract DeployCMTATWithBlacklistAndSanctionsList is Script {
function deploy(address admin, address forwarder, ISanctionsList sanctionsOracle)
public
returns (
- CMTATStandalone token,
+ CMTATStandardStandalone token,
RuleEngine ruleEngine,
RuleBlacklist ruleBlacklist,
RuleSanctionsList ruleSanctionsList
@@ -46,7 +46,7 @@ contract DeployCMTATWithBlacklistAndSanctionsList is Script {
ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
// Deploy CMTAT with the deployer as temporary admin so we can configure it.
- token = new CMTATStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
+ token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
// Deploy rules; each rule is owned directly by the intended admin.
ruleBlacklist = new RuleBlacklist(admin, address(0));
@@ -75,7 +75,7 @@ contract DeployCMTATWithBlacklistAndSanctionsList is Script {
function run()
external
returns (
- CMTATStandalone token,
+ CMTATStandardStandalone token,
RuleEngine ruleEngine,
RuleBlacklist ruleBlacklist,
RuleSanctionsList ruleSanctionsList
diff --git a/script/DeployCMTATWithWhitelist.s.sol b/script/DeployCMTATWithWhitelist.s.sol
index e01f4fd..63fb6cc 100644
--- a/script/DeployCMTATWithWhitelist.s.sol
+++ b/script/DeployCMTATWithWhitelist.s.sol
@@ -2,7 +2,7 @@
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
@@ -10,7 +10,7 @@ import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
contract DeployCMTATWithWhitelist is Script {
function deploy(address admin, address forwarder, bool checkSpender)
public
- returns (CMTATStandalone token, RuleWhitelist rule)
+ returns (CMTATStandardStandalone token, RuleWhitelist rule)
{
ICMTATConstructor.ERC20Attributes memory erc20Attributes =
ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
@@ -24,7 +24,7 @@ contract DeployCMTATWithWhitelist is Script {
);
ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
- token = new CMTATStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
+ token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
rule = new RuleWhitelist(admin, address(0), checkSpender, false);
token.setRuleEngine(IRuleEngine(address(rule)));
@@ -35,7 +35,7 @@ contract DeployCMTATWithWhitelist is Script {
}
}
- function run() external returns (CMTATStandalone token, RuleWhitelist rule) {
+ function run() external returns (CMTATStandardStandalone token, RuleWhitelist rule) {
vm.startBroadcast();
(token, rule) = deploy(msg.sender, address(0), false);
vm.stopBroadcast();
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/RuleMintAllowance.sol b/src/rules/operation/RuleMintAllowance.sol
new file mode 100644
index 0000000..a676fda
--- /dev/null
+++ b/src/rules/operation/RuleMintAllowance.sol
@@ -0,0 +1,60 @@
+// 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 {RuleMintAllowanceBase} from "./abstract/RuleMintAllowanceBase.sol";
+
+/**
+ * @title RuleMintAllowance
+ * @notice AccessControl variant of RuleMintAllowance.
+ * `DEFAULT_ADMIN_ROLE` implicitly holds all roles.
+ * `ALLOWANCE_OPERATOR_ROLE` can set, increase, and decrease per-minter allowances.
+ * `COMPLIANCE_MANAGER_ROLE` can bind/unbind the rule to a RuleEngine.
+ */
+contract RuleMintAllowance is AccessControlModuleStandalone, RuleMintAllowanceBase {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param admin Address of the contract admin.
+ */
+ constructor(address admin) AccessControlModuleStandalone(admin) {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ 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);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ function _authorizeSetMintAllowance() internal view virtual override onlyRole(ALLOWANCE_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/RuleMintAllowanceOwnable2Step.sol b/src/rules/operation/RuleMintAllowanceOwnable2Step.sol
new file mode 100644
index 0000000..230fb69
--- /dev/null
+++ b/src/rules/operation/RuleMintAllowanceOwnable2Step.sol
@@ -0,0 +1,57 @@
+// 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 {RuleMintAllowanceBase} from "./abstract/RuleMintAllowanceBase.sol";
+import {Ownable2StepERC165Module} from "../../modules/Ownable2StepERC165Module.sol";
+
+/**
+ * @title RuleMintAllowanceOwnable2Step
+ * @notice Ownable2Step variant of RuleMintAllowance.
+ * The owner manages all allowances and compliance bindings.
+ */
+contract RuleMintAllowanceOwnable2Step is RuleMintAllowanceBase, Ownable2Step, Ownable2StepERC165Module {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param owner Address of the contract owner.
+ */
+ constructor(address owner) Ownable(owner) {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ 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;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ function _authorizeSetMintAllowance() 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/operation/abstract/RuleMintAllowanceBase.sol b/src/rules/operation/abstract/RuleMintAllowanceBase.sol
new file mode 100644
index 0000000..2825f37
--- /dev/null
+++ b/src/rules/operation/abstract/RuleMintAllowanceBase.sol
@@ -0,0 +1,231 @@
+// 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 {VersionModule} from "../../../modules/VersionModule.sol";
+import {RuleMintAllowanceInvariantStorage} from "./RuleMintAllowanceInvariantStorage.sol";
+
+/**
+ * @title RuleMintAllowanceBase
+ * @notice Core logic for per-minter mint quota enforcement.
+ * @dev Operators set the number of tokens each minter address is allowed to mint.
+ * Each mint reduces the minter's allowance. Allowances can be set to an absolute
+ * value or adjusted incrementally via `increaseMintAllowance`/`decreaseMintAllowance`.
+ *
+ * The rule tracks mints via the 4-arg `transferred(spender, from=0, to, value)` path
+ * introduced in CMTAT v3.3. The 3-arg `transferred(from=0, to, value)` path has no
+ * minter identity and performs no deduction.
+ *
+ * `detectTransferRestriction(from, to, value)` always returns TRANSFER_OK because
+ * the minter identity is unavailable in the 3-arg call; use
+ * `detectTransferRestrictionFrom(minter, address(0), to, amount)` to query allowance.
+ *
+ * Callers of the state-modifying `transferred()` functions must be bound via
+ * `bindToken(ruleEngineAddress)` before minting starts. In a standard CMTAT +
+ * RuleEngine setup the RuleEngine address is the entity to bind.
+ */
+abstract contract RuleMintAllowanceBase is
+ VersionModule,
+ ERC3643ComplianceModule,
+ RuleMintAllowanceInvariantStorage,
+ IRule
+{
+ /*//////////////////////////////////////////////////////////////
+ STATE
+ //////////////////////////////////////////////////////////////*/
+
+ mapping(address minter => uint256 allowance) public mintAllowance;
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ modifier onlyAllowanceOperator() {
+ _authorizeSetMintAllowance();
+ _;
+ }
+
+ function _authorizeSetMintAllowance() internal view virtual;
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override(IRule) returns (bool) {
+ return restrictionCode == CODE_MINTER_ALLOWANCE_EXCEEDED;
+ }
+
+ function created(address, uint256) external virtual override onlyBoundToken {}
+
+ function destroyed(address, uint256) external virtual override onlyBoundToken {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets `minter`'s allowance to an absolute `amount`.
+ */
+ function setMintAllowance(address minter, uint256 amount) public virtual onlyAllowanceOperator {
+ _setMintAllowance(minter, amount);
+ }
+
+ /**
+ * @notice Increases `minter`'s allowance by `amount`.
+ */
+ function increaseMintAllowance(address minter, uint256 amount) public virtual onlyAllowanceOperator {
+ uint256 newAllowance = mintAllowance[minter] + amount;
+ mintAllowance[minter] = newAllowance;
+ emit MintAllowanceIncreased(minter, amount, newAllowance);
+ }
+
+ /**
+ * @notice Decreases `minter`'s allowance by `amount`. Reverts if the reduction
+ * would underflow (i.e. `amount > current allowance`).
+ */
+ function decreaseMintAllowance(address minter, uint256 amount) public virtual onlyAllowanceOperator {
+ uint256 current = mintAllowance[minter];
+ require(amount <= current, RuleMintAllowance_DecreaseBelowZero(minter, current, amount));
+ uint256 newAllowance = current - amount;
+ mintAllowance[minter] = newAllowance;
+ emit MintAllowanceDecreased(minter, amount, newAllowance);
+ }
+
+ /**
+ * @notice Binds a caller to this rule. Reverts if a caller is already bound.
+ * @dev Enforces single-target binding to prevent one allowance state from being
+ * shared across several RuleEngines/tokens. To migrate, call `unbindToken`
+ * first, then bind the new caller.
+ */
+ function bindToken(address token) public virtual override onlyComplianceManager {
+ require(getTokenBound() == address(0), RuleMintAllowance_TokenAlreadyBound());
+ _bindToken(token);
+ }
+
+ function messageForTransferRestriction(uint8 restrictionCode)
+ public
+ pure
+ override(IERC1404)
+ returns (string memory)
+ {
+ if (restrictionCode == CODE_MINTER_ALLOWANCE_EXCEEDED) {
+ return TEXT_MINTER_ALLOWANCE_EXCEEDED;
+ }
+ return TEXT_CODE_NOT_FOUND;
+ }
+
+ /**
+ * @dev 3-arg path: no minter identity available; performs no deduction.
+ * Mints always arrive via the 4-arg path in CMTAT v3.3+.
+ */
+ function transferred(address from, address to, uint256 value)
+ public
+ virtual
+ override(IERC3643IComplianceContract)
+ onlyBoundToken
+ {
+ _transferred(from, to, value);
+ }
+
+ /**
+ * @dev 4-arg path: `spender` is the minter when `from == address(0)`.
+ * Deducts `value` from `mintAllowance[spender]`; reverts if insufficient.
+ */
+ function transferred(address spender, address from, address to, uint256 value)
+ public
+ virtual
+ override(IRuleEngine)
+ onlyBoundToken
+ {
+ _transferredFrom(spender, from, to, value);
+ }
+
+ /**
+ * @dev Always returns TRANSFER_OK: the minter address is not available in the
+ * 3-arg call. Call `detectTransferRestrictionFrom` to check a minter's quota.
+ */
+ function detectTransferRestriction(address, address, uint256)
+ public
+ view
+ virtual
+ override(IERC1404)
+ returns (uint8)
+ {
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ function detectTransferRestrictionFrom(address spender, address from, address to, uint256 value)
+ public
+ view
+ virtual
+ override(IERC1404Extend)
+ returns (uint8)
+ {
+ return _detectTransferRestrictionFrom(spender, from, to, value);
+ }
+
+ /**
+ * @dev Always returns true: use `canTransferFrom` to check mint allowance.
+ */
+ function canTransfer(address, address, uint256)
+ public
+ view
+ virtual
+ override(IERC3643ComplianceRead)
+ returns (bool)
+ {
+ return true;
+ }
+
+ function canTransferFrom(address spender, address from, address to, uint256 value)
+ public
+ view
+ virtual
+ override(IERC7551Compliance)
+ returns (bool)
+ {
+ return detectTransferRestrictionFrom(spender, from, to, value)
+ == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ function _detectTransferRestrictionFrom(address spender, address from, address, uint256 value)
+ internal
+ view
+ virtual
+ returns (uint8)
+ {
+ if (from == address(0) && mintAllowance[spender] < value) {
+ return CODE_MINTER_ALLOWANCE_EXCEEDED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ function _transferred(address, address, uint256) internal virtual {
+ // 3-arg path: no minter identity; regular transfers are not tracked by this rule.
+ }
+
+ function _transferredFrom(address spender, address from, address, uint256 value) internal virtual {
+ if (from != address(0)) {
+ return;
+ }
+ uint256 current = mintAllowance[spender];
+ require(value <= current, RuleMintAllowance_AllowanceExceeded(address(this), spender, current, value));
+ uint256 remaining = current - value;
+ mintAllowance[spender] = remaining;
+ emit MintAllowanceConsumed(spender, value, remaining);
+ }
+
+ function _setMintAllowance(address minter, uint256 amount) internal virtual {
+ mintAllowance[minter] = amount;
+ emit MintAllowanceSet(minter, amount);
+ }
+}
diff --git a/src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol b/src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol
new file mode 100644
index 0000000..c82246e
--- /dev/null
+++ b/src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleSharedInvariantStorage} from "../../validation/abstract/invariant/RuleSharedInvariantStorage.sol";
+
+abstract contract RuleMintAllowanceInvariantStorage is RuleSharedInvariantStorage {
+ /* ============ Role ============ */
+ bytes32 public constant ALLOWANCE_OPERATOR_ROLE = keccak256("ALLOWANCE_OPERATOR_ROLE");
+
+ /* ============ State variables ============ */
+ string constant TEXT_MINTER_ALLOWANCE_EXCEEDED = "MintAllowance: minter allowance exceeded";
+ // It is very important that each rule uses a unique code
+ uint8 public constant CODE_MINTER_ALLOWANCE_EXCEEDED = 70;
+
+ /* ============ Events ============ */
+ event MintAllowanceSet(address indexed minter, uint256 newAllowance);
+ event MintAllowanceIncreased(address indexed minter, uint256 addedAmount, uint256 newAllowance);
+ event MintAllowanceDecreased(address indexed minter, uint256 reducedAmount, uint256 newAllowance);
+ event MintAllowanceConsumed(address indexed minter, uint256 consumed, uint256 remaining);
+
+ /* ============ Custom error ============ */
+ error RuleMintAllowance_AllowanceExceeded(address rule, address minter, uint256 allowance, uint256 amount);
+ error RuleMintAllowance_DecreaseBelowZero(address minter, uint256 currentAllowance, uint256 reductionAmount);
+ error RuleMintAllowance_TokenAlreadyBound();
+}
diff --git a/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol b/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol
index cb37bdd..24c34f9 100644
--- a/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol
+++ b/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol
@@ -61,14 +61,16 @@ abstract contract RuleSpenderWhitelistBase is RuleAddressSet, RuleNFTAdapter, Ru
return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
}
- function _detectTransferRestrictionFrom(address spender, address, address, uint256)
+ function _detectTransferRestrictionFrom(address spender, address from, address to, uint256)
internal
view
virtual
override
returns (uint8)
{
- if (spender != address(0) && !_isAddressListed(spender)) {
+ // Mint (from == address(0)) and burn (to == address(0)) are exempt from the spender check:
+ // the minter/burner acts on its own authority, not as a delegated ERC-20 spender.
+ if (from != address(0) && to != address(0) && !_isAddressListed(spender)) {
return CODE_ADDRESS_SPENDER_NOT_WHITELISTED;
}
return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
diff --git a/src/rules/validation/abstract/base/RuleWhitelistBase.sol b/src/rules/validation/abstract/base/RuleWhitelistBase.sol
index 6cdb6cd..8391872 100644
--- a/src/rules/validation/abstract/base/RuleWhitelistBase.sol
+++ b/src/rules/validation/abstract/base/RuleWhitelistBase.sol
@@ -89,7 +89,9 @@ abstract contract RuleWhitelistBase is RuleAddressSet, RuleWhitelistShared, IIde
override
returns (uint8)
{
- if (checkSpender && !isAddressListed(spender)) {
+ // Mint (from == address(0)) and burn (to == address(0)) are exempt from the spender check:
+ // the minter/burner acts on its own authority, not as a delegated ERC-20 spender.
+ if (checkSpender && from != address(0) && to != address(0) && !isAddressListed(spender)) {
return CODE_ADDRESS_SPENDER_NOT_WHITELISTED;
}
return _detectTransferRestriction(from, to, value);
diff --git a/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol b/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
index 0e3d188..ccfe447 100644
--- a/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
+++ b/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
@@ -124,7 +124,9 @@ abstract contract RuleWhitelistWrapperBase is
override
returns (uint8)
{
- if (!checkSpender) {
+ // Mint (from == address(0)) and burn (to == address(0)) are exempt from the spender check:
+ // the minter/burner acts on its own authority, not as a delegated ERC-20 spender.
+ if (!checkSpender || from == address(0) || to == address(0)) {
return _detectTransferRestriction(from, to, value);
}
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/DeploymentScripts/DeployCMTATWithBlacklist.t.sol b/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
index 30a0cb5..8701f3d 100644
--- a/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
@@ -2,19 +2,19 @@
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
-import {CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {DeployCMTATWithBlacklist} from "script/DeployCMTATWithBlacklist.s.sol";
contract DeployCMTATWithBlacklistTest is Test {
function testDeployCMTATWithBlacklist() public {
DeployCMTATWithBlacklist script = new DeployCMTATWithBlacklist();
- (CMTATStandalone token, RuleBlacklist rule) = _deploy(script);
+ (CMTATStandardStandalone token, RuleBlacklist rule) = _deploy(script);
assertEq(address(token.ruleEngine()), address(rule));
}
- function _deploy(DeployCMTATWithBlacklist script) internal returns (CMTATStandalone token, RuleBlacklist rule) {
+ function _deploy(DeployCMTATWithBlacklist script) internal returns (CMTATStandardStandalone token, RuleBlacklist rule) {
(token, rule) = script.deploy(address(1), address(0));
}
}
diff --git a/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol b/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
index 35d1f9a..e269aa0 100644
--- a/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
@@ -2,7 +2,7 @@
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
-import {CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {RuleSanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
@@ -39,7 +39,7 @@ contract DeployCMTATWithBlacklistAndSanctionsListTest is
uint8 constant TRANSFER_OK = 0;
uint256 constant INITIAL_BALANCE = 100;
- CMTATStandalone token;
+ CMTATStandardStandalone token;
RuleEngine ruleEngine;
RuleBlacklist ruleBlacklist;
RuleSanctionsList ruleSanctionsList;
@@ -255,8 +255,9 @@ contract DeployCMTATWithBlacklistAndSanctionsListTest is
vm.expectRevert(
abi.encodeWithSelector(
- RuleBlacklist_InvalidTransfer.selector,
+ RuleBlacklist_InvalidTransferFrom.selector,
address(ruleBlacklist),
+ ADMIN,
address(0),
ADDRESS3,
amount,
diff --git a/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol b/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
index ded0513..d7987b3 100644
--- a/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
@@ -2,18 +2,18 @@
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
-import {CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
import {DeployCMTATWithWhitelist} from "script/DeployCMTATWithWhitelist.s.sol";
contract DeployCMTATWithWhitelistTest is Test {
function testDeployCMTATWithWhitelist() public {
DeployCMTATWithWhitelist script = new DeployCMTATWithWhitelist();
- (CMTATStandalone token, RuleWhitelist rule) = _deploy(script);
+ (CMTATStandardStandalone token, RuleWhitelist rule) = _deploy(script);
assertEq(address(token.ruleEngine()), address(rule));
}
- function _deploy(DeployCMTATWithWhitelist script) internal returns (CMTATStandalone token, RuleWhitelist rule) {
+ function _deploy(DeployCMTATWithWhitelist script) internal returns (CMTATStandardStandalone token, RuleWhitelist rule) {
(token, rule) = script.deploy(address(1), address(0), false);
}
}
diff --git a/test/HelperContract.sol b/test/HelperContract.sol
index d7a11a0..bca9d7f 100644
--- a/test/HelperContract.sol
+++ b/test/HelperContract.sol
@@ -1,7 +1,7 @@
//SPDX-License-Identifier: MPL-2.0
pragma solidity ^0.8.20;
-import {CMTATStandalone} from "CMTAT/deployment/CMTATStandalone.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
// RuleEngine
import {RuleEngineInvariantStorage} from "RuleEngine/modules/library/RuleEngineInvariantStorage.sol";
@@ -21,6 +21,10 @@ import {RuleConditionalTransferLight} from "src/rules/operation/RuleConditionalT
import {
RuleConditionalTransferLightInvariantStorage
} from "src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol";
+// RuleMintAllowance
+import {
+ RuleMintAllowanceInvariantStorage
+} from "src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol";
import {
RuleWhitelistInvariantStorage
} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol";
@@ -39,7 +43,7 @@ import {
} from "src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol";
// utils
-import {CMTATDeployment} from "RuleEngine/../test/utils/CMTATDeployment.sol";
+import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
/**
* @title Constants used by the tests
@@ -52,6 +56,7 @@ abstract contract HelperContract is
RuleMaxTotalSupplyInvariantStorage,
RuleIdentityRegistryInvariantStorage,
RuleConditionalTransferLightInvariantStorage,
+ RuleMintAllowanceInvariantStorage,
RuleEngineInvariantStorage
{
// Test result
@@ -87,7 +92,7 @@ abstract contract HelperContract is
// CMTAT
CMTATDeployment cmtatDeployment;
- CMTATStandalone internal cmtatContract;
+ CMTATStandardStandalone internal cmtatContract;
// RuleEngine Mock
RuleEngine public ruleEngineMock;
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/RuleBlacklist/CMTATIntegration.t.sol b/test/RuleBlacklist/CMTATIntegration.t.sol
index 8bd6a1a..cb3d033 100644
--- a/test/RuleBlacklist/CMTATIntegration.t.sol
+++ b/test/RuleBlacklist/CMTATIntegration.t.sol
@@ -4,7 +4,7 @@ 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 {CMTATDeployment} from "RuleEngine/../test/utils/CMTATDeployment.sol";
+import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
/**
@@ -207,8 +207,9 @@ contract CMTATIntegration is Test, HelperContract {
// Act
vm.expectRevert(
abi.encodeWithSelector(
- RuleBlacklist_InvalidTransfer.selector,
+ RuleBlacklist_InvalidTransferFrom.selector,
address(ruleBlacklist),
+ DEFAULT_ADMIN_ADDRESS,
ZERO_ADDRESS,
ADDRESS1,
amount,
diff --git a/test/RuleBlacklist/CMTATIntegrationDirect.t.sol b/test/RuleBlacklist/CMTATIntegrationDirect.t.sol
index 9e47b29..9e9f715 100644
--- a/test/RuleBlacklist/CMTATIntegrationDirect.t.sol
+++ b/test/RuleBlacklist/CMTATIntegrationDirect.t.sol
@@ -3,7 +3,7 @@ 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 {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
@@ -178,8 +178,9 @@ contract CMTATIntegrationDirectBlacklist is Test, HelperContract {
vm.expectRevert(
abi.encodeWithSelector(
- RuleBlacklist_InvalidTransfer.selector,
+ RuleBlacklist_InvalidTransferFrom.selector,
address(ruleBlacklist),
+ DEFAULT_ADMIN_ADDRESS,
ZERO_ADDRESS,
ADDRESS1,
amount,
diff --git a/test/RuleConditionalTransferLightMultiToken/CMTATIntegrationDirectMultiToken.t.sol b/test/RuleConditionalTransferLightMultiToken/CMTATIntegrationDirectMultiToken.t.sol
new file mode 100644
index 0000000..7c67eb3
--- /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 "test/utils/CMTATDeployment.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol";
+
+contract CMTATIntegrationDirectMultiToken is Test, HelperContract {
+ CMTATStandardStandalone private tokenA;
+ CMTATStandardStandalone 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/RuleMintAllowance/CMTATIntegration.t.sol b/test/RuleMintAllowance/CMTATIntegration.t.sol
new file mode 100644
index 0000000..00ff6c6
--- /dev/null
+++ b/test/RuleMintAllowance/CMTATIntegration.t.sol
@@ -0,0 +1,197 @@
+// 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 "test/utils/CMTATDeployment.sol";
+import {RuleMintAllowance} from "src/rules/operation/RuleMintAllowance.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+
+/**
+ * @title End-to-end integration test: CMTAT + RuleEngine + RuleMintAllowance
+ * @notice Verifies that the mint allowance is enforced through the full CMTAT call chain.
+ * In CMTAT v3.3+ the minter address is passed as `spender` when the rule engine
+ * calls `transferred(spender, address(0), to, value)`.
+ */
+contract CMTATIntegration is Test, HelperContract {
+ address constant MINTER = address(10);
+
+ RuleMintAllowance private mintAllowanceRule;
+
+ function setUp() public {
+ cmtatDeployment = new CMTATDeployment();
+ cmtatContract = cmtatDeployment.cmtat();
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ ruleEngineMock = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, address(cmtatContract));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule = new RuleMintAllowance(DEFAULT_ADMIN_ADDRESS);
+
+ // Bind the rule to the RuleEngine (the entity that calls transferred() on the rule)
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.bindToken(address(ruleEngineMock));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ ruleEngineMock.addRule(mintAllowanceRule);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.setRuleEngine(ruleEngineMock);
+
+ // Grant minter role on CMTAT to MINTER
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.grantRole(keccak256("MINTER_ROLE"), MINTER);
+ }
+
+ function testMintSucceedsWithSufficientAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 500);
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 300);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 300);
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 200);
+ }
+
+ function testMintRevertsWhenAllowanceExceeded() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 100);
+
+ vm.prank(MINTER);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleMintAllowance_AllowanceExceeded.selector, address(mintAllowanceRule), MINTER, 100, 101
+ )
+ );
+ cmtatContract.mint(ADDRESS1, 101);
+ }
+
+ function testMintRevertsWithZeroAllowance() public {
+ // Default allowance is 0
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, 1);
+ }
+
+ function testBatchMintSucceedsWithinCumulativeAllowance() public {
+ address[] memory accounts = new address[](2);
+ accounts[0] = ADDRESS1;
+ accounts[1] = ADDRESS2;
+
+ uint256[] memory values = new uint256[](2);
+ values[0] = 300;
+ values[1] = 200;
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 500);
+
+ vm.prank(MINTER);
+ cmtatContract.batchMint(accounts, values);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 300);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 200);
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 0);
+ }
+
+ function testBatchMintRevertsAndRollsBackWhenCumulativeAllowanceExceeded() public {
+ address[] memory accounts = new address[](2);
+ accounts[0] = ADDRESS1;
+ accounts[1] = ADDRESS2;
+
+ uint256[] memory values = new uint256[](2);
+ values[0] = 300;
+ values[1] = 201;
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 500);
+
+ vm.prank(MINTER);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleMintAllowance_AllowanceExceeded.selector, address(mintAllowanceRule), MINTER, 200, 201
+ )
+ );
+ cmtatContract.batchMint(accounts, values);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 0);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 0);
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 500);
+ }
+
+ function testMultipleMintersSeparateAllowances() public {
+ address minter2 = address(11);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.grantRole(keccak256("MINTER_ROLE"), minter2);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 500);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(minter2, 200);
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 500);
+ vm.prank(minter2);
+ cmtatContract.mint(ADDRESS2, 200);
+
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 0);
+ assertEq(mintAllowanceRule.mintAllowance(minter2), 0);
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 500);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 200);
+ }
+
+ function testDetectTransferRestrictionFromViaCMTAT() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 100);
+
+ // Sufficient allowance
+ assertEq(cmtatContract.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 100), TRANSFER_OK);
+ assertTrue(cmtatContract.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 100));
+
+ // Exceeds allowance
+ assertEq(
+ cmtatContract.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 101),
+ CODE_MINTER_ALLOWANCE_EXCEEDED
+ );
+ assertFalse(cmtatContract.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 101));
+ }
+
+ function testRegularTransfersAreNotRestricted() public {
+ // Set up balances via admin mint (admin has unlimited system-level access before rule kicks in)
+ // We need to give admin an allowance to mint first
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(DEFAULT_ADMIN_ADDRESS, 1000);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(ADDRESS1, 500);
+
+ // Regular transfer should not be restricted by mint allowance rule
+ assertTrue(cmtatContract.canTransfer(ADDRESS1, ADDRESS2, 100));
+ assertEq(cmtatContract.detectTransferRestriction(ADDRESS1, ADDRESS2, 100), TRANSFER_OK);
+
+ vm.prank(ADDRESS1);
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ cmtatContract.transfer(ADDRESS2, 100);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 100);
+ }
+
+ function testAllowanceCanBeIncreasedAfterExhaustion() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.setMintAllowance(MINTER, 100);
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 0);
+
+ // Next mint fails
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, 1);
+
+ // Operator increases allowance
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ mintAllowanceRule.increaseMintAllowance(MINTER, 50);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 50);
+ assertEq(mintAllowanceRule.mintAllowance(MINTER), 0);
+ }
+}
diff --git a/test/RuleMintAllowance/RuleMintAllowance.t.sol b/test/RuleMintAllowance/RuleMintAllowance.t.sol
new file mode 100644
index 0000000..225aa01
--- /dev/null
+++ b/test/RuleMintAllowance/RuleMintAllowance.t.sol
@@ -0,0 +1,367 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {RuleMintAllowance} from "src/rules/operation/RuleMintAllowance.sol";
+import {AccessControlModuleStandalone} from "src/modules/AccessControlModuleStandalone.sol";
+import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.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 "src/mocks/IERC3643ComplianceFull.sol";
+
+contract RuleMintAllowanceTest is Test, HelperContract {
+ uint8 internal constant CODE_ALLOWANCE_EXCEEDED = 70;
+ string internal constant TEXT_ALLOWANCE_EXCEEDED = "MintAllowance: minter allowance exceeded";
+
+ address constant OPERATOR = address(10);
+ address constant MINTER = address(11);
+ address constant BOUND_ENGINE = address(12);
+
+ RuleMintAllowance private rule;
+
+ function setUp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleMintAllowance(DEFAULT_ADMIN_ADDRESS);
+ // Bind a fake engine so transferred() calls succeed
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(BOUND_ENGINE);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DEPLOYMENT
+ //////////////////////////////////////////////////////////////*/
+
+ function testCannotDeployWithZeroAdmin() public {
+ vm.expectRevert(AccessControlModuleStandalone.AccessControlModuleStandalone_AddressZeroNotAllowed.selector);
+ new RuleMintAllowance(ZERO_ADDRESS);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ALLOWANCE MANAGEMENT
+ //////////////////////////////////////////////////////////////*/
+
+ function testSetMintAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+ }
+
+ function testSetMintAllowanceEmitsEvent() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectEmit(true, false, false, true);
+ emit MintAllowanceSet(MINTER, 1000);
+ rule.setMintAllowance(MINTER, 1000);
+ }
+
+ function testSetMintAllowanceOverridesExisting() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 500);
+ assertEq(rule.mintAllowance(MINTER), 500);
+ }
+
+ function testIncreaseMintAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 500);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.increaseMintAllowance(MINTER, 300);
+ assertEq(rule.mintAllowance(MINTER), 800);
+ }
+
+ function testIncreaseMintAllowanceEmitsEvent() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 500);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectEmit(true, false, false, true);
+ emit MintAllowanceIncreased(MINTER, 300, 800);
+ rule.increaseMintAllowance(MINTER, 300);
+ }
+
+ function testDecreaseMintAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.decreaseMintAllowance(MINTER, 400);
+ assertEq(rule.mintAllowance(MINTER), 600);
+ }
+
+ function testDecreaseMintAllowanceEmitsEvent() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectEmit(true, false, false, true);
+ emit MintAllowanceDecreased(MINTER, 400, 600);
+ rule.decreaseMintAllowance(MINTER, 400);
+ }
+
+ function testDecreaseMintAllowanceRevertsOnUnderflow() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectRevert(abi.encodeWithSelector(RuleMintAllowance_DecreaseBelowZero.selector, MINTER, 100, 200));
+ rule.decreaseMintAllowance(MINTER, 200);
+ }
+
+ function testDecreaseMintAllowanceToExactZero() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.decreaseMintAllowance(MINTER, 100);
+ assertEq(rule.mintAllowance(MINTER), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ function testOnlyOperatorCanSetAllowance() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.setMintAllowance(MINTER, 1000);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.grantRole(ALLOWANCE_OPERATOR_ROLE, OPERATOR);
+ vm.prank(OPERATOR);
+ rule.setMintAllowance(MINTER, 1000);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+ }
+
+ function testOnlyOperatorCanIncrease() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.increaseMintAllowance(MINTER, 50);
+ }
+
+ function testOnlyOperatorCanDecrease() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.decreaseMintAllowance(MINTER, 50);
+ }
+
+ function testDefaultAdminHasOperatorRole() public {
+ assertTrue(rule.hasRole(ALLOWANCE_OPERATOR_ROLE, DEFAULT_ADMIN_ADDRESS));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DETECT TRANSFER RESTRICTION
+ //////////////////////////////////////////////////////////////*/
+
+ function testDetectTransferRestrictionAlwaysOk() public view {
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 100), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 100), TRANSFER_OK);
+ }
+
+ function testCanTransferAlwaysTrue() public view {
+ assertTrue(rule.canTransfer(ADDRESS1, ADDRESS2, 100));
+ assertTrue(rule.canTransfer(ZERO_ADDRESS, ADDRESS2, 100));
+ }
+
+ function testDetectTransferRestrictionFromMintWithSufficientAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 500);
+ assertEq(rule.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 500), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 500));
+ }
+
+ function testDetectTransferRestrictionFromMintExceedsAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+ assertEq(rule.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 101), CODE_ALLOWANCE_EXCEEDED);
+ assertFalse(rule.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 101));
+ }
+
+ function testDetectTransferRestrictionFromRegularTransferAlwaysOk() public view {
+ // Non-mint transfers are always OK regardless of allowance
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10000), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10000));
+ }
+
+ function testDetectTransferRestrictionFromBurnAlwaysOk() public view {
+ // Burns (to == address(0)) are not restricted
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS1, ADDRESS1, ZERO_ADDRESS, 10000), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(ADDRESS1, ADDRESS1, ZERO_ADDRESS, 10000));
+ }
+
+ function testZeroAllowanceMintBlocked() public view {
+ // Default allowance is 0; any mint amount should be blocked
+ assertEq(rule.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 1), CODE_ALLOWANCE_EXCEEDED);
+ assertFalse(rule.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS1, 1));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TRANSFERRED (STATE)
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferredFourArgConsumesAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 400);
+ assertEq(rule.mintAllowance(MINTER), 600);
+ }
+
+ function testTransferredFourArgEmitsConsumedEvent() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(BOUND_ENGINE);
+ vm.expectEmit(true, false, false, true);
+ emit MintAllowanceConsumed(MINTER, 400, 600);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 400);
+ }
+
+ function testTransferredFourArgRevertsOnExceedAllowance() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+ vm.prank(BOUND_ENGINE);
+ vm.expectRevert(
+ abi.encodeWithSelector(RuleMintAllowance_AllowanceExceeded.selector, address(rule), MINTER, 100, 101)
+ );
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 101);
+ }
+
+ function testTransferredFourArgRegularTransferNoOp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ // Regular transfer (from != address(0)) should not affect allowance
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(ADDRESS3, ADDRESS1, ADDRESS2, 500);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+ }
+
+ function testTransferredThreeArgNoOp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ // 3-arg path: no deduction even for from == address(0)
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(ZERO_ADDRESS, ADDRESS1, 500);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+ }
+
+ function testTransferredOnlyBoundTokenCan3Arg() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+ }
+
+ function testTransferredOnlyBoundTokenCan4Arg() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 100);
+ }
+
+ function testMultipleMintConsumesSequentially() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 300);
+ assertEq(rule.mintAllowance(MINTER), 700);
+
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS2, 700);
+ assertEq(rule.mintAllowance(MINTER), 0);
+
+ // Next mint must fail
+ vm.prank(BOUND_ENGINE);
+ vm.expectRevert();
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 1);
+ }
+
+ function testSetAllowanceResetsAfterExhaustion() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 100);
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 100);
+ assertEq(rule.mintAllowance(MINTER), 0);
+
+ // Operator resets the allowance
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 200);
+ assertEq(rule.mintAllowance(MINTER), 200);
+
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 200);
+ assertEq(rule.mintAllowance(MINTER), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ RESTRICTION CODE & MESSAGE
+ //////////////////////////////////////////////////////////////*/
+
+ function testCanReturnRestrictionCode() public view {
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_ALLOWANCE_EXCEEDED));
+ assertFalse(rule.canReturnTransferRestrictionCode(CODE_NONEXISTENT));
+ }
+
+ function testMessageForTransferRestriction() public view {
+ assertEq(rule.messageForTransferRestriction(CODE_ALLOWANCE_EXCEEDED), TEXT_ALLOWANCE_EXCEEDED);
+ assertEq(rule.messageForTransferRestriction(CODE_NONEXISTENT), TEXT_CODE_NOT_FOUND);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ERC-165
+ //////////////////////////////////////////////////////////////*/
+
+ function testSupportsInterface() public view {
+ assertTrue(rule.supportsInterface(type(IAccessControl).interfaceId));
+ assertTrue(rule.supportsInterface(RuleInterfaceId.IRULE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(type(IERC7551Compliance).interfaceId));
+ assertTrue(rule.supportsInterface(type(IERC3643ComplianceFull).interfaceId));
+ assertFalse(rule.supportsInterface(bytes4(0xdeadbeef)));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ COMPLIANCE MODULE (BINDING)
+ //////////////////////////////////////////////////////////////*/
+
+ function testBindTokenOnlyComplianceManager() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.bindToken(ADDRESS2);
+
+ vm.expectRevert(RuleMintAllowance_TokenAlreadyBound.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(ADDRESS2);
+ }
+
+ function testBindTokenAfterUnbind() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.unbindToken(BOUND_ENGINE);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(ADDRESS2);
+ assertTrue(rule.isTokenBound(ADDRESS2));
+ }
+
+ function testUnbindToken() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.unbindToken(BOUND_ENGINE);
+ assertFalse(rule.isTokenBound(BOUND_ENGINE));
+ }
+
+ function testCreatedAndDestroyedAreNoOps() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(BOUND_ENGINE);
+ rule.created(ADDRESS1, 500);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+
+ vm.prank(BOUND_ENGINE);
+ rule.destroyed(ADDRESS1, 300);
+ assertEq(rule.mintAllowance(MINTER), 1000);
+ }
+}
diff --git a/test/RuleMintAllowance/RuleMintAllowanceOwnable2Step.t.sol b/test/RuleMintAllowance/RuleMintAllowanceOwnable2Step.t.sol
new file mode 100644
index 0000000..3568864
--- /dev/null
+++ b/test/RuleMintAllowance/RuleMintAllowanceOwnable2Step.t.sol
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {RuleMintAllowanceOwnable2Step} from "src/rules/operation/RuleMintAllowanceOwnable2Step.sol";
+import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol";
+import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol";
+import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol";
+import {OwnableInterfaceId} from "RuleEngine/modules/library/OwnableInterfaceId.sol";
+import {Ownable2StepInterfaceId} from "RuleEngine/modules/library/Ownable2StepInterfaceId.sol";
+import {IERC7551Compliance} from "CMTAT/interfaces/tokenization/draft-IERC7551.sol";
+import {IERC3643ComplianceFull} from "src/mocks/IERC3643ComplianceFull.sol";
+
+contract RuleMintAllowanceOwnable2StepTest is Test, HelperContract {
+ address constant OWNER = address(1);
+ address constant MINTER = address(11);
+ address constant BOUND_ENGINE = address(12);
+
+ RuleMintAllowanceOwnable2Step private rule;
+
+ function setUp() public {
+ vm.prank(OWNER);
+ rule = new RuleMintAllowanceOwnable2Step(OWNER);
+ vm.prank(OWNER);
+ rule.bindToken(BOUND_ENGINE);
+ }
+
+ function testOwnerCanSetAllowance() public {
+ vm.prank(OWNER);
+ rule.setMintAllowance(MINTER, 500);
+ assertEq(rule.mintAllowance(MINTER), 500);
+ }
+
+ function testNonOwnerCannotSetAllowance() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.setMintAllowance(MINTER, 500);
+ }
+
+ function testOwnerCanIncreaseAndDecrease() public {
+ vm.prank(OWNER);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(OWNER);
+ rule.increaseMintAllowance(MINTER, 200);
+ assertEq(rule.mintAllowance(MINTER), 1200);
+ vm.prank(OWNER);
+ rule.decreaseMintAllowance(MINTER, 300);
+ assertEq(rule.mintAllowance(MINTER), 900);
+ }
+
+ function testOwnerCanBindUnbind() public {
+ vm.expectRevert(RuleMintAllowance_TokenAlreadyBound.selector);
+ vm.prank(OWNER);
+ rule.bindToken(ADDRESS2);
+
+ vm.prank(OWNER);
+ rule.unbindToken(BOUND_ENGINE);
+
+ vm.prank(OWNER);
+ rule.bindToken(ADDRESS2);
+ assertTrue(rule.isTokenBound(ADDRESS2));
+ vm.prank(OWNER);
+ rule.unbindToken(ADDRESS2);
+ assertFalse(rule.isTokenBound(ADDRESS2));
+ }
+
+ function testNonOwnerCannotBind() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.bindToken(ADDRESS2);
+ }
+
+ function testTransferredConsumesAllowance() public {
+ vm.prank(OWNER);
+ rule.setMintAllowance(MINTER, 1000);
+ vm.prank(BOUND_ENGINE);
+ rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS1, 300);
+ assertEq(rule.mintAllowance(MINTER), 700);
+ }
+
+ function testOwnershipTransferTwoStep() public {
+ vm.prank(OWNER);
+ rule.transferOwnership(ADDRESS1);
+ assertEq(rule.pendingOwner(), ADDRESS1);
+ assertEq(rule.owner(), OWNER);
+
+ vm.prank(ADDRESS1);
+ rule.acceptOwnership();
+ assertEq(rule.owner(), ADDRESS1);
+ }
+
+ function testSupportsInterface() public view {
+ assertTrue(rule.supportsInterface(OwnableInterfaceId.IERC173_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(Ownable2StepInterfaceId.IOWNABLE2STEP_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(RuleInterfaceId.IRULE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(type(IERC7551Compliance).interfaceId));
+ assertTrue(rule.supportsInterface(type(IERC3643ComplianceFull).interfaceId));
+ assertFalse(rule.supportsInterface(bytes4(0xdeadbeef)));
+ }
+}
diff --git a/test/RuleSpenderWhitelist/RuleSpenderWhitelist.t.sol b/test/RuleSpenderWhitelist/RuleSpenderWhitelist.t.sol
index 075475b..e1aaad1 100644
--- a/test/RuleSpenderWhitelist/RuleSpenderWhitelist.t.sol
+++ b/test/RuleSpenderWhitelist/RuleSpenderWhitelist.t.sol
@@ -40,6 +40,20 @@ contract RuleSpenderWhitelistTest is Test, HelperContract {
rule.transferred(ADDRESS3, ADDRESS1, ADDRESS2, 10);
}
+ function testMintExemptFromSpenderCheck() public {
+ // Minter (spender) not whitelisted — mint must still succeed because from == address(0).
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS2, 10), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS2, 10));
+ rule.transferred(ADDRESS3, ZERO_ADDRESS, ADDRESS2, 10);
+ }
+
+ function testBurnExemptFromSpenderCheck() public {
+ // Burner (spender) not whitelisted — burn must still succeed because to == address(0).
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ZERO_ADDRESS, 10), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(ADDRESS3, ADDRESS1, ZERO_ADDRESS, 10));
+ rule.transferred(ADDRESS3, ADDRESS1, ZERO_ADDRESS, 10);
+ }
+
function testTransferFromAllowedWhenSpenderWhitelisted() public {
vm.prank(DEFAULT_ADMIN_ADDRESS);
rule.addAddress(ADDRESS3);
diff --git a/test/RuleWhitelist/CMTATIntegration.t.sol b/test/RuleWhitelist/CMTATIntegration.t.sol
index c861294..06c14e4 100644
--- a/test/RuleWhitelist/CMTATIntegration.t.sol
+++ b/test/RuleWhitelist/CMTATIntegration.t.sol
@@ -3,7 +3,7 @@ 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 {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
diff --git a/test/RuleWhitelist/CMTATIntegrationWhitelistWrapper.t.sol b/test/RuleWhitelist/CMTATIntegrationWhitelistWrapper.t.sol
index a102d90..1ebf60a 100644
--- a/test/RuleWhitelist/CMTATIntegrationWhitelistWrapper.t.sol
+++ b/test/RuleWhitelist/CMTATIntegrationWhitelistWrapper.t.sol
@@ -3,7 +3,7 @@ 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 {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
import {RuleWhitelistWrapper} from "src/rules/validation/deployment/RuleWhitelistWrapper.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
diff --git a/test/RuleWhitelist/CMTATRuleEngineIntegration.t.sol b/test/RuleWhitelist/CMTATRuleEngineIntegration.t.sol
index 6a5794d..1c5cd76 100644
--- a/test/RuleWhitelist/CMTATRuleEngineIntegration.t.sol
+++ b/test/RuleWhitelist/CMTATRuleEngineIntegration.t.sol
@@ -3,7 +3,7 @@ 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 {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
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);
diff --git a/test/utils/CMTATDeployment.sol b/test/utils/CMTATDeployment.sol
new file mode 100644
index 0000000..0ce653f
--- /dev/null
+++ b/test/utils/CMTATDeployment.sol
@@ -0,0 +1,32 @@
+//SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
+
+contract CMTATDeployment {
+ // Share with helper contract
+ address constant ZERO_ADDRESS = address(0);
+ address constant DEFAULT_ADMIN_ADDRESS = address(1);
+
+ CMTATStandardStandalone public cmtat;
+
+ constructor() {
+ // CMTAT
+ ICMTATConstructor.ERC20Attributes memory erc20Attributes =
+ ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
+ ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
+ ICMTATConstructor.ExtraInformationAttributes(
+ "CMTAT_ISIN",
+ IERC1643CMTAT.DocumentInfo(
+ "Terms", "https://cmta.ch", 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b
+ ),
+ "CMTAT_info"
+ );
+ ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(ZERO_ADDRESS));
+ cmtat = new CMTATStandardStandalone(
+ ZERO_ADDRESS, DEFAULT_ADMIN_ADDRESS, erc20Attributes, extraInformationAttributes, engines
+ );
+ }
+}