Rules is a collection of on-chain compliance and transfer-restriction rules designed for use with the CMTA RuleEngine and the CMTAT token standard.
Each rule can be used standalone, directly plugged into a CMTAT token, or managed collectively via a RuleEngine.
The RuleEngine is an external smart contract that applies transfer restrictions to security tokens such as CMTAT or ERC-3643-compatible tokens through a RuleEngine.
Rules are modular validator contracts that the RuleEngine or CMTAT compatible token can call on every transfer to ensure regulatory and business-logic compliance.
Current package version: v0.4.0 (contracts report version() → "0.4.0"). Built against CMTAT v3.3.0-rc1 and RuleEngine v3.0.0-rc4; see Compatibility for the supported range.
This project has not undergone an audit and is provided as-is without any warranties.
- Schema
- Overview
- Compatibility
- Specifications
- Architecture
- Types of Rules
- Quick Start
- Deployment Guide
- Rules details
- Access Control
- Toolchains and Usage
- API
- Security
- Intellectual property
- Using rules with CMTAT and ERC-3643 tokens through a RuleEngine
- Using a rule directly with CMTAT and ERC-3643 tokens
- Rules are controllers that validate or modify token transfers.
- They can be applied:
- Directly on CMTAT (no RuleEngine required), or
- Through the RuleEngine (for multi-rule orchestration).
- Rules enforce conditions such as:
- Whitelisting / blacklisting
- Sanctions checks
- Multi-party operator-managed lists
- Conditional approvals
- Arbitrary compliance logic
A rule can be consumed in three ways. All three call the same rule contract; they differ only in who calls it and how much of the compliance interface is required.
| Mode | Caller | What the rule must implement | When to use |
|---|---|---|---|
| Direct CMTAT rule | A CMTAT token calls the rule directly (no RuleEngine) | IRuleEngine (canTransfer + transferred, including the spender-aware overload) |
A single rule is enough; no multi-rule orchestration needed |
| RuleEngine-managed rule | A RuleEngine aggregates one or more rules and calls each on every transfer |
IRule (IRuleEngine + canReturnTransferRestrictionCode) |
Several rules must be combined, ordered, or share restriction codes |
| ERC-3643 through RuleEngine | An ERC-3643 token drives created / destroyed / transfer hooks on a RuleEngine, which forwards them to the rules |
Rules as above; the RuleEngine implements the full ERC-3643 ICompliance |
The token is ERC-3643 and needs full ICompliance — a standalone rule cannot back an ERC-3643 token directly |
Interface details for each mode are documented under Architecture; full signatures live in the API reference.
| Component | Compatible Versions |
|---|---|
| Rules v0.4.0 | CMTAT ≥ v3.0.0 (tested against v3.3.0-rc1) RuleEngine v3.0.0-rc4 |
Spender-aware paths (e.g. RuleMintAllowance) rely on the 4-argument canTransferFrom / transferred(spender, from, to, value) callbacks, which require a CMTAT / RuleEngine that forwards the spender to the rule; this repository is validated against CMTAT v3.3.0-rc1. The other rules only use the 3-argument path and work across the full CMTAT ≥ v3.0.0 range.
Each Rule implements the interface IRuleEngine defined in CMTAT.
This interface declares the ERC-3643 functions transferred (read-write) and canTransfer (read-only) with several other functions related to ERC-1404, ERC-7551 and ERC-3643.
Each rule implements the following functions from the ERC-3643 ICompliance interface
function canTransfer(address _from, address _to, uint256 _amount) external view returns (bool);
function transferred(address _from, address _to, uint256 _amount) external;However, contrary to the RuleEngine, the whole interface is currently not implemented (e.g. created and destroyed) and as a result, the rule cannot directly support ERC-3643 token.
The alternative to use a Rule with an ERC-3643 token is through the RuleEngine, which implements the whole ICompliance interface.
The diagram below shows the recommended integration: the ERC-3643 token drives transfer, mint (created) and burn (destroyed) compliance hooks on the RuleEngine, which forwards them to the rules. A rule used on its own only implements canTransfer + transferred, so it cannot back an ERC-3643 token directly.
Diagram source: doc/img/readme-erc3643-integration.puml.
To improve compatibility with ERC-721 and ERC-1155, most validation rules implement the interface IERC7943NonFungibleComplianceExtend which includes compliance functions with the tokenId argument. Operation rules (such as RuleConditionalTransferLight) are ERC-20 only and do not expose the ERC-721/1155 interfaces. RuleMaxTotalSupply is ERC-20 only as well and does not expose ERC-721/1155 interfaces.
While no rules currently apply restriction on the token id, the validation interfaces can be used to implement flexible restriction on ERC-721 or ERC-1155 tokens.
// IERC7943NonFungibleCompliance interface
// Read-only functions
function canTransfer(address from, address to, uint256 tokenId, uint256 amount) external view returns (bool allowed)
// IERC7943NonFungibleComplianceExtend interface
// Read-only functions
function detectTransferRestriction(address from, address to, uint256 tokenId, uint256 amount) external view returns (uint8 code);
function detectTransferRestrictionFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external view returns (uint8 code);
function canTransferFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external returns (bool allowed);
// State modifying functions (write)
function transferred(address from, address to, uint256 tokenId, uint256 value) external;
function transferred(address spender, address from, address to, uint256 tokenId, uint256 value) external;The diagram below shows a non-fungible transfer flowing through the tokenId-aware compliance signatures. For validation rules a single transferred(...) call both validates and reverts — it internally runs detectTransferRestrictionFrom and requires TRANSFER_OK — so no separate pre-check is required in the transfer path; the read-only detectTransferRestriction* / canTransfer* overloads remain available for off-chain queries. The RuleNFTAdapter carries the tokenId argument but currently delegates to the address-based checks (from / to / spender), so no rule restricts on the token id yet.
Diagram source: doc/img/readme-erc721-erc1155-compliance.puml.
*Basecontracts contain core logic without an access-control policy.*InvariantStoragecontracts group constants, custom errors, and events.*Commoncontracts provide shared helper logic across variants (legacy naming retained for compatibility).
src/modules/: reusable modules shared across rules (AccessControlModuleStandalone,MetaTxModuleStandalone,VersionModule).src/rules/interfaces/: shared interfaces (IAddressList,IIdentityRegistry,ISanctionsList,ITransferContext).src/rules/validation/abstract/: shared base contracts and invariant storage.src/rules/validation/abstract/base/: base contracts with core rule logic (no access control).src/rules/validation/abstract/core/: shared adapters/validation helpers.src/rules/validation/abstract/invariant/: invariant storage contracts (constants, errors, events).src/rules/validation/deployment/: deployable validation rules (concrete contracts).src/rules/operation/: read-write (operation) rules that modify state on transfer.test/: Foundry tests, one folder per rule.script/: deployment scripts.
It is very important that each rule uses a unique code
Here is the list of codes used by the different rules
| Contract | Constant name | Value |
|---|---|---|
| All | TRANSFER_OK (from CMTAT) | 0 |
| RuleWhitelist | CODE_ADDRESS_FROM_NOT_WHITELISTED | 21 |
| CODE_ADDRESS_TO_NOT_WHITELISTED | 22 | |
| CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 23 | |
| CODE_MINT_NOT_ALLOWED | 24 | |
| CODE_BURN_NOT_ALLOWED | 25 | |
| Reserved slot | 26-29 | |
| RuleSanctionList | CODE_ADDRESS_FROM_IS_SANCTIONED | 30 |
| CODE_ADDRESS_TO_IS_SANCTIONED | 31 | |
| CODE_ADDRESS_SPENDER_IS_SANCTIONED | 32 | |
| Reserved slot | 33-35 | |
| RuleBlacklist | CODE_ADDRESS_FROM_IS_BLACKLISTED | 36 |
| CODE_ADDRESS_TO_IS_BLACKLISTED | 37 | |
| CODE_ADDRESS_SPENDER_IS_BLACKLISTED | 38 | |
| Reserved slot | 39-45 | |
| RuleConditionalTransferLight | CODE_TRANSFER_REQUEST_NOT_APPROVED | 46 |
| Reserved slot | 47-49 | |
| RuleMaxTotalSupply | CODE_MAX_TOTAL_SUPPLY_EXCEEDED | 50 |
| Reserved slot | 51-54 | |
| RuleIdentityRegistry | CODE_ADDRESS_FROM_NOT_VERIFIED | 55 |
| CODE_ADDRESS_TO_NOT_VERIFIED | 56 | |
| CODE_ADDRESS_SPENDER_NOT_VERIFIED | 57 | |
| Reserved slot | 58-59 | |
| RuleERC2980 | CODE_ADDRESS_FROM_IS_FROZEN | 60 |
| CODE_ADDRESS_TO_IS_FROZEN | 61 | |
| CODE_ADDRESS_SPENDER_IS_FROZEN | 62 | |
| CODE_ADDRESS_TO_NOT_WHITELISTED | 63 | |
| CODE_MINT_NOT_ALLOWED | 64 | |
| CODE_BURN_NOT_ALLOWED | 65 | |
| RuleSpenderWhitelist | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 66 |
| Reserved slot | 67-69 | |
| RuleMintAllowance | CODE_MINTER_ALLOWANCE_EXCEEDED | 70 |
| Reserved slot | 71-74 |
Note:
- The CMTAT already uses the code 0-6 and the code 7-12 should be left free to allow further additions in the CMTAT.
- If you decide to create your own rules, we encourage you to use code > 100 to leave free the other restriction codes for future rules added in this project.
- Reserved slots are intentionally left unused for future rule expansion (maximum of 3 per rule).
- New rule code blocks should start at codes ending in
1or6(e.g.,21,26), leaving the remaining codes in the previous block for that prior rule’s reserved slots. - Current allocations are legacy; new rules should follow the start-at-1-or-6 policy without changing existing codes.
Every rule implements the minimal interface expected by CMTAT, notably:
function transferred(address from, address to, uint256 value)
function transferred(address spender, address from, address to, uint256 value)This makes rules directly pluggable into CMTAT without any intermediary RuleEngine.
Rules also expose an optional unified entrypoint using MultiTokenTransferContext / FungibleTransferContext (see ITransferContext) to pass a single struct instead of multiple arguments. This is a helper API inspired by TokenF and does not replace the standard ERC-3643 / RuleEngine interfaces. Validation rules generally expose both the non-fungible and fungible variants; RuleConditionalTransferLight and RuleMaxTotalSupply expose only the fungible variant.
Two struct variants are available:
// For ERC-721 / ERC-1155 (includes tokenId)
struct MultiTokenTransferContext {
bytes4 selector; // function selector of the original call
address sender; // operator/spender (address(0) for direct transfers)
address from; // token sender
address to; // token recipient
uint256 value; // amount transferred
uint256 tokenId; // token id (non-fungible)
bytes data; // Optional token-provided metadata for rules
}
// For ERC-20 (no tokenId)
struct FungibleTransferContext {
bytes4 selector; // function selector of the original call
address sender; // operator/spender (address(0) for direct transfers)
address from; // token sender
address to; // token recipient
uint256 value; // amount transferred
bytes data; // Optional token-provided metadata for rules
}Both structs are passed to transferred(MultiTokenTransferContext calldata ctx) or transferred(FungibleTransferContext calldata ctx). If ctx.sender is non-zero, the spender-aware path is used internally; otherwise the standard two-party path is used. The data field is reserved for optional token-provided metadata that rules can interpret.
When used through the RuleEngine, a rule must also implement:
interface IRule is IRuleEngine {
function canReturnTransferRestrictionCode(uint8 restrictionCode)
external
view
returns (bool);
}The RuleEngine can then:
- Aggregate multiple rules
- Execute them sequentially on each transfer
- Return restriction codes
- Mutate rule state (operation rules)
The same rule can also be plugged directly into a CMTAT token (see Rules as Standalone Compliance Contracts above): the direct-CMTAT path only requires IRuleEngine, while the RuleEngine-managed path additionally requires IRule. Full signatures for both interfaces are documented in the API reference (IRuleEngine, IERC1404Extend, IERC7551Compliance, IERC3643IComplianceContract).
There are two categories of rules: validation rules (read-only) and operation rules (read-write).
| Need | Rule |
|---|---|
| Only approved holders can send/receive | RuleWhitelist |
| Combine several whitelists (OR logic) | RuleWhitelistWrapper |
Restrict transferFrom operators (spenders) |
RuleSpenderWhitelist |
| Block known bad addresses | RuleBlacklist |
| Block sanctioned addresses (Chainalysis oracle) | RuleSanctionsList |
| Cap total token supply | RuleMaxTotalSupply |
| Require identity-registry verification (ERC-3643) | RuleIdentityRegistry |
| ERC-2980 Swiss compliance (whitelist + frozenlist) | RuleERC2980 |
| Require operator approval per transfer | RuleConditionalTransferLight |
| Per-transfer approval across several directly-bound tokens (not behind a RuleEngine) | RuleConditionalTransferLightMultiToken |
| Limit mint quota per minter | RuleMintAllowance |
Each rule is also available in Ownable2Step and AccessControl variants; see Choosing a Rule Variant. Stateful rules have binding constraints — see the Binding model table.
Rules do not all treat the spender, mint/burn, or an unset oracle the same way. The full side-by-side table — who each rule screens (from / to / spender on transferFrom / mint / burn), how it behaves when its oracle/registry is unset, whether it is stateful, and which pre-flight view is authoritative — is in RULE_SEMANTICS.md. The differences most likely to surprise an integrator:
- Spender on mint.
RuleWhitelist/RuleWhitelistWrapper/RuleSpenderWhitelistexempt the minter;RuleBlacklist/RuleSanctionsListscreen it (deny-list, by design);RuleIdentityRegistryalso screens it, so the minter must itself be identity-verified;RuleMintAllowancedebits the minter's quota. - Unset oracle/registry.
RuleSanctionsList(oracle unset) andRuleIdentityRegistry(registry unset) fail open — all transfers pass. An emptyRuleWhitelistWrapperfails closed. - Authoritative pre-flight view. For
RuleMintAllowance,canTransferis not authoritative — usecanTransferFrom. ForRuleConditionalTransferLightMultiToken,detectTransferRestrictionismsg.sender-dependent.
Validation rules only read blockchain state — they never modify it during a transfer. They implement transferred() as a view function: it re-runs the same restriction check and reverts if the transfer would be blocked, but writes nothing to storage.
All validation rules implement IRuleEngine to be usable both standalone (plugged directly into CMTAT) and via the RuleEngine.
Available validation rules: RuleWhitelist, RuleWhitelistWrapper, RuleSpenderWhitelist, RuleBlacklist, RuleSanctionsList, RuleMaxTotalSupply, RuleIdentityRegistry, RuleERC2980.
A community made project, RuleSelf, which uses Self, a zero-knowledge identity is also available but is not developed or maintained by CMTA.
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, RuleConditionalTransferLightMultiToken, RuleMintAllowance.
A full-featured variant, RuleConditionalTransfer, is maintained as a separate experimental repository at CMTA/RuleConditionalTransfer.
# 1. Clone the repository
git clone <repo-url>
cd Rules
# 2. Install Foundry (if not already installed)
# https://book.getfoundry.sh/getting-started/installation
# 3. Install submodule dependencies
forge install
# 4. Compile
forge build
# 5. Run tests
forge test
⚠️ Before production deployment: this project has not undergone an audit. Review the unaudited status, configure roles with least privilege (grant only the roles each operator needs, and prefer theOwnable2Stepvariants for single-owner setups), and run an end-to-end transfer test on the target token setup.
- Deploy the rule contract(s) with the desired admin and optional module addresses.
- Configure the rule state and roles, including whitelist/blacklist entries and oracle or registry addresses.
- Add rules to the RuleEngine, or set the rule directly on the CMTAT token.
- Verify the transfer flow end-to-end with a small test transfer before enabling production flows.
Deployment scripts:
script/DeployCMTATWithWhitelist.s.solscript/DeployCMTATWithBlacklist.s.solscript/DeployCMTATWithBlacklistAndSanctionsList.s.sol— CMTAT + RuleEngine with blacklist and sanctions rules
Several rules are available in multiple access-control variants. Use the simplest one that fits your needs:
AccessControlvariants: use when you need multi-operator roles or delegated administration.Ownable2Stepvariants: use when you want a safer two-step ownership transfer.
- Cannot modify blockchain state during transfers.
- Used for simple eligibility checks.
- Examples:
- Whitelist
- Whitelist Wrapper
- Spender Whitelist
- Blacklist
- Sanction list (Chainalysis)
- ERC-2980 (whitelist + frozenlist)
- Can update state during transfer calls.
- Example:
- Conditional Transfer (approval-based)
| Rule | Type [read-only / read-write] |
ERC-721 / ERC-1155 | ERC-3643 via RuleEngine / CMTAT path * | Security Audit planned in the roadmap | Description |
|---|---|---|---|---|---|
| RuleWhitelist | Read-only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a whitelist. |
| RuleWhitelistWrapper | Read-Only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a group of whitelist rules managed by different operators. |
| RuleBlacklist | Read-Only | ✔ | ✔ | ✔ | This rule can be used to forbid transfer from/to addresses in the blacklist |
| RuleSanctionList | Read-Only | ✔ | ✔ | ✔ | The purpose of this contract is to use the oracle contract from Chainalysis to forbid transfer from/to an address included in a sanctions designation (US, EU, or UN). |
| RuleMaxTotalSupply | Read-Only | ✘ | ✔ | ✔ | This rule limits minting so that the total supply never exceeds a configured maximum. |
| RuleIdentityRegistry | Read-Only | ✔ | ✔ | ✔ | This rule checks the ERC-3643 Identity Registry for transfer participants when configured. |
| 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. |
| RuleMintAllowance | Read-Write | ✘ | Partial † | ✔ | Enforces a per-minter mint quota managed by an operator; each mint reduces the minter's allowance. Regular transfers and burns are not restricted. |
| 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 (community) | — | ✘ | — | ✘ (community project) |
Use Self, 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. |
All rules implement the CMTAT rule interfaces needed by their supported transfer paths. Some operation rules require the spender-aware callback, as documented in their rule-specific notes.
* A checkmark in this column means the rule enforces compliance for ERC-3643 tokens through a RuleEngine or the CMTAT transfer path — it does not mean the rule is itself a full ERC-3643 ICompliance contract. A standalone rule implements only canTransfer + transferred, so it cannot back an ERC-3643 token directly; use it through a RuleEngine, which implements the full ICompliance interface (see Integration modes).
† RuleMintAllowance is Partial: it does not advertise the full ERC-3643 ICompliance interface via ERC-165 because its per-minter mint quota requires the spender-aware mint callback to identify the minter, which the 3-argument ERC-3643 mint callback cannot provide.
Detailed technical documentation for each rule is available in doc/technical/:
| Rule | Document |
|---|---|
| RuleWhitelist | RuleWhitelist.md |
| RuleWhitelistWrapper | RuleWhitelistWrapper.md |
| RuleBlacklist | RuleBlacklist.md |
| RuleSanctionsList | RuleSanctionList.md |
| RuleMaxTotalSupply | RuleMaxTotalSupply.md |
| RuleIdentityRegistry | RuleIdentityRegistry.md |
| RuleSpenderWhitelist | RuleSpenderWhitelist.md |
| RuleERC2980 | RuleERC2980.md |
| RuleConditionalTransferLight | RuleConditionalTransferLight.md |
| RuleConditionalTransferLightMultiToken | RuleConditionalTransferLightMultiToken.md |
| RuleMintAllowance | RuleMintAllowance.md |
Stateful (operation) rules restrict which caller may consume their state via transferred(), so the target must be explicitly bound with bindToken. The binding model differs per rule:
| Rule | Binding model | Notes |
|---|---|---|
RuleConditionalTransferLight |
Single token + optional RuleEngine | Two independent bindings: bindToken(token) sets the ERC-20 this rule acts on, bindRuleEngine(engine) authorises the engine to call transferred. transferred accepts either. Behind a RuleEngine, bind both — then approveAndTransferIfAllowed works too. Rebind only after unbindToken / unbindRuleEngine. See Binding: token vs RuleEngine |
RuleConditionalTransferLightMultiToken |
Multiple direct tokens only | Approvals keyed by (token, from, to, value) but consumed under msg.sender. RuleEngine — bind each token directly (CMTAT.setRuleEngine(rule)). Behind an engine the rule either reverts or silently loses all per-token isolation; see Deployment topology |
RuleMintAllowance |
Single RuleEngine/token | Bind the RuleEngine address in a CMTAT + RuleEngine setup; rebind only after unbindToken. Requires the spender-aware mint callback |
Validation (read-only) rules have no binding requirement: they hold no per-transfer state and can be shared across tokens and RuleEngines freely.
RuleIdentityRegistry: allows burns (to == address(0)) even if the sender is not verified. This matters only if the token allows self-burn.RuleIdentityRegistry: can be disabled withclearIdentityRegistry(), which allows all transfers to pass this rule.RuleIdentityRegistry: constructor acceptsaddress(0)to start in a disabled state.
RuleSanctionsList: rejects zero address insetSanctionListOracle. UseclearSanctionListOracle()to disable checks.RuleSanctionsList: constructor acceptsaddress(0)to start in a disabled state.
RuleMaxTotalSupply: trusts the configuredtokenContractto return an accuratetotalSupply().RuleMaxTotalSupply: does not allow clearing the token contract; disable the rule by removing it from the RuleEngine or token.
RuleWhitelistWrapper: requires child rules that implementIAddressList. A wrapper with zero rules rejects all transfers (fail-closed).- Scan cost is paid on every transfer, by the transferring user. The wrapper makes one external
STATICCALLper child rule — ~8.8k gas each — and the scan runs during transfer execution, not only in views. At the default cap of 10 children the worst case is ~90k gas per transfer (~121k withcheckSpender = true). - Two amplifiers: a transfer that is going to be rejected never resolves its target addresses, so it never early-exits and always scans all children — the failing path is the most expensive one. And
checkSpender = trueadds a third address that must also be found, lowering the early-exit rate. - Operator responsibility: keep the child list at or below the default
maxRules = 10, and order children by expected hit rate so the early exit fires sooner. The scan is linear (~8.8k gas/child, measured flat up to 200 children), sosetMaxRulesaccepts any non-zero value and raising the cap to 100 makes every transfer cost ~884k gas. That is a permanent tax on holders rather than a broken token — transfers still fit in a block until ~3,400 children — but it cannot be undone for transfers already paid. Full cost model and guidance: RuleWhitelistWrapper.md.
RuleSpenderWhitelist: only checks the spender intransferFrom; direct transfers always pass this rule.
RuleERC2980: frozenlist takes priority over whitelist; an address that is both whitelisted and frozen is rejected.RuleERC2980: a frozen address acting astransferFromspender is also blocked (code 62), even iffromandtoare not frozen.RuleERC2980: sender (from) does not need to be whitelisted; only recipient (to) must be whitelisted.
RuleConditionalTransferLight: approvals are keyed by(from, to, value)and are not nonce-based.RuleConditionalTransferLight:approveAndTransferIfAllowedapproves and immediately executestransferFromwhen this rule has allowance; it assumes token callback totransferred().RuleConditionalTransferLight:transferred()is restricted to the single token bound viabindToken; second bind reverts withRuleConditionalTransferLight_TokenAlreadyBounduntilunbindToken.RuleConditionalTransferLight: mints (from == address(0)) and burns (to == address(0)) are exempt from approval checks;createdanddestroyeddelegate to_transferred.
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;createdanddestroyeddelegate to_transferred.RuleConditionalTransferLightMultiToken: with a sharedRuleEngine, 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:
RuleConditionalTransferLightMultiTokensupports several tokens when integrated directly with each token contract. It must not be used for per-token approval isolation through a sharedRuleEngine.
- 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 helpersvirtual. - All AccessControl variants: use
AccessControlEnumerable, so role members can be enumerated withgetRoleMember/getRoleMemberCount; default admin is treated as having all roles viahasRole, but may not appear in role member lists unless explicitly granted. - All meta-tx-enabled rules:
forwarderIrrevocableis accepted as-is (includingaddress(0)) and is not validated against ERC-165 because some forwarders do not implement it. - All rules: implement
IERC3643VersionviaVersionModuleand exposeversion()returning"0.4.0".
Currently, there are eight validation rules: whitelist, whitelist wrapper, spender whitelist, blacklist, sanctions list, max total supply, identity registry, and ERC-2980.
Only whitelisted addresses may hold or receive tokens. Transfers are rejected if:
fromis not whitelistedtois not whitelisted
The rule is read-only: it only checks stored state.
- Constructor parameter
allowMintBurnsets bothallowMintandallowBurn— the common case. UsesetAllowMint(bool)/setAllowBurn(bool)afterwards for independent control (e.g. permanently close issuance while keeping redemptions open). - Mint/burn permission is an explicit flag, never list membership of
address(0). The zero address can never enter the list (addAddress(address(0))reverts), soisVerified(address(0))/contains(address(0))stayfalse, as ERC-3643 requires. - The flag gates the operation only: a permitted mint still requires a whitelisted recipient; a permitted burn still requires a whitelisted sender.
- Blocked mint/burn return dedicated codes
24/25(not the misleading "sender not whitelisted").
Example
During a transfer, this rule, called by the RuleEngine, will check if the address concerned is in the list, applying a read operation on the blockchain.
Usage scenario
An operator configures CMTAT to use RuleWhitelist. The issuer tries to mint to Alice via mint/transfer and the token calls detectTransferRestriction/transferred; Alice is not listed so the call reverts. The operator calls addAddress(Alice). The issuer retries the mint and it succeeds.
This rule only checks transferFrom spender authorization:
- Direct transfers (
transfer) are always allowed by this rule. transferFromis rejected whenspenderis not listed.- Restriction code:
66(CODE_ADDRESS_SPENDER_NOT_WHITELISTED).
Usage scenario
The operator deploys RuleSpenderWhitelist and sets it in the token or RuleEngine. Alice calls transfer to Bob and it passes this rule. Bob then tries transferFrom(Alice, Bob, amount) and it is rejected until the operator calls addAddress(Bob) (or whichever spender account should be authorized).
Allows independent whitelist groups managed by different operators.
- Each operator manages a dedicated whitelist.
- A transfer is allowed only if both addresses belong to at least one operator-managed list.
- Enables multi-party compliance
Usage scenario
Two operators maintain separate whitelists using addRule/setRules and each child rule’s addAddress. A transfer between Alice and Bob is allowed if at least one child whitelist returns true for both via areAddressesListed; otherwise detectTransferRestriction rejects it.
This rule inherits from RuleEngineValidationCommon. Thus the whitelist rules are managed with the same architecture and code than for the ruleEngine. For example, rules are added with the functions setRules or addRule.
Opposite of whitelist:
- Transfer fails if either address is blacklisted.
Usage scenario
The operator sets RuleBlacklist on the token. The issuer tries to transfer to Bob; detectTransferRestriction passes. The operator calls addAddress(Bob). A subsequent transfer to Bob is rejected until removeAddress(Bob) is called.
Implements the ERC-2980 Swiss Compliant Asset Token transfer restriction using two independent address lists managed in a single rule:
- Whitelist: only whitelisted addresses may receive tokens. Senders do not need to be whitelisted and may freely transfer tokens they already hold.
- Frozenlist: frozen addresses are completely blocked — they can neither send nor receive tokens. Additionally, a frozen address acting as a
transferFromspender will have the transfer rejected (code 62), even iffromandtoare not frozen. - Priority: frozenlist is checked first. If
from,to, orspenderis frozen, the transfer is rejected regardless of whitelist membership. - Mint/burn handling: governed by the explicit
allowMint/allowBurnflags, never by whitelistingaddress(0). The zero address can never enter either list, so the mandatory ERC-2980 getterswhitelist(address(0))/frozenlist(address(0))always returnfalse.allowMintBurn = false(default-safe): mint is refused with code 64, burn with code 65.allowMintBurn = true: both permitted. A permitted mint still requires the recipient to be whitelisted and not frozen; a permitted burn still requires the sender not to be frozen.- Independently settable afterwards via
setAllowMint(bool)/setAllowBurn(bool).
- Constructors:
RuleERC2980(address admin, address forwarderIrrevocable, bool allowMintBurn)RuleERC2980Ownable2Step(address owner, address forwarderIrrevocable, bool allowMintBurn)
Restriction codes:
| Constant | Code | Meaning |
|---|---|---|
CODE_ADDRESS_FROM_IS_FROZEN |
60 | Sender is frozen |
CODE_ADDRESS_TO_IS_FROZEN |
61 | Recipient is frozen |
CODE_ADDRESS_SPENDER_IS_FROZEN |
62 | Spender is frozen |
CODE_ADDRESS_TO_NOT_WHITELISTED |
63 | Recipient is not whitelisted |
CODE_MINT_NOT_ALLOWED |
64 | Minting is disabled (allowMint == false) |
CODE_BURN_NOT_ALLOWED |
65 | Burning is disabled (allowBurn == false) |
Deviation from spec: the ERC-2980 Whitelistable / Freezable example interfaces define single-address management functions that return bool and do not revert on duplicates or missing entries. This implementation reverts on invalid single-item operations, consistent with the codebase convention. Batch operations remain non-reverting.
Usage scenario
The operator deploys RuleERC2980 and chooses allowBurn according to the redemption policy. The issuer whitelists Alice with addWhitelistAddress(Alice). A transfer to Alice succeeds. The compliance officer freezes Bob with addFrozenlistAddress(Bob). Any transfer from or to Bob is now rejected even if Bob was previously whitelisted.
Uses the Chainalysis Oracle to reject transfers involving sanctioned addresses.
- Checks lists for: US, EU, and UN sanctions.
- Documentation: Chainalysis Oracle for sanctions screening
- If
fromortois sanctioned, transfer is rejected.
The documentation and contract addresses are available here: Chainalysis oracle for sanctions screening.
Example
During a transfer, if either address (from or to) is in the sanction list of the Oracle, the rule will return false, and the transfer will be rejected by the CMTAT.
Usage scenario
The operator sets the Chainalysis oracle with setSanctionListOracle. The token’s transfer path calls detectTransferRestriction; if the oracle flags from or to, the transfer is rejected. Calling clearSanctionListOracle disables checks.
Limits minting so that total supply never exceeds a configured maximum. Transfers and burns are not affected; only mints (from == address(0)) are checked.
Usage scenario
The operator deploys RuleMaxTotalSupply with setMaxTotalSupply(1_000_000) and sets the token with setTokenContract. When the issuer mints and totalSupply + amount exceeds the limit, detectTransferRestriction rejects the mint. Transfers between holders still pass.
ERC-3643 conformant: only the RECEIVER is verified. The specification mandates exactly one identity check — "The receiver MUST be whitelisted on the Identity Registry and verified" — and states that transferFrom "works the same way", that mint "only require[s] the receiver", and that burn "bypasses all checks on eligibility". The sender, the spender and the minter are therefore not verified by default.
Checking the sender is deliberately avoided: ERC-3643 screens only the receiver precisely so that an investor whose identity lapses can still exit their position by sending to a verified counterparty. Screening the sender would trap them — unable to receive and unable to send.
Stricter screening is available as an explicit opt-in, never a silent default:
checkSender— also verify the sender (stricter than ERC-3643).checkSpender— also verify the spender ontransferFrom(stricter than ERC-3643). Mint and burn stay exempt regardless.
Constructors: RuleIdentityRegistry(address admin, address identityRegistry, bool checkSender, bool checkSpender) — pass false, false for the conformant default. Both flags are settable afterwards via setCheckSender(bool) / setCheckSpender(bool).
Usage scenario
The operator calls setIdentityRegistry(registry). The issuer attempts a transfer to Alice; detectTransferRestriction consults isVerified and rejects if Alice is unverified. After the registry marks Alice verified, the transfer succeeds. Calling clearIdentityRegistry disables checks.
There are three operation rules available: RuleConditionalTransferLight, RuleConditionalTransferLightMultiToken, and RuleMintAllowance.
This rule requires that transfers must be approved by an operator before being executed. It hashes (from, to, value) to track approvals and allows the same transfer to be approved multiple times. Each successful transfer consumes one approval, applying a write operation on the blockchain. Mints (from == address(0)) and burns (to == address(0)) are exempt and always pass without requiring approval.
Usage scenario
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.
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.
For the same reason, it does not advertise the full ERC-3643 ICompliance interface through ERC-165; the 3-arg callbacks alone cannot enforce the mint quota.
⚠️ canTransfer/detectTransferRestrictionare not authoritative for this rule — they are hardcoded to "allowed" because the 3-arg signature has no minter identity, so they disagree with enforcement. Pre-flight a mint with the spender-aware viewcanTransferFrom(minter, address(0), to, value)(ordetectTransferRestrictionFrom). See RuleMintAllowance.md.
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.
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).
The module AccessControlModuleStandalone implements RBAC access control by inheriting from OpenZeppelin's AccessControlEnumerable.
Each rule implements its own access control by inheriting from AccessControlModuleStandalone. The default admin is the address passed as admin to the constructor at deployment.
AccessControlModuleStandalone overrides OpenZeppelin's hasRole so that any account holding DEFAULT_ADMIN_ROLE returns true for every role check. This is intentional: the OpenZeppelin DEFAULT_ADMIN_ROLE holder can already grant itself any role at any time, so treating it as implicitly holding all roles from the start removes unnecessary ceremony and makes access management easier in practice.
Practical consequences integrators must be aware of:
grantRoleto a default admin is a no-op._grantRolechecks!hasRole(role, account)before writing storage; since the admin already returnstruevia the override, the storage write and theRoleGrantedevent are skipped. The admin will not appear ingetRoleMember/getRoleMemberCountenumerations for non-default roles unless the role was explicitly granted before the admin was set.revokeRole/renounceRoleon a non-default role for a default admin are misleading. They emitRoleRevokedand clear the storage flag, buthasRolecontinues to returntruebecause the account still holdsDEFAULT_ADMIN_ROLE. The effective privilege is unchanged. To fully remove access,DEFAULT_ADMIN_ROLEitself must be revoked.- Off-chain monitoring should use
hasRolequeries, not role-membership events or enumeration, to determine the effective privileges of admin accounts.
See also docs.openzeppelin.com - AccessControl
| Role | Hash | Functions (by rule) |
|---|---|---|
DEFAULT_ADMIN_ROLE |
0x0000000000000000000000000000000000000000000000000000000000000000 |
grantRole, revokeRole, renounceRole (all AccessControl rules); setCheckSpender (RuleWhitelist, RuleWhitelistWrapper); setMaxTotalSupply, setTokenContract (RuleMaxTotalSupply); setIdentityRegistry, clearIdentityRegistry (RuleIdentityRegistry) |
ADDRESS_LIST_ADD_ROLE |
0x1b03c849816e077359373cf0a8d6d8f741d643bc1e95273ffe11515f83bebf61 |
addAddress, addAddresses (RuleWhitelist, RuleBlacklist) |
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 / 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) |
FROZENLIST_REMOVE_ROLE |
0x8be92b33a413d98540bfb0edc9129253db6d924f6c2e32c4b7809d237f7b2aaa |
removeFrozenlistAddress, removeFrozenlistAddresses (RuleERC2980) |
For simpler ownership-based control, Ownable2Step variants (two-step ownership transfer) are available:
RuleWhitelistOwnable2StepRuleBlacklistOwnable2StepRuleWhitelistWrapperOwnable2StepRuleSanctionsListOwnable2StepRuleIdentityRegistryOwnable2StepRuleMaxTotalSupplyOwnable2StepRuleERC2980Ownable2StepRuleConditionalTransferLightOwnable2StepRuleConditionalTransferLightMultiTokenOwnable2StepRuleMintAllowanceOwnable2Step
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).
Common access control between the blacklist rule and whitelist rule.
These roles are listed above in the Role Summary table.
This repository is developed and tested with Foundry; a Hardhat config is also present for compilation and a small smoke test. Build settings (foundry.toml / hardhat.config.js): solc v0.8.34, EVM Prague, optimizer on (200 runs).
| Task | Command |
|---|---|
| Install / update submodules | forge install · forge update |
| Build | forge build |
| Contract sizes | forge compile --sizes |
| Run all tests | forge test |
| Run one test | forge test --match-contract <name> --match-test <fn> |
| Gas report | forge test --gas-report |
| Gas snapshot | forge snapshot (check only: forge snapshot --check) |
| Coverage | forge coverage |
Coverage report (doc/coverage) |
forge coverage --no-match-coverage "(script|mocks|test)" --report lcov && genhtml lcov.info --branch-coverage --prefix "$PWD/" --output-dir coverage |
| Invariant suite only | forge test --match-path "test/invariant/*" |
| Format | forge fmt |
| Deploy a script | forge script script/<Deploy...>.s.sol --rpc-url <url> --account <keystore> |
The two stateful (operation) rules — RuleConditionalTransferLight and RuleMintAllowance — are covered by a handler-driven StdInvariant suite in test/invariant/, which fuzzes long randomly-ordered call sequences and re-checks four invariants after every step (8 192 calls each, fail_on_revert = true):
| Invariant | Asserts |
|---|---|
invariant_approvalConservation |
totalApproved − totalCancelled − totalExecuted == Σ approvalCounts — approvals are never double-spent or lost |
invariant_noApprovalExceedsTotalRecorded |
Σ approvalCounts ≤ totalApproved |
invariant_allowanceMatchesGhost |
the on-chain mint quota exactly matches an independently-computed ghost mirror, after any interleaving |
invariant_mintedNeverExceedsCredited |
Σ minted ≤ Σ credited |
Both suites are mutation-verified: injecting an approval double-spend or an off-by-one quota deduction makes them fail. Validation rules are read-only and hold no per-transfer state, so they are covered by unit and fuzz tests instead.
Full details — handler architecture, ghost variables, the negative controls, the coverage map against the threat-model invariants, and how to add a new one — are in doc/technical/INVARIANT_TESTS.md.
Deployment scripts: script/DeployCMTATWithWhitelist.s.sol, script/DeployCMTATWithBlacklist.s.sol, script/DeployCMTATWithBlacklistAndSanctionsList.s.sol.
Deployment key security: avoid passing
--private-keyon the command line (visible in shell history and to any process that can read/proc). Prefer hardware wallets (--ledger,--trezor) or encrypted keystores (--account <keystore>). See Foundry best practices.
For the full toolchain guide — dependency versions, Hardhat commands, HTML coverage generation, the gas-benchmark workflow, and the generic Forge / Cast / Anvil / Chisel reference — see doc/FOUNDRY.md and the Foundry book.
All rules implement IRuleEngine. The behaviour of transferred() differs by rule type:
- Validation rules implement
transferred()asview: it re-runs the restriction check and reverts if the transfer would be blocked, but does not modify state. - Operation rules implement
transferred()as a state-mutating function: it updates storage as part of the transfer (e.g. consuming an approval inRuleConditionalTransferLight).
function transferred(address spender, address from, address to, uint256 value)
external;
Called by a token or RuleEngine after a transfer. For validation rules, enforces the restriction check. For operation rules, mutates internal state.
| Name | Type | Description |
|---|---|---|
spender |
address |
Address executing the transfer (owner, operator, or approved). |
from |
address |
Current token holder. |
to |
address |
Recipient address. |
value |
uint256 |
Amount transferred. |
function detectTransferRestriction(address from, address to, uint256 value)
external
view
returns (uint8);
Returns a restriction code describing why a transfer is blocked.
| Name | Type | Description |
|---|---|---|
from |
address |
Sender address. |
to |
address |
Recipient address. |
value |
uint256 |
Amount being transferred. |
| Name | Type | Description |
|---|---|---|
0 |
uint8 |
Transfer allowed. |
| other | uint8 |
Implementation-defined restriction code. |
function messageForTransferRestriction(uint8 restrictionCode)
external
view
returns (string memory);
Returns a human-readable message associated with a restriction code.
| Name | Type | Description |
|---|---|---|
restrictionCode |
uint8 |
Restriction code returned by detectTransferRestriction. |
| Name | Type | Description |
|---|---|---|
message |
string |
Explanation for the restriction code. |
enum REJECTED_CODE_BASE {
TRANSFER_OK,
TRANSFER_REJECTED_DEACTIVATED,
TRANSFER_REJECTED_PAUSED,
TRANSFER_REJECTED_FROM_FROZEN,
TRANSFER_REJECTED_TO_FROZEN,
TRANSFER_REJECTED_SPENDER_FROZEN,
TRANSFER_REJECTED_FROM_INSUFFICIENT_ACTIVE_BALANCE
}
Base transfer restriction codes used by ERC-1404 extensions.
function detectTransferRestrictionFrom(
address spender,
address from,
address to,
uint256 value
)
external
view
returns (uint8);
Restriction code for transfers performed by a spender (approved operator).
| Name | Type | Description |
|---|---|---|
spender |
address |
Address performing the transfer. |
from |
address |
Current token owner. |
to |
address |
Recipient address. |
value |
uint256 |
Transfer amount. |
| Name | Type | Description |
|---|---|---|
code |
uint8 |
0 if transfer allowed, otherwise a restriction code. |
function canTransferFrom(address spender, address from, address to, uint256 value)
external
view
returns (bool);
Determines if a spender-initiated transfer is permitted.
| Name | Type | Description |
|---|---|---|
spender |
address |
Caller executing transfer. |
from |
address |
Token owner. |
to |
address |
Recipient. |
value |
uint256 |
Amount. |
| Name | Type | Description |
|---|---|---|
allowed |
bool |
true if transfer permitted. |
function canTransfer(address from, address to, uint256 value)
external
view
returns (bool isValid);
Returns whether a transfer is compliant.
| Name | Type | Description |
|---|---|---|
from |
address |
Sender. |
to |
address |
Receiver. |
value |
uint256 |
Transfer amount. |
| Name | Type | Description |
|---|---|---|
isValid |
bool |
true if compliant. |
function transferred(address from, address to, uint256 value)
external;
Hook invoked during an ERC-20 token transfer.
| Name | Type | Description |
|---|---|---|
from |
address |
Previous owner. |
to |
address |
New owner. |
value |
uint256 |
Amount transferred. |
This API is common to whitelist and blacklist rules
function addAddresses(address[] calldata targetAddresses)
public
onlyAddressListAdd
Adds multiple addresses to the internal address set.
- Does not revert if one or more addresses are already listed.
- Restricted by the rule's access control policy (role- or owner-based).
- Emits
AddAddresses. Skipped/added counts are not emitted to keep gas cost minimal.
| Name | Type | Description |
|---|---|---|
targetAddresses |
address[] |
Array of addresses to be added to the set. |
function removeAddresses(address[] calldata targetAddresses)
public
onlyAddressListRemove
Removes multiple addresses from the internal set.
- Does not revert if an address is not currently listed.
- Restricted by the rule's access control policy (role- or owner-based).
- Emits
RemoveAddresses. Skipped/removed counts are not emitted to keep gas cost minimal.
| Name | Type | Description |
|---|---|---|
targetAddresses |
address[] |
Array of addresses to be removed. |
function addAddress(address targetAddress)
public
onlyAddressListAdd
Adds a single address to the set.
- Reverts if the address is already listed.
- Restricted by the rule's access control policy (role- or owner-based).
- Emits an
AddAddressevent.
| Name | Type | Description |
|---|---|---|
targetAddress |
address |
Address to add. |
function removeAddress(address targetAddress)
public
onlyAddressListRemove
Removes a single address from the set.
- Reverts if the address is not listed.
- Restricted by the rule's access control policy (role- or owner-based).
- Emits a
RemoveAddressevent.
| Name | Type | Description |
|---|---|---|
targetAddress |
address |
Address to remove. |
function listedAddressCount() public view returns (uint256 count)
Returns the total number of addresses currently listed in the internal set.
| Name | Type | Description |
|---|---|---|
count |
uint256 |
Total number of listed addresses. |
function contains(address targetAddress)
public
view
override(IIdentityRegistryContains)
returns (bool isListed)
Checks whether a specific address is listed.
Implements IIdentityRegistryContains.
| Name | Type | Description |
|---|---|---|
targetAddress |
address |
Address to check. |
| Name | Type | Description |
|---|---|---|
isListed |
bool |
true if the address is listed, otherwise false. |
function isAddressListed(address targetAddress)
public
view
returns (bool isListed)
Returns whether a given address is included in the internal set.
| Name | Type | Description |
|---|---|---|
targetAddress |
address |
Address to check. |
| Name | Type | Description |
|---|---|---|
isListed |
bool |
Listing status. |
function areAddressesListed(address[] memory targetAddresses)
public
view
returns (bool[] memory results)
Checks the listing status of multiple addresses in a single call.
| Name | Type | Description |
|---|---|---|
targetAddresses |
address[] |
Array of addresses to check. |
| Name | Type | Description |
|---|---|---|
results |
bool[] |
Array of boolean listing results, aligned by index. |
It is possible to add the null address (0x0) to the address list. In a whitelist, this enables mint/burn flows (since from/to can be zero). In a blacklist, adding 0x0 blocks mint/burn.
For RuleWhitelist, you can also pre-list 0x0 at deployment using the constructor parameter allowMintBurn=true.
addAddress If the address already exists, the transaction is reverted to save gas. addAddresses If one of the addresses already exist, there is no change for this address. The transaction remains valid (no revert).
removeAddress If the address does not exist in the whitelist, the transaction is reverted to save gas. removeAddresses If the address does not exist in the whitelist, there is no change for this address. The transaction remains valid (no revert).
Compliance interface for ERC-721 / ERC-1155–style non-fungible assets. This is implemented by validation rules only. RuleConditionalTransferLight and RuleMaxTotalSupply are ERC-20 only and do not implement this interface.
For ERC-721, amount must always be 1.
| Name | Description |
|---|---|
| canTransfer | Verifies whether a transfer is permitted according to the token’s compliance rules. |
function canTransfer(
address from,
address to,
uint256 tokenId,
uint256 amount
) external view returns (bool allowed)
Verifies whether a token transfer is permitted according to the rule-based compliance logic.
- Must not modify state.
- May enforce checks such as allowlists, blocklists, freezing, transfer limits, regulatory rules.
- Must return
falseif the transfer is not permitted.
| Name | Type | Description |
|---|---|---|
from |
address |
Current token owner. |
to |
address |
Receiving address. |
tokenId |
uint256 |
Token ID. |
amount |
uint256 |
Transfer amount (always 1 for ERC-721). |
| Name | Type | Description |
|---|---|---|
allowed |
bool |
true if transfer is allowed; otherwise false. |
Extended compliance interface for ERC-721 / ERC-1155 non-fungible assets. This is implemented by validation rules only. RuleConditionalTransferLight and RuleMaxTotalSupply are ERC-20 only and do not implement this interface.
Adds restriction-code reporting, spender-aware checks, and a post-transfer hook.
For ERC-721, amount / value must always be 1.
| Name | Description |
|---|---|
| detectTransferRestriction | Returns a restriction code indicating why a transfer is blocked. |
| detectTransferRestrictionFrom | Returns a restriction code for a spender-initiated transfer. |
| canTransferFrom | Checks whether a spender-initiated transfer is allowed. |
| transferred | Notifies the compliance engine that a transfer has occurred. |
function detectTransferRestriction(
address from,
address to,
uint256 tokenId,
uint256 amount
) external view returns (uint8 code)
Returns a restriction code describing whether and why a transfer is blocked.
- Must not modify state.
- Must return
0when the transfer is allowed. - Non-zero codes should follow ERC-1404 or similar standards.
| Name | Type | Description |
|---|---|---|
from |
address |
Current token holder. |
to |
address |
Receiving address. |
tokenId |
uint256 |
Token ID. |
amount |
uint256 |
Transfer amount (1 for ERC-721). |
| Name | Type | Description |
|---|---|---|
code |
uint8 |
0 if allowed; otherwise a restriction code. |
function detectTransferRestrictionFrom(
address spender,
address from,
address to,
uint256 tokenId,
uint256 value
) external view returns (uint8 code)
Returns a restriction code for a transfer initiated by a spender (approved operator or owner).
- Must not modify state.
- Must return
0when the transfer is permitted.
| Name | Type | Description |
|---|---|---|
spender |
address |
Address performing the transfer. |
from |
address |
Current owner. |
to |
address |
Recipient address. |
tokenId |
uint256 |
Token ID being checked. |
value |
uint256 |
Transfer amount (1 for ERC-721). |
| Name | Type | Description |
|---|---|---|
code |
uint8 |
0 if allowed; otherwise restriction code. |
function canTransferFrom(
address spender,
address from,
address to,
uint256 tokenId,
uint256 value
) external view returns (bool allowed)
Checks whether a spender-initiated transfer is allowed under the compliance rules.
- Must not modify state.
- Should internally use
detectTransferRestrictionFrom.
| Name | Type | Description |
|---|---|---|
spender |
address |
Address executing the transfer. |
from |
address |
Current owner. |
to |
address |
Recipient. |
tokenId |
uint256 |
Token ID. |
value |
uint256 |
Transfer amount (1 for ERC-721 token). |
| Name | Type | Description |
|---|---|---|
allowed |
bool |
true if transfer is allowed. |
function transferred(
address spender,
address from,
address to,
uint256 tokenId,
uint256 value
) external
Signals to the compliance engine that a transfer has successfully occurred.
- May modify compliance state.
- For stateful rules, should be called by the token contract or RuleEngine after a successful transfer.
- Rules may enforce access control on callers depending on their policy.
| Name | Type | Description |
|---|---|---|
spender |
address |
Address executing the transfer. |
from |
address |
Previous owner. |
to |
address |
New owner. |
tokenId |
uint256 |
Token transferred. |
value |
uint256 |
Transfer amount (1 for ERC-721 token). |
Compliance rule enforcing sanctions-screening for token transfers. Integrates a sanctions-oracle (e.g., Chainalysis) to block transfers when the sender, recipient, or spender is sanctioned.
constructor(address admin, address forwarderIrrevocable, ISanctionsList sanctionContractOracle_)Initializes access control, meta-transaction forwarder, and optionally the sanctions oracle.
function setSanctionListOracle(ISanctionsList sanctionContractOracle_)
public
virtual
onlyRole(SANCTIONLIST_ROLE)Set the sanctions-oracle contract used for transfer-restriction checks.
| Name | Type | Description |
|---|---|---|
sanctionContractOracle_ |
ISanctionsList |
Address of the sanctions-oracle. Zero address is not allowed; use clearSanctionListOracle. |
Updates the sanctions-oracle contract reference.
This function may only be called by accounts granted the SANCTIONLIST_ROLE.
Passing the zero address reverts; use clearSanctionListOracle to disable checks.
| Event | Description |
|---|---|
SetSanctionListOracle(address) |
Emitted when the sanctions-oracle address is updated. |
Compliance rule that caps total token supply; only mints (from == address(0)) are restricted.
constructor(address admin, address tokenContract_, uint256 maxTotalSupply_)Initializes access control, the token contract, and the max supply.
function setMaxTotalSupply(uint256 newMaxTotalSupply)
public
virtual
onlyRole(DEFAULT_ADMIN_ROLE)Updates the configured maximum supply.
function setTokenContract(address tokenContract_)
public
virtual
onlyRole(DEFAULT_ADMIN_ROLE)Sets the token contract used to read totalSupply().
Operation rule requiring explicit approval before a transfer executes.
function bindToken(address token)
public
onlyRole(COMPLIANCE_MANAGER_ROLE)Binds a token so it may call transferred().
function unbindToken(address token)
public
onlyRole(COMPLIANCE_MANAGER_ROLE)Revokes the token binding.
function approveTransfer(address from, address to, uint256 value)
public
onlyTransferApproverApproves one transfer (consumed on execution).
function cancelTransferApproval(address from, address to, uint256 value)
public
onlyTransferApproverRemoves one approval for the transfer.
function approveAndTransferIfAllowed(address from, address to, uint256 value)
public
onlyTransferApprover
returns (bool)Approves then calls SafeERC20.safeTransferFrom on the bound token using this rule as spender.
function approvedCount(address from, address to, uint256 value)
public
view
returns (uint256)Returns the number of approvals for the transfer hash.
The published report is CLAUDE_AUDIT.md — findings, invariant verification, access-control verification, what was remediated, and the open improvement backlog. It is backed by the working deliverables at the repository root:
| Document | Contents |
|---|---|
CLAUDE_AUDIT.md |
The audit report. Findings, invariant + access-control verification, remediation record, open backlog |
THREAT_MODEL.md |
Trust model and actors, 30 catalogued threats with IDs, data-flow diagrams, 12 invariants, reachable privileged surface |
RESULT.md |
Findings, invariant and access-control verification, and an explicit disposition for every threat ID |
TEST_IMPROVEMENT.md |
Test-gap analysis and the deferred test backlog |
Outcome: 0 Critical, 0 High, 0 Medium, 2 Low, 8 Informational. Two hypotheses that would have been High were specifically probed and cleared: an ERC-2771 forwarder cannot impersonate a bound token (the operation rules deliberately do not inherit ERC2771Context), and the hand-rolled keccak preimage in _transferHash is injective.
| ID | Severity | Summary |
|---|---|---|
| F-1 | Low | RuleIdentityRegistry screens the minter as spender on mint, unlike its three sibling allowlist rules, so issuance halts unless the minter is itself identity-verified. Fail-closed; no bypass |
| F-4 | Low | RuleConditionalTransferLightMultiToken stores approvals under the caller-supplied token but consumes them under msg.sender. Behind a shared RuleEngine this strands token-keyed approvals and collapses per-token isolation |
| F-2, F-3, F-5, F-7, F-8, F-9, F-10, F-14 | Info | Max-supply views panic on overflow; approveAndTransferIfAllowed is direct-binding-only; the wrapper does not interface-check child rules; RuleMintAllowance.canTransfer is not authoritative; multi-token detectTransferRestriction depends on msg.sender; unbindToken leaves stale state; documentation drift |
Proofs live in test/ThreatModel/ThreatModelTests.t.sol (18 tests: 15 unit/integration, 3 fuzz).
See the consolidated Audit & Security-Analysis Overview for the full index and triage. Latest tool outputs (including feedback documents) are in doc/security/audits/tools/v0.4.0/.
Commands used for v0.4.0 (mocks excluded):
slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
> doc/security/audits/tools/v0.4.0/slither-report.md
aderyn -x mocks --output doc/security/audits/tools/v0.4.0/aderyn-report.mdStatic analysis with Aderyn 0.6.5, re-run 2026-07-14 after the security remediation. Full report and feedback in doc/security/audits/tools/v0.4.0/. No High/Medium issues; nothing to fix — all 9 Low findings are by-design or false positives (see feedback). The run initially reported 10: an Unused Import (dead RuleTransferValidation import in the two RuleSpenderWhitelist deployment files) was a genuine cosmetic defect and has been fixed.
| ID | Title | Instances | Verdict |
|---|---|---|---|
| L-1 | Centralization Risk | 68 | By design (regulated token issuer model) |
| L-2 | Unspecific Solidity Pragma | 63 | By design (^0.8.20 library; project pins solc 0.8.34) |
| L-3 | Address State Variable Set Without Checks | 1 | False positive — zero-check enforced at public setSanctionListOracle |
| L-4 | PUSH0 Opcode | 64 | By design — project targets Prague EVM |
| L-5 | Modifier Invoked Only Once | 2 | By design — template method pattern |
| L-6 | Empty Block | 61 | By design — _authorize*() hooks + required interface no-ops |
| L-7 | Loop Contains require/revert |
3 | By design — recommendation rejected. Batch adds revert on address(0) on purpose: skipping it made the emitted event name the sentinel as a set member |
| L-8 | Costly operations inside loop | 7 | By design — EnumerableSet requires one SSTORE per element |
| L-9 | Unchecked Return | 13 | Mixed — majority false positives; constructor _grantRole intentional |
| — | Unused Import | 0 | Fixed during this run (was 2) |
Static analysis with Slither 0.11.5, re-run 2026-07-14 after the security remediation (tally unchanged from the previous run). Full report and feedback in doc/security/audits/tools/v0.4.0/. Nothing to fix — the two High arbitrary-send-erc20 hits are false positives (approval-gated, allowance-checked compliance flow); see feedback.
| Category | Severity | Instances | Verdict |
|---|---|---|---|
| arbitrary-send-erc20 | High | 2 | False positive — from guarded by onlyTransferApprover, recorded approval, allowance check, bound token (light + multi-token) |
| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before internal helper |
| calls-loop | Low | 16 | By design — wrapper must query each child rule; child rules are read-only |
| assembly | Informational | 2 | By design — memory-safe hash in _transferHash (light + multi-token) |
| naming-convention | Informational | 2 | By design — parameter names match ERC-2980 spec |
| unused-state | Informational | 8 | False positive — RuleNFTAdapter constants used in base dispatch (per-contract analysis limitation) |
Static analysis was performed with Aderyn. The full report and the project team's feedback are available in doc/security/audits/tools/v0.3.0/.
| ID | Title | Instances | Verdict |
|---|---|---|---|
| L-1 | Centralization Risk | 46 | Acknowledged — by design (regulated token issuer model) |
| L-2 | Unspecific Solidity Pragma | 54 | Acknowledged — intentional for a library |
| L-3 | Address State Variable Set Without Checks | 1 | False positive — check enforced in public-facing function |
| L-4 | PUSH0 Opcode | 54 | Acknowledged — project targets Prague EVM |
| L-5 | Modifier Invoked Only Once | 2 | Acknowledged — template method pattern; inlining would break abstraction |
| L-6 | Empty Block | 38 | Acknowledged — _authorize*() hooks use modifiers; intentional no-op implementations in required interface paths |
| L-7 | Costly operations inside loop | 6 | Acknowledged — unavoidable (EnumerableSet requires one SSTORE per element) |
| L-8 | Unchecked Return | 13 | Mixed — mostly false positives (void helpers or pre-checked single-item paths); constructor _grantRole intentionally ignored |
No high-severity issues were reported.
Static analysis was performed with Slither. The full report and the project team's feedback are available in doc/security/audits/tools/v0.3.0/.
| Category | Severity | Instances | Verdict |
|---|---|---|---|
| arbitrary-send-erc20 | High | 1 | False positive — from is guarded by onlyTransferApprover, ERC-20 allowance check, and a pre-recorded approval |
| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before calling internal helper |
| calls-loop | Low | 16 | Acknowledged — by design; wrapper must query each child rule; child rules are read-only |
| assembly | Informational | 1 | Acknowledged — intentional gas optimisation in _transferHash; minimal and well-scoped |
| naming-convention | Informational | 2 | Acknowledged — parameter names match ERC-2980 spec |
| unindexed-event-address | Informational | 2 | Out of scope (both in lib/RuleEngine); IAddressList events previously fixed |
| unused-state | Informational | 8 | False positive — RuleNFTAdapter constants used in base dispatch logic; Slither per-contract analysis limitation |
AI-assisted static analysis was performed with Wake Arena by Ackee Blockchain Security. The full report and the project team's feedback are available in doc/security/audits/tools/v0.2.0/.
Ackee Blockchain Security, Wake Arena AI Report | CMTA: Rules, March 16, 2026 18:00 UTC.
| ID | Title | Severity | Confidence | Verdict |
|---|---|---|---|---|
| H-1 | ConditionalTransferLight approvals not scoped by token | High | High | Fixed — single-token binding enforced in bindToken; RuleConditionalTransferLight_TokenAlreadyBound error added |
| M-1 | Incomplete supportsInterface breaks ERC-165 discovery |
Medium | High | Fixed — pre-computed constants + IERC7551Compliance + full ERC-3643 ICompliance ID (IERC3643ComplianceFull, 0x3144991c) added |
| I-1 | RuleERC2980 docs omit frozen spender on transferFrom |
Informational | High | Fixed (doc only) — README, AGENTS.md, and CLAUDE.md updated to document spender freeze path |
| I-2 | hasRole override: admin implicitly passes all role checks |
Informational | High | Fixed (doc only) — dedicated section added to README documenting intentional design and off-chain monitoring guidance |
The code is copyright (c) Capital Market and Technology Association, 2022-2026, and is released under Mozilla Public License 2.0.












