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.
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 (defined in
src/modules/VersionModule.sol) - Solidity: ^0.8.20 (compiled with 0.8.36)
- EVM target: Prague
- License: MPL-2.0
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 codeDependencies are git submodules. Initialize with forge install, update with forge update.
CMTAT submodule also needs cd lib/CMTAT && npm install for its OpenZeppelin deps.
- 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.mdat the root is the short overview (project, architecture, main files, quick start);doc/README.mdis 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.
- Create or update the technical documentation in
doc/technical - Update
README.md(root overview) anddoc/README.md(full reference) as applicable - Create or update tests, targeting 100% code coverage — check with
forge coverage --report summary - Update
CHANGELOG.md
| 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.
RuleEngine — RBAC via AccessControl (multi-operator)
RuleEngineOwnable — ERC-173 Ownable (single-owner)
RuleEngineOwnable2Step — ERC-173 Ownable2Step (single-owner, two-step handover)
All three share their core logic through RuleEngineBase directly or via RuleEngineOwnableShared.
RuleEngineBase (abstract)
├── VersionModule → version() returns "3.0.0"
├── 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
├── ERC2771ModuleStandalone → gasless support
└── RuleEngineBase
RuleEngineOwnable
├── ERC2771ModuleStandalone → gasless support
├── RuleEngineOwnableShared
│ └── RuleEngineBase
└── Ownable (OZ) → ERC-173
RuleEngineOwnable2Step
├── ERC2771ModuleStandalone → gasless support
├── RuleEngineOwnableShared
│ └── RuleEngineBase
└── Ownable2Step (OZ) → ERC-173
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 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.
Rule validation uses a two-layer override:
RulesManagementModule._checkRule()— checks zero address + duplicatesRuleEngineBase._checkRule()— callsRulesManagementModule._checkRule()then validates ERC-165 interface
// RulesManagementModule (generic checks):
function _checkRule(address rule_) internal view virtual {
if (rule_ == address(0x0)) revert ...ZeroNotAllowed();
if (_rules.contains(rule_)) revert ...AlreadyExists();
}
// RuleEngineBase (adds ERC-165 check):
function _checkRule(address rule_) internal view virtual override {
RulesManagementModule._checkRule(rule_);
if (!ERC165Checker.supportsInterface(rule_, RuleInterfaceId.IRULE_INTERFACE_ID))
revert RuleEngine_RuleInvalidInterface();
}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 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/ERC3643ComplianceExtendedModuleare thin ERC-3643 adapters: they addgetTokenBound()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.
Both rules and bound tokens use EnumerableSet.AddressSet:
_rulesinRulesManagementModule— the set of active rules_boundTokensinTokenBindingModule— tokens allowed to calltransferred
This gives O(1) add/remove/contains and iterable storage.
| Interface | Purpose | Where Defined |
|---|---|---|
IRule |
What every rule must implement (extends IRuleEngineERC1404) |
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:0x2497d6cb(src/modules/library/RuleInterfaceId.sol)IERC3643Compliance:0x3144991c, extended:0x646ba2be,IERC7551Compliance:0x7157797f(ComplianceInterfaceId.sol)IERC1404:0xab84a5c8(ERC1404InterfaceId.sol)IRuleEngine: fromCMTAT/library/RuleEngineInterfaceId.solIERC1404Extend: fromCMTAT/library/ERC1404ExtendInterfaceId.solERC-173:0x7f5828d0(OwnableInterfaceId.sol);Ownable2Stepsubset: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.
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.
src/
├── deployment/
│ ├── RuleEngine.sol # RBAC variant (deploy this)
│ ├── RuleEngineOwnable.sol # Ownable variant (deploy this)
│ └── RuleEngineOwnable2Step.sol # Ownable2Step variant (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)
├── RuleEngineOwnable/ # Tests for RuleEngineOwnable
├── RuleEngineOwnable2Step/ # Tests for RuleEngineOwnable2Step
└── RuleWhitelist/ # Tests for the whitelist mock rule
script/ # Foundry example/deployment scripts
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
RuleEnginego intest/RuleEngine/, tests forRuleEngineOwnablego intest/RuleEngineOwnable/ - Tests for
RuleEngineOwnable2Stepgo intest/RuleEngineOwnable2Step/ - Use
HelperContractfor RBAC tests,HelperContractOwnablefor Ownable tests - Use
HelperContractOwnable2StepforRuleEngineOwnable2Steptests - Always use specific error selectors in
vm.expectRevert() - When adding a feature to
RuleEngineBase, add tests for all deployable variants
| 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 |
- Only bound tokens can call
transferred(),created(),destroyed() - Rules are validated via ERC-165 before being added — they must support
IRULE_INTERFACE_ID - No duplicate rules —
EnumerableSetprevents this - No zero-address rules — checked in
_checkRule - Admin has all roles in
RuleEngine(thehasRoleoverride) - Forwarder is immutable — set at construction, cannot be changed
- 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.
- 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
internalfunctions must be markedvirtual, so inheriting contracts can override them. - Use
require(condition, CustomError(...))for custom errors; avoid directrevert CustomError(...). - In
src/, avoidsupercalls 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 docoutput and diffs; they are not searchable (grep WARNINGfinds the marker,grep ⚠️depends on the shell); and they encode as multi-byte sequences that can be silently mangled by tooling. This applies tosrc/,test/andscript/. Markdown documentation may use emoji freely — the restriction is Solidity comments only. - Run
forge fmtbefore committing
- Create the module in
src/modules/ - Create an invariant storage contract in
src/modules/library/for errors/events - Add a virtual access control hook (e.g.,
_onlyNewManager()) - Have
RuleEngineBaseinherit the module - Override the hook in both
RuleEngineandRuleEngineOwnable - Add tests in
test/RuleEngine/,test/RuleEngineOwnable/, andtest/RuleEngineOwnable2Step/
- Create the rule in
src/mocks/rules/ - Implement
IRule(which extendsIRuleEngineERC1404) - Implement ERC-165 with
IRULE_INTERFACE_ID - Add tests using the existing
HelperContractbase
- Update the virtual hook in the relevant module
- Update overrides in both
RuleEngine.solandRuleEngineOwnable.sol - Update tests in all affected test directories