feat: composite policies (UNION/INTERSECT) — interface, mock, and tests#174
Conversation
Interface-only proposal for composite policies on IPolicyRegistry: - Extend PolicyType with UNION and INTERSECT (append-only; leaf discriminants 0/1 unchanged, gate rides the policy ID top byte). - Add createCompositePolicy(admin, gate, operands) and updateCompositeOperands(policyId, operands). Update is a full-set overwrite (replace-all), re-validated as at creation. - Add CompositePolicyCreated and CompositeOperandsUpdated events, and EmptyOperandSet / InvalidOperand errors. - Note on createPolicy/createPolicyWithAccounts that composite gates revert IncompatiblePolicyType. Operands are flat-only (leaf ALLOWLIST/BLOCKLIST), capped at the composite operand limit, and the set may not be empty. This changes only the interface; MockPolicyRegistry and the Rust precompile implementation follow in a separate PR, so the mock will not compile against this interface until then. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
Interface Coverage✅ All interface functions have test coverage. |
|
| function createPolicy(address admin, PolicyType policyType) external returns (uint64 newPolicyId); | ||
|
|
||
| /// @notice Creates a new policy seeded with `accounts` as initial members. Permissionless. | ||
| /// @notice Creates a new leaf policy seeded with `accounts` as initial members. Permissionless. |
There was a problem hiding this comment.
Remove leaf here
Drop the "not an incremental edit" binary contrast and the "silently" adverb; make the flat-only note active. Wording only, no signature or behavior change. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||
| /// @notice A composite policy (UNION or INTERSECT) was created over `operands`. | ||
| event CompositePolicyCreated( | ||
| uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] operands |
There was a problem hiding this comment.
feel like I want to see the clarification of "policy" in there
| uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] operands | |
| uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] operandPolicies |
There was a problem hiding this comment.
Do we even need this event? Why not just use PolicyCreated as-is and then combine with the event below for declaring operand policies updated?
There was a problem hiding this comment.
I think we can use policy created. Only thing is I would assume a user want's an event that lists the new childPolicyIds for the composite, when a policy is created / updated.
There was a problem hiding this comment.
The existing PolicyCreated event does declare the policy type so I think your goal is met as-is
There was a problem hiding this comment.
But it wouldn't solve the event that the user can see the new childPoloicyId's
There was a problem hiding this comment.
Updated to PolicyCreated, and a UpdateCompositePolicyEvent both being emitted
Co-authored-by: katzman <steve.katzman@coinbase.com>
Co-authored-by: Conner Swenberg <conner.swenberg@coinbase.com>
Co-authored-by: katzman <steve.katzman@coinbase.com>
Co-authored-by: Conner Swenberg <conner.swenberg@coinbase.com>
| /// @notice A composite policy (UNION or INTERSECT) was created over `childPolicyIds`. | ||
| event CompositePolicyCreated( | ||
| uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] childPolicyIds | ||
| ); |
There was a problem hiding this comment.
Seems to ignore discussion here: #174 (comment).
As we keep adding policy types with more nuance, we'll continually run up against the desire to have custom created events that have all of the content of PolicyCreated, but with a new field(s). I think we should take the opinionated stance that we have one canonical creation event (PolicyCreated) and we use *Updated events for the type-specific field(s). This gives indexers the cleanest experience where this is only one path for creation to look at ever and only one path for field updates to look at (regardless of creation/update).
| /// @notice A composite policy (UNION or INTERSECT) was created over `childPolicyIds`. | |
| event CompositePolicyCreated( | |
| uint64 indexed policyId, address indexed creator, PolicyType policyType, uint64[] childPolicyIds | |
| ); |
There was a problem hiding this comment.
So the create function fires two events fires two events, Created and UpdatePolicy ?
Use the canonical PolicyCreated event for every policy type and surface composite child policies via CompositePolicyUpdated (now emitted on both creation and update). Gives indexers one creation path and one per-field update path. Addresses PR #174 review. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
| /// @dev Reverts with `PolicyNotFound` when any child policy does not exist. | ||
| /// @dev Reverts with `InvalidChildPolicy` when any child policy is itself a composite | ||
| /// (not a simple policy). | ||
| /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. |
There was a problem hiding this comment.
Aren't we going to explicitly check that 2 < childPolicyIds <= 4? What risk is there of overflow?
There was a problem hiding this comment.
They share the create counter which can overflow
There was a problem hiding this comment.
oooh for the global policy id. i don't think this needs explicit callout here...
Co-authored-by: katzman <steve.katzman@coinbase.com>
Co-authored-by: katzman <steve.katzman@coinbase.com>
Co-authored-by: katzman <steve.katzman@coinbase.com>
Co-authored-by: katzman <steve.katzman@coinbase.com>
…com/base/base-std into composite-policy-interface-proposal
| /// @dev Reverts with `TooFewChildPolicies` when `childPolicyIds.length < 2`; there is no clear-the-list path. | ||
| /// @dev Reverts with `BatchSizeTooLarge(4)` when `childPolicyIds.length > 4` |
There was a problem hiding this comment.
asymmetry of these events feels weird, feels like we should do one OutsideAllowedChildPoliciesRange(received, min, max) or similar
* feat: create composite polocies * feat: create mock polocy * test: reject built-in sentinels as composite children Add tests asserting createCompositePolicy and updateComposite revert InvalidChildPolicy when a child is a built-in sentinel (ALWAYS_ALLOW_ID / ALWAYS_BLOCK_ID), and align the IPolicyRegistry natspec accordingly. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * style: forge fmt Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * refactor: replace composite count errors with ChildPoliciesOutsideOfRange Collapse the TooFewChildPolicies (< 2) and BatchSizeTooLarge(4) (> 4) composite child-count checks into a single ChildPoliciesOutsideOfRange(min, max) error reverting with (2, 4). Remove the now-unused TooFewChildPolicies error; BatchSizeTooLarge is retained for account-membership batches. Add MIN_CHILD_POLICIES and update the composite tests + revert-order walks accordingly. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
📊 Forge Coverage (
|
| File | Lines | Stmts | Branches | Funcs |
|---|---|---|---|---|
| 🟢 B20FactoryLib.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🔴 test/lib/ForceFeeder.sol | 0.00% | 0.00% | 100.00% | 0.00% |
| 🔴 test/lib/PrecompileProbe.sol | 0.00% | 0.00% | 0.00% | 0.00% |
| 🟢 MockActivationRegistry.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockActivationRegistryStorage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20Asset.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟡 MockB20Factory.sol | 98.96% | 99.10% | 100.00% | 100.00% |
| 🟢 MockB20Stablecoin.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20Storage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockPolicyRegistry.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockPolicyRegistryStorage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| Total | 96.90% | 97.48% | 98.53% | 97.04% |
Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).
The Interface Coverage CI check flags any test/lib/mocks function with zero test hits. childrenSlot / childrenBaseSlot were added for the composite child set but never exercised. Add a slot-helper test that reads a composite's child array via childrenSlot, matching the existing slot-helper test pattern. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
| /// | ||
| /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. | ||
| /// @dev Panics with arithmetic overflow (Panic 0x11) when the policy counter has reached its maximum value. | ||
| /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is a composite gate. |
There was a problem hiding this comment.
createPolicy and createPolicyWithAccounts have natspec mentioning that they revert for composite policy types, but it looks like
policyRegistry.createPolicy(admin, PolicyType.UNION); // does not revert, creating a UNION with 0 children
policyRegistry.createPolicy(admin, PolicyType.INTERSECT); // isAuthorized(id, anyAddr) == true
policyRegistry.createPolicyWithAccounts(admin, PolicyType.UNION, accts); // does not revert
don't revert as documented
There was a problem hiding this comment.
Oh yep nice catch
Summary
Adds composite-policy support to the policy registry: the
IPolicyRegistryinterface surface, theMockPolicyRegistryreference implementation, and the full unit-test suite. (Includes the mock implementation merged via #175.)A composite policy combines existing simple policies (ALLOWLIST/BLOCKLIST) under a logic gate. The gate rides the policy ID's top byte, so composites need no separate type storage.
Interface (
IPolicyRegistry)PolicyTypegainsUNION(2) andINTERSECT(3). Append-only: leaf discriminants (BLOCKLIST=0,ALLOWLIST=1) are unchanged.createCompositePolicy(address admin, PolicyType policyType, uint64[] childPolicyIds)— creates a UNION/INTERSECT over existing simple child policies. Children must exist and be simple (never a composite or a built-in sentinel); the child count must be in[2, 4].updateComposite(uint64 policyId, uint64[] childPolicyIds)— full replace of the child set, re-validated exactly as at creation. The gate is fixed in the ID and cannot change.isAuthorized— composites evaluate live over current child membership: UNION is OR (any child authorizes; empty → false), INTERSECT is AND (every child authorizes; empty → true).CompositePolicyUpdated(policyId, updater, childPolicyIds)— emitted on creation and every update, carrying the full post-update child set for single-log indexing. (Creation also emits the canonicalPolicyCreated+PolicyAdminUpdated.)ChildPoliciesOutsideOfRange(min, max)when the child count is outside[2, 4];InvalidChildPolicy(childPolicyId)when a child is a built-in sentinel or a composite. ReusesPolicyNotFound/IncompatiblePolicyType/Unauthorized/ZeroAddress.Mock implementation
MockPolicyRegistryStorage: adds achildrenmapping (uint64 => uint64[]) at layout offset 4 (offsets 0–3 unchanged) plus achildrenSlothelper for Rust slot-math parity.MockPolicyRegistry: implementscreateCompositePolicy/updateCompositewith the canonical revert precedence, and splits composite evaluation into_isAuthorizedUnion/_isAuthorizedIntersect. Child validation (_requireCreatedSimplePolicies) is two-pass soPolicyNotFoundprecedesInvalidChildPolicy.Tests
test/unit/PolicyRegistry/: composite create/update (reverts + success), sequential revert-order walks,isAuthorizedUNION/INTERSECT/mixed-child/live-membership semantics, and built-in-sentinel-child rejection.Testing
forge test→ 658 passed, 0 failed, 4 skipped (the 4 skipped are mock-only counter-overflow tests guarded byvm.skip(livePrecompiles)).forge fmt --checkclean.Generated with Claude Code