Skip to content

Commit dd526ce

Browse files
committed
feat(rule-engine): add admin/owner-configurable on-chain maxRules cap (default 10), enforce limits + event/tests, and document rc3/nethermind finding #3 remediation
1 parent ea4bab8 commit dd526ce

13 files changed

Lines changed: 246 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,31 @@ forge lint
4545
- Update surya doc by running the 3 scripts in [./doc/script](./doc/script)
4646
- Update changelog
4747

48+
### v3.0.0-rc3 - 2026-04-28
4849

50+
### Security
51+
52+
- Enforce an on-chain maximum rule count in `RulesManagementModule` to mitigate transfer liveness risk from unbounded per-transfer rule iteration (Nethermind AuditAgent finding 3 follow-up).
53+
- Add cap checks in both `addRule` and `setRules`, reverting with `RuleEngine_RulesManagementModule_MaxRulesExceeded(uint256)` when exceeded.
54+
55+
### Added
56+
57+
- Add `maxRules()` and `setMaxRules(uint256)` to `IRulesManagementModule`.
58+
- Add `DEFAULT_MAX_RULES = 10` and initialize module state with this default cap.
59+
- Add `SetMaxRules(uint256)` event emitted on cap updates.
60+
- Add dedicated access-control hook for cap governance:
61+
- `RuleEngine`: `DEFAULT_ADMIN_ROLE` can update cap.
62+
- `RuleEngineOwnable` and `RuleEngineOwnable2Step`: owner can update cap.
63+
64+
### Testing
65+
66+
- Add tests for default cap value, cap enforcement for `addRule` and `setRules`, and access control on `setMaxRules`.
67+
- Add event-emission coverage for `SetMaxRules`.
4968

5069
### v3.0.0-rc2 - 2026-04-14
5170

71+
Commit: `ec4a24a96ca30e2ef8f79a06e49846a431e9b4b1`
72+
5273
### Dependencies
5374

5475
- Update CMTAT submodule to [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0).

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ This diagram illustrates how a transfer with a CMTAT or ERC-3643 token with a Ru
6767
2. The transfer function inside the token calls the ERC-3643 function `transferred` from the RuleEngine with the following parameters inside: `from, to, value`.
6868
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).
6969

70-
> **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. There is no hard on-chain maximum rule count; administrators are responsible for sizing the rule set for their target blockchain and should keep it small. A misconfigured or gas-heavy rule can also impact all transfers.
70+
> **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.
7171
7272
> **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.
7373
@@ -77,6 +77,7 @@ This diagram illustrates how a transfer with a CMTAT or ERC-3643 token with a Ru
7777

7878
| RuleEngine version | Compatible Versions |
7979
| ------------------------------------------------------------ | ------------------------------------------------------------ |
80+
| **v3.0.0-rc3** | CMTAT ≥ v3.0.0<br />CMTAT target version: [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0) |
8081
| **v3.0.0-rc2** | CMTAT ≥ v3.0.0<br />CMTAT target version: [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0) |
8182
| **v3.0.0-rc1** | CMTAT ≥ v3.0.0<br />CMTAT target version: [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0) |
8283
| **v3.0.0-rc0** | CMTAT ≥ v3.0.0<br /> |

doc/security/audits/tools/nethermind-audit-agent/v3.0.0-rc1/audit_agent_report_1_v3.0.0-rc1-feedback.md

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -88,35 +88,45 @@ Code check confirmed:
8888

8989
**File(s):** `src/modules/RulesManagementModule.sol`, `src/RuleEngineBase.sol`
9090

91-
**Remediation commit:** `1caf4ea` `Id-3: docs(rules-management): clarify operator-owned rule-count risk and blockchain-dependent gas limits`
91+
**Remediation commits:** `1caf4ea` (rc2 docs warning), `v3.0.0-rc3` hard-cap implementation
9292

9393
### Finding summary
9494

9595
`addRule`/`setRules` have no cap on rule count. `_transferred` and `_detectTransferRestriction` loop over all rules per transfer. Adding too many rules can push gas cost above the block limit, permanently freezing token operations.
9696

9797
### Developer assessment
9898

99-
**Partially agree.** The risk is real as an operational concern. However, a fixed on-chain maximum would be deployment-dependent and potentially overly restrictive across different blockchains and rule complexities. Operator responsibility is retained; the mitigation is explicit documentation.
99+
**Updated in rc3: fully addressed on-chain.** The prior rc2 docs-only mitigation was insufficient for liveness guarantees. `v3.0.0-rc3` introduces an on-chain configurable cap with a safe default.
100100

101101
### Implementation
102102

103-
- **`src/modules/RulesManagementModule.sol`**:
104-
- `setRules`: "No on-chain maximum number of rules is enforced. Operators are responsible for keeping the rule set size compatible with the target chain gas limits."
105-
- `addRule`: "No on-chain maximum number of rules is enforced. Adding too many rules can increase transfer-time gas usage because rule checks are linear in rule count."
106-
- `_transferred` (both overloads): "Complexity is O(number of configured rules). Large rule sets can make transfers too expensive on chains with lower block gas limits."
107-
- **`README.md`** — explicit "How it works" warning paragraph added about gas scaling and absence of an on-chain rule count cap.
108-
109-
No runtime logic was changed.
103+
- **`src/modules/library/RulesManagementModuleInvariantStorage.sol`**
104+
- Add `DEFAULT_MAX_RULES = 10`.
105+
- Add `RuleEngine_RulesManagementModule_MaxRulesExceeded(uint256)`.
106+
- Add `RuleEngine_RulesManagementModule_MaxRulesZeroNotAllowed()`.
107+
- Add `SetMaxRules(uint256)` event.
108+
- **`src/interfaces/IRulesManagementModule.sol`**
109+
- Add `maxRules()` and `setMaxRules(uint256)`.
110+
- **`src/modules/RulesManagementModule.sol`**
111+
- Add `_maxRules` state initialized to default cap (`10`).
112+
- Enforce cap in `addRule` and `setRules`.
113+
- Add governance setter `setMaxRules(uint256)` with zero-value guard.
114+
- **Access control overrides**
115+
- `RuleEngine`: cap setter restricted to `DEFAULT_ADMIN_ROLE`.
116+
- `RuleEngineOwnable` / `RuleEngineOwnable2Step`: cap setter restricted to owner.
117+
- **`README.md`**
118+
- Replace outdated “no on-chain cap” warning with current rc3 behavior.
110119

111120
### Verification
112121

113-
Code check confirmed:
114-
- `RulesManagementModule.sol` line 46: `setRules` NatSpec — gas/cap warning present. ✓
115-
- `RulesManagementModule.sol` line 75: `addRule` NatSpec — linear gas warning present. ✓
116-
- `RulesManagementModule.sol` lines 169–173 and 188–192: `_transferred` overloads — O(n) and block-gas warning present. ✓
117-
- `README.md` line 69: gas-limit warning block present. ✓
122+
Code and tests confirm:
123+
- cap default is `10`;
124+
- `addRule` and `setRules` revert above cap;
125+
- cap update emits `SetMaxRules`;
126+
- cap setter is admin/owner restricted by variant;
127+
- zero cap is rejected.
118128

119-
**Status: Implemented (warnings only — no hard cap by design).**
129+
**Status: Fixed in `v3.0.0-rc3` (runtime mitigation implemented).**
120130

121131
---
122132

src/deployment/RuleEngine.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ contract RuleEngine is ERC2771ModuleStandalone, RuleEngineBase, AccessControlEnu
6868
//////////////////////////////////////////////////////////////*/
6969
function _onlyComplianceManager() internal virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
7070
function _onlyRulesManager() internal virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
71+
function _onlyRulesLimitManager() internal virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
7172

7273
/**
7374
* @dev This surcharge is not necessary if you do not use the MetaTxModule

src/deployment/RuleEngineOwnable.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ contract RuleEngineOwnable is RuleEngineOwnableShared, Ownable {
2525
* @dev Access control check using Ownable pattern
2626
*/
2727
function _onlyRulesManager() internal virtual override onlyOwner {}
28+
function _onlyRulesLimitManager() internal virtual override onlyOwner {}
2829

2930
/**
3031
* @dev Access control check using Ownable pattern

src/deployment/RuleEngineOwnable2Step.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ contract RuleEngineOwnable2Step is RuleEngineOwnableShared, Ownable2Step {
2727
* @dev Access control check using Ownable pattern
2828
*/
2929
function _onlyRulesManager() internal virtual override onlyOwner {}
30+
function _onlyRulesLimitManager() internal virtual override onlyOwner {}
3031

3132
/**
3233
* @dev Access control check using Ownable pattern

src/interfaces/IRulesManagementModule.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ pragma solidity ^0.8.20;
66
import {IRule} from "./IRule.sol";
77

88
interface IRulesManagementModule {
9+
/**
10+
* @notice Returns the maximum number of rules allowed in the engine.
11+
*/
12+
function maxRules() external view returns (uint256);
13+
14+
/**
15+
* @notice Updates the maximum number of rules allowed in the engine.
16+
* @dev Access control is implementation specific (admin/owner).
17+
* @param maxRules_ New maximum number of rules.
18+
*/
19+
function setMaxRules(uint256 maxRules_) external;
20+
921
/**
1022
* @notice Defines the rules for the rule engine.
1123
* @dev Sets the list of rule contract addresses for s.

src/modules/RulesManagementModule.sol

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ abstract contract RulesManagementModule is RulesManagementModuleInvariantStorage
1818
_;
1919
}
2020

21+
modifier onlyRulesLimitManager() {
22+
_onlyRulesLimitManager();
23+
_;
24+
}
25+
2126
/* ==== Type declaration === */
2227
using EnumerableSet for EnumerableSet.AddressSet;
2328

2429
/* ==== State Variables === */
2530
/// @dev Array of rules
2631
EnumerableSet.AddressSet internal _rules;
32+
/// @dev Maximum number of rules allowed in the engine.
33+
uint256 internal _maxRules = DEFAULT_MAX_RULES;
2734

2835
/*//////////////////////////////////////////////////////////////
2936
PUBLIC/EXTERNAL FUNCTIONS
@@ -37,15 +44,16 @@ abstract contract RulesManagementModule is RulesManagementModuleInvariantStorage
3744
* Reverts if `rules_` is empty. Use {clearRules} to remove all rules explicitly.
3845
* To transition from one non-empty set to another without an enforcement gap,
3946
* call this function directly with the new set.
40-
* No on-chain maximum number of rules is enforced. Operators are responsible
41-
* for keeping the rule set size compatible with the target chain gas limits.
4247
* Security convention: rule contracts should be treated as trusted business logic,
4348
* but should not also be granted {RULES_MANAGEMENT_ROLE}.
4449
*/
4550
function setRules(IRule[] calldata rules_) public virtual override(IRulesManagementModule) onlyRulesManager {
4651
if (rules_.length == 0) {
4752
revert RuleEngine_RulesManagementModule_ArrayIsEmpty();
4853
}
54+
if (rules_.length > _maxRules) {
55+
revert RuleEngine_RulesManagementModule_MaxRulesExceeded(_maxRules);
56+
}
4957
if (_rules.length() > 0) {
5058
_clearRules();
5159
}
@@ -66,16 +74,36 @@ abstract contract RulesManagementModule is RulesManagementModuleInvariantStorage
6674

6775
/**
6876
* @inheritdoc IRulesManagementModule
69-
* @dev No on-chain maximum number of rules is enforced. Adding too many rules
70-
* can increase transfer-time gas usage because rule checks are linear in rule count.
77+
* @dev Reverts when the configured maximum number of rules is already reached.
7178
* Security convention: do not grant {RULES_MANAGEMENT_ROLE} to rule contracts.
7279
*/
7380
function addRule(IRule rule_) public virtual override(IRulesManagementModule) onlyRulesManager {
81+
if (_rules.length() >= _maxRules) {
82+
revert RuleEngine_RulesManagementModule_MaxRulesExceeded(_maxRules);
83+
}
7484
_checkRule(address(rule_));
7585
require(_rules.add(address(rule_)), RuleEngine_RulesManagementModule_OperationNotSuccessful());
7686
emit AddRule(rule_);
7787
}
7888

89+
/**
90+
* @inheritdoc IRulesManagementModule
91+
*/
92+
function maxRules() public view virtual override(IRulesManagementModule) returns (uint256) {
93+
return _maxRules;
94+
}
95+
96+
/**
97+
* @inheritdoc IRulesManagementModule
98+
*/
99+
function setMaxRules(uint256 maxRules_) public virtual override(IRulesManagementModule) onlyRulesLimitManager {
100+
if (maxRules_ == 0) {
101+
revert RuleEngine_RulesManagementModule_MaxRulesZeroNotAllowed();
102+
}
103+
_maxRules = maxRules_;
104+
emit SetMaxRules(maxRules_);
105+
}
106+
79107
/**
80108
* @inheritdoc IRulesManagementModule
81109
*/
@@ -197,4 +225,5 @@ abstract contract RulesManagementModule is RulesManagementModuleInvariantStorage
197225
}
198226

199227
function _onlyRulesManager() internal virtual;
228+
function _onlyRulesLimitManager() internal virtual;
200229
}

src/modules/library/RulesManagementModuleInvariantStorage.sol

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ pragma solidity ^0.8.20;
55
import {IRule} from "../../interfaces/IRule.sol";
66

77
abstract contract RulesManagementModuleInvariantStorage {
8+
uint256 public constant DEFAULT_MAX_RULES = 10;
9+
810
/* ==== Errors === */
911
error RuleEngine_RulesManagementModule_RuleAddressZeroNotAllowed();
1012
error RuleEngine_RulesManagementModule_RuleAlreadyExists();
1113
error RuleEngine_RulesManagementModule_RuleDoNotMatch();
1214
error RuleEngine_RulesManagementModule_ArrayIsEmpty();
1315
error RuleEngine_RulesManagementModule_OperationNotSuccessful();
16+
error RuleEngine_RulesManagementModule_MaxRulesExceeded(uint256 maxRules);
17+
error RuleEngine_RulesManagementModule_MaxRulesZeroNotAllowed();
1418

1519
/* ============ Events ============ */
1620
/**
@@ -30,6 +34,12 @@ abstract contract RulesManagementModuleInvariantStorage {
3034
*/
3135
event ClearRules();
3236

37+
/**
38+
* @notice Emitted when the maximum allowed number of rules is updated.
39+
* @param maxRules The new rule cap.
40+
*/
41+
event SetMaxRules(uint256 maxRules);
42+
3343
/* ==== Constant === */
3444
/// @notice Role to manage the ruleEngine
3545
// Will not be present in the final bytecode if not used

test/RuleEngine/AccessControl/RuleEngineAccessControl.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,23 @@ contract RuleEngineTest is Test, HelperContract {
9595
vm.expectRevert(ERC3643ComplianceModule.RuleEngine_ERC3643Compliance_UnauthorizedCaller.selector);
9696
ruleEngineMock.transferred(address(0), ADDRESS1, ADDRESS2, 10);
9797
}
98+
99+
function testCannotAttackerSetMaxRules() public {
100+
vm.prank(ATTACKER);
101+
vm.expectRevert(
102+
abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, ATTACKER, bytes32(0))
103+
);
104+
ruleEngineMock.setMaxRules(12);
105+
}
106+
107+
function testRulesManagerCannotSetMaxRulesWithoutAdminRole() public {
108+
vm.prank(RULE_ENGINE_OPERATOR_ADDRESS);
109+
ruleEngineMock.grantRole(RULES_MANAGEMENT_ROLE, WHITELIST_OPERATOR_ADDRESS);
110+
111+
vm.prank(WHITELIST_OPERATOR_ADDRESS);
112+
vm.expectRevert(
113+
abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, WHITELIST_OPERATOR_ADDRESS, bytes32(0))
114+
);
115+
ruleEngineMock.setMaxRules(12);
116+
}
98117
}

0 commit comments

Comments
 (0)