diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index bc427b8..31de2dc 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -7,11 +7,17 @@ ## Summary -This feature introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND), collectively called composite policies. A composite policy authorizes by combining the results of two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). The children of composite policies are only existing simple policies; this constraint is enforced at write time. The feature enables policy reuse by allowing a single composite policy to reference multiple simple policies. Updating one child policy automatically updates every composite that references it. +Asset issuers often use the Policy Registry to maintain compliance lists. They and other Policy Registry users can also depend on shared lists maintained by other policy owners. This feature lets them compose these policies without copying entries into a new list or maintaining infrastructure to synchronize updates. + +The feature introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND), collectively called composite policies. A `UNION` policy authorizes an account if any child policy authorizes it. An `INTERSECT` policy authorizes an account only if every child policy authorizes it. Each composite references two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). Composite policies cannot reference other composites, and the registry enforces this constraint when a composite is created or updated. Authorization uses each child's current state, so updating a child automatically affects every composite that references it. ## Motivation -The policy registry currently supports simple boolean policies through `isAuthorized`, where each policy independently returns true or false. In practice, access control often requires combining multiple policies. For example, an application might require both KYC verification and ProUser status, or either ProUser status or LifetimeUser status. The current architecture requires a user to listen to changes on a different allowlist and flatten into one, which duplicates lists and requires infrastructure to keep them up to date. This feature allows policy reuse by creating composite policies that combine the results of other policies, which simplifies maintenance because updating one child policy updates every composite that references it. +Asset issuance platforms often manage many assets that share authorization requirements. An issuer can reuse one policy across these assets, but assigning that policy directly leaves no way to customize authorization for an individual asset. A composite policy lets the issuer use shared policies by default while preserving per-asset overrides. For example, a `UNION` can combine a shared allowlist with a token-specific allowlist. + +Without composition, users must copy entries from source policies into a new, flattened policy and operate infrastructure that monitors and synchronizes every source update. This approach duplicates policy data and can leave the copy stale when synchronization is delayed or fails. Until the copy catches up, valid transfers can be rejected or transfers that the source policy no longer authorizes can proceed. + +Access control can also require more than one condition. An application might require both KYC verification and ProUser status, or accept either ProUser status or LifetimeUser status. Composite policies support these cases by introducing `UNION` (OR) and `INTERSECT` (AND). Because authorization evaluates each child policy's current state, one child update immediately applies to every composite that references it, without list-copying infrastructure. ## Background @@ -23,7 +29,7 @@ B20 is a token precompile that uses policies to restrict operations such as tran The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. Currently, B20 tokens use the Policy Registry for `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. -### Simple Policies +#### Simple Policies Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. @@ -34,10 +40,35 @@ Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. ### Interface Changes -The following interface changes are verified via `cast sig` and `cast keccak` against `src/interfaces/IPolicyRegistry.sol`. +The relevant `IPolicyRegistry` interface changes are: + +```solidity +enum PolicyType { + BLOCKLIST, + ALLOWLIST, + UNION, + INTERSECT +} + +error ChildPoliciesOutsideOfRange(); +error InvalidChildPolicy(uint64 childPolicyId); + +event CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); + +function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) + external + returns (uint64 newPolicyId); + +function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; + +function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); + +function MIN_COMPOSITE_CHILD_POLICIES() external view returns (uint256); +function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256); +``` | Symbol | Selector / Topic0 | Status | Notes | -|--------|-------------------|--------|-------| +| ------ | ----------------- | ------ | ----- | | `createCompositePolicy(address,uint8,uint64[])` | `0x6fdd1491` | NEW | `PolicyType` ABI-encodes as `uint8`; creates a UNION/INTERSECT composite | | `updateComposite(uint64,uint64[])` | `0xbfe142c0` | NEW | Full replacement of the child set | | `compositePolicyChildIds(uint64)` | `0x7c40df74` | NEW (view) | Returns the stored child set verbatim; empty for non-composites | @@ -51,14 +82,18 @@ The following interface changes are verified via `cast sig` and `cast keccak` ag | `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same new `IncompatiblePolicyType` rejection | The `PolicyType` enum introduces two new values: + - `UNION = 2` — authorized if any child policy authorizes the account (OR) - `INTERSECT = 3` — authorized only if every child policy authorizes the account (AND) #### `createCompositePolicy(admin, policyType, childPolicyIds)` -The `childPolicyIds` array must contain between 2 and 4 entries (enforced by `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`). The cap of 4 bounds worst-case `isAuthorized` gas and the authorization audit surface. Every child must be an existing simple policy (`ALLOWLIST` or `BLOCKLIST`) — never another composite, never a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). +- `childPolicyIds` must contain at least `MIN_COMPOSITE_CHILD_POLICIES` (`2`) and no more than `MAX_COMPOSITE_CHILD_POLICIES` (`4`). +- The `isAuthorized` gas cost increases with each child policy evaluated because each child requires a membership storage read. The highest cost occurs when all four children are evaluated. +- Each child must be an existing `ALLOWLIST` or `BLOCKLIST` policy. Composite policies and the built-in `ALWAYS_ALLOW` and `ALWAYS_BLOCK` policies are not valid children. The canonical revert order is: + 1. `ZeroAddress` (admin) 2. `IncompatiblePolicyType` (policyType not UNION/INTERSECT) 3. `ChildPoliciesOutsideOfRange` (count not in `[2, 4]`) @@ -66,15 +101,17 @@ The canonical revert order is: 5. `InvalidChildPolicy` (a child is itself composite or sentinel, checked as a second pass) The function emits, in order: + - `PolicyCreated(policyId, creator, policyType)` - `PolicyAdminUpdated(policyId, address(0), admin)` - `CompositePolicyUpdated(policyId, creator, childPolicyIds)` #### `updateComposite(policyId, childPolicyIds)` -This function performs a full replacement of the child set. There is no partial-update or clear-the-list operation. The same child-validity rules as `createCompositePolicy` apply: existing simple policies only, 2 to 4 of them. +This function replaces the entire child set with two to four existing simple policies, subject to the same validation rules as `createCompositePolicy`. It does not support partial updates or an empty child set. The canonical revert order is: + 1. `PolicyNotFound` (composite itself doesn't exist) 2. `IncompatiblePolicyType` (`policyId` is a simple policy) 3. `Unauthorized` (caller isn't the current admin — fires before the count check) @@ -84,40 +121,83 @@ The canonical revert order is: The function emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no `PolicyAdminUpdated`, since the admin does not change. +### Behavioural Changes + #### Existing Functions with Changed Revert Behavior -`createPolicy` and `createPolicyWithAccounts` (both already live on Beryl) are simple-policy constructors that now reject `UNION`/`INTERSECT` with `IncompatiblePolicyType`. This is not merely a newly-reachable branch — the revert for the same calldata changes across the fork. Pre-Cobalt, the `PolicyType` enum had only `BLOCKLIST`/`ALLOWLIST`, so calldata carrying type byte `2`/`3` failed ABI enum decode (Solidity reference: `Panic(0x21)`, enum-conversion out of range). Post-Cobalt, byte `2`/`3` decodes cleanly as `UNION`/`INTERSECT`, then the explicit guard reverts `IncompatiblePolicyType`. +`createPolicy` and `createPolicyWithAccounts` revert with `IncompatiblePolicyType` when creating a `UNION` or `INTERSECT` policy. -**UNVERIFIED**: The exact pre-Cobalt revert of the Rust precompile for an out-of-range `PolicyType` byte is not asserted here. The Solidity mock does not model ABI enum decode. Confirm via `base-forge test` before publishing, or document only as Solidity-reference behavior. +#### Authorization Implementation -### Behavioural Changes +`isAuthorized` uses the same result from each child, whether that child is an `ALLOWLIST` or a `BLOCKLIST`. +The composite only determines how to combine those results: -A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 needs zero code changes because it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. +Composite creation and updates reject composite children. Authorization therefore evaluates only simple child +policies and does not recurse into another composite. -`isAuthorized` on a composite is live and short-circuiting, not a snapshot: -- It reads each child's current membership on every call — no snapshot from creation or the last `updateComposite`. -- `UNION` short-circuits `true` on the first authorizing child. -- `INTERSECT` short-circuits `false` on the first non-authorizing child. -- Recursion never exceeds depth 1 because every child is validated to be a simple policy at write time. A composite's children can never themselves be composites. +```text +isAuthorized(policyId, account): + if policy is ALLOWLIST: + return account is in the policy -`isAuthorized` on a well-formed but never-created composite ID collapses to empty-child-set semantics: `UNION` returns `false` (deny-all), `INTERSECT` returns `true` (allow-all — an AND over zero children is vacuously true). This parallels the simple-policy empty-set rule (`ALLOWLIST` → `false`, `BLOCKLIST` → `true`). Consumers that store a composite ID (for example, on a B20 policy slot) MUST validate `policyExists(policyId)` at write time. A typo'd INTERSECT ID would silently behave as `ALWAYS_ALLOW`. + if policy is BLOCKLIST: + return account is not in the policy -Gas: a composite reads more policy IDs than a simple policy (its child list, plus each evaluated child's membership), so `isAuthorized` on a composite costs more gas than on a simple policy. + if policy is UNION: + for each child policy: + if isAuthorized(child, account): + return true + return false -Child order affects gas, never the outcome: -- `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an account is authorized. -- It only shifts where the short-circuit lands. Put the child most likely to short-circuit first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas. + if policy is INTERSECT: + for each child policy: + if not isAuthorized(child, account): + return false + return true +``` + +#### Authorization Details + +- Evaluation is live, not a snapshot. Each call reads the current membership of each evaluated child. +- Evaluation short-circuits. `UNION` stops at the first authorizing child, and `INTERSECT` stops at the first + non-authorizing child. +- Gas cost depends on the number of child policies evaluated. Child order can therefore affect gas, but it + cannot affect the authorization result. Put the child most likely to short-circuit first. +- `ALLOWLIST` and `BLOCKLIST` children use the same composite evaluation path. Each child first resolves its + own authorization result, and then the composite combines those results. +- Duplicate child IDs are allowed. The registry preserves their order and does not deduplicate them. +- `updateComposite` requires two to four children, so an existing composite cannot become empty or undersized. +- A child remains effective if its admin renounces. Renouncing freezes future membership changes but does not + delete the child or change its current authorization results. +- A well-formed but never-created `UNION` ID has no children and returns `false`. A well-formed but never-created + `INTERSECT` ID has no children and returns `true`. Consumers that store policy IDs MUST call + `policyExists(policyId)` before storing them; otherwise, an invalid `INTERSECT` ID behaves like `ALWAYS_ALLOW`. -Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child list. Deduplicating would cost extra gas on every write for a set already capped at 4 entries, for little value. `UNION`/`INTERSECT` are idempotent under duplicates anyway. +#### State Changes -A composite can never shrink below 2 children via `updateComposite` — it enforces the same `[2, 4]` range as creation, so there is no path to an empty or undersized composite. +**Storage layout change:** A `children` mapping is added at offset 4 in the `base.policy_registry` ERC-7201 +namespace. The change is additive. Existing state at offsets 0–3 is unchanged, and no storage migration is +needed. Offset 4 is relative to the namespace location, not literal EVM slot 4. -If a child policy's admin renounces, the parent composite keeps working. `renounceAdmin` only clears the child's admin and freezes its future membership changes. The child still exists and `isAuthorized` on it still resolves normally, so the composite keeps evaluating it exactly as before. +- Namespace location: `0x00503aeb06982fa1fe3151dc68f90b3946c55c449dfd447e49dcaece71ba4a00` +- Placed at `CHILDREN_OFFSET = 4` +- Field type: `mapping(uint64 policyId => uint64[] childPolicyIds) children` -#### State Changes +For each `policyId`, the mapping entry stores the dynamic array length. Array elements start at the hash of that +entry and pack four `uint64` child policy IDs into each 256-bit slot. The two-to-four-child limit means each +composite uses one element slot. -- New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, appended at offset 4 within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4). This is appended so existing state at offsets 0–3 is unmodified and no storage migration is needed. -- Reused state: one shared global counter (`nextCounter`) across simple and composite policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A composite policy ID encodes `PolicyType` in the top byte and the next available counter value in the low 56 bits — the same encoding scheme as simple policies, not a separate counter. +| Bits | Array index | Field | +| ------- | ----------- | ---------------------- | +| 0–63 | 0 | `childPolicyIds[0]` | +| 64–127 | 1 | `childPolicyIds[1]` | +| 128–191 | 2 | `childPolicyIds[2]` | +| 192–255 | 3 | `childPolicyIds[3]` | + +**Reused state:** Simple and composite policies share the global `nextCounter`. The counter starts at 2 because +`0` and `1` are reserved for `ALWAYS_ALLOW` and `ALWAYS_BLOCK`. A composite policy ID encodes `PolicyType` in +the top byte and the next available counter value in the low 56 bits. This is the same encoding scheme that +simple policies use; composite policies do not use a separate counter. ### Examples @@ -168,6 +248,7 @@ Future authorization checks use the new child set immediately (live evaluation, **Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite`. **Alternative 1: One generic COMPOSITE type** + - Store a separate operator (AND, OR, NOT, XOR) in composite storage. - Rejected because: - Requires storing both "composite" flag and the operator. @@ -176,6 +257,7 @@ Future authorization checks use the new child set immediately (live evaluation, - Generic boolean expressions create a larger gas and audit surface. **Alternative 2: Token-level policy groups** + - Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator. - Rejected because: - Composite policies would not be reusable entities. @@ -184,6 +266,7 @@ Future authorization checks use the new child set immediately (live evaluation, - Spreads complexity across more contracts. **Alternative 3: Incremental child updates** + - Provide `addCompositeOperand` / `removeCompositeOperand` functions. - Rejected because: - Child list is capped at 4 entries. @@ -192,6 +275,7 @@ Future authorization checks use the new child set immediately (live evaluation, - Caller can resend the complete list at low cost. **Alternative 4: Separate creator functions** + - Use `createUnionPolicy` and `createIntersectPolicy`. - Rejected because: - Doubles the creation API surface. @@ -199,6 +283,7 @@ Future authorization checks use the new child set immediately (live evaluation, - Future operators would require additional functions. **Alternative 5: Nested composites (a composite referencing another composite)** + - Allow composite children, to some bounded depth, instead of restricting children to simple `ALLOWLIST`/`BLOCKLIST` policies. - Rejected because: - Restricting children to simple policies guarantees `isAuthorized` recursion terminates at depth 1 — no cycle risk, no unbounded traversal. @@ -207,16 +292,17 @@ Future authorization checks use the new child set immediately (live evaluation, ## Migration Steps -- **Backwards-compatible**: Existing simple policies (`ALLOWLIST`/`BLOCKLIST`) continue to work unchanged. No action required if you do not need composite behavior. +**Backwards-compatible**: Existing simple policies (`ALLOWLIST`/`BLOCKLIST`) continue to work unchanged. No action required if you do not need composite behavior. + +**For users currently flattening multiple lists into one policy**: -- **For users currently flattening multiple lists into one policy**: - 1. Identify the simple policies you want to combine. - 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. - 3. Update the B20 token's policy scope to point to the new composite policy ID: - - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` - - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. - 4. Remove the old flattened policy if no longer needed. +1. Identify the simple policies you want to combine. +2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. +3. Update the B20 token's policy scope to point to the new composite policy ID: + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. +4. Remove the old flattened policy if no longer needed. -- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt. +**No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt. -- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation. \ No newline at end of file +**No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation. \ No newline at end of file