Skip to content

Latest commit

 

History

History
388 lines (303 loc) · 25.7 KB

AGENTS.md

File metadata and controls

388 lines (303 loc) · 25.7 KB

AI Agent Guide for RuleEngine

This file helps AI agents (Cursor, Claude Code, etc.) understand and work with this codebase.

AGENTS.md and CLAUDE.md files must always be identical — always update both together.

Project Summary

RuleEngine is a Solidity smart contract system that enforces transfer restrictions for CMTAT and ERC-3643 tokens. It acts as an external controller that calls pluggable rule contracts on each token transfer, mint, or burn.

  • Version: 3.0.0-rc7 (defined in src/modules/VersionModule.sol; the string carries the release-candidate suffix and must be bumped with each tag — update the version() assertions in test/RuleEngine/RuleEngineDeployment.t.sol, test/utils/ERC2771VariantTestBase.sol, test/RuleEngineOwnable/RuleEngineOwnableDeployment.t.sol, test/RuleEngineOwnable2Step/RuleEngineOwnable2Step.t.sol and test/hardhat/RuleEngine.smoke.js with it)
  • Solidity: ^0.8.27 (compiled with 0.8.37). src/ and script/ declare ^0.8.27: it is the first version that supports require(cond, CustomError()) on the legacy pipeline, so do not lower it
  • EVM target: Osaka (Fusaka upgrade)
  • License: MPL-2.0

Build & Test Commands

forge build              # Compile all contracts
forge test               # Run all tests
forge test -vvv          # Verbose test output
forge test --match-contract <Name> --match-test <fn>  # Run specific test
forge coverage           # Code coverage
forge coverage --no-match-coverage "(mocks|test)" --report lcov  # Production coverage (src/ + script/)
forge fmt                # Format code
FOUNDRY_PROFILE=erc3643 forge test  # Real ERC-3643 token suite (test/ERC3643Real/, solc 0.8.30)
FOUNDRY_INVARIANT_RUNS=256 FOUNDRY_INVARIANT_DEPTH=500 forge test --match-contract InvariantTest  # deeper invariant run (foundry.toml caps it at 64 x 50)

test/ERC3643Real/ deploys the vendored T-REX 4.2.0-beta1 Token, which pins pragma solidity 0.8.30. It is skipped by the default profile and built by [profile.erc3643] (solc 0.8.30, out-erc3643/, context-scoped @onchain-id remapping to the stubs in test/utils/onchainid/). Files there must pin pragma solidity 0.8.30. Never move the context-scoped remapping into remappings.txt: hardhat-foundry rejects it.

Dependencies are git submodules. Initialize with git submodule update --init (non-recursive is enough), update with forge update. remappings.txt resolves @openzeppelin/... to this repo's own submodules for every file, including the CMTAT sources, so cd lib/CMTAT && npm install and --recursive are only needed to run CMTAT's own Hardhat suite.

Agent Workflow

  • Never create git commits. Provide commit messages only when they are requested.
  • Always run the full test suite (forge test) after any code modification — including lint-driven or mechanical refactors — before reporting completion.
  • Always update the documentation to reflect the latest change. There are two READMEs: README.md at the root is the short overview (project, architecture, main files, quick start); doc/README.md is the full reference (interfaces, Ethereum API, deployment, UML, audits). Update whichever the change affects — often both.
  • After each implemented feature or fix, provide a one-line GitHub commit message covering all changes since the last commit.

When implementing a new rule or feature

  1. Create or update the technical documentation in doc/technical
  2. Update README.md (root overview) and doc/README.md (full reference) as applicable
  3. Create or update tests, targeting 100% code coverage — check with forge coverage --report summary
  4. Update CHANGELOG.md

Import Remappings

Alias Path
CMTAT/ lib/CMTAT/contracts/
CMTATv3.0.0/ lib/CMTATv3.0.0/contracts/
@openzeppelin/contracts/ lib/openzeppelin-contracts/contracts

Use @openzeppelin/contracts/ for OpenZeppelin imports and CMTAT/ for CMTAT imports. For project files, src/ and script/ import relatively (./modules/..., ../src/...); the src/ remapping is used by the tests.

Architecture

Six Deployable Contracts

RuleEngine                  — RBAC via AccessControl (multi-operator), no ERC-2771
RuleEngine2771              — RBAC via AccessControl + ERC-2771 (immutable trusted forwarder)
RuleEngineOwnable           — ERC-173 Ownable (single-owner), no ERC-2771
RuleEngineOwnable2771       — ERC-173 Ownable + ERC-2771
RuleEngineOwnable2Step      — ERC-173 Ownable2Step (single-owner, two-step handover), no ERC-2771
RuleEngineOwnable2Step2771  — ERC-173 Ownable2Step + ERC-2771

All six share their core logic through RuleEngineBase: RuleEngine extends it directly with RBAC, and the four ownable variants go through RuleEngineOwnableShared. Each access-control model has a version without ERC-2771 (constructor (admin|owner_, tokenContract)) and a *2771 version with an immutable trusted forwarder (constructor (admin|owner_, forwarderIrrevocable, tokenContract)). Every X2771 extends its counterpart X (RuleEngine2771 is ERC2771ModuleStandalone, RuleEngine, and likewise for the ownable pair) and adds only ERC2771ModuleStandalone plus the three Context overrides. The deployables live in src/deployment/accessControl/ (RBAC) and src/deployment/ownable/ (ERC-173).

Inheritance Hierarchy

RuleEngineBase (abstract)
├── VersionModule                         → version() returns "3.0.0-rc7"
├── RulesManagementModule                 → add/remove/set/clear rules, maxRules cap
│   ├── AccessControl (OZ)
│   └── RulesManagementModuleInvariantStorage  → errors, events, roles
├── ERC3643ComplianceExtendedModule       → ERC-3643 flavour of the binding registry
│   ├── ERC3643ComplianceModule           → ERC-3643 adapter: getTokenBound(), compliance naming
│   │   ├── IERC3643Compliance
│   │   └── TokenBindingModule            → bind/unbind tokens (standard-agnostic registry)
│   │       ├── ITokenBinding
│   │       └── TokenBindingModuleInvariantStorage  → errors
│   └── TokenBindingExtendedModule        → batch binding, token self-binding (standard-agnostic)
│       └── ITokenBindingExtended
├── RuleEngineInvariantStorage            → errors
└── IRuleEngineERC1404                    → CMTAT interface

RuleEngine                              → no gasless support
├── RuleEngineBase
├── AccessControlEnumerable (OZ)
└── ERC3643ComplianceRolesStorage, RulesManagementModuleRolesStorage → roles

RuleEngine2771
├── ERC2771ModuleStandalone → gasless support
└── RuleEngine

RuleEngineOwnable
├── RuleEngineOwnableShared             → no gasless support
│   └── RuleEngineBase
└── Ownable (OZ) → ERC-173

RuleEngineOwnable2771
├── ERC2771ModuleStandalone → gasless support
└── RuleEngineOwnable

RuleEngineOwnable2Step
├── RuleEngineOwnableShared             → no gasless support
│   └── RuleEngineBase
└── Ownable2Step (OZ) → ERC-173

RuleEngineOwnable2Step2771
├── ERC2771ModuleStandalone → gasless support
└── RuleEngineOwnable2Step

Access Control Pattern

Modules define virtual internal hooks for access control. Concrete contracts override them:

// In RulesManagementModule (abstract):
function _onlyRulesManager() internal virtual;
function _onlyRulesLimitManager() internal virtual;  // guards setMaxRules

// In TokenBindingModule (abstract):
function _onlyTokenBindingManager() internal virtual;
// wired by ERC3643ComplianceModule to its own abstract hook:
function _onlyComplianceManager() internal virtual;

// RuleEngine (and RuleEngine2771, which extends it) overrides with RBAC:
function _onlyRulesManager() internal virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
function _onlyRulesLimitManager() internal virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
function _onlyComplianceManager() internal virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}

// RuleEngineOwnable overrides with Ownable:
function _onlyRulesManager() internal virtual override onlyOwner {}
function _onlyRulesLimitManager() internal virtual override onlyOwner {}
function _onlyComplianceManager() internal virtual override onlyOwner {}

When adding a new protected function, follow this pattern: define a virtual hook in the module, then override it in RuleEngine, RuleEngineOwnable, and RuleEngineOwnable2Step.

_checkRule Override Chain

Rule validation uses a two-layer override:

  1. RulesManagementModule._checkRule() — checks the zero address (duplicates are rejected by _addRule through the return value of _rules.add)
  2. RuleEngineBase._checkRule() — calls RulesManagementModule._checkRule() then validates ERC-165 interface
// RulesManagementModule (generic checks):
function _checkRule(address rule_) internal view virtual {
    require(rule_ != address(0), RuleEngine_RulesManagementModule_RuleAddressZeroNotAllowed());
}

// RulesManagementModule._addRule (duplicate check):
require(_rules.add(address(rule_)), RuleEngine_RulesManagementModule_RuleAlreadyExists());

// RuleEngineBase (adds ERC-165 check):
function _checkRule(address rule_) internal view virtual override {
    RulesManagementModule._checkRule(rule_);
    require(_supportsRuleInterface(rule_), RuleEngine_RuleInvalidInterface());
}

// ERC-165 support checked once, then the current IRule ID, then the legacy (pre-rc7) one:
function _supportsRuleInterface(address rule_) internal view virtual returns (bool) {
    if (!ERC165Checker.supportsERC165(rule_)) return false;
    return ERC165Checker.supportsERC165InterfaceUnchecked(rule_, RuleInterfaceId.IRULE_INTERFACE_ID)
        || ERC165Checker.supportsERC165InterfaceUnchecked(rule_, RuleInterfaceId.IRULE_LEGACY_INTERFACE_ID);
}

Rule Execution Flow

CMTAT only:       RuleEngine.transferred(spender, from, to, value)   ← transferFrom, mint, burn (spender = _msgSender())
                   ├── onlyBoundToken modifier (caller must be bound)
                   └── for each rule in _rules:
                         rule.transferred(spender, from, to, value)  // reverts if disallowed

CMTAT + ERC-3643: RuleEngine.transferred(from, to, value)            ← standard transfer (spender == address(0))
                   ├── onlyBoundToken modifier
                   └── for each rule in _rules:
                         rule.transferred(from, to, value)

ERC-3643 only:    RuleEngine.created(to, value)                      ← ERC-3643 mint entry point
                   ├── onlyBoundToken modifier
                   └── calls _transferred(address(0), to, value)

ERC-3643 only:    RuleEngine.destroyed(from, value)                  ← ERC-3643 burn entry point
                   ├── onlyBoundToken modifier
                   └── calls _transferred(from, address(0), value)

CMTAT and ERC-3643 use disjoint entry points. The 4-argument transferred is declared by CMTAT's IRuleEngine (lib/CMTAT/contracts/interfaces/engine/IRuleEngine.sol), so an ERC-3643 token never reaches it. Conversely created / destroyed are declared by IERC3643Compliance and CMTAT never calls them — CMTAT routes mint and burn through the 4-argument transferred instead. Only the 3-argument transferred is shared by both.

CMTAT selects the overload in ValidationModuleRuleEngine._callRuleEngineTransferred, branching on spender != address(0). A standard transfer has no spender (CMTATBaseCommon.transfer passes address(0) internally), so the else branch calls the 3-argument transferred(from, to, value) — the zero address is a branch condition only and is never forwarded to the engine. transferFrom, mint and burn carry _msgSender() as spender and take the 4-argument overload. Neither is a fallback: which one is called depends purely on the operation.

Since CMTAT v3.3.0, mint (from == address(0)) and burn (to == address(0)) therefore also reach the 4-argument overload with the operator as spender. Rules that check spender must skip or adapt that check for mint/burn to avoid blocking those operations unintentionally.

created and destroyed use the 3-argument _transferred path (no spender), consistent with the ERC-3643 spec which does not carry a spender for mint/burn.

View path: detectTransferRestriction() iterates rules, returns first non-zero code.

The 3-argument view path fails open for spender-dependent rules. detectTransferRestriction and canTransfer carry no spender, so a rule keyed by spender (e.g. a per-minter mint allowance) cannot evaluate the operation and must answer "no restriction". The engine aggregates that answer, so these two views can report a mint as allowed that transferred(spender, ...) will revert. Use the 4-argument detectTransferRestrictionFrom / canTransferFrom to pre-check an operation that has an operator. See H-1 in doc/security/audits/tools/v3.0.0-rc5/CLAUDE_ANALYSIS.md.

Token Binding Layering

Token binding is split so it can be reused outside this project:

  • TokenBindingModule / TokenBindingExtendedModule (+ ITokenBinding / ITokenBindingExtended, TokenBindingModuleInvariantStorage) hold the whole registry and depend only on OpenZeppelin (Context, EnumerableSet). No rule, ERC-1404 or ERC-3643 code.
  • ERC3643ComplianceModule / ERC3643ComplianceExtendedModule are thin ERC-3643 adapters: they add getTokenBound() and wire _onlyTokenBindingManager() to _onlyComplianceManager().

Keep new binding logic in the generic modules and new ERC-3643 logic in the adapters. src/mocks/TokenBindingStandaloneMock.sol (+ test/TokenBinding/) pins that the registry still works standalone. See doc/technical/TokenBinding-module.md.

Storage: EnumerableSet

Both rules and bound tokens use EnumerableSet.AddressSet:

  • _rules in RulesManagementModule — the set of active rules
  • _boundTokens in TokenBindingModule — tokens allowed to call transferred

This gives O(1) add/remove/contains and iterable storage.

Key Interfaces

Interface Purpose Where Defined
IRule What every rule must implement: the six functions the engine calls + ERC-165 (extends IERC1404Extend, IERC3643IComplianceContract, IERC165; no canTransfer*) src/interfaces/IRule.sol
IRulesManagementModule Rule CRUD operations src/interfaces/IRulesManagementModule.sol
ITokenBinding Token binding registry, standard-agnostic src/interfaces/ITokenBinding.sol
ITokenBindingExtended Batch binding, token self-binding, getTokenBounds src/interfaces/ITokenBindingExtended.sol
IERC3643Compliance ERC-3643 compliance hooks (extends ITokenBinding) src/interfaces/IERC3643Compliance.sol
IRuleEngine Full CMTAT integration interface lib/CMTAT/contracts/interfaces/engine/IRuleEngine.sol

ERC-165 interface IDs:

  • IRule: 0xb1a69752 (src/modules/library/RuleInterfaceId.sol). Narrowed in rc7 (was 0x2497d6cb, which also covers canTransfer / canTransferFrom); the engine accepts both, the legacy one as IRULE_LEGACY_INTERFACE_ID (RuleEngineBase._supportsRuleInterface)
  • IERC3643Compliance: 0x3144991c, extended: 0x646ba2be, IERC7551Compliance: 0x7157797f (ComplianceInterfaceId.sol)
  • IERC1404: 0xab84a5c8 (ERC1404InterfaceId.sol)
  • IRuleEngine: from CMTAT/library/RuleEngineInterfaceId.sol
  • IERC1404Extend: from CMTAT/library/ERC1404ExtendInterfaceId.sol
  • ERC-173: 0x7f5828d0 (OwnableInterfaceId.sol); Ownable2Step subset: 0x9ab669ef

The project's IDs are computed, not hardcoded. type(I).interfaceId covers only the functions I declares directly, so each constant XORs the interface with its parents; a marker interface that declares nothing of its own — IERC3643ComplianceExtended — has a type(...).interfaceId of 0x00000000 and must never be used for an ERC-165 check. test/RuleEngine/IRuleInterfaceId.t.sol pins every constant to its wire value, so an upstream interface change fails a test instead of silently changing what supportsInterface answers. ERC-173 and the Ownable2Step subset stay literal: neither has an interface declaration in scope.

Invariant Storage Pattern

Errors, events, and role constants are centralized in "invariant storage" abstract contracts:

Contract Contains
RuleEngineInvariantStorage RuleEngine_AdminWithAddressZeroNotAllowed, RuleEngine_RuleInvalidInterface
RulesManagementModuleInvariantStorage Rule errors, AddRule/RemoveRule/ClearRules events, RULES_MANAGEMENT_ROLE
TokenBindingModuleInvariantStorage TokenBinding_* binding errors (standard-agnostic, no RuleEngine_ prefix)

Convention: Error names follow Contract_Module_ErrorName pattern. Test contracts inherit these to access .selector for vm.expectRevert.

Project Structure

src/
├── deployment/
│   ├── accessControl/
│   │   ├── RuleEngine.sol         # RBAC variant, no ERC-2771 (deploy this)
│   │   └── RuleEngine2771.sol     # RBAC variant with ERC-2771 (deploy this)
│   └── ownable/
│       ├── RuleEngineOwnable.sol      # Ownable variant, no ERC-2771 (deploy this)
│       ├── RuleEngineOwnable2771.sol  # Ownable variant with ERC-2771 (deploy this)
│       ├── RuleEngineOwnable2Step.sol # Ownable2Step variant, no ERC-2771 (deploy this)
│       └── RuleEngineOwnable2Step2771.sol # Ownable2Step variant with ERC-2771 (deploy this)
├── RuleEngineBase.sol             # Abstract core logic (do not deploy)
├── RuleEngineOwnableShared.sol    # Shared logic for ownable variants
├── interfaces/                    # IRule, IRulesManagementModule, ITokenBinding(Extended), IERC3643Compliance(Extended)
├── modules/                       # VersionModule, RulesManagementModule, TokenBinding(Extended)Module,
│                                  # ERC3643Compliance(Extended)Module, ERC2771ModuleStandalone
│   └── library/                   # InvariantStorage contracts, RuleInterfaceId
└── mocks/                         # Test-only/reference contracts

test/
├── HelperContract.sol             # Base helper for RuleEngine tests
├── HelperContractOwnable.sol      # Base helper for RuleEngineOwnable tests
├── HelperContractOwnable2Step.sol # Base helper for RuleEngineOwnable2Step tests
├── utils/                         # CMTAT deployment helpers
├── RuleEngine/                    # Tests for RuleEngine (RBAC)
├── RuleEngine2771/                # Tests for RuleEngine2771 (RBAC + ERC-2771)
├── RuleEngineOwnable/             # Tests for RuleEngineOwnable
├── RuleEngineOwnable2Step/        # Tests for RuleEngineOwnable2Step
├── RuleEngineOwnable2771/         # Tests for RuleEngineOwnable2771
├── RuleEngineOwnable2Step2771/     # Tests for RuleEngineOwnable2Step2771
├── ERC3643Real/                   # Real T-REX token tests (FOUNDRY_PROFILE=erc3643 only)
├── RuleStandalone/                # A rule set directly as a CMTAT token's rule engine (no RuleEngine)
└── RuleWhitelist/                 # Tests for the whitelist mock rule

script/                            # Foundry example/deployment scripts

Test Conventions

For detailed test conventions, templates, helper contracts, test addresses, naming patterns, and the base test pattern, see the testing skill: .claude/skills/testing/SKILL.md.

Key points:

  • Tests for RuleEngine go in test/RuleEngine/, tests for RuleEngineOwnable go in test/RuleEngineOwnable/
  • Tests for RuleEngineOwnable2Step go in test/RuleEngineOwnable2Step/
  • Tests for each *2771 contract go in its own test/<Contract>/ directory (RuleEngine2771, RuleEngineOwnable2771, RuleEngineOwnable2Step2771)
  • Use HelperContract for RBAC tests, HelperContractOwnable for Ownable tests
  • Use HelperContractOwnable2Step for RuleEngineOwnable2Step tests
  • Always use specific error selectors in vm.expectRevert()
  • When adding a feature to RuleEngineBase, add tests for all deployable variants
  • Behaviour shared by the variants is tested once in an abstract base under test/utils/ (ERC2771TokenGateTestBase, ERC2771VariantTestBase, NoForwarderTestBase, RuleAggregationTestBase, RuleEngineInvariantTestBase), with a short per-variant file in each test/RuleEngine*/ directory that only deploys the variant
  • Before trusting a new test, check it can fail: break the line it guards and run the suite (the rc7 code-quality review mutation-tests the suite this way)

RBAC Roles (RuleEngine and RuleEngine2771)

Role Identifier Purpose
DEFAULT_ADMIN_ROLE 0x00...00 Has all roles (via hasRole override)
RULES_MANAGEMENT_ROLE keccak256("RULES_MANAGEMENT_ROLE") Add/remove/set/clear rules
COMPLIANCE_MANAGER_ROLE keccak256("COMPLIANCE_MANAGER_ROLE") Bind/unbind tokens

Key Invariants

  1. Only bound tokens can call transferred(), created(), destroyed()
  2. Rules are validated via ERC-165 before being added — they must support IRULE_INTERFACE_ID or the legacy IRULE_LEGACY_INTERFACE_ID
  3. No duplicate rules — EnumerableSet prevents this
  4. No zero-address rules — checked in _checkRule
  5. Admin has all roles in RuleEngine and RuleEngine2771 (the hasRole override in RuleEngine, inherited by RuleEngine2771)
  6. Forwarder is immutable — set at construction, cannot be changed (the three *2771 contracts; the others have none)
  7. Token identity uses msg.sender, never _msgSender() — _checkBoundToken (onlyBoundToken) and the token self-binding check in _authorizeTokenBindingChange compare the raw caller so the ERC-2771 forwarder cannot pass as a token; administrative checks keep _msgSender() (gasless admin). Pinned by test/utils/ERC2771TokenGateTestBase.sol
  8. Rules and bound tokens are disjoint — RuleEngineBase._checkBindingTarget rejects binding a current rule and _checkRule rejects adding a bound token as a rule, both with RuleEngine_RulesManagementModule_RuleAccountCannotReceivePrivileges. Pinned by invariant_RulesAndBoundTokensAreDisjoint
  9. Rule contracts in src/mocks/ are reference implementations — they are useful for testing and examples, not as production rule contracts. Production rules live in a separate repository.

Solidity Style

  • Follow the Solidity style guide
  • NatSpec comments on all public/external functions
  • Function ordering: constructor, receive, fallback, external, public, internal, private (view/pure last within each group)
  • Function declaration order: visibility, mutability, virtual, override, custom modifiers
  • All internal functions must be marked virtual, so inheriting contracts can override them.
  • Use require(condition, CustomError(...)) for custom errors; avoid direct revert CustomError(...). src/ has no conditional revert left (the only two are the unconditional ones of RuleOperationRevertMock). Note that require evaluates the error's arguments even when the check passes: an argument read from storage costs an extra read, so cache it in a local first (see RulesManagementModule.addRule).
  • In src/, avoid super calls and prefer explicit parent-contract calls (e.g., AccessControl.grantRole(...)) for readability and deterministic inheritance behavior.
  • Section headers: /* ============ SECTION ============ */
  • No emoji in code comments or NatSpec. Use a plain word marker instead: WARNING:, NOTE:, IMPORTANT:. Emoji render inconsistently across editors, terminals, forge doc output and diffs; they are not searchable (grep WARNING finds the marker, grep ⚠️ depends on the shell); and they encode as multi-byte sequences that can be silently mangled by tooling. This applies to src/, test/ and script/. Markdown documentation may use emoji freely — the restriction is Solidity comments only.
  • Run forge fmt before committing

Common Tasks

Adding a new module

  1. Create the module in src/modules/
  2. Create an invariant storage contract in src/modules/library/ for errors/events
  3. Add a virtual access control hook (e.g., _onlyNewManager())
  4. Have RuleEngineBase inherit the module
  5. Override the hook in RuleEngine, RuleEngineOwnable and RuleEngineOwnable2Step
  6. Add tests for all six deployables (one directory each under test/), preferably through a shared test/utils/*TestBase.sol

Adding a new rule (mock)

  1. Create the rule in src/mocks/rules/
  2. Implement IRule; to also support the standalone role (set directly as a CMTAT token's rule engine), implement CMTAT's IRuleEngineERC1404 too and advertise RULE_ENGINE_INTERFACE_ID. The 4-argument transferred then needs override(IRule, IRuleEngine)
  3. Implement ERC-165 with IRULE_INTERFACE_ID
  4. transferred* hooks may update the rule's own storage but must not make a state-changing external call; external reads must be view (STATICCALL). Six CMTAT variants have no reentrancy guard around transferred (see doc/technical/RuleEngine-with-CMTAT.md §4.6). A rule can also be set directly as a CMTAT token's rule engine (§4.8): keep canTransfer* consistent with detectTransferRestriction*, and a production stateful rule must restrict who calls transferred
  5. Add tests using the existing HelperContract base

Modifying access control

  1. Update the virtual hook in the relevant module
  2. Update overrides in RuleEngine.sol, RuleEngineOwnable.sol and RuleEngineOwnable2Step.sol
  3. Update tests in all affected test directories