Summary
Cobalt adds composite policies toPolicyRegistry. A composite
combines two to four existing simple ALLOWLIST or BLOCKLIST policies under a UNION (OR) or
INTERSECT (AND) gate. Create a composite with createCompositePolicy and replace its complete
child set with updateComposite.
The change is additive. Existing Beryl selectors, events, errors, and simple-policy behavior remain
dialable at Cobalt. createPolicy and createPolicyWithAccounts add one revert path:
IncompatiblePolicyType when called with a composite policyType.
Motivation
Asset issuers often reuse authorization policies across assets, such as a KYC allowlist or a sanctions blocklist. Before Cobalt, combining more than one policy required copying their members into a flattened policy and operating offchain infrastructure to synchronize every source update. That duplication can become stale: a valid account can be rejected, or a removed account can remain authorized, until the copied list is updated. Authorization can also require an explicit OR or AND relationship. For example, a policy can admit an account that is on either a shared or token-specific allowlist, or it can require an account to be both KYC-verified and in a Pro User allowlist. Composite policies express those relationships without copying membership data. Each authorization check uses the current state of every evaluated child policy.What Changed
Policy Context
B20 stores aPolicyRegistry policy ID for each restricted operation. When an operation is
attempted, B20 calls isAuthorized(policyId, account) and rejects the operation if the account is
not authorized.
BLOCKLIST and ALLOWLIST remain simple policies. They are the only valid composite children.
Interface Changes
PolicyType is append-only: UNION = 2 and INTERSECT = 3 follow BLOCKLIST = 0 and
ALLOWLIST = 1. This preserves the existing packed policy-ID encoding.
IPolicyRegistry.sol
For the complete current interface, see the IPolicyRegistry reference.
Composite Policy Creation
createCompositePolicy(admin, policyType, childPolicyIds) creates a UNION or INTERSECT policy,
assigns admin as its initial administrator, and returns a new policy ID. It stores references to
its children rather than copying their membership.
The child set must contain two to four existing simple policies. Child policies cannot be composites
or the ALWAYS_ALLOW and ALWAYS_BLOCK built-in sentinels. Each evaluated child requires a
membership storage read, so gas increases with the number of children evaluated.
Validation runs in this order:
ZeroAddress—adminisaddress(0).IncompatiblePolicyType—policyTypeis notUNIONorINTERSECT.ChildPoliciesOutsideOfRange— the child count is outside[2, 4].PolicyNotFound— one or more children do not exist; the registry completes this pass before checking child types.InvalidChildPolicy— a child is a composite or built-in sentinel.
PolicyCreated(policyId, creator, policyType),
PolicyAdminUpdated(policyId, address(0), admin), and
CompositePolicyUpdated(policyId, creator, childPolicyIds), in that order.
Composite Policy Updates
updateComposite(policyId, childPolicyIds) atomically replaces a composite’s complete child set.
It does not support a partial update or an empty child set. The event
CompositePolicyUpdated(policyId, updater, childPolicyIds) is emitted on success; an update does
not emit PolicyAdminUpdated because the administrator does not change.
Validation runs in this order:
PolicyNotFound—policyIddoes not exist.IncompatiblePolicyType—policyIdis not aUNIONorINTERSECTpolicy.Unauthorized— the caller is not the current administrator. A renounced composite cannot be updated.ChildPoliciesOutsideOfRange— the new child count is outside[2, 4].PolicyNotFound— one or more new children do not exist.InvalidChildPolicy— a new child is a composite or built-in sentinel.
Behavioral Changes
Existing Constructor Reverts
createPolicy and createPolicyWithAccounts continue to create only simple policies. Both now
revert with IncompatiblePolicyType if their policyType is UNION or INTERSECT.
Authorization Evaluation
isAuthorized evaluates composites as follows:
Authorization Evaluation
UNION stops at the first authorizing child and INTERSECT stops at the
first non-authorizing child. Child ordering cannot change the authorization result, but it can change
gas usage; place the child most likely to short-circuit first.
The registry preserves child order and permits duplicate child IDs. It neither sorts nor
deduplicates them. A child remains effective after its administrator renounces administration:
renunciation freezes future membership updates but does not delete the policy or change its current
authorization result.
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 can
behave like ALWAYS_ALLOW.
State Changes
The change adds achildren mapping at offset 4 in the base.policy_registry ERC-7201 namespace.
It is additive: existing offsets 0 through 3 are unchanged and no storage migration is required.
The offset is relative to the namespace location, not literal EVM storage slot 4.
Each mapping entry stores the dynamic-array length. Its elements begin at the hash of that entry and
pack four
uint64 child IDs into one 256-bit storage slot, which covers the two-to-four-child limit.
Simple and composite policies share the global
nextCounter, which starts at 2 because 0 and
1 are reserved for ALWAYS_ALLOW and ALWAYS_BLOCK. A composite policy ID stores its
PolicyType in the top byte and the next counter value in the low 56 bits; composites do not use a
separate counter.
Examples
AssumeemployeesPolicyId and approvedRegionPolicyId are existing ALLOWLIST policies. Both
examples authorize an account that appears in either list for transfers.
Before: Flattened Policy
B20 stores one policy ID per scope, so both source lists must be copied into a flattened allowlist. Offchain infrastructure then watches both sources and propagates membership changes to the copy.Flattened Policy
After: Composite Policy
Create aUNION composite that references the source policies and assign its ID to B20. B20 needs
no composite-specific logic: it continues to pass the stored policy ID to PolicyRegistry.
Composite Policy
PolicyCreated(policyId, admin, UNION), then
PolicyAdminUpdated(policyId, address(0), admin), then
CompositePolicyUpdated(policyId, admin, childPolicyIds). Adding Alice to employeesPolicyId
authorizes her on the next check without copying members into another policy.
Design Decisions and Alternatives Considered
Cobalt uses two explicit policy types, a singlecreateCompositePolicy function, and full
replacement through updateComposite.
One Generic Composite Type
A generic composite type with a separately stored operator such as AND, OR, NOT, or XOR would add storage and authorization complexity without a requirement for additional operators. ExplicitUNION and INTERSECT types keep the policy ID encoding, gas profile, and audit surface smaller.
Token-Level Policy Groups
Storing multiple policy IDs and an operator on each B20 token would prevent reusable composites and spread the change across token variants, factories, and authorization hot paths. A reusable PolicyRegistry policy keeps B20’s policy slot as an opaqueuint64 ID.
Incremental Child Updates
Separate add/remove operations would need array-mutation, length, and deduplication behavior. A composite has at most four children, so atomic full replacement is simpler and inexpensive.Separate Creator Functions
SeparatecreateUnionPolicy and createIntersectPolicy functions would duplicate the creation API.
A single createCompositePolicy supports both operators consistently.
Nested Composites
Allowing composites to reference composites requires depth limits, cycle protections, and potentially unbounded authorization traversal. Restricting children to simple policies guarantees depth-1 evaluation and bounds worst-case gas.Migration
This change is non-breaking. Existing simpleALLOWLIST and BLOCKLIST policies continue to work,
and integrations that do not need composite behavior require no action.
To replace a flattened policy:
- Identify the existing simple policies to combine.
- Create a
UNIONorINTERSECTcomposite withcreateCompositePolicy. - Write the composite ID to the relevant B20 policy scope with
b20.updatePolicy. - Remove the old flattened policy if it is no longer needed.
uint64 policy ID it uses for simple policies, so no
B20 contract change is required.