[TOC]
This rule aggregates multiple child whitelist rules using OR logic. An address is considered whitelisted if it appears in any of the registered child rules. This enables a multi-operator model where each operator manages their own whitelist independently.
Each child rule must implement IAddressList. The wrapper iterates through all registered rules and returns true for an address as soon as one rule lists it. Iteration stops early once all required addresses are resolved.
The sequence below shows how the wrapper aggregates its child whitelist rules when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer.
Diagram source: doc/img/rule-whitelist-wrapper-flow.puml.
| Parameter | Description |
|---|---|
admin |
Address granted DEFAULT_ADMIN_ROLE (implicitly holds all roles) |
forwarderIrrevocable |
ERC-2771 trusted forwarder address for meta-transactions (use address(0) to disable) |
checkSpender_ |
If true, spender address in transferFrom is also validated against child rules |
When enabled, the spender in transferFrom must be listed in at least one child rule. Toggled post-deployment by the admin with setCheckSpender(bool).
The wrapper reuses restriction codes from the whitelist rule:
| Constant | Code | Meaning |
|---|---|---|
CODE_ADDRESS_FROM_NOT_WHITELISTED |
21 | Sender is not in any child whitelist |
CODE_ADDRESS_TO_NOT_WHITELISTED |
22 | Recipient is not in any child whitelist |
CODE_ADDRESS_SPENDER_NOT_WHITELISTED |
23 | Spender is not in any child whitelist (only when checkSpender is enabled) |
| Role | Description |
|---|---|
DEFAULT_ADMIN_ROLE |
Manages all roles; can call all privileged functions |
RULES_MANAGEMENT_ROLE |
May add, remove, or set the list of child whitelist rules |
| Function | Role required | Description |
|---|---|---|
setRules(address[] rules_) |
RULES_MANAGEMENT_ROLE |
Replaces the entire list of child rules |
addRule(address rule_) |
RULES_MANAGEMENT_ROLE |
Adds a single child rule |
removeRule(address rule_) |
RULES_MANAGEMENT_ROLE |
Removes a single child rule |
clearRules() |
RULES_MANAGEMENT_ROLE |
Removes all child rules |
Enables or disables spender checks. Restricted to DEFAULT_ADMIN_ROLE.
Returns true if the address is listed in at least one child rule.
Returns the child rule at the given index.
Returns the number of registered child rules.
_detectTransferRestrictionForTargets makes one external STATICCALL per child rule:
for (uint256 i = 0; i < rulesLength; ++i) {
bool[] memory isListed = IAddressList(rule(i)).areAddressesListed(targetAddress); // <- external call
for (uint256 j = 0; j < targetAddress.length; ++j) {
if (isListed[j]) { result[j] = true; }
}
// early exit: stop as soon as EVERY target address has been resolved
...
}This is not only a view cost. The wrapper's transferred() → _detectTransferRestriction path runs the same scan during transfer execution, so the gas is paid by the transferring user, on every transfer, for the life of the token.
The scan is linear in the number of children actually scanned, at a marginal cost of ~8.8k gas per child (detectTransferRestriction, 2 target addresses). Measured:
| Children | Allowed pair, resolved only by the last child | Rejected pair (never early-exits) | Marginal gas/child |
|---|---|---|---|
| 1 | ~7.0k | ~10.7k | — |
| 5 | ~42.3k | ~46.0k | ~8.8k |
| 10 (the default cap) | ~86.4k | ~90.0k | ~9.0k |
| 25 | — | ~222.3k | ~8.9k |
| 50 | — | ~442.8k | ~8.9k |
| 100 | — | ~884.2k | ~8.8k |
| 200 | — | ~1.77M | ~8.8k |
The marginal cost stays flat (8,891 → 8,855 → 8,841 → 8,841 gas/child from n=25 to n=200), so memory expansion is negligible at any realistic child count and the model gas ≈ N × 8.8k holds.
With checkSpender = true (3 target addresses instead of 2), the same 10-child wrapper costs ~116k (allowed) to ~121k (rejected).
This is a cost problem, not a liveness problem. Even 200 children (~1.77M gas) is only ~6% of a 30M block; a transfer would not fail to fit until roughly 3,400 children. Long before that, the wrapper simply makes every transfer economically painful. Treat the numbers above as a per-holder tax, not as a safety cliff.
- The early exit only fires once every target address is resolved. A transfer that is going to be rejected — because
from,to(orspender) is in no child list — never resolves, and therefore scans all N children. The most expensive path is the failing one, and the user pays for it before the revert. checkSpender = trueadds a third address that must also be found before the loop can break. It materially lowers the early-exit hit rate and pushes more transfers toward the full-N scan (≈ +35% at 10 children, per the table above).
- Keep the child list small — stay at or below the default cap of 10.
addRulereverts oncerulesCount() >= maxRules, andmaxRulesdefaults toDEFAULT_MAX_RULES = 10. At that cap the worst case is ~90k gas of scanning per transfer: significant, but safe. - Raising
maxRulesis a decision with a permanent, per-transfer cost for every holder.setMaxRulesonly rejects0— it accepts any other value. A rules manager who raises the cap to 100 makes the worst-case scan cost ~884k gas on every transfer; at 200 it is ~1.77M. That is a tax on holders, not a broken token — transfers still fit in a block — but it is paid forever and cannot be refunded. Nothing untrusted can trigger this: onlyRULES_MANAGEMENT_ROLE(or the owner) can add child rules or raise the cap. The size of the child list is the operator's responsibility. - Order children by expected hit rate. Put the whitelist that resolves the most addresses first, so the early exit fires as early as possible. This is free and materially reduces the average cost.
- Prefer fewer, larger child lists over many small ones. The per-child overhead is an external call; the number of addresses inside a child does not affect the scan cost.
Three operators (A, B, C) each manage their own RuleWhitelist. The RuleWhitelistWrapper is configured with all three as child rules. A transfer between any two addresses whitelisted by any one of the operators will pass. The wrapper admin grants RULES_MANAGEMENT_ROLE to a coordinator who adds and removes child rules as operators join or leave.



