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 forA OR B,
A AND NOT B, or NOT A without extra policies.
What Changed
Policy ID Layout
A policy ID is auint64. 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
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 sharedALLOWLIST sanctionedId whose members are sanctioned addresses (authorized means
on the list), the inverse authorizes every account that is not on the list:
Standalone Invert
kycId and not on sanctionedId, with an INTERSECT
composite and an inverted child:
Composite With an Inverted Child
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 unusedPolicyType 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:- Compute the inverse with
invertedPolicyId(policyId), or set bit 63 directly. - 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 opaqueuint64. - Consumers that store policy IDs must still validate
policyExists(policyId)at write time. This works for inverted IDs because existence resolves to the base.