diff --git a/docs/base-chain/specs/upgrades/cobalt/policy-registry-composite-policies.mdx b/docs/base-chain/specs/upgrades/cobalt/policy-registry-composite-policies.mdx new file mode 100644 index 000000000..5b5058350 --- /dev/null +++ b/docs/base-chain/specs/upgrades/cobalt/policy-registry-composite-policies.mdx @@ -0,0 +1,135 @@ +--- +title: "PolicyRegistry composite policies" +description: "Reference for UNION and INTERSECT composite policies added to the PolicyRegistry at the Cobalt hardfork." +--- + +At the Cobalt hardfork, the [PolicyRegistry](/base-chain/specs/upgrades/beryl/b20#policy-registry) gains **composite policies**: a policy that authorizes an account by combining 2–4 existing simple `ALLOWLIST` / `BLOCKLIST` policies under a `UNION` (OR) or `INTERSECT` (AND) gate. Every Beryl selector, event topic, and error keeps its exact 4-byte selector and topic0. The only change to existing behavior is one new revert path on `createPolicy` and `createPolicyWithAccounts` that rejects composite `policyType` values with the already-existing `IncompatiblePolicyType` error. + + +Cobalt is not yet live. Until the hardfork activates, every composite selector on this page is undialable. Only the Beryl simple-policy surface exists on-chain today. + + +## When to use a composite policy + +Reach for a composite policy when a single scope on a B20 token needs to combine multiple simple policies: + +- **`UNION` (OR).** Authorize an account if *any* child policy authorizes it. Example: allow transfers from either a KYC allowlist or a market-maker allowlist. +- **`INTERSECT` (AND).** Authorize an account only if *every* child policy authorizes it. Example: require an account to pass a jurisdiction allowlist *and* not appear on a sanctions blocklist. + +B20 policy scopes (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `MINT_RECEIVER_POLICY`, `SEIZE_HOLDER_POLICY`, and the rest) store an opaque `uint64` policy ID. A composite ID drops into any scope with no B20-side change. + +## Policy types + +`PolicyType` is append-only. Existing values and the packed policy-ID top-byte encoding are unchanged. + +| Type | Value | Available since | +|------|-------|-----------------| +| `BLOCKLIST` | `0` | Beryl | +| `ALLOWLIST` | `1` | Beryl | +| `UNION` | `2` | Cobalt | +| `INTERSECT` | `3` | Cobalt | + +## Bounds + +| Constant | Selector | Value | +|----------|----------|-------| +| `MIN_COMPOSITE_CHILD_POLICIES()` | `0xb3ae29f7` | `2` | +| `MAX_COMPOSITE_CHILD_POLICIES()` | `0x54309870` | `4` | + +Both constants are read-only and always callable, whether or not Cobalt is active. + +## Functions + +| Function | Selector | Notes | +|----------|----------|-------| +| `createCompositePolicy(address admin, uint8 policyType, uint64[] childPolicyIds)` | `0x6fdd1491` | Creates a `UNION` or `INTERSECT` policy over 2–4 existing simple policy IDs. Gated by the ActivationRegistry, same as `createPolicy`. | +| `updateComposite(uint64 policyId, uint64[] childPolicyIds)` | `0xbfe142c0` | Replaces the composite's child set in full. No partial-update or clear-the-list path. | +| `compositePolicyChildIds(uint64 policyId)` | `0x7c40df74` | Read-only child-set getter. Always callable. | + +`createPolicy(address,uint8)` (`0xca5d55f6`) and `createPolicyWithAccounts(address,uint8,address[])` (`0xa2d3044f`) keep their selectors and now revert with `IncompatiblePolicyType` when `policyType` is `UNION` or `INTERSECT`. + +## Events + +`PolicyCreated(uint64,address,uint8)` (topic0 `0x718d87917f0c4cfd1263707ef0e77c656ed8d8bfaca06152bdb0b8094142ec27`) is also emitted for composite creation, with `policyType` set to `UNION` or `INTERSECT`. + +`CompositePolicyUpdated(uint64 policyId, address updater, uint64[] childPolicyIds)` is new at Cobalt (topic0 `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363`). It is emitted on composite creation and on every `updateComposite`, and carries the complete post-update child set. + +`AllowlistUpdated` and `BlocklistUpdated` are not emitted for composites — composites have no membership set of their own. + +## Errors + +| Error | Selector | Thrown when | +|-------|----------|-------------| +| `IncompatiblePolicyType()` | `0xf1011ef5` | `createPolicy` / `createPolicyWithAccounts` gets a composite `policyType`; `createCompositePolicy` gets a non-composite `policyType`; `updateComposite` targets a simple policy. | +| `PolicyNotFound()` | `0x720caa4f` | The composite target itself does not exist in `updateComposite`, or any listed child does not exist in `createCompositePolicy` / `updateComposite`. | +| `ChildPoliciesOutsideOfRange()` | `0x697ec868` | Child count is outside `[2, 4]`. | +| `InvalidChildPolicy(uint64 childPolicyId)` | `0x46508ef6` | A listed child is itself a composite, or is the `ALWAYS_ALLOW` / `ALWAYS_BLOCK` built-in sentinel. | + +### Revert precedence + +`createCompositePolicy` runs its checks in this fixed order — each check fires before the next: + +1. `ZeroAddress` — `admin == address(0)`. +2. `IncompatiblePolicyType` — `policyType` is not `UNION` or `INTERSECT`. +3. `ChildPoliciesOutsideOfRange` — `childPolicyIds.length` is outside `[2, 4]`. +4. `PolicyNotFound` — any listed child does not exist (checked in one pass over the whole set). +5. `InvalidChildPolicy` — any listed child is a composite or a built-in sentinel (second pass). + +`updateComposite` runs its checks in this fixed order: + +1. `PolicyNotFound` — `policyId` does not exist. +2. `IncompatiblePolicyType` — `policyId` is a simple policy, not a composite. +3. `Unauthorized` — caller is not the current admin. A renounced composite (admin `address(0)`) can never be updated. +4. `ChildPoliciesOutsideOfRange` — new child count is outside `[2, 4]`. +5. `PolicyNotFound` — any new child does not exist. +6. `InvalidChildPolicy` — any new child is a composite or a built-in sentinel. + +## Evaluation + +`isAuthorized` on a composite calls each child policy's `isAuthorized` live. It is never a snapshot taken at creation or last update. `UNION` returns `true` on the first authorizing child and short-circuits. `INTERSECT` returns `false` on the first non-authorizing child. + +Recursion never exceeds depth 1: every child is validated to be a simple `ALLOWLIST` or `BLOCKLIST` policy at write time, so a composite's children can never themselves be composites. + +## Example + +```solidity +// Two simple policies already exist on-chain. +uint64 kycAllowlist = 0x0100000000000042; // ALLOWLIST +uint64 sanctionsBlocklist = 0x0000000000000043; // BLOCKLIST + +// INTERSECT: only accounts that are on the KYC allowlist AND not on the +// sanctions blocklist are authorized. +uint64[] memory children = new uint64[](2); +children[0] = kycAllowlist; +children[1] = sanctionsBlocklist; + +uint64 compositeId = policyRegistry.createCompositePolicy( + adminAddress, + uint8(PolicyType.INTERSECT), + children +); + +// Attach to a B20 policy scope. Validate the ID first, exactly as for a simple policy. +require(policyRegistry.policyExists(compositeId), "policy missing"); +b20Token.updatePolicy(TRANSFER_SENDER_POLICY, compositeId); + +// Later: replace the child set in full. Omitted children stop governing the composite. +uint64[] memory newChildren = new uint64[](3); +newChildren[0] = kycAllowlist; +newChildren[1] = sanctionsBlocklist; +newChildren[2] = jurisdictionAllowlist; +policyRegistry.updateComposite(compositeId, newChildren); +``` + +## Guarantees and edge cases + +- **No nested composites.** `createCompositePolicy` and `updateComposite` revert `InvalidChildPolicy(childPolicyId)` for any child whose type is `UNION` or `INTERSECT`. +- **No built-in sentinels as children.** `ALWAYS_ALLOW` and `ALWAYS_BLOCK` also revert with `InvalidChildPolicy`. To fold always-allow / always-block behavior into a composite, create a real `ALLOWLIST` or `BLOCKLIST` policy that reproduces the effect and reference it instead. +- **Duplicates are permitted.** The registry neither sorts nor de-duplicates the stored child list. Duplicates cost extra per-call evaluation but do not change the result — `UNION` and `INTERSECT` are idempotent under duplicates. +- **No path to an under-sized composite.** Every `updateComposite` call re-enforces `[2, 4]`, so a composite can never be shrunk below two children. +- **Renouncing a child admin does not break the parent.** `renounceAdmin` on a child clears the child's admin and freezes its future membership changes. The child still exists, `isAuthorized` on it still resolves, and the composite keeps evaluating it. +- **No composite-specific activation flag.** `createCompositePolicy` and `updateComposite` are gated by the same ActivationRegistry flag as `createPolicy`, `updateAllowlist`, and the rest. `compositePolicyChildIds`, the `MIN` / `MAX` bounds, and `isAuthorized` on a composite ID are always callable. + + +As with any policy ID, validate `policyExists(policyId)` before writing a composite ID to a B20 policy scope. `isAuthorized` never reverts on a non-existent ID — it collapses to empty-member-set semantics and will silently authorize or deny every account. + diff --git a/docs/docs.json b/docs/docs.json index f6ddd67f2..b8b32b611 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -262,7 +262,8 @@ { "group": "Cobalt", "pages": [ - "base-chain/specs/upgrades/cobalt/eip-8130" + "base-chain/specs/upgrades/cobalt/eip-8130", + "base-chain/specs/upgrades/cobalt/policy-registry-composite-policies" ] }, {