This repository includes the RuleEngine contracts for CMTAT and ERC-3643 tokens.
The RuleEngine is an external contract used to apply transfer restrictions to another contract, such as CMTAT and ERC-3643 tokens. Acting as a controller, it can call different contract rules and apply these rules on each transfer.
This project has not undergone an audit and is provided as-is without any warranties.
- Contract Variants
- Motivation
- How it works
- How to include it
- Interface
- Technical
- Functionality
- Ethereum API
- Security
- Documentation
- Toolchains and Usage
- Intellectual property
Six deployable contracts are available. Each access-control model comes without ERC-2771 and with it (*2771, immutable trusted forwarder):
| Contract | Access Control | Interface | Use Case |
|---|---|---|---|
RuleEngine |
Role-Based (AccessControlEnumerable) | RBAC roles | Multi-operator environments with granular permissions, no ERC-2771 |
RuleEngine2771 |
Role-Based (AccessControlEnumerable) | RBAC roles + ERC-2771 | Same as RuleEngine, administrable through an immutable trusted forwarder (gasless) |
RuleEngineOwnable |
ERC-173 Ownership | Ownable |
Single-owner setups, simpler administration, no ERC-2771 |
RuleEngineOwnable2771 |
ERC-173 Ownership | Ownable + ERC-2771 |
Same as RuleEngineOwnable, administrable through a trusted forwarder (gasless) |
RuleEngineOwnable2Step |
ERC-173 Ownership (two-step transfer) | Ownable2Step |
Single-owner setups with safer ownership handover, no ERC-2771 |
RuleEngineOwnable2Step2771 |
ERC-173 Ownership (two-step transfer) | Ownable2Step + ERC-2771 |
Same as RuleEngineOwnable2Step, administrable through a trusted forwarder (gasless) |
ERC-3643 compliance specification indicates the use of ERC-173.
The standard relies on ERC-173 to define contract ownership, with the owner having the responsibility of setting the Compliance parameters and binding the Compliance to a Token contract.
All deployable contracts share the same core functionality (RuleEngineBase, extended directly by RuleEngine or through RuleEngineOwnableShared) and support:
- ERC-1404 transfer restrictions
- ERC-3643 compliance interface
- Multiple token bindings
ERC-2771 meta-transactions (gasless) are supported by the three *2771 contracts only. RuleEngine, RuleEngineOwnable and RuleEngineOwnable2Step have no trusted forwarder: every caller is the raw msg.sender. Pick the *2771 version of an access-control model only when its operators should administer the engine through a forwarder.
Warning (shared engine across multiple tokens): A "multi-tenant" setup here means one RuleEngine instance is shared by several token contracts (all bound through
bindToken). In this setup, tokens must be equally trusted and governed together. ERC-3643 callbacks (transferred,created,destroyed) do not pass the token address to rules, so stateful/accounting rules are not safe for mutually untrusted tokens sharing the same engine.
- Why use a dedicated contract with rules instead of implementing it directly in CMTAT or ERC-3643 tokens?
There are several reasons to do this:
-
Flexibility: These different features are not standard and common to all tokens. From an implementation perspective, using a rule engine with custom rules allows for each issuer or contract user to decide which rules to apply.
-
Code efficiency: The CMTAT token (and generally also all ERC-3643 tokens) is currently "heavy," meaning its contract code size is close to the maximum limit. This makes it challenging to add new features directly inside the token contract.
-
Reusability:
-
The RuleEngine can be used inside other contracts besides CMTAT. For instance, the RuleEngine has been used in our contract to distribute dividends.
-
A same deployed
RuleEnginecan also be used with several different tokens if the rules allow it. This is the case for most read-only rules, but not for the supply and balance caps (RuleMaxTotalSupply,RuleChainlinkPoR,RuleMaxBalance), which read one token configured at deployment, nor for the operation rules. See Available Rules.
-
Why use this RuleEngine contract instead of setting the rule directly in the token contract?
- Using a RuleEngine allows to call several different rules. For example, a blacklist rule to allow the issuer to manage its own list of blacklisted addresses and a sanctionlist rule to use the Chainalysis oracle for sanctions screening to forbid transfers from addresses listed in sanctions designations published by organizations such as the US, EU, or UN.
When may the use of RuleEngine not be appropriate?
- If you plan to call only one rule (e.g a whitelist rule), it could make sense to directly set the rule in the token contract instead of using a RuleEngine. This will simplify configuration and reduce runtime gas costs. Two exceptions:
- ERC-3643 tokens need the engine. The token calls
bindTokenfromsetCompliance, andcreated/destroyedon mint and burn. The engine implements the full compliance surface; most rules (all validation rules) do not, sosetCompliance(rule)or a later mint/burn reverts. - Some rules must be set directly on the token.
RuleConditionalTransferLightMultiTokenkeys its approvals on the calling token (msg.sender); behind an engine the caller is the engine, so every transfer reverts or approvals are stranded. See Available Rules.
- ERC-3643 tokens need the engine. The token calls
This diagram illustrates how a transfer with a CMTAT or ERC-3643 token with a RuleEngine works:
Diagram source: doc/schema/plantuml/ruleengine-overview.puml.
- The token holders initiate a transfer transaction on the token contract.
- The transfer function inside the token calls the RuleEngine:
transferred(from, to, value)for a plain transfer,transferred(spender, from, to, value)for any CMTAT operation with an operator (transferFrom, mint, burn), orcreated/destroyedfor an ERC-3643 mint / burn. See Like CMTAT for the complete routing. - The Rule Engine calls each rule separately. If the transfer is not authorized by the rule, the rule must directly revert (no return value).
CMTAT and ERC-3643 tokens use disjoint entry points on the RuleEngine, so each has its own sequence diagram. The 4-argument transferred(spender, from, to, value) is declared by CMTAT's IRuleEngine and is never reached by an ERC-3643 token; conversely created / destroyed are declared by IERC3643Compliance and are never called by CMTAT. Only the 3-argument transferred(from, to, value) is shared.
Diagram source: doc/schema/plantuml/ruleengine-flow-cmtat.puml.
Diagram source: doc/schema/plantuml/ruleengine-flow-erc3643.puml.
Warning: The RuleEngine iterates over all configured rules on every transfer (and on every call to
detectTransferRestriction,canTransfer, etc.). Adding a large number of rules increases gas consumption for each transfer and may eventually exceed the block gas limit, effectively preventing any transfer from succeeding. An on-chain rule cap is enforced (maxRules), set to10by default, and can be changed by governance (DEFAULT_ADMIN_ROLEonRuleEngine, owner on ownable variants). A misconfigured or gas-heavy rule can still impact all transfers.
Warning (restriction code conventions): Rule implementations should use unique ERC-1404 restriction codes across the rule set. If several rules intentionally share the same restriction code, they should return the exact same
messageForTransferRestrictiontext for that code to avoid inconsistent operator/user feedback.
| RuleEngine version | Compatible Versions |
|---|---|
| v3.0.0-rc7 | CMTAT ≥ v3.0.0 CMTAT target version: v3.3.0-rc4 |
| v3.0.0-rc6 | CMTAT ≥ v3.0.0 CMTAT target version: v3.3.0-rc3 |
| v3.0.0-rc5 | CMTAT ≥ v3.0.0 CMTAT target version: v3.3.0-rc3 |
| v3.0.0-rc4 | CMTAT ≥ v3.0.0 CMTAT target version: v3.3.0-rc1 |
| v3.0.0-rc2 | CMTAT ≥ v3.0.0 CMTAT target version: v3.2.0 |
| v1.0.2.1 | CMTAT v2.3.0 (audited) |
Note (CMTAT v3.0.0 – v3.2.0): the engine works with every CMTAT ≥ v3.0.0, but on v3.0.0 – v3.2.0 mint, burn and the minter
batchTransferreach the engine on the 3-argumenttransferred(no spender). Spender-keyed rules (a per-minter mint allowance, minter screening) are therefore inert on those versions. The 4-argument routing of mint and burn starts with CMTAT v3.3.0, see CMTAT v3.0.0 and Like CMTAT.
CMTAT provides the following function to set a RuleEngine inside a CMTAT token:
setRuleEngine(IRuleEngine ruleEngine_) This function is defined in the extension module ValidationModuleRuleEngine
CMTAT v3.0.0, the last audited CMTAT release, is the oldest version the engine supports, and is tested against it (lib/CMTATv3.0.0). The engine needs no change, but the token routes operations differently from v3.3.0:
transfer→ 3-argumenttransferred(from, to, value);transferFrom→ 4-argumenttransferred(spender, from, to, value), as on later versions.mint/burn(and their batch, cross-chain andburnFromforms, and the minterbatchTransfer) → 3-argument, with no operator. Since v3.3.0 they take the 4-argument overload.- Consequence: a spender-keyed rule (per-minter mint allowance, minter screening) is inert on mint and burn up to v3.2.0, while rules enforced on the 3-argument path apply normally.
Table, consequences and tests: RuleEngine-with-CMTAT.md §CMTAT v3.0.0. Pinned by test/RuleEngine/RulesManagementModuleTest/CMTATOperationRoutingV3.t.sol.
ERC-3643 defined the following function in the standard interface to set a compliance contract
setCompliance(address _compliance)RuleEngine supports the ERC-3643/T-REX pattern where the token contract binds and unbinds itself when setCompliance is called.
In other words, a token can call:
bindToken(address(this))unbindToken(address(this))
To keep this feature secure, self-bind/self-unbind is gated:
- A token can call
bindToken(address(this))andunbindToken(address(this))only if it was explicitly approved first. - Approval is set by governance/compliance admin using:
setTokenSelfBindingApproval(address token, bool approved)
- Approval status can be checked with:
isTokenSelfBindingApproved(address token)
This preserves compatibility with ERC-3643 tokens that do:
if (address(_tokenCompliance) != address(0)) {
_tokenCompliance.unbindToken(address(this));
}
_tokenCompliance = IModularCompliance(_compliance);
_tokenCompliance.bindToken(address(this));while preventing arbitrary third-party contracts from self-binding.
Recommended operational sequence:
- On the target RuleEngine, grant self-binding approval for the token.
- Call token
setCompliance(newRuleEngine). - (Optional) Revoke self-binding approval after migration if no longer needed.
While the RuleEngine has been designed for CMTAT and ERC-3643 tokens, it can be used with other contracts to apply transfer restrictions.
For that, the only thing to do is to import in your contract the interface IRuleEngine(CMTAT) or IERC3643Compliance (ERC-3643), which declares the corresponding functions to call by the token contract. This interface can be found here (CMTAT v3.3.0-rc4, the version this project is tested against; also available locally in lib/CMTAT/contracts/interfaces/engine/IRuleEngine.sol).
If you need non-standard helper functions (batch bind/unbind, self-binding approval APIs, multi-token getter), use IERC3643ComplianceExtended.
Before each ERC-20 transfer, mint, or burn, CMTAT calls the RuleEngine from ValidationModuleRuleEngine._transferred (reached through _checkTransferred). The external call is isolated in _callRuleEngineTransferred, which dispatches to one of two transferred overloads depending on whether a non-zero spender is present.
// Called when spender == address(0): direct `transfer` only
function transferred(address from, address to, uint256 value)
// Called when spender != address(0): every other operation
function transferred(address spender, address from, address to, uint256 value)Since CMTAT v3.3.0, every operation initiated by an operator goes through the 4-argument overload, with the operator passed as spender. The routing is documented in the IRuleEngine.transferred NatSpec since CMTAT v3.3.0-rc4:
| Operation | Overload | spender |
from |
to |
|---|---|---|---|---|
transfer |
3-argument | — | token holder | recipient |
transferFrom |
4-argument | approved spender | token holder | recipient |
mint / batchMint / crosschainMint / mint leg of burnAndMint |
4-argument | minter / bridge (_msgSender()) |
address(0) |
recipient |
burn / batchBurn / burnFrom / self-burn / crosschainBurn / burn leg of burnAndMint |
4-argument | burner / bridge (_msgSender()) |
token holder | address(0) |
minter batchTransfer |
4-argument | minter (_msgSender()) |
minter (spender == from) |
recipient |
forcedTransfer (enforcer) |
not called | — | — | — |
The 3-argument overload is therefore only called for a direct transfer. forcedTransfer moves tokens without calling the engine, so stateful rules are not notified (unlike ERC-3643 T-REX, whose forcedTransfer notifies compliance). The reference list is CMTAT's ruleengine-integration.md; see also RuleEngine-with-CMTAT.md §2. Up to CMTAT v3.2.0, mint, burn and the minter batchTransfer used the 3-argument overload.
Rule authoring note: Rules that check the
spenderargument intransferred(spender, from, to, value)must explicitly handle the mint (from == address(0)) and burn (to == address(0)) cases. A spender check that is intended only fortransferFromwill also fire for mints and burns unless the rule skips it whenfromortois the zero address. SeeRuleWhitelistandRuleSpenderWhitelistin the Rules repository for reference implementations.
For example, CMTAT defines the interaction with the RuleEngine inside a specific module, ValidationModuleRuleEngine, and calls it from CMTATBaseRuleEngine (code below from CMTAT v3.3.0-rc4).
CMTATBaseRuleEngine: the token's common checks run first, then the RuleEngine hook.
function _checkTransferred(address spender, address from, address to, uint256 value) internal virtual override(CMTATBaseCommon) {
CMTATBaseCommon._checkTransferred(spender, from, to, value);
ValidationModuleRuleEngine._transferred(spender, from, to, value);
}ValidationModuleRuleEngine: the generic module checks (pause, freeze, …) revert on failure, then the engine is called if one is set. The overload is chosen in_callRuleEngineTransferred. That function isvirtual, so the Standard, Snapshot and ERC-7551 variants wrap it in anonReentrantguard.
function _transferred(address spender, address from, address to, uint256 value) internal virtual {
_canTransferGenericByModuleAndRevert(spender, from, to);
IRuleEngine ruleEngine_ = ruleEngine();
if (address(ruleEngine_) != address(0)) {
_callRuleEngineTransferred(ruleEngine_, spender, from, to, value);
}
}
function _callRuleEngineTransferred(
IRuleEngine ruleEngine_,
address spender,
address from,
address to,
uint256 value
) internal virtual {
if (spender != address(0)) {
ruleEngine_.transferred(spender, from, to, value);
} else {
ruleEngine_.transferred(from, to, value);
}
}_checkTransferred is called before each transfer, mint and burn. Since CMTAT v3.2.0, _transferred no longer returns a bool (it did up to v3.1.0): a rejected operation reverts in the module checks or in the engine itself.
The ERC-3643 defines several functions used as entrypoints for an ERC-3643 token.
As for CMTAT, the main entrypoint is transferred which must be called for each ERC-20 transfer.
Contrary to CMTAT, ERC-3643 does not apply restriction on the spender address (transferFrom).
They are the following:
// read-only function
function canTransfer(address from, address to, uint256 value) external view returns (bool);
// ERC-20 transfer
function transferred(address from, address to, uint256 value) external;
// mint
function created(address to, uint256 value) external;
// burn
function destroyed(address from, uint256 value) external;The RuleEngine base interface is defined in the CMTAT repository (IRuleEngine.sol).
IRuleEngineextendsIERC7551Compliance(which extendsIERC3643ComplianceRead),IERC3643IComplianceContractandIERC165.IRuleEngineERC1404extendsIERC1404Extend(which extendsIERC1404) andIRuleEngine. The engine implements this one, since the token exposes ERC-1404.
Since CMTAT v3.2.0, IRuleEngine no longer extends IERC1404Extend. See the IRuleEngine section for its sol2uml diagram.
// IRuleEngine
function transferred(address spender, address from, address to, uint256 value)
external;
// IERC-1404
function detectTransferRestriction(address from,address to,uint256 value)
external view returns (uint8);
function messageForTransferRestriction(uint8 restrictionCode)
external view returns (string memory);
// IERC-1404Extend
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
}
function detectTransferRestrictionFrom(address spender,address from,address to,uint256 value)
external view returns (uint8);
// IERC7551Compliance
function canTransferFrom(address spender,address from,address to,uint256 value)
external view returns (bool);
// IER3643ComplianceRead
function canTransfer(address from,address to,uint256 value)
external view returns (bool isValid);
// IERC3643IComplianceContract
function transferred(address from, address to, uint256 value)
external;Note:
IERC7551Compliancecomes fromdraft-IERC7551(not final) and, in this project, is used as a subset compliance interface focused oncanTransferFrom.
The ERC-3643 compliance interface is defined in IERC3643Compliance.sol. Non-standard helper functions are defined in IERC3643ComplianceExtended.sol.
Token binding itself is not specific to ERC-3643, so it is declared in the standard-agnostic
ITokenBinding.sol (bindToken, unbindToken, isTokenBound,
TokenBound / TokenUnbound) and ITokenBindingExtended.sol
(batch binding, token self-binding, getTokenBounds), which the two ERC-3643 interfaces above extend.
The RuleEngine modules are split as follows:
- Token binding registry, reusable outside any compliance context: TokenBindingModule.sol and TokenBindingExtendedModule.sol
- Base ERC-3643 surface: ERC3643ComplianceModule.sol
- Non-standard extensions: ERC3643ComplianceExtendedModule.sol
The toolchain includes the following components, where the versions are the latest ones that we tested:
- Foundry (forge-std) v1.17.0
- Solidity 0.8.37
- OpenZeppelin Contracts (submodule) v5.7.0
- CMTAT v3.3.0-rc4
Two access control mechanisms are available depending on which contract you deploy:
The RuleEngine and RuleEngine2771 contracts use Role-Based Access Control (RBAC) via OpenZeppelin's AccessControlEnumerable, wired once in RuleEngine; RuleEngine2771 extends it. They differ only in ERC-2771 support.
Each module defines the roles useful to restrict its functions. The contract overrides the OpenZeppelin function hasRole to give by default all the roles to the admin.
RulesManagementModule itself is access-control agnostic; RBAC is wired in RuleEngine.
Note: this hasRole override does not add the admin address to each role's enumerable member set. As a result, getRoleMember / getRoleMemberCount for a specific role do not include the admin unless that role is explicitly granted.
See also docs.openzeppelin.com - AccessControlEnumerable
The RuleEngineOwnable contract uses ERC-173 ownership via OpenZeppelin's Ownable.
All protected functions require the caller to be the contract owner. The owner can:
- Transfer ownership to another address via
transferOwnership(address) - Renounce ownership via
renounceOwnership()(makes the contract ownerless)
This is a simpler access control model suitable for single-owner deployments.
See also docs.openzeppelin.com - Ownable
The RuleEngineOwnable2Step contract uses OpenZeppelin's Ownable2Step, which keeps the same owner-only protections and adds safer ownership handover with transferOwnership(address) + acceptOwnership().
See also docs.openzeppelin.com - Ownable2Step
The table below summarizes which ERC-165 interfaces are advertised by each deployment version via supportsInterface(bytes4).
| Interface | Interface ID | RuleEngine / RuleEngine2771 (RBAC deployments) | RuleEngineOwnable / RuleEngineOwnable2771 | RuleEngineOwnable2Step / RuleEngineOwnable2Step2771 |
|---|---|---|---|---|
IERC165 |
0x01ffc9a7 |
✔ | ✔ | ✔ |
IRuleEngine |
0x20c49ce7 |
✔ | ✔ | ✔ |
IERC1404 |
0xab84a5c8 |
✔ | ✔ | ✔ |
IERC1404Extend |
0x78a8de7d |
✔ | ✔ | ✔ |
IERC3643Compliance |
0x3144991c |
✔ | ✔ | ✔ |
IERC3643ComplianceExtended |
0x646ba2be |
✔ | ✔ | ✔ |
IERC7551Compliance (subset) |
0x7157797f |
✔ | ✔ | ✔ |
IERC173 |
0x7f5828d0 |
✘ | ✔ | ✔ |
Ownable2Step specific (pendingOwner(), acceptOwnership()) |
0x9ab669ef |
✘ | ✘ | ✔ |
IAccessControl |
0x7965db0b |
✔ | ✘ | ✘ |
IAccessControlEnumerable |
0x5a05180f |
✔ | ✘ | ✘ |
The six interfaces common to all variants come from RuleEngineBase._supportsRuleEngineBaseInterface;
each deployable adds the IDs of its own access-control model on top. ERC-2771 has no ERC-165 id, so each *2771
contract advertises exactly the same set as its counterpart without a forwarder (pinned by
test/utils/ERC2771VariantTestBase.sol).
Notes:
RuleEngine/RuleEngine2771advertise OpenZeppelin RBAC interfaces becauseRuleEngineinheritsAccessControlEnumerableandRuleEngine2771extendsRuleEngine.RuleEngineOwnable/RuleEngineOwnable2Stepintentionally do not advertiseIAccessControl.Ownable2Stepspecific interface ID is defined inOwnable2StepInterfaceIdand includes onlypendingOwner()andacceptOwnership().- Two ERC-165 conventions coexist, both on purpose.
IRuleEngine(0x20c49ce7) is CMTAT's constant and is computed withoutIERC165.supportsInterface;IRule(0xb1a69752) is computed with it (RuleInterfaceIdXORs inIERC165). Both values are the ones deployed tokens and rules already check, and both are pinned bytest/RuleEngine/IRuleInterfaceId.t.sol: do not "fix" one to match the other. IRule(0xb1a69752) is deliberately absent from this table. The engine does not implementIRule; it requires it of every rule, checking it in_checkRulebefore a rule is added. Do not expectruleEngine.supportsInterface(0xb1a69752)to return true.IRulewas narrowed in v3.0.0-rc7. It requires the six functions the engine calls (transferredx2,detectTransferRestriction(From),messageForTransferRestriction,canReturnTransferRestrictionCode) plus ERC-165, and no longercanTransfer/canTransferFrom. The ID moved from0x2497d6cbto0xb1a69752. The engine accepts both: the legacy ID (RuleInterfaceId.IRULE_LEGACY_INTERFACE_ID) describes a superset of the current interface, so rules built before rc7 can still be added. A contract advertising neither, or not implementing ERC-165 correctly, is rejected withRuleEngine_RuleInvalidInterface.- The IDs are computed from the interfaces, not hardcoded — see
ComplianceInterfaceId,RuleInterfaceIdandERC1404InterfaceId, andtest/RuleEngine/IRuleInterfaceId.t.sol, which pins each one to the wire value above.type(I).interfaceIdcounts only the functionsIdeclares directly, so every constant XORs the interface with its parents. - Never use
type(IERC3643ComplianceExtended).interfaceId. That interface declares no function of its own — it only combinesIERC3643ComplianceandITokenBindingExtended— so the expression evaluates to0x00000000. UseComplianceInterfaceId.ERC3643_COMPLIANCE_EXTENDED_INTERFACE_ID(0x646ba2be), which is computed fromITokenBindingExtended.
Here is the list of roles and their 32 bytes identifier for the RuleEngine contract.
The default admin is the address put in argument (admin) inside the constructor.
It is set in the constructor when the contract is deployed.
Note: For
RuleEngineOwnableandRuleEngineOwnable2Step, all protected functions are controlled by the singleowneraddress instead of roles.
Warning (role assignment): Rule contracts should be treated as trusted logic components and kept separate from governance/operator identities. The protocol now enforces key protections on-chain: in RBAC deployments,
grantRolereverts if the target account is in the rule set; in ownable deployments,transferOwnershipreverts if the new owner is in the rule set. In multi-token deployments, do not grant any governance/operator privileges to token contract addresses (bound tokens should remain data-plane callers only, meaning runtime compliance callbacks such astransferred,created, anddestroyed). This token-privilege separation is intentionally documented as an operational constraint (not enforced on-chain) to preserve flexibility for integrators who explicitly want to extend their token and route selected RuleEngine control-plane actions through token logic (control-planehere means configuration/governance actions such asbindToken,unbindToken, role grants, ownership changes, and rule management).
| Defined in | 32 bytes identifier | |
|---|---|---|
| DEFAULT_ADMIN_ROLE | OpenZeppelin AccessControl |
0x0000000000000000000000000000000000000000000000000000000000000000 |
| Modules | ||
| COMPLIANCE_MANAGER_ROLE | ERC3643ComplianceRolesStorage | 0xe5c50d0927e06141e032cb9a67e1d7092dc85c0b0825191f7e1cede600028568 |
| RULES_MANAGEMENT_ROLE | RulesManagementModuleInvariantStorage | 0xea5f4eb72290e50c32abd6c23e45de3d8300b3286e1cbc2e293114b92e034e5e |
Here is a schema of the Access Control for RuleEngine.
Diagram source: doc/schema/plantuml/ruleengine-access-control.puml.
Here is a summary table for each restricted function defined in a module. For function signatures, struct arguments are represented with their corresponding native type.
Note: For
RuleEngineOwnableandRuleEngineOwnable2Step, replace the role requirement withonlyOwnerfor all protected functions.
| Function signature | Visibility [public/external] | Input variables (Function arguments) | Output variables (return value) |
Role Required | |
|---|---|---|---|---|---|
| Modules | |||||
| RulesManagementModule | |||||
setRules(address[] rules_) |
public | IRule[] rules_ |
- | RULES_MANAGEMENT_ROLE | |
clearRules() |
public | - | - | RULES_MANAGEMENT_ROLE | |
addRule(address rule_) |
public | IRule rule_ |
- | RULES_MANAGEMENT_ROLE | |
removeRule(address rule_) |
public | IRule rule_ |
- | RULES_MANAGEMENT_ROLE | |
setMaxRules(uint256 maxRules_) |
public | uint256 maxRules_ |
- | DEFAULT_ADMIN_ROLE | |
| TokenBindingModule | |||||
bindToken(address token) |
public | address token |
- | COMPLIANCE_MANAGER_ROLE or approved token self-call | |
unbindToken(address token) |
public | address token |
- | COMPLIANCE_MANAGER_ROLE or approved token self-call | |
| TokenBindingExtendedModule | |||||
bindTokens(address[] tokens) |
public | address[] tokens |
- | COMPLIANCE_MANAGER_ROLE | |
unbindTokens(address[] tokens) |
public | address[] tokens |
- | COMPLIANCE_MANAGER_ROLE | |
setTokenSelfBindingApproval(address token,bool approved) |
public | address token,bool approved |
- | COMPLIANCE_MANAGER_ROLE | |
setTokenSelfBindingApprovalBatch(address[] tokens,bool approved) |
public | address[] tokens,bool approved |
- | COMPLIANCE_MANAGER_ROLE | |
| RuleEngineBase | |||||
transferred(address from,address to,uint256 value) |
public | address from,address to, uint256 value |
- | onlyBoundToken (modifier) | |
transferred(address spender,address from,address to,uint256 value) |
public | address spender,address from,address to, uint256 value |
- | onlyBoundToken (modifier) |
Here is the UML of the main contracts. The diagrams are generated with sol2uml, one per contract or interface, by doc/script/script_sol2uml.sh; rerun that script after changing a contract's surface.
RuleEngine
├── RuleEngineBase (core functionality)
├── AccessControlEnumerable (RBAC)
├── ERC3643ComplianceRolesStorage
└── RulesManagementModuleRolesStorage
RuleEngine2771
├── ERC2771ModuleStandalone (gasless support)
└── RuleEngine
RuleEngine and RuleEngine2771 differ only by ERC-2771: same constructor checks, roles, grantRole / hasRole overrides and ERC-165 answers, all held by RuleEngine. RuleEngine2771 extends RuleEngine (the same pattern as RuleEngineOwnable2771 / RuleEngineOwnable2Step2771) and adds the immutable trusted forwarder and the _msgSender / _msgData / _contextSuffixLength overrides.
RuleEngineOwnable shares the same base functionality as RuleEngine but uses ERC-173 ownership instead of RBAC.
RuleEngineOwnable
├── RuleEngineOwnableShared (shared ownable deployment logic)
│ └── RuleEngineBase (core functionality)
│ ├── VersionModule
│ ├── RulesManagementModule
│ ├── ERC3643ComplianceModule (core ERC-3643)
│ ├── ERC3643ComplianceExtendedModule (project extensions)
│ └── IRuleEngineERC1404
└── Ownable (ERC-173 access control)
RuleEngineOwnable2771
├── ERC2771ModuleStandalone (gasless support)
└── RuleEngineOwnable
Key differences from RuleEngine:
- Constructor takes
owner_instead ofadmin - All protected functions use
onlyOwnermodifier - Supports
transferOwnership()andrenounceOwnership() - Implements ERC-173 interface (
supportsInterface(0x7f5828d0)returnstrue)
RuleEngineOwnable2Step shares the same base functionality as RuleEngineOwnable but uses OpenZeppelin's Ownable2Step for safer ownership handover.
RuleEngineOwnable2Step
├── RuleEngineOwnableShared (shared ownable deployment logic)
│ └── RuleEngineBase
│ ├── VersionModule
│ ├── RulesManagementModule
│ ├── ERC3643ComplianceModule (core ERC-3643)
│ ├── ERC3643ComplianceExtendedModule (project extensions)
│ └── IRuleEngineERC1404
└── Ownable2Step (ERC-173 access control with pending owner)
RuleEngineOwnable2Step2771
├── ERC2771ModuleStandalone (gasless support)
└── RuleEngineOwnable2Step
Each ownable *2771 contract is its counterpart plus the immutable trusted forwarder and the _msgSender / _msgData / _contextSuffixLength overrides; constructor checks, transferOwnership guard, hooks and ERC-165 are inherited unchanged.
Key differences from RuleEngineOwnable:
- Uses a two-step ownership transfer flow:
transferOwnership()thenacceptOwnership() - The current owner retains privileges until the pending owner accepts ownership
- The rule-account guard runs at nomination and again at acceptance: a pending owner that has been added as a rule cannot call
acceptOwnership()until it is removed from the rule set - Reuses
RuleEngineOwnableSharedfor the constructor and ERC-165 (via OpenZeppelinERC165) - Implements ERC-173 interface (
supportsInterface(0x7f5828d0)returnstrue) - Implements Ownable2Step-specific ERC-165 interface (
supportsInterface(0x9ab669ef)returnstrue), coveringpendingOwner()andacceptOwnership()
Here is the surya graph of RuleEngineBase, which holds the logic shared by the six deployables (each deployable
only adds its constructor, access-control hooks and, for the *2771 contracts, the ERC-2771 overrides; their own
graphs are in doc/schema/surya/surya_graph):
Several functionalities are not implemented because it makes more sense to directly implement them in the token smart contract
The RuleEngine can be removed from the main token contract by calling these dedicated functions
- CMTAT ≥ v3.0.0:
setRuleEngine(IRuleEngine ruleEngine_) - ERC-3643 token:
setCompliance(address _compliance)
Rules are maintained in a dedicated repository: github.com/CMTA/Rules.
Rules can be used in two ways:
- Directly on CMTAT (single-rule setup, no RuleEngine orchestration:
token.setRuleEngine(rule)). Every rule of the Rules repository advertisesRULE_ENGINE_INTERFACE_IDfor this role. Not for ERC-3643: the token callsbindToken,createdanddestroyed, which the validation rules do not implement — use the engine. No bound-token gate stands in front of the rule, so a stateful rule must restrict who calls itstransferred; see RuleEngine-with-CMTAT.md §4.8. Tested with the reference rules intest/RuleStandalone/. - Through this RuleEngine (multi-rule orchestration with sequential execution).
Warning:
RuleConditionalTransferLightMultiTokenmust be set directly on each token and never added to a RuleEngine: it keys approvals on the calling token (msg.sender), which is the engine when it sits behind one. It nevertheless advertisesIRule, soaddRuleaccepts it. See its documentation.
Rule families:
| Family | Behavior | Examples |
|---|---|---|
| Validation rules (read-only) | Evaluate transfer eligibility without mutating rule state | RuleWhitelist, RuleWhitelistWrapper, RuleReceiverWhitelist, RuleBlacklist, RuleSanctionsList, RuleIdentityRegistry, RuleSpenderWhitelist, RuleERC2980, RuleMaxTotalSupply, RuleMaxTotalSupplyERC3643, RuleMaxBalance, RuleChainlinkPoR, RuleChainlinkPoRERC3643 |
| Operation rules (read-write) | Evaluate transfer eligibility and can update rule-specific state on transfer | RuleConditionalTransferLight, RuleConditionalTransferLightMultiToken, RuleMintAllowance |
Additional integration notes:
- For RuleEngine integration, a rule must implement
IRuleand advertise its ERC-165 ID0xb1a69752. Since v3.0.0-rc7 that ID no longer includescanTransfer/canTransferFrom. A rule advertising only the older0x2497d6cbis still accepted. This is the case of every rule up to Rules v0.6.0, which is built against RuleEngine v3.0.0-rc6 and advertises0x2497d6cb. - RuleEngine executes configured rules in order and reverts on the first failing rule in state-changing paths.
- Restriction codes should remain unique across the composed rule set. Keep CMTAT-reserved ranges free and use dedicated code ranges per rule. The Rules repository uses codes
21to83(see the table below);RuleWhitelistandRuleWhitelistWrappershare21–25, and the two conditional-transfer rules share46, with the same messages. - The operation rules restrict who calls
transferred. Their hooks accept only the bound token or, forRuleConditionalTransferLight, a separately bound engine. Behind a RuleEngine the rule must therefore be told about the engine, or every transfer reverts:RuleConditionalTransferLight:bindToken(token)andbindRuleEngine(engine). Approvals are keyed(from, to, value)with no token dimension, so bind only an engine that serves this one token.RuleMintAllowance:bindToken(engine)(the caller it accepts is the engine, not the token).
- Some validation rules protect one token per instance.
RuleMaxTotalSupply,RuleChainlinkPoR, their ERC-3643 variants andRuleMaxBalancereadtotalSupply()/balanceOf()from a token configured at deployment, not from the token that triggered the check (the engine does not forward it). An instance added to an engine bound to several tokens checks every token against the configured one. Deploy one instance per token, or one engine per token. See RuleEngine-with-CMTAT.md §4.4. - ERC-3643 compatibility differs per rule. An ERC-3643 token never forwards a spender and calls compliance after it moves the value, so spender legs are inert, the stock cap rules count the amount twice, and
RuleMintAllowancedebits nothing. TheERC-3643column below summarises the Rules repository's per-rule compatibility matrix. - For the latest list of production rules, audits, and status, use the Rules repository as the source of truth.
Here is a summary tab of available rules, see github.com/CMTA/Rules, which remains the source of truth (this table was last synced with Rules v0.6.0). IdentityRegistryWhitelist, also in that repository, is an identity registry, not a rule: it implements no IRule and must never be added to a RuleEngine.
ERC-3643 column: ✔ works; ✔¹ works, but a spender check or flag never fires (the token forwards no spender); ⚠ use the …ERC3643 variant; ✘ not supported or inert.
| Rule | Type | Codes | ERC-3643 | Description |
|---|---|---|---|---|
| RuleWhitelist | Read-only | 21–25 | ✔¹ | Restricts transfers to addresses inside a whitelist. Mint and burn are enabled by allowMint / allowBurn flags; address(0) can never be listed. |
| RuleWhitelistWrapper | Read-only | 21–25 | ✔¹ | Restricts transfers to addresses listed in at least one of several whitelist rules managed by different operators. Child rules must declare themselves allow-lists; wrappers cannot be nested. |
| RuleBlacklist | Read-only | 36–38 | ✔¹ | Forbids transfers from/to blacklisted addresses. |
| RuleSanctionsList | Read-only | 30–32 | ✔¹ | Uses the Chainalysis sanctions oracle to forbid transfers from/to an address included in a sanctions designation (US, EU, or UN). |
| RuleMaxTotalSupply | Read-only | 50, 51 | ⚠ | Limits minting so that the total supply never exceeds a configured maximum. For CMTAT. One token per instance. |
| RuleMaxTotalSupplyERC3643 | Read-only | 50, 51 | ✔ | Same cap, for ERC-3643 tokens, which mint first and notify compliance afterwards. Not interchangeable with RuleMaxTotalSupply. |
| RuleMaxBalance | Read-only | 82, 83 | ✘ | Caps how many tokens a single address may hold, with operator-managed exemptions. CMTAT only. One token per instance. |
| RuleChainlinkPoR | Read-only | 75–79 | ⚠ | Caps minting at the reserves reported by a Chainlink Proof of Reserve feed. For CMTAT. One token per instance. |
| RuleChainlinkPoRERC3643 | Read-only | 75–79 | ✔ | Same reserve cap, for ERC-3643 tokens. Not interchangeable with RuleChainlinkPoR. |
| RuleReceiverWhitelist | Read-only | 81 | ✔ | Whitelist screening only the receiver, reproducing ERC-3643's eligibility rule. |
| RuleIdentityRegistry | Read-only | 55–57 | ✔¹ | Checks the ERC-3643 Identity Registry. Screens the receiver only by default, as ERC-3643 requires; sender and spender checks are opt-in flags. |
| RuleSpenderWhitelist | Read-only | 66 | ✘ | Blocks transferFrom when the spender is not in the whitelist. Direct transfers are always allowed. Inert on ERC-3643, which forwards no spender. |
| RuleERC2980 | Read-only | 60–65 | ✔¹ | 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 | 46 | ✔ | Transfers must be approved by an operator before being executed. Each approval is consumed once and the same transfer can be approved multiple times. Behind an engine: bindToken(token) and bindRuleEngine(engine), with an engine serving this token only. |
| RuleConditionalTransferLightMultiToken | Read-write | 46 | ✘ | Same approval mechanism, with approvals scoped per token. Set it directly on each token, never behind a RuleEngine (see the warning above). |
| RuleMintAllowance | Read-write | 70 | ✘ | Per-minter mint quota, debited on each mint. Uses the operator that CMTAT ≥ v3.3.0 passes as spender on the 4-argument transferred; inert on earlier CMTAT versions and on ERC-3643 (every mint passes). Behind an engine: bindToken(engine). Pre-check with canTransferFrom, not canTransfer. |
| RuleConditionalTransfer (external) | Read-write | — | — | 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) | — | — | — | 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. |
RuleEngine2771, RuleEngineOwnable2771 and RuleEngineOwnable2Step2771 support client-side gasless transactions using the standard ERC-2771. RuleEngine, RuleEngineOwnable and RuleEngineOwnable2Step do not: deploy the *2771 version of the access-control model you need a forwarder for.
The contract uses the OpenZeppelin contract ERC2771Context (through ERC2771ModuleStandalone), which allows a contract to get the original client with _msgSender() instead of the feepayer given by msg.sender.
At deployment, the constructor parameter forwarderIrrevocable of a *2771 contract has to be set with the address of the forwarder. To disable ERC-2771, deploy the version without the 2771 suffix rather than passing address(0).
After deployment, the forwarder is immutable and can not be changed.
ERC-2771 lets the trusted forwarder name the sender of a call: when msg.sender is the forwarder, _msgSender() returns the address appended to the calldata. This is what makes gasless administration possible, and the engine's administrative checks rely on it.
Two checks, however, identify a token rather than a person, and they compare the raw msg.sender instead:
| Check | Where | Identifies | Resolved with |
|---|---|---|---|
onlyBoundToken (_checkBoundToken) on transferred, created, destroyed |
TokenBindingModule |
the bound token calling the engine | msg.sender |
Token self-binding in bindToken / unbindToken (ERC-3643 setCompliance) |
TokenBindingExtendedModule._authorizeTokenBindingChange |
the approved token binding itself | msg.sender |
Rule management, binding manager, setMaxRules, self-binding approval, roles / ownership |
the access-control hooks of each deployable | an operator | _msgSender() (relayable) |
Purpose of the fix (v3.0.0-rc7). Up to rc6, both token checks used _msgSender(). A trusted forwarder could therefore append a bound token's address to a call and pass as that token:
- Forged callbacks. By calling
transferred/created/destroyed"as" a bound token, the forwarder drove every rule with no token movement. Read-only rules are unaffected, but stateful rules were not: a per-minter mint quota could be consumed with no mint, and a one-time transfer approval could be used up so that the real transfer then fails. - Forged self-unbinding. By calling
unbindToken(token)"as" a token approved for self-binding, the forwarder could unbind it. Every transfer, mint and burn of that token then reverted until an operator re-bound it.
A signature-verifying forwarder never produces such a call, because a token contract cannot sign a request. The exposure was a buggy, malicious or permissive forwarder, which is fixed at deployment. The fix removes that dependency: a token always calls the engine directly from its own transfer, mint, burn or setCompliance, never through a forwarder, so for every legitimate token call msg.sender == _msgSender(), and comparing msg.sender rejects only the impersonation. Operators can still bind and unbind tokens, and manage rules, through signed meta-transactions. CMTAT makes the same choice for its bridge gate (ERC20CrossChainModule.onlyTokenBridge).
The behaviour is pinned for the three ERC-2771 deployables by test/RuleEngine2771/ERC2771TokenGate.t.sol, test/RuleEngineOwnable2771/ERC2771TokenGate.t.sol and test/RuleEngineOwnable2Step2771/ERC2771TokenGate.t.sol (shared suite test/utils/ERC2771TokenGateTestBase.sol). See also RuleEngine-with-CMTAT.md §4.7.
References:
-
OpenGSN has deployed several forwarders, see their documentation for examples.
A proxy architecture (upgradeable) increases the code complexity as well as the runtime gas cost for each transaction. This is why the RuleEngine is not upgradeable.
Moreover, in a proxy architecture, each new implementation must be compatible (storage) with the precedent implementation, which can reduce the ability to improve the code.
In case you use the same RuleEngine for several different tokens, unfortunately, you will have to update the address of the RuleEngine set in each token contract separately.
There are no functionalities to put the RuleEngine in pause .
The RuleEngine can be removed from the main token contract by calling the dedicated functions to manage the RuleEngine
There are no functionalities to kill/deactivate the contracts.
Similar to the pause functionality, the RuleEngine can be directly removed from the main token contract.
constructor(
address admin,
address tokenContract
)| Parameter | Type | Description |
|---|---|---|
| admin | address | Address granted DEFAULT_ADMIN_ROLE (has all roles); must not be zero |
| tokenContract | address | Token to bind at deployment (can be zero) |
constructor(
address admin,
address forwarderIrrevocable,
address tokenContract
)| Parameter | Type | Description |
|---|---|---|
| admin | address | Address granted DEFAULT_ADMIN_ROLE (has all roles); must not be zero |
| forwarderIrrevocable | address | ERC-2771 trusted forwarder address, immutable |
| tokenContract | address | Token to bind at deployment (can be zero) |
Up to v3.0.0-rc6,
RuleEnginetook the three parameters ofRuleEngine2771. A deployment script that passes a forwarder must switch toRuleEngine2771; one that passesaddress(0)drops the middle argument.
constructor(
address owner_,
address tokenContract
)| Parameter | Type | Description |
|---|---|---|
| owner_ | address | Address set as contract owner (ERC-173); must not be zero (OwnableInvalidOwner) |
| tokenContract | address | Token to bind at deployment (can be zero) |
constructor(
address owner_,
address forwarderIrrevocable,
address tokenContract
)| Parameter | Type | Description |
|---|---|---|
| owner_ | address | Address set as contract owner (ERC-173); must not be zero (OwnableInvalidOwner) |
| forwarderIrrevocable | address | ERC-2771 trusted forwarder address, immutable |
| tokenContract | address | Token to bind at deployment (can be zero) |
Up to v3.0.0-rc6,
RuleEngineOwnableandRuleEngineOwnable2Steptook the three parameters of their*2771versions. A deployment script that passes a forwarder must switch to the*2771contract; one that passesaddress(0)drops the middle argument.
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| RuleEngineBase | Implementation | VersionModule, RulesManagementModule, ERC3643ComplianceExtendedModule, RuleEngineInvariantStorage, IRuleEngineERC1404 | ||
| └ | transferred | Public ❗️ | 🛑 | onlyBoundToken |
| └ | transferred | Public ❗️ | 🛑 | onlyBoundToken |
| └ | created | Public ❗️ | 🛑 | onlyBoundToken |
| └ | destroyed | Public ❗️ | 🛑 | onlyBoundToken |
| └ | detectTransferRestriction | Public ❗️ | NO❗️ | |
| └ | detectTransferRestrictionFrom | Public ❗️ | NO❗️ | |
| └ | messageForTransferRestriction | Public ❗️ | NO❗️ | |
| └ | canTransfer | Public ❗️ | NO❗️ | |
| └ | canTransferFrom | Public ❗️ | NO❗️ | |
| └ | _detectTransferRestriction | Internal 🔒 | ||
| └ | _detectTransferRestrictionFrom | Internal 🔒 | ||
| └ | _messageForTransferRestriction | Internal 🔒 | ||
| └ | _checkRule | Internal 🔒 | ||
| └ | _checkBindingTarget | Internal 🔒 | ||
| └ | _supportsRuleInterface | Internal 🔒 | ||
| └ | _supportsRuleEngineBaseInterface | Internal 🔒 |
| Symbol | Meaning |
|---|---|
| 🛑 | Function can modify state |
| 💵 | Function is payable |
What every rule implements (src/interfaces/IRule.sol). The diagram shows the two functions IRule declares
itself; it also inherits IERC1404Extend (detectTransferRestriction, detectTransferRestrictionFrom,
messageForTransferRestriction), IERC3643IComplianceContract (3-argument transferred) and IERC165, all
described below. ERC-165 ID 0xb1a69752; the legacy 0x2497d6cb (which also includes canTransfer /
canTransferFrom) is still accepted by the engine.
function transferred(address spender,address from,address to,uint256 value)
public virtual override(IRuleEngine)
onlyBoundTokenFunction called whenever tokens are transferred from one wallet to another.
Must revert if the transfer is invalid.
Same name as ERC-3643 but with an additional spender parameter.
This function can be used to update state variables of the RuleEngine contract.
Can only be called by the token contract bound to the RuleEngine.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| spender | address | The spender address initiating the transfer. |
| from | address | The token holder address. |
| to | address | The receiver address. |
| value | uint256 | The amount of tokens involved in the transfer. |
Note: ERC-7551 is draft (not final). The
IERC7551Complianceinterface used here is a subset interface exposing the compliance checkcanTransferFrom.
Checks if spender can transfer value tokens from from to to under compliance rules.
Does not check balances or access rights (Access Control).
Input Parameters:
| Name | Type | Description |
|---|---|---|
| spender | address | The address performing the transfer. |
| from | address | The source address. |
| to | address | The destination address. |
| value | uint256 | The number of tokens to transfer. |
Return Values:
| Type | Description |
|---|---|
| bool | True if the transfer complies with policy. |
Returns true if the transfer is valid, and false otherwise.
Does not check balances or access rights (Access Control).
Warning (spender-dependent rules fail open here): this signature carries no
spender. A rule whose decision depends on the spender — for example a per-minter mint allowance — cannot evaluate the operation on this path and must answer "no restriction". The RuleEngine aggregates that answer, socanTransferanddetectTransferRestrictioncan report a mint as allowed that the state-changingtransferred(spender, from, to, value)will revert. To pre-check an operation that has an operator, use the 4-argumentcanTransferFrom/detectTransferRestrictionFrom.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| from | address | The source address. |
| to | address | The destination address. |
| value | uint256 | The number of tokens to transfer. |
Return Values:
| Type | Description |
|---|---|
| bool | True if the transfer is valid, false otherwise. |
function transferred(address from,address to,uint256 value)
public virtual override(IERC3643IComplianceContract)
onlyBoundTokenUpdates the compliance contract state whenever tokens are transferred.
Can only be called by the token contract bound to this compliance logic. This function can be used to update internal state variables.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| from | address | The address of the sender. |
| to | address | The address of the receiver. |
| value | uint256 | The number of tokens involved in the transfer. |
function created(address to, uint256 value)
public virtual override(IERC3643Compliance)
onlyBoundTokenUpdates the compliance contract state when tokens are created (minted).
Called by the token contract when new tokens are issued to an account. Reverts if the minting does not comply with the rules.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| to | address | The address receiving the minted tokens. |
| value | uint256 | The number of tokens created. |
function destroyed(address from, uint256 value)
public virtual override(IERC3643Compliance)
onlyBoundToken Updates the compliance contract state when tokens are destroyed (burned).
Called by the token contract when tokens are redeemed or burned. Reverts if the burning does not comply with the rules.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| from | address | The address whose tokens are being destroyed. |
| value | uint256 | The number of tokens destroyed. |
Returns a uint8 code to indicate if a transfer is restricted or not.
Implements the restriction logic of {ERC-1404}. Examples of restriction logic include:
- checking if the recipient is whitelisted,
- checking if the sender’s tokens are frozen during a lock-up period, etc.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| from | address | The source address. |
| to | address | The destination address. |
| value | uint256 | The number of tokens to transfer. |
Return Values:
| Type | Description |
|---|---|
| uint8 | Restriction code (0 means the transfer is authorized). |
Returns a human-readable explanation for a transfer restriction code.
Implements {ERC-1404} standard message accessor.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| restrictionCode | uint8 | The restriction code to interpret. |
Return Values:
| Type | Description |
|---|---|
| string | A message describing why the transfer is restricted. |
Error codes for transfer restrictions.
Codes 6–9 are reserved for future CMTAT ruleEngine extensions.
| Name | Value | Description |
|---|---|---|
| TRANSFER_OK | 0 | Transfer authorized. |
| TRANSFER_REJECTED_PAUSED | 1 | Transfer rejected because the token is paused. |
| TRANSFER_REJECTED_FROM_FROZEN | 2 | Transfer rejected because the sender’s address is frozen. |
| TRANSFER_REJECTED_TO_FROZEN | 3 | Transfer rejected because the recipient’s address is frozen. |
| TRANSFER_REJECTED_SPENDER_FROZEN | 4 | Transfer rejected because the spender’s address is frozen. |
| TRANSFER_REJECTED_FROM_INSUFFICIENT_ACTIVE_BALANCE | 5 | Transfer rejected because the sender does not have enough active (unfrozen) balance. |
Returns a uint8 code to indicate if a transfer is restricted or not.
This is an extension of {ERC-1404} with an additional spender parameter to enforce restriction logic on delegated transfers.
Examples of restriction logic include:
- verifying if the recipient is whitelisted,
- verifying if tokens are locked for either sender or spender, etc.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| spender | address | The address initiating the transfer (for delegated transfers). |
| from | address | The source address. |
| to | address | The destination address. |
| value | uint256 | The number of tokens to transfer. |
Return Values:
| Type | Description |
|---|---|
| uint8 | Restriction code (0 means the transfer is authorized). |
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| VersionModule | Implementation | IERC3643Version | ||
| └ | version | Public ❗️ | NO❗️ |
function version() external view returns (string memory version_);function version()
public view virtual override(IERC3643Base)
returns (string memory version_)Description
Returns the current version of the RuleEngine contract (currently "3.0.0-rc7").
Useful for identifying which version of the smart contract is deployed and in use. Release candidates carry their -rcN suffix, so the string matches the release tag.
Return
| Name | Type | Description |
|---|---|---|
version_ |
string | The version string of the RuleEngine implementation (e.g., "3.0.0-rc7"). |
TokenBindingModule holds the token binding registry itself: the set of tokens allowed to call the
bound-token entry points, bindToken / unbindToken / isTokenBound, and the onlyBoundToken
guard. It is standard-agnostic — it contains no ERC-3643, ERC-1404 or rule logic and depends only on
OpenZeppelin's Context and EnumerableSet — so it can be reused as-is by any project that has to
bind tokens. A deployment only has to provide the access control by implementing
_onlyTokenBindingManager(); src/mocks/TokenBindingStandaloneMock.sol is a minimal example of such
a reuse, outside any compliance context.
TokenBindingExtendedModule adds the conveniences of the registry that are not part of any token
standard: batch bind/unbind, token self-binding approval (used by ERC-3643 setCompliance), and
getTokenBounds(). It also replaces the default binding authorization with one that accepts an
approved token binding itself.
The events and functions of both modules are documented in the events and functions reference below,
under ERC3643ComplianceExtendedModule, since that is the form in which the RuleEngine exposes them.
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| TokenBindingModule | Implementation | Context, ITokenBinding, TokenBindingModuleInvariantStorage | ||
| └ | bindToken | Public ❗️ | 🛑 | NO❗️ |
| └ | unbindToken | Public ❗️ | 🛑 | NO❗️ |
| └ | isTokenBound | Public ❗️ | NO❗️ | |
| └ | _bindToken | Internal 🔒 | 🛑 | |
| └ | _checkBindingTarget | Internal 🔒 | ||
| └ | _unbindToken | Internal 🔒 | 🛑 | |
| └ | _authorizeTokenBindingChange | Internal 🔒 | 🛑 | |
| └ | _onlyTokenBindingManager | Internal 🔒 | 🛑 | |
| └ | _checkBoundToken | Internal 🔒 | ||
| TokenBindingExtendedModule | Implementation | TokenBindingModule, ITokenBindingExtended | ||
| └ | bindTokens | Public ❗️ | 🛑 | onlyTokenBindingManager |
| └ | unbindTokens | Public ❗️ | 🛑 | onlyTokenBindingManager |
| └ | setTokenSelfBindingApproval | Public ❗️ | 🛑 | onlyTokenBindingManager |
| └ | setTokenSelfBindingApprovalBatch | Public ❗️ | 🛑 | onlyTokenBindingManager |
| └ | isTokenSelfBindingApproved | Public ❗️ | NO❗️ | |
| └ | getTokenBounds | Public ❗️ | NO❗️ | |
| └ | _unbindToken | Internal 🔒 | 🛑 | |
| └ | _setTokenSelfBindingApproval | Internal 🔒 | 🛑 | |
| └ | _authorizeTokenBindingChange | Internal 🔒 | 🛑 |
ERC3643ComplianceModule is a thin ERC-3643 adapter over TokenBindingModule: it adds the
ERC-3643 specific view getTokenBound() and names the binding manager in compliance terms, wiring
the generic _onlyTokenBindingManager() hook to _onlyComplianceManager(), which the deployable
contracts implement (COMPLIANCE_MANAGER_ROLE for RuleEngine, onlyOwner for the ownable
variants). The compliance callbacks transferred, created and destroyed are implemented by
RuleEngineBase, since they depend on the rules rather than on the binding.
| Contract | Type | Bases | ||
|---|---|---|---|---|
| └ | Function Name | Visibility | Mutability | Modifiers |
| ERC3643ComplianceModule | Implementation | TokenBindingModule, IERC3643Compliance | ||
| └ | getTokenBound | Public ❗️ | NO❗️ | |
| └ | _onlyTokenBindingManager | Internal 🔒 | 🛑 | |
| └ | _onlyComplianceManager | Internal 🔒 | 🛑 |
ERC3643ComplianceExtendedModule combines ERC3643ComplianceModule with TokenBindingExtendedModule and declares IERC3643ComplianceExtended. It carries no logic of its own: the project-specific helpers that are not part of the ERC-3643 base interface (IERC3643Compliance) — batch bind/unbind, self-binding approval APIs and getTokenBounds() — are standard-agnostic and therefore implemented in TokenBindingExtendedModule.
event TokenBound(address token)Emitted when a token is successfully bound to the compliance contract.
Event Parameters:
| Name | Type | Description |
|---|---|---|
| token | address | The address of the token that was bound. |
event TokenUnbound(address token)Emitted when a token is successfully unbound from the compliance contract.
Event Parameters:
| Name | Type | Description |
|---|---|---|
| token | address | The address of the token that was unbound. |
function bindToken(address token)
public override virtual Associates a token contract with this compliance contract.
Implemented by TokenBindingModule. Authorization goes through _authorizeTokenBindingChange, which
accepts the compliance manager (COMPLIANCE_MANAGER_ROLE for RuleEngine, the owner for the ownable
variants), or the token itself when its self-binding has been approved.
The compliance contract may restrict operations on the bound token according to its internal compliance logic. Reverts if the token is already bound.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| token | address | The address of the token to bind. |
function unbindToken(address token)
public override virtual Removes the association of a token contract from this compliance contract.
Implemented by TokenBindingModule, with the same authorization as bindToken.
Reverts if the token is not currently bound.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| token | address | The address of the token to unbind. |
function isTokenBound(address token)
public view virtual override
returns (bool)Checks whether a token is currently bound to this compliance contract.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| token | address | The token address to verify. |
Return Values:
| Type | Description |
|---|---|
| bool | True if the token is bound, false otherwise. |
function getTokenBound()
public view virtual override
returns (address)Returns the single token currently bound to this compliance contract.
If multiple tokens are supported, consider using getTokenBounds().
Note that there are no guarantees on the ordering of values inside the array, and it may change when more values are added or removed.
Return Values:
| Type | Description |
|---|---|
| address | The address of the currently bound token. |
function getTokenBounds()
public view override
returns (address[] memory)Returns all tokens currently bound to this compliance contract.
This is a view-only function and does not modify state. This function is not part of the original ERC-3643 specification.
This operation will copy the entire storage to memory, which can be quite expensive.
This is designed to mostly be used by view accessors that are queried without any gas fees.
Return Values:
| Type | Description |
|---|---|
| address[] | An array of addresses of bound token contracts. |
event AddRule(IRule indexed rule)Emitted when a new rule is added to the rule set.
Event Parameters:
| Name | Type | Description |
|---|---|---|
| rule | IRule | The address of the rule contract that was added. |
event RemoveRule(IRule indexed rule)Emitted when a rule is removed from the rule set.
Event Parameters:
| Name | Type | Description |
|---|---|---|
| rule | IRule | The address of the rule contract that was removed. |
event ClearRules()Emitted when all rules are cleared from the rule set.
This event has no parameters.
function setRules(IRule[] calldata rules_)
public virtual override(IRulesManagementModule)
onlyRole(RULES_MANAGEMENT_ROLE)Defines the complete list of rules for the rule engine.
Any previously configured rules are completely replaced.
Rules must be deployed contracts implementing the expected IRule interface.
Reverts if any rule address is zero or if duplicates are detected.
This function calls _clearRules if at least one rule is still configured
Input Parameters:
| Name | Type | Description |
|---|---|---|
| rules_ | IRule[] | The array of IRule contracts to configure as the active rules. |
function rulesCount()
public view virtual override(IRulesManagementModule)
returns (uint256)Returns the total number of currently configured rules.
Equivalent to the length of the internal rules array.
Return Values:
| Type | Description |
|---|---|
| uint256 | The number of active rules. |
function rule(uint256 ruleId)
public view virtual override(IRulesManagementModule)
returns (address)Retrieves the rule address at a specific index.
Return the zero address if out of bounds.
Note that there are no guarantees on the ordering of values inside the array, and it may change when more values are added or removed.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| ruleId | uint256 | The index of the desired rule in the array. |
Return Values:
| Type | Description |
|---|---|
| address | The address of the corresponding IRule contract. |
function rules()
public view virtual override(IRulesManagementModule)
returns (address[] memory)Returns the full list of currently configured rules.
This is a view-only function and does not modify state.
This operation will copy the entire storage to memory, which can be quite expensive.
This is designed to mostly be used by view accessors that are queried without any gas fees.
Return Values:
| Type | Description |
|---|---|
| address[] | An array containing all active rule contract addresses. |
function clearRules()
public virtual override(IRulesManagementModule)
onlyRole(RULES_MANAGEMENT_ROLE)Removes all configured rules.
After calling this function, no rules will remain set.
Developers should keep in mind that this function has an unbounded cost and using it may render the function uncallable if the set grows to the point where clearing it consumes too much gas to fit in a block.
function addRule(IRule rule_)
public virtual override(IRulesManagementModule)
onlyRole(RULES_MANAGEMENT_ROLE)Adds a new rule to the current rule set.
Reverts if the rule address is zero or already exists in the set.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| rule_ | IRule | The IRule contract to add. |
function removeRule(IRule rule_)
public virtual
override(IRulesManagementModule)
onlyRole(RULES_MANAGEMENT_ROLE)Removes a specific rule from the current rule set.
Reverts if the provided rule is not found or does not match the stored rule at its index.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| rule_ | IRule | The IRule contract to remove. |
function containsRule(IRule rule_)
public view virtual override(IRulesManagementModule)
returns (bool)Checks whether a specific rule is currently configured.
Input Parameters:
| Name | Type | Description |
|---|---|---|
| rule_ | IRule | The IRule contract to check for membership. |
Return Values:
| Type | Description |
|---|---|
| bool | True if the rule is present, false otherwise. |
Please see SECURITY.md (CMTAT main repository).
The contracts (v.1.0.2) have been audited by ABDK Consulting, a globally recognized firm specialized in smart contracts' security.
Fixed version : v1.0.2
The first audit was performed by ABDK on the version 1.0.1.
The release v1.0.2 contains the different fixes and improvements related to this audit.
The final report is available in ABDK_CMTA_CMTATRuleEngine_v_1_0.pdf.
Note: This scan was performed by an AI-powered automated tool, not a formal human-led audit.
| Version | Report | Assessment |
|---|---|---|
| Scan #1 (Feb 2026) | audit_agent_report_1_v3.0.0-rc1.pdf | feedback.md |
7 findings — 0 High · 1 Medium · 1 Low · 4 Info · 1 Best Practices
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | Medium | Cross-token rule state pollution in multi-tenant deployments | NatSpec + README warnings. Interface fix deferred (requires CMTAT coordination). |
| 2 | Low | RuleEngineOwnable misreports IAccessControl via ERC-165 |
Fixed: explicit interface whitelist + negative test added. |
| 3 | Info | Unbounded rules loop — potential permanent DoS | Fixed in v3.0.0-rc3: on-chain configurable cap (maxRules) with default 10, enforced in addRule and setRules. |
| 4 | Info | Restriction code and message can come from different rules | Convention documented in NatSpec and README (no logic change by design). |
| 5 | Info | Re-entrant rule can modify rule set during transferred() |
Fixed in v3.0.0-rc3: rule accounts cannot receive roles in RBAC RuleEngine; ownable variants reject ownership transfer to rule accounts. |
| 6 | Info | Missing ERC-3643 and IERC7551Compliance interface IDs | Fixed: both IDs added to supportsInterface in both contracts, with tests. |
| 7 | Best Practices | setRules does not allow an empty array |
NatSpec clarification added (behavior unchanged by design). |
Here is the list of report performed with Slither
| Version | Report | Assessment |
|---|---|---|
| v3.0.0-rc7 | slither-report.md | slither-report-feedback.md |
| v3.0.0-rc6 | slither-report.md | slither-report-feedback.md |
| v3.0.0-rc5 | slither-report.md | slither-report-feedback.md |
| v3.0.0-rc4 | slither-report.md | slither-report-feedback.md |
| v3.0.0-rc3 | slither-report.md | slither-report-feedback.md |
| v3.0.0-rc2 | slither-report.md | slither-report-feedback.md |
slither . --checklist --filter-paths "openzeppelin-contracts|test|CMTAT|forge-std|mocks" > slither-report.mdLatest run (v3.0.0-rc7, 29556e5, 111 contracts analysed, mocks excluded; findings unchanged from rc6): 2 finding categories —
0 High · 0 Medium · 10 Low · 2 Informational. Nothing to fix.
| ID | Detector | Impact | Instances | Assessment |
|---|---|---|---|---|
| 0–9 | calls-loop |
Low | 10 | Accepted by design — fan-out to rule contracts is the core architecture |
| 10–11 | unindexed-event-address |
Informational | 2 | Accepted by design — TokenBound/TokenUnbound keep the unindexed signature of the ERC-3643 reference |
Here is the list of report performed with Aderyn
aderyn -x mocks --output aderyn-report.md| Version | Report | Assessment |
|---|---|---|
| v3.0.0-rc7 | aderyn-report.md | aderyn-report-feedback.md |
| v3.0.0-rc6 | aderyn-report.md | aderyn-report-feedback.md |
| v3.0.0-rc5 | aderyn-report.md | aderyn-report-feedback.md |
| v3.0.0-rc4 | aderyn-report.md | aderyn-report-feedback.md |
| v3.0.0-rc3 | aderyn-report.md | aderyn-report-feedback.md |
| v3.0.0-rc2 | aderyn-report.md | aderyn-report-feedback.md |
Latest run (v3.0.0-rc7, 29556e5): 31 Solidity files, 716 nSLOC, mocks excluded. Findings unchanged from rc6.
0 High · 8 Low (91 instances). Nothing to fix.
| ID | Finding | Instances | Assessment |
|---|---|---|---|
| L-1 | Centralization Risk | 14 | Accepted by design — privileged compliance tool |
| L-2 | Unspecific Solidity Pragma | 26 | Accepted by design — intentional for library reusability |
| L-3 | PUSH0 Opcode | 31 | Not applicable — project targets the Osaka EVM, which has PUSH0 (since Shanghai) |
| L-4 | Modifier Invoked Only Once | 1 | Accepted by design — keeps hook-style access-control abstraction |
| L-5 | Empty Block | 10 | Accepted by design — access-control hook pattern, and the default binding-target hook |
| L-6 | Loop Contains require/revert |
4 | Accepted by design — setRules and bindTokens/unbindTokens are atomic batch operations |
| L-7 | Costly Operations Inside Loop | 4 | Accepted — unavoidable SSTORE in batch operations |
| L-8 | Unchecked Return | 1 | Accepted — _grantRole return is irrelevant in constructor |
Overview of every analysis performed: AUDIT_OVERVIEW.md.
Here a summary of the main documentation
| Document | Link/Files |
|---|---|
| Integration with CMTAT | doc/technical/RuleEngine-with-CMTAT.md |
| Integration with ERC-3643 | doc/technical/RuleEngine-with-ERC3643.md |
| Token binding module (reusable outside this project) | doc/technical/TokenBinding-module.md |
| Toolchain | doc/TOOLCHAIN.md |
| Surya report | doc/schema/surya |
| Code-quality review (incl. tests) | doc/security/audits/tools/v3.0.0-rc7/CLAUDE_ANALYSIS.md |
| Script review | doc/security/audits/tools/v3.0.0-rc5/CLAUDE_ANALYSIS_SCRIPT.md |
| Audit overview | doc/security/audits/AUDIT_OVERVIEW.md |
See also Taurus - Token Transfer Management: How to Apply Restrictions with CMTAT and ERC-1404 (RuleEngine v2.02 and CMTAT v2.4.0)
This repository is primarily developed and tested with Foundry.
Hardhat configuration is also present to allow compiling the contracts and running a small smoke test with Hardhat.
Parts of this project were written with the help of AI coding assistants, principally Claude Code (Anthropic) and Codex (OpenAI).
Here are the settings for Hardhat and Foundry.
-
hardhat.config.js- Solidity v0.8.37
- EVM version: Osaka (Fusaka upgrade)
- Optimizer: true, 200 runs
-
foundry.toml- Solidity v0.8.37
- EVM version: Osaka (Fusaka upgrade)
- Optimizer: true, 200 runs
The contracts are developed and tested with Foundry, a smart contract development toolchain.
To install the Foundry suite, please refer to the official instructions in the Foundry book.
You must first initialize the submodules, with
forge install
See also the command's documentation.
Later you can update all the submodules with:
forge update
See also the command's documentation.
The official documentation is available in the Foundry website
# Build all contracts
forge build
# Build specific contract
forge build --contracts src/deployment/accessControl/RuleEngine.sol
forge build --contracts src/deployment/ownable/RuleEngineOwnable.sol
forge build --contracts src/deployment/ownable/RuleEngineOwnable2Step.solThe compiled artifacts of the six deployables for v3.0.0-rc7 (ABI, bytecode, metadata; solc 0.8.37, EVM Osaka, optimizer 200 runs) are committed in doc/compilation/v3.0.0-rc7: foundry/ holds the forge build output (out/), hardhat/ the npx hardhat compile output (artifacts/, the .dbg.json files point to a build-info file that is not committed). Both toolchains produce the same runtime bytecode size.
forge build --sizesLatest output (2026-10-16, v3.0.0-rc7) for the six deployables:
| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) |
|---|---|---|---|---|
| RuleEngine | 7,997 | 8,943 | 16,579 | 40,209 |
| RuleEngine2771 | 8,400 | 9,616 | 16,176 | 39,536 |
| RuleEngineOwnable | 6,970 | 7,708 | 17,606 | 41,444 |
| RuleEngineOwnable2771 | 7,368 | 8,161 | 17,208 | 40,991 |
| RuleEngineOwnable2Step | 7,219 | 7,982 | 17,357 | 41,170 |
| RuleEngineOwnable2Step2771 | 7,657 | 8,475 | 16,919 | 40,677 |
All six remain well below the EIP-170 runtime limit (24,576 B). The ownable variants are smaller because Ownable has less overhead than AccessControlEnumerable, and each *2771 variant adds about 400 B for the trusted forwarder.
You can run the tests with
forge testTo run a specific test, use
forge test --match-contract <contract name> --match-test <function name>Generate gas report
forge test --gas-reportThe tests that deploy the real ERC-3643 token (T-REX 4.2.0-beta1, pragma solidity 0.8.30) run in a dedicated profile and are skipped by the default one:
FOUNDRY_PROFILE=erc3643 forge testCI runs both profiles (.github/workflows/ci.yml). See RuleEngine-with-ERC3643.md §4.8.
See also the test framework's official documentation, and that of the test commands.
There is also a small Hardhat smoke test to confirm the main RuleEngine contract can be compiled and deployed through Hardhat:
npx hardhat test test/hardhat/RuleEngine.smoke.jsA code coverage is available in index.html.
- Perform a code coverage
forge coverage
- Generate LCOV report
forge coverage --report lcov
- Generate
index.html
forge coverage --no-match-coverage "(mocks|test)" --report lcov && genhtml lcov.info --branch-coverage --output-dir coverageSee Solidity Coverage in VS Code with Foundry & Foundry forge coverage
The official documentation is available in the Foundry website
| Scenario | Recommended Contract |
|---|---|
| Multiple operators with different permissions | RuleEngine |
| Multiple operators, administration through gasless meta-transactions | RuleEngine2771 |
| Single administrator | RuleEngineOwnable |
| Single administrator with safer ownership handover | RuleEngineOwnable2Step |
| Any of the above, administered through gasless meta-transactions | the same contract with the 2771 suffix |
| Integration with existing RBAC systems | RuleEngine |
| Simpler deployment and management | RuleEngineOwnable |
The scripts in script/ are example deployment flows.
Warning:
RuleEngineScript.s.solandCMTATWithRuleEngineScript.s.soldeployRuleWhitelistMockfromsrc/mocks/. That contract is a reference/mock rule for testing and demos, not a production rule contract.
For production deployments, source rule contracts from the dedicated CMTA/Rules repository and adapt the script parameters accordingly.
To run the example scripts, create a .env file. The value for CMTAT_ADDRESS is required only for RuleEngineScript.s.sol.
Warning: putting your private key in a .env file is not the most secure approach.
- File
.env
PRIVATE_KEY=<YOUR_PRIVATE_KEY>
CMTAT_ADDRESS=<CMTAT_ADDRESS>
Private Keys: Never expose your private keys. The .env file here used in this project should not be used for production. See getfoundry.sh - Key Management
- Command
CMTAT with RuleEngine
forge script script/CMTATWithRuleEngineScript.s.sol:CMTATWithRuleEngineScript --rpc-url=$RPC_URL --broadcast --verify -vvv- Value of YOUR_RPC_URL with a local instance of anvil : 127.0.0.1:8545
forge script script/CMTATWithRuleEngineScript.s.sol:CMTATWithRuleEngineScript --rpc-url=127.0.0.1:8545 --broadcast --verify -vvvOnly RuleEngine with the mock/reference RuleWhitelistMock contract
forge script script/RuleEngineScript.s.sol:RuleEngineScript --rpc-url=$RPC_URL --broadcast --verify -vvv- With anvil
forge script script/RuleEngineScript.s.sol:RuleEngineScript --rpc-url=127.0.0.1:8545 --broadcast --verify -vvv- Choose the access-control model (
RuleEngine,RuleEngineOwnableorRuleEngineOwnable2Step), then whether it needs ERC-2771. - If ERC-2771 support is needed, deploy the
*2771version and choose its trusted forwarder address; otherwise deploy the version without the suffix. A forwarder must be a signature-verifying ERC-2771 forwarder: it can relay administrative calls (rule management, binding) on behalf of any operator who signs them. It cannot act as a token (see Gasless support). - Decide whether the token should be bound in the constructor or later via
bindToken. - CMTAT: bind the token on the engine (
engine.bindToken(cmtat), or the constructor'stokenContractargument) — CMTAT never self-binds, and an engine with no bound token rejects every transfer withTokenBinding_UnauthorizedCaller. Then point the token at the engine withcmtat.setRuleEngine(engine)(tokenDEFAULT_ADMIN_ROLE), or pass it at token construction. - ERC-3643: grant
setTokenSelfBindingApproval(token, true)on the engine before callingtoken.setCompliance(engine), which self-binds the token. - Source production rule contracts from the CMTA/Rules repository, not from
src/mocks/. - Verify post-deployment permissions: owner for ownable variants, or admin plus role assignments for the RBAC variants.
RuleEngine follows the solidity style guideline and the natspec format for comments
We use Foundry's built-in formatter and linter:
# Format all Solidity files
forge fmt
# Check formatting without modifying files
forge fmt --check
# Run the Solidity linter
forge lint- Orders of Functions
Functions are grouped according to their visibility and ordered:
1. constructor
2. receive function (if exists)
3. fallback function (if exists)
4. external
5. public
6. internal
7. private
Within a grouping, place the view and pure functions last
- Function declaration
1. Visibility
2. Mutability
3. Virtual
4. Override
5. Custom modifiers
The code is copyright (c) Capital Market and Technology Association, 2022-2026, and is released under Mozilla Public License 2.0.

































