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
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 (defined in
src/modules/VersionModule.sol) - Solidity: ^0.8.20 (compiled with 0.8.34)
- EVM target: Prague
- 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 "(script|mocks|test)" --report lcov # Production coverage
forge fmt # Format code
Dependencies are git submodules. Initialize with forge install, update with forge update.
CMTAT submodule also needs cd lib/CMTAT && npm install for its OpenZeppelin deps.
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, CMTAT/ for CMTAT imports, src/ for local imports.
Architecture
Three Deployable Contracts
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.
Inheritance Hierarchy
RuleEngineBase (abstract)
├── VersionModule → version() returns "3.0.0"
├── RulesManagementModule → add/remove/set/clear rules
│ ├── AccessControl (OZ)
│ └── RulesManagementModuleInvariantStorage → errors, events, roles
├── ERC3643ComplianceModule → bind/unbind tokens
│ └── IERC3643Compliance
├── 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
Access Control Pattern
Modules define virtual internal hooks for access control. Concrete contracts override them:
// In RulesManagementModule (abstract):
function _onlyRulesManager() internal virtual;
// In ERC3643ComplianceModule (abstract):
function _onlyComplianceManager() internal virtual;
// RuleEngine overrides with RBAC:
function _onlyRulesManager() internal virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
function _onlyComplianceManager() internal virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
// RuleEngineOwnable overrides with Ownable:
function _onlyRulesManager() 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:
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();
}
Rule Execution Flow
Token.transfer() → RuleEngine.transferred(from, to, value)
├── onlyBoundToken modifier (caller must be bound)
└── for each rule in _rules:
rule.transferred(from, to, value) // reverts if disallowed
View path: detectTransferRestriction() iterates rules, returns first non-zero code.
Storage: EnumerableSet
Both rules and bound tokens use EnumerableSet.AddressSet:
_rulesinRulesManagementModule— the set of active rules_boundTokensinERC3643ComplianceModule— tokens allowed to calltransferred
This gives O(1) add/remove/contains and iterable storage.
Key Interfaces
| Interface | Purpose | Where Defined |
|---|---|---|
IRule | What every rule must implement (extends IRuleEngineERC1404) | src/interfaces/IRule.sol |
IRulesManagementModule | Rule CRUD operations | src/interfaces/IRulesManagementModule.sol |
IERC3643Compliance | Token binding + compliance hooks | src/interfaces/IERC3643Compliance.sol |
IRuleEngine | Full CMTAT integration interface | lib/CMTAT/contracts/interfaces/engine/IRuleEngine.sol |
ERC-165 interface IDs:
IRule:0x2497d6cb(defined insrc/modules/library/RuleInterfaceId.sol)IRuleEngine: fromCMTAT/library/RuleEngineInterfaceId.solIERC1404Extend: fromCMTAT/library/ERC1404ExtendInterfaceId.solERC-173:0x7f5828d0(hardcoded inRuleEngineOwnable)
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 |
Convention: Error names follow Contract_Module_ErrorName pattern. Test contracts inherit these to access .selector for vm.expectRevert.
Project Structure
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, IERC3643Compliance
├── modules/ # VersionModule, RulesManagementModule, ERC3643ComplianceModule, 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
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
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
RBAC Roles (RuleEngine only)
| 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
- 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.
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
- In
src/, avoidsupercalls and prefer explicit parent-contract calls (e.g.,AccessControl.grantRole(...)) for readability and deterministic inheritance behavior. - Section headers:
/* ============ SECTION ============ */ - Run
forge fmtbefore committing
Common Tasks
Adding a new module
- 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/
Adding a new rule (mock)
- Create the rule in
src/mocks/rules/ - Implement
IRule(which extendsIRuleEngineERC1404) - Implement ERC-165 with
IRULE_INTERFACE_ID - Add tests using the existing
HelperContractbase
Modifying access control
- Update the virtual hook in the relevant module
- Update overrides in both
RuleEngine.solandRuleEngineOwnable.sol - Update tests in all affected test directories