Skip to main content

Abstract

Denim lets any policy ID reference the opposite (NOT) of its result at query time. When bit 63 (INVERTED_POLICY_BIT) is set, isAuthorized resolves the base policy and returns the negation of that policy’s decision. The flag applies to every policy type: ALLOWLIST, BLOCKLIST, UNION, and INTERSECT. Members stay on the base policy and are shared, not copied, so an update to the base also updates its inverse. Invert creates no new record and no new create path. The change is non-breaking: existing IDs have bit 63 unset and behave exactly as before.

Motivation

The Policy Registry increasingly serves as a shared registry of address lists that other policies compose around. For example, an issuer may maintain one KYC list and need it to mean “only these addresses” in one scope and “exclude these addresses” in another. Before Denim, the opposite outcome required a second policy of the other type with a copy of the same addresses. Every membership change had to land on both; if one update lagged, valid accounts were rejected or invalid ones admitted. A composite that needed “NOT A” had to point at that mirror. Encoding inversion in the policy reference makes one registry entry reusable for A OR B, A AND NOT B, or NOT A without extra policies.

What Changed

Policy ID Layout

A policy ID is a uint64. Bits 0–55 hold a unique counter; bits 56–63 hold the PolicyType. The four types in use (0–3) occupy bits 56–57, leaving 58–63 unused. Denim reserves bit 63 as the invert bit.
Policy ID Layout

Interface Changes

IPolicyRegistry.sol
invertedPolicyId does not check existence. A missing or malformed base is denied later, at isAuthorized.

Behavioral Changes

Authorization

isAuthorized gains a leading invert branch. All non-inverted paths are unchanged.
Authorization Evaluation
Inverting a composite negates the composite’s combined result. An inverted ID over a never-created base returns false; it never becomes allow-everyone.

Getters Strip to Base

Read views clear bit 63 with a shared _basePolicyId(id) = id & ~INVERTED_POLICY_BIT helper and read the base record. An inverted ID has no record of its own; it mirrors the base’s existence, admin, pending admin, and child set. A token can store an inverted ID and re-validate it exactly as it would a plain one.

Composite Children

A composite child may carry the invert bit. The registry checks existence and type against the base:

State and Gas

Denim adds no storage slots. Invert is query-time only: storage keys, type decode, and existence resolve against the stripped ID. An inverted query runs the fail-closed existence guard on the base, then the existing dispatch, then one boolean flip in memory. Non-inverted queries are unchanged.

Examples

Given a shared ALLOWLIST sanctionedId whose members are sanctioned addresses (authorized means on the list), the inverse authorizes every account that is not on the list:
Standalone Invert
Allow transfers only for accounts on kycId and not on sanctionedId, with an INTERSECT composite and an inverted child:
Composite With an Inverted Child
Fail-closed: for any never-created base, isAuthorized(base | INVERTED_POLICY_BIT, account) returns false. Round-trip: invertedPolicyId(invertedPolicyId(id)) == id, and policyExists(invertedPolicyId(id)) == policyExists(id).

Design Decisions and Alternatives Considered

Encoding NOT in bit 63 adds no storage and no create path, and any policy, simple or composite, can be inverted on its own. The tradeoff: the flag occupies unused PolicyType bitspace, and every getter must strip it through _basePolicyId.

New NOT Policy Type

createNot(admin, base) would allocate a record pointing at a base, with the clearest explorer legibility. Standalone NOT would cost ~3 SLOADs vs 1 for a mirror blocklist, and “A AND NOT X” ~6 vs 4. It also adds a create path and deepens hot-path recursion as a composite child.

Per-Child Invert Bitmask on the Composite

A ≤4-bit mask packed into the children length word would flip individual children. It only works inside a composite, so a simple policy could not be inverted without wrapping it in a two-child composite. It could later compose on top of the invert bit.

Migration

This change is non-breaking. All existing selectors, events, and errors are unchanged, and existing composites are unaffected. To adopt:
  1. Compute the inverse with invertedPolicyId(policyId), or set bit 63 directly.
  2. Bind it to a B20 scope with updatePolicy, or pass it as a composite child. B20 needs no change; it treats the ID as an opaque uint64.
  3. Consumers that store policy IDs must still validate policyExists(policyId) at write time. This works for inverted IDs because existence resolves to the base.
For the current interface, see the IPolicyRegistry reference.