Skip to content

Latest commit

 

History

History
2089 lines (1385 loc) · 105 KB

README.md

File metadata and controls

2089 lines (1385 loc) · 105 KB

RuleEngine

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.

Table of Contents

Contract Variants

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.

Motivation

  • 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 RuleEngine can 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 bindToken from setCompliance, and created / destroyed on mint and burn. The engine implements the full compliance surface; most rules (all validation rules) do not, so setCompliance(rule) or a later mint/burn reverts.
    • Some rules must be set directly on the token. RuleConditionalTransferLightMultiToken keys 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.

How it works

This diagram illustrates how a transfer with a CMTAT or ERC-3643 token with a RuleEngine works:

RuleEngine overview

Diagram source: doc/schema/plantuml/ruleengine-overview.puml.

  1. The token holders initiate a transfer transaction on the token contract.
  2. 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), or created / destroyed for an ERC-3643 mint / burn. See Like CMTAT for the complete routing.
  3. 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.

With a CMTAT token

RuleEngine flow with a CMTAT token

Diagram source: doc/schema/plantuml/ruleengine-flow-cmtat.puml.

With an ERC-3643 token

RuleEngine flow with an ERC-3643 token

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 to 10 by default, and can be changed by governance (DEFAULT_ADMIN_ROLE on RuleEngine, 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 messageForTransferRestriction text for that code to avoid inconsistent operator/user feedback.

How to set it

Compatibility

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 batchTransfer reach the engine on the 3-argument transferred (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 ≥ v3.0.0

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

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-argument transferred(from, to, value); transferFrom → 4-argument transferred(spender, from, to, value), as on later versions.
  • mint / burn (and their batch, cross-chain and burnFrom forms, and the minter batchTransfer) → 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 token

ERC-3643 defined the following function in the standard interface to set a compliance contract

setCompliance(address _compliance)

Making setCompliance work with RuleEngine

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)) and unbindToken(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:

  1. On the target RuleEngine, grant self-binding approval for the token.
  2. Call token setCompliance(newRuleEngine).
  3. (Optional) Revoke self-binding approval after migration if no longer needed.

How to include it

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.

Like CMTAT

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)

CMTAT v3.3.0 — mint and burn use the spender path

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 spender argument in transferred(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 for transferFrom will also fire for mints and burns unless the rule skips it when from or to is the zero address. See RuleWhitelist and RuleSpenderWhitelist in 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 is virtual, so the Standard, Snapshot and ERC-7551 variants wrap it in a nonReentrant guard.
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.

Like ERC-3643

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;

Interface

CMTAT

The RuleEngine base interface is defined in the CMTAT repository (IRuleEngine.sol).

  • IRuleEngine extends IERC7551Compliance (which extends IERC3643ComplianceRead), IERC3643IComplianceContract and IERC165.
  • IRuleEngineERC1404 extends IERC1404Extend (which extends IERC1404) and IRuleEngine. 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: IERC7551Compliance comes from draft-IERC7551 (not final) and, in this project, is used as a subset compliance interface focused on canTransferFrom.

ERC-3643

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:

ERC3643ComplianceModuleUML

Technical

Dependencies

The toolchain includes the following components, where the versions are the latest ones that we tested:

Access Control

Two access control mechanisms are available depending on which contract you deploy:

RuleEngine and RuleEngine2771 (RBAC - AccessControlEnumerable)

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

RuleEngineOwnable (ERC-173 Ownership)

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

RuleEngineOwnable2Step (ERC-173 Ownership, two-step transfer)

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

ERC-165 Support by Deployment Version

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 / RuleEngine2771 advertise OpenZeppelin RBAC interfaces because RuleEngine inherits AccessControlEnumerable and RuleEngine2771 extends RuleEngine.
  • RuleEngineOwnable / RuleEngineOwnable2Step intentionally do not advertise IAccessControl.
  • Ownable2Step specific interface ID is defined in Ownable2StepInterfaceId and includes only pendingOwner() and acceptOwnership().
  • Two ERC-165 conventions coexist, both on purpose. IRuleEngine (0x20c49ce7) is CMTAT's constant and is computed without IERC165.supportsInterface; IRule (0xb1a69752) is computed with it (RuleInterfaceId XORs in IERC165). Both values are the ones deployed tokens and rules already check, and both are pinned by test/RuleEngine/IRuleInterfaceId.t.sol: do not "fix" one to match the other.
  • IRule (0xb1a69752) is deliberately absent from this table. The engine does not implement IRule; it requires it of every rule, checking it in _checkRule before a rule is added. Do not expect ruleEngine.supportsInterface(0xb1a69752) to return true.
  • IRule was narrowed in v3.0.0-rc7. It requires the six functions the engine calls (transferred x2, detectTransferRestriction(From), messageForTransferRestriction, canReturnTransferRestrictionCode) plus ERC-165, and no longer canTransfer / canTransferFrom. The ID moved from 0x2497d6cb to 0xb1a69752. 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 with RuleEngine_RuleInvalidInterface.
  • The IDs are computed from the interfaces, not hardcoded — see ComplianceInterfaceId, RuleInterfaceId and ERC1404InterfaceId, and test/RuleEngine/IRuleInterfaceId.t.sol, which pins each one to the wire value above. type(I).interfaceId counts only the functions I declares 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 combines IERC3643Compliance and ITokenBindingExtended — so the expression evaluates to 0x00000000. Use ComplianceInterfaceId.ERC3643_COMPLIANCE_EXTENDED_INTERFACE_ID (0x646ba2be), which is computed from ITokenBindingExtended.

Role list (RuleEngine only)

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 RuleEngineOwnable and RuleEngineOwnable2Step, all protected functions are controlled by the single owner address 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, grantRole reverts if the target account is in the rule set; in ownable deployments, transferOwnership reverts 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 as transferred, created, and destroyed). 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-plane here means configuration/governance actions such as bindToken, 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

Schema (RuleEngine)

Here is a schema of the Access Control for RuleEngine.

RuleEngine access control

Diagram source: doc/schema/plantuml/ruleengine-access-control.puml.

Role by modules (RuleEngine)

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 RuleEngineOwnable and RuleEngineOwnable2Step, replace the role requirement with onlyOwner for 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)

UML

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

RuleEngineUML

RuleEngine2771UML

surya_inheritance_RuleEngine surya_inheritance_RuleEngine2771

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

RuleEngineOwnableUML

RuleEngineOwnable shares the same base functionality as RuleEngine but uses ERC-173 ownership instead of RBAC.

RuleEngineOwnable2771UML

surya_inheritance_RuleEngineOwnable surya_inheritance_RuleEngineOwnable2771 surya_inheritance_RuleEngineOwnableShared

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 of admin
  • All protected functions use onlyOwner modifier
  • Supports transferOwnership() and renounceOwnership()
  • Implements ERC-173 interface (supportsInterface(0x7f5828d0) returns true)

RuleEngineOwnable2Step

RuleEngineOwnable2StepUML

RuleEngineOwnable2Step shares the same base functionality as RuleEngineOwnable but uses OpenZeppelin's Ownable2Step for safer ownership handover.

RuleEngineOwnable2Step2771UML

surya_inheritance_RuleEngineOwnable2Step surya_inheritance_RuleEngineOwnable2Step2771

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() then acceptOwnership()
  • 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 RuleEngineOwnableShared for the constructor and ERC-165 (via OpenZeppelin ERC165)
  • Implements ERC-173 interface (supportsInterface(0x7f5828d0) returns true)
  • Implements Ownable2Step-specific ERC-165 interface (supportsInterface(0x9ab669ef) returns true), covering pendingOwner() and acceptOwnership()

Graph

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):

surya_graph_RuleEngineBase

Functionality

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)

Available Rules

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 advertises RULE_ENGINE_INTERFACE_ID for this role. Not for ERC-3643: the token calls bindToken, created and destroyed, 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 its transferred; see RuleEngine-with-CMTAT.md §4.8. Tested with the reference rules in test/RuleStandalone/.
  • Through this RuleEngine (multi-rule orchestration with sequential execution).

Warning: RuleConditionalTransferLightMultiToken must 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 advertises IRule, so addRule accepts 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 IRule and advertise its ERC-165 ID 0xb1a69752. Since v3.0.0-rc7 that ID no longer includes canTransfer / canTransferFrom. A rule advertising only the older 0x2497d6cb is 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 advertises 0x2497d6cb.
  • 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 21 to 83 (see the table below); RuleWhitelist and RuleWhitelistWrapper share 21–25, and the two conditional-transfer rules share 46, with the same messages.
  • The operation rules restrict who calls transferred. Their hooks accept only the bound token or, for RuleConditionalTransferLight, a separately bound engine. Behind a RuleEngine the rule must therefore be told about the engine, or every transfer reverts:
    • RuleConditionalTransferLight: bindToken(token) and bindRuleEngine(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 and RuleMaxBalance read totalSupply() / 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 RuleMintAllowance debits nothing. The ERC-3643 column 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.

Rules details

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.

Gasless support (ERC-2771)

surya_inheritance_ERC2771ModuleStandalone.sol

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.

Token calls are never resolved through the forwarder

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:

Upgradeable

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.

Urgency mechanism

Pause

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

Kill / Deactivate the contracts

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.

Ethereum API

Contract Constructors

RuleEngine Constructor

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)

RuleEngine2771 Constructor

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, RuleEngine took the three parameters of RuleEngine2771. A deployment script that passes a forwarder must switch to RuleEngine2771; one that passes address(0) drops the middle argument.

RuleEngineOwnable and RuleEngineOwnable2Step Constructors

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)

RuleEngineOwnable2771 and RuleEngineOwnable2Step2771 Constructors

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, RuleEngineOwnable and RuleEngineOwnable2Step took the three parameters of their *2771 versions. A deployment script that passes a forwarder must switch to the *2771 contract; one that passes address(0) drops the middle argument.

RuleEngineBase

RuleEngineBaseUML

Contracts Description Table

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 🔒
Legend
Symbol Meaning
🛑 Function can modify state
💵 Function is payable

IRule

IRuleUML

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.

IRuleEngine

IRuleEngineUML

transferred(address spender, address from, address to, uint256 value)
function transferred(address spender,address from,address to,uint256 value) 
public virtual override(IRuleEngine) 
onlyBoundToken

Function 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.

IERC7551Compliance

IERC7551ComplianceUML

Note: ERC-7551 is draft (not final). The IERC7551Compliance interface used here is a subset interface exposing the compliance check canTransferFrom.

canTransferFrom(address spender, address from, address to, uint256 value) -> bool

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.

IERC3643ComplianceRead

IERC3643ComplianceReadUML


canTransfer(address from, address to, uint256 value) -> bool

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, so canTransfer and detectTransferRestriction can report a mint as allowed that the state-changing transferred(spender, from, to, value) will revert. To pre-check an operation that has an operator, use the 4-argument canTransferFrom / 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.

IERC3643IComplianceContract

IERC3643IComplianceContractUML


transferred(address from, address to, uint256 value)
function transferred(address from,address to,uint256 value) 
public virtual override(IERC3643IComplianceContract) 
onlyBoundToken

Updates 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.

IERC3643Compliance


created(address to, uint256 value)
function created(address to, uint256 value) 
public virtual override(IERC3643Compliance) 
onlyBoundToken

Updates 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.

destroyed(address from, uint256 value)
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.

IERC1404

IERC1404UML


detectTransferRestriction(address from, address to, uint256 value) -> uint8

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).

messageForTransferRestriction(uint8 restrictionCode) -> string

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.

IERC1404Extend

IERC1404ExtendUML

enum REJECTED_CODE_BASE

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.

detectTransferRestrictionFrom(address spender, address from, address to, uint256 value) -> uint8

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).

VersionModule

VersionModuleUML

Contracts Description Table

Contract Type Bases
└ Function Name Visibility Mutability Modifiers
VersionModule Implementation IERC3643Version
└ version Public ❗️ NO❗️

version()

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

TokenBindingModuleUML

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.

TokenBindingExtendedModuleUML

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.

Contracts Description Table

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

ERC3643ComplianceModuleUML

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.

Contracts Description Table

Contract Type Bases
└ Function Name Visibility Mutability Modifiers
ERC3643ComplianceModule Implementation TokenBindingModule, IERC3643Compliance
└ getTokenBound Public ❗️ NO❗️
└ _onlyTokenBindingManager Internal 🔒 🛑
└ _onlyComplianceManager Internal 🔒 🛑

ERC3643ComplianceExtendedModule

ERC3643ComplianceExtendedModuleUML

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.

Events

TokenBound(address token)
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.

TokenUnbound(address token)
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.

Functions

bindToken(address token)
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.

unbindToken(address token)
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.

isTokenBound(address token) -> bool
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.

getTokenBound() -> address
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.

getTokenBounds() -> address[]
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.

RulesManagementModule

RuleManagementModuleUML

Events

event AddRule(address rule)
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(address rule)
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()
event ClearRules()

Emitted when all rules are cleared from the rule set.

This event has no parameters.

Functions

setRules(address[] rules_)
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.

rulesCount() -> uint256
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.

rule(uint256 ruleId) -> address
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.

rules() -> address[]
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.

clearRules()
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.


addRule(address rule_)
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.

removeRule(address rule_)
 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.

containsRule(address rule_) -> bool
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.

Security

Vulnerability disclosure

Please see SECURITY.md (CMTAT main repository).

Audit

First Audit - March 2022

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.

Tools

Nethermind AuditAgent

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).

Slither

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.md

Latest 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

Aderyn

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.

Documentation

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)

Toolchains and Usage

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).

Configuration

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

Toolchain installation

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.

Initialization

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.

Compilation

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.sol

The 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.

Contract size

forge build --sizes

Latest 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.

Testing

You can run the tests with

forge test

To run a specific test, use

forge test --match-contract <contract name> --match-test <function name>

Generate gas report

forge test --gas-report

The 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 test

CI 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.js

Coverage

A code coverage is available in index.html.

code-coverage

  • 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 coverage

See Solidity Coverage in VS Code with Foundry & Foundry forge coverage

Deployment

The official documentation is available in the Foundry website

Choosing a Contract

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

Script

The scripts in script/ are example deployment flows.

Warning: RuleEngineScript.s.sol and CMTATWithRuleEngineScript.s.sol deploy RuleWhitelistMock from src/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 -vvv

Only 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

Production Deployment Checklist

  • Choose the access-control model (RuleEngine, RuleEngineOwnable or RuleEngineOwnable2Step), then whether it needs ERC-2771.
  • If ERC-2771 support is needed, deploy the *2771 version 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's tokenContract argument) — CMTAT never self-binds, and an engine with no bound token rejects every transfer with TokenBinding_UnauthorizedCaller. Then point the token at the engine with cmtat.setRuleEngine(engine) (token DEFAULT_ADMIN_ROLE), or pass it at token construction.
  • ERC-3643: grant setTokenSelfBindingApproval(token, true) on the engine before calling token.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.

Solidity style guideline

RuleEngine follows the solidity style guideline and the natspec format for comments

Formatting & Linting

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

Intellectual property

The code is copyright (c) Capital Market and Technology Association, 2022-2026, and is released under Mozilla Public License 2.0.