From 683daf03e28dea7406be694d7dea24987bbce729 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 10:11:28 -0400 Subject: [PATCH 01/12] feat(policy): add NOT/invert policy semantics to reference mock Introduce an invert (NOT) flag on the high bit of the uint64 policy ID so one membership set can be evaluated as include or exclude without maintaining a mirror list. When the bit is set, isAuthorized resolves the base policy (id & ~POLICY_INVERT_BIT) and returns the opposite of its decision. Fail-closed by construction: an inverted ID over an unknown or malformed base returns false rather than authorizing everyone -- the guard that keeps the flag safe on gated mint / transfer / seize paths. The base's members are shared, never copied, so updating the base updates the inverse. - B20Constants: add POLICY_INVERT_BIT (single source of truth) and a pure invertPolicy() helper (no new registry selector; negation is pure bit math). - IPolicyRegistry: document the invert contract on isAuthorized, the read getters (strip-to-base), and composite create/update (inverted simple child allowed for "A AND NOT X"; inverted composite child rejected). No signature changes. - MockPolicyRegistry: invert handling in _isAuthorized (fail-closed flip), strip-to-base in the getters, and composite-child validation on the base. - Tests: isAuthorizedInvert.t.sol -- fail-closed invariants first, then the simple/built-in truth tables, INTERSECT[A, ~X], child validation, getter strip semantics, and the helper. Scope is the base-std reference mock; the Rust precompile is unchanged. Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 33 +++ src/lib/B20Constants.sol | 22 ++ test/lib/mocks/MockPolicyRegistry.sol | 91 ++++++-- .../PolicyRegistry/isAuthorizedInvert.t.sol | 217 ++++++++++++++++++ 4 files changed, 345 insertions(+), 18 deletions(-) create mode 100644 test/unit/PolicyRegistry/isAuthorizedInvert.t.sol diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 8969163..3a290b1 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -5,6 +5,15 @@ pragma solidity >=0.8.20 <0.9.0; /// /// @notice Singleton registry of simple and composite policies. Policies are referenced by /// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. +/// +/// @dev Policy ID layout: bits `[63:56]` are the type byte, bits `[55:0]` the counter. +/// The `PolicyType` discriminant occupies the low two bits of the type byte; the top +/// bit (bit 63, `B20Constants.POLICY_INVERT_BIT`) is the **invert flag** and is +/// orthogonal to the type. When set, a query resolves the base policy +/// (`policyId & ~POLICY_INVERT_BIT`) and `isAuthorized` returns the opposite of the +/// base's decision — so one membership set can be evaluated as include or exclude +/// without a mirror list. The flag is fail-closed (see `isAuthorized`) and is not a +/// `PolicyType`; the enum is never extended to carry it. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES @@ -127,6 +136,10 @@ interface IPolicyRegistry { /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite /// and never a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK). The child-policy set is /// capped at 4. + /// @dev A child may carry the invert flag (`base | POLICY_INVERT_BIT`) to mean "NOT on this + /// list" — e.g. `INTERSECT[A, ~X]` reads as "on A and not on X". Validation resolves the + /// base (flag stripped); the base must still be an existing simple policy, so an inverted + /// composite child is rejected with `InvalidChildPolicy`. The child is stored verbatim. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in @@ -227,6 +240,14 @@ interface IPolicyRegistry { /// BLOCKLIST -> true). /// /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. + /// @dev Invert flag: when bit 63 (`B20Constants.POLICY_INVERT_BIT`) is set, the base policy + /// `policyId & ~POLICY_INVERT_BIT` is evaluated and the result is negated — the inverse + /// of any policy, including a whole composite. The flag applies after a defined base + /// result, so it is **fail-closed**: an inverted ID whose base does not exist or is + /// malformed returns `false`, never allow-everyone. (This differs from the plain + /// empty-member-set semantics above, which are only reached without the flag.) An + /// inverted composite child (`base | POLICY_INVERT_BIT` inside a child set) negates + /// that leaf before the gate combines it. /// /// @param policyId Policy to query. /// @param account Account to check. @@ -250,6 +271,10 @@ interface IPolicyRegistry { /// @notice Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. /// + /// @dev The invert flag is stripped first, so an inverted ID resolves to its base: + /// `policyExists(base | POLICY_INVERT_BIT) == policyExists(base)`. A token may store an + /// inverted policy ID per scope and re-validate it here exactly as a plain one. + /// /// @param policyId Policy to query. /// /// @return Whether the policy exists. @@ -258,6 +283,9 @@ interface IPolicyRegistry { /// @notice Returns the current admin of `policyId`, or `address(0)` for built-in sentinels, /// renounced policies, unknown IDs, and malformed IDs. Never reverts. /// + /// @dev The invert flag is stripped first; an inverted ID has no record of its own and + /// resolves to its base's admin (`policyAdmin(base | POLICY_INVERT_BIT) == policyAdmin(base)`). + /// /// @param policyId Policy to query. /// /// @return Current admin, or `address(0)`. @@ -267,6 +295,8 @@ interface IPolicyRegistry { /// no transfer is in flight or for built-in sentinels, unknown IDs, and malformed IDs. /// Never reverts. /// + /// @dev The invert flag is stripped first; an inverted ID resolves to its base's pending admin. + /// /// @param policyId Policy to query. /// /// @return Pending admin, or `address(0)`. @@ -280,6 +310,9 @@ interface IPolicyRegistry { /// @dev An empty return unambiguously means "not a composite". /// @dev The registry preserves the caller's ordering verbatim and neither sorts nor /// de-duplicates. + /// @dev The invert flag on `policyId` is stripped first, so an inverted composite ID + /// resolves to the base composite's child set. Child IDs are returned verbatim, + /// including any per-child invert flag, so indexers can render `NOT` per child. /// /// @param policyId Policy to query. /// diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 1a27d1d..7741ded 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -21,6 +21,28 @@ library B20Constants { bytes32 internal constant SEIZE_EXEMPT_POLICY = keccak256("SEIZE_EXEMPT_POLICY"); bytes32 internal constant SEIZE_RECEIVER_POLICY = keccak256("SEIZE_RECEIVER_POLICY"); + /// @notice High bit of a `uint64` policy ID that inverts the base policy's decision. + /// @dev `isAuthorized(base | POLICY_INVERT_BIT, account)` returns the opposite of + /// `isAuthorized(base, account)`, and is fail-closed: an inverted ID whose base + /// does not exist or is malformed returns `false` rather than authorizing + /// everyone. The other queries strip this bit and resolve to the base, so + /// `policyExists(base | POLICY_INVERT_BIT) == policyExists(base)` — a token can + /// store an inverted policy ID per scope and re-validate it like a plain one. + /// A live policy counter occupies only the low 56 bits (the type byte sits at + /// `[63:56]`), so bit 63 never collides with an issued ID. + uint64 internal constant POLICY_INVERT_BIT = uint64(1) << 63; + + /// @notice Returns the negated form of `policyId` by toggling the invert flag, so + /// `isAuthorized(invertPolicy(id), account) == !isAuthorized(id, account)` for an + /// existing base. Involutive: `invertPolicy(invertPolicy(id)) == id`. + /// @dev Pure bit math — no registry call. A composite child set uses this to express + /// "NOT on this list", e.g. `INTERSECT[A, invertPolicy(X)]`. + /// @param policyId The policy ID to negate. + /// @return The policy ID with its invert flag toggled. + function invertPolicy(uint64 policyId) internal pure returns (uint64) { + return policyId ^ POLICY_INVERT_BIT; + } + /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | SEIZE); 15 = 0b1111. uint8 internal constant ALL_FEATURES_PAUSED = 15; diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index d1d770a..574d2f0 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.20; import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; +import {B20Constants} from "base-std/lib/B20Constants.sol"; import {MockPolicyRegistryStorage} from "base-std-test/lib/mocks/MockPolicyRegistryStorage.sol"; @@ -70,6 +71,24 @@ contract MockPolicyRegistry is IPolicyRegistry { // Policy ID encoding: top byte = uint8(PolicyType), low 56 bits = counter. uint64 internal constant POLICY_ID_TYPE_SHIFT = 56; + /// @notice Invert (NOT) flag carved from the high bit of the policy-ID type byte. + /// @dev Bits `[57:56]` hold the `PolicyType` discriminant (0..3); bits `[62:58]` + /// are unused. Bit 63 — the top bit of the type byte — is reserved as the + /// invert flag: when set, `isAuthorized` resolves the base policy + /// (`policyId & ~INVERT_BIT`) and returns the OPPOSITE of its decision. + /// The base's members are shared, never copied, so one membership set can be + /// evaluated as include or exclude without maintaining a mirror list. + /// + /// Fail-closed by construction: an inverted ID whose base does not exist or + /// is malformed denies (returns false), never flips an unknown-ID deny into + /// allow-everyone. See `_isAuthorized`. + /// + /// A live counter never reaches bit 63 (it is a 56-bit value under the type + /// byte), so no previously-issued ID collides with the invert encoding. + /// @dev Aliases `B20Constants.POLICY_INVERT_BIT` — the single source of truth shared + /// with consumers — so the mock and callers can never disagree on the bit. + uint64 internal constant INVERT_BIT = B20Constants.POLICY_INVERT_BIT; + /// @notice Per-call membership-batch limit. `createPolicyWithAccounts`, /// `updateAllowlist`, and `updateBlocklist` revert with /// `BatchSizeTooLarge(MAX_BATCH_SIZE)` when `accounts.length` @@ -238,20 +257,18 @@ contract MockPolicyRegistry is IPolicyRegistry { // ============================================================ /// @inheritdoc IPolicyRegistry + /// @dev An inverted ID resolves to the existence of its base: `policyExists(~id)` + /// equals `policyExists(id)`, so a token may store and later re-validate an + /// inverted policy exactly as it would a plain one. function policyExists(uint64 policyId) external view returns (bool) { - if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; - if (!_isWellFormed(policyId)) return false; - // Use the typed `policyExistsFromPacked` helper rather than a raw - // `packed != 0` test. Functionally identical given the encoding - // invariant (exists bit is always set when `_encode` writes the - // slot), but matches the Rust precompile's `packed.exists()` - // call and survives any future encoding change that adds bits - // above the admin lane without setting the exists bit. - return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); + return _policyExists(policyId); } /// @inheritdoc IPolicyRegistry + /// @dev An inverted ID has no record of its own; it resolves to its base's admin, + /// matching `policyExists` (`policyAdmin(~id) == policyAdmin(id)`). function policyAdmin(uint64 policyId) external view returns (address) { + policyId = policyId & ~INVERT_BIT; if (!_isWellFormed(policyId)) return address(0); // No fast path for built-in IDs needed: lazy init writes them with // a zero admin, so the normal storage read returns address(0) for @@ -276,13 +293,18 @@ contract MockPolicyRegistry is IPolicyRegistry { // below would also return `address(0)` for built-ins in normal // operation (they never have a pending admin staged), but the // explicit branch removes that assumption from the trust boundary. + policyId = policyId & ~INVERT_BIT; if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return address(0); if (!_isWellFormed(policyId)) return address(0); return MockPolicyRegistryStorage.layout().pendingAdmins[policyId]; } /// @inheritdoc IPolicyRegistry + /// @dev An inverted composite ID resolves to the base composite's child set. Child + /// IDs are returned verbatim as stored, so any per-child invert flag remains + /// visible to indexers. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { + policyId = policyId & ~INVERT_BIT; if (!_isWellFormed(policyId)) return new uint64[](0); if (!_isComposite(policyId)) return new uint64[](0); return MockPolicyRegistryStorage.layout().children[policyId]; @@ -349,6 +371,20 @@ contract MockPolicyRegistry is IPolicyRegistry { if (packed == 0) revert PolicyNotFound(); } + /// @dev Existence predicate shared by the external `policyExists` view and the + /// fail-closed guard in `_isAuthorized`. Strips the invert flag first, so an + /// inverted ID exists iff its base exists. Never reverts. + function _policyExists(uint64 policyId) internal view returns (bool) { + policyId = policyId & ~INVERT_BIT; + if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; + if (!_isWellFormed(policyId)) return false; + // Typed `policyExistsFromPacked` rather than a raw `packed != 0` test: identical + // given the encoding invariant (the exists bit is always set when `_encode` + // writes the slot), but matches the Rust precompile's `packed.exists()` and + // survives a future encoding that adds bits above the admin lane. + return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); + } + /// @dev Core authorization logic shared by the external view and composite /// child evaluation. Never reverts. /// @@ -358,6 +394,18 @@ contract MockPolicyRegistry is IPolicyRegistry { /// `_isAuthorized` per child, each of which resolves via the simple path /// (or a built-in short-circuit). function _isAuthorized(uint64 policyId, address account) internal view returns (bool) { + // Invert (NOT) handled before any other branch so it composes uniformly: a + // top-level inverted ID inverts its base, and an inverted composite child inverts + // that leaf as the recursion descends. FAIL-CLOSED: an inverted ID over an + // unknown or malformed base denies rather than flipping a would-be deny into + // allow-everyone — the one property that makes the invert flag safe on gated + // mint / transfer / seize paths. The base's decision is only inverted once it is + // known to resolve against a real policy. + if (policyId & INVERT_BIT != 0) { + uint64 base = policyId & ~INVERT_BIT; + if (!_policyExists(base)) return false; + return !_isAuthorized(base, account); + } // Built-in short-circuits precede any SLOAD; sentinels have no // storage entry. if (policyId == ALWAYS_ALLOW_ID) return true; @@ -400,20 +448,27 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @dev Requires every composite child to be a created, custom, SIMPLE policy: - /// it must exist, must not be a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK), - /// and must not itself be a composite. Two passes so `PolicyNotFound` takes - /// precedence over `InvalidChildPolicy` across the whole set (matches the - /// canonical revert order the Rust precompile mirrors). + /// its base must exist, must not be a built-in sentinel (ALWAYS_ALLOW / + /// ALWAYS_BLOCK), and must not itself be a composite. Two passes so + /// `PolicyNotFound` takes precedence over `InvalidChildPolicy` across the whole + /// set (matches the canonical revert order the Rust precompile mirrors). + /// + /// A child may carry the invert flag (`base | INVERT_BIT`) to express + /// "NOT on this list" — e.g. `INTERSECT[A, ~X]` reads as "on A and not on X". + /// Validation resolves the base (bit stripped): a composite base is still + /// rejected, so the invert flag cannot smuggle a nested gate past the flat-tree + /// invariant. The child is stored verbatim (flag intact); `_isAuthorized` + /// inverts that leaf during evaluation. function _requireCreatedSimplePolicies(uint64[] calldata childPolicyIds) internal view { MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout(); - // Pass 1: existence. + // Pass 1: existence of the base (an inverted child references its base's members). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - if ($.policies[childPolicyIds[i]] == 0) revert PolicyNotFound(); + if ($.policies[childPolicyIds[i] & ~INVERT_BIT] == 0) revert PolicyNotFound(); } - // Pass 2: must be a simple policy + // Pass 2: the base must be a simple policy (never a sentinel or a composite). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - uint64 child = childPolicyIds[i]; - if (_isBuiltin(child) || _isComposite(child)) revert InvalidChildPolicy(child); + uint64 base = childPolicyIds[i] & ~INVERT_BIT; + if (_isBuiltin(base) || _isComposite(base)) revert InvalidChildPolicy(childPolicyIds[i]); } } diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol new file mode 100644 index 0000000..f05aa87 --- /dev/null +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; +import {B20Constants} from "base-std/lib/B20Constants.sol"; + +import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; + +/// @notice Covers the invert (NOT) flag on the policy ID: `isAuthorized` resolves the +/// base policy (`policyId & ~INVERT_BIT`) and returns the opposite of its +/// decision, so one membership set can be evaluated as include or exclude +/// without maintaining a mirror list. +/// +/// @dev The load-bearing property is FAIL-CLOSED: an inverted ID over an unknown or +/// malformed base must deny, never flip a would-be deny into allow-everyone on a +/// gated mint / transfer / seize path. Those cases lead the suite. +contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { + /// @dev The shared invert flag (bit 63 of the ID); single source of truth. + uint64 internal constant INVERT_BIT = B20Constants.POLICY_INVERT_BIT; + + function _addAllowlistMember(uint64 policyId, address account) internal { + address[] memory accounts = new address[](1); + accounts[0] = account; + vm.prank(admin); + policyRegistry.updateAllowlist(policyId, true, accounts); + } + + function _addBlocklistMember(uint64 policyId, address account) internal { + address[] memory accounts = new address[](1); + accounts[0] = account; + vm.prank(admin); + policyRegistry.updateBlocklist(policyId, true, accounts); + } + + // ============================================================ + // FAIL-CLOSED INVARIANT (the point of 2a) + // ============================================================ + + /// @notice Inverting an uncreated (unknown) base denies rather than allowing everyone. + /// @dev The whole reason the invert flag is gated on base existence. Without the gate + /// a garbage or typo'd ID with the bit set would authorize every account. + function test_isAuthorized_success_invertUnknownAllowlistBaseDenies(uint56 counter, address account) public view { + vm.assume(counter > 1); + uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); + assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + /// @notice Inverting an uncreated BLOCKLIST base also denies (fail-closed), even though + /// a plain unknown blocklist authorizes — existence is what gates the flip. + function test_isAuthorized_success_invertUnknownBlocklistBaseDenies(uint56 counter, address account) public view { + vm.assume(counter > 1); + uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.BLOCKLIST)) << 56) | uint64(counter); + // Sanity: the plain unknown blocklist authorizes (empty-member-set semantics)... + assertTrue(policyRegistry.isAuthorized(base, account)); + // ...but its inverse must NOT become allow-everyone; the base does not exist. + assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + /// @notice Inverting a malformed base (type byte above the enum, after stripping the + /// invert flag) denies. + function test_isAuthorized_success_invertMalformedBaseDenies(uint64 seed, address account) public view { + uint64 base = _malformedPolicyId(seed) & ~INVERT_BIT; + assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + // ============================================================ + // SIMPLE-POLICY INVERSION + // ============================================================ + + /// @notice NOT(allowlist): a member of the base is denied by the inverse. + function test_isAuthorized_success_invertAllowlistMemberDenied(address account) public { + uint64 base = _createAllowlist(); + _addAllowlistMember(base, account); + assertTrue(policyRegistry.isAuthorized(base, account)); + assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + /// @notice NOT(allowlist): a non-member of the base is authorized by the inverse. + function test_isAuthorized_success_invertAllowlistNonMemberAuthorized(address account) public { + uint64 base = _createAllowlist(); + assertFalse(policyRegistry.isAuthorized(base, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + /// @notice NOT(blocklist): a blocked account (base denies) is authorized by the inverse. + function test_isAuthorized_success_invertBlocklistMemberAuthorized(address account) public { + uint64 base = _createBlocklist(); + _addBlocklistMember(base, account); + assertFalse(policyRegistry.isAuthorized(base, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + } + + // ============================================================ + // BUILT-IN INVERSION + // ============================================================ + + /// @notice NOT(ALWAYS_ALLOW) denies every account. + function test_isAuthorized_success_invertAlwaysAllowDenies(address account) public { + // Touch the registry so the built-ins are initialized before the query. + _createAllowlist(); + assertFalse(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_ALLOW_ID | INVERT_BIT, account)); + } + + /// @notice NOT(ALWAYS_BLOCK) authorizes every account. + function test_isAuthorized_success_invertAlwaysBlockAuthorizes(address account) public { + _createAllowlist(); + assertTrue(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_BLOCK_ID | INVERT_BIT, account)); + } + + // ============================================================ + // COMPOSITE WITH PER-CHILD INVERT: "A AND NOT X" + // ============================================================ + + /// @notice INTERSECT[A, ~X] reads as "on A and not on X". A member of both A and X is + /// denied (fails the NOT-X leg); a member of A only is authorized. + function test_isAuthorized_success_intersectAllowAndNotX(address account) public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + _addAllowlistMember(a, account); + + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + ); + + // account is on A and NOT on X -> authorized. + assertTrue(policyRegistry.isAuthorized(composite, account)); + + // Add account to X: now it is on A but IS on X -> the ~X leg denies. + _addAllowlistMember(x, account); + assertFalse(policyRegistry.isAuthorized(composite, account)); + } + + // ============================================================ + // COMPOSITE-CHILD VALIDATION WITH INVERT FLAG + // ============================================================ + + /// @notice An inverted simple child is accepted and stored verbatim (flag intact). + function test_createCompositePolicy_success_invertedSimpleChild() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + ); + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[1], x | INVERT_BIT); + } + + /// @notice An inverted child whose base does not exist reverts with PolicyNotFound — + /// the invert flag cannot smuggle a non-existent child past validation. + function test_createCompositePolicy_revert_invertedChildBaseNotFound() public { + uint64 a = _createAllowlist(); + uint64 missing = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(9999); + vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); + policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, missing | INVERT_BIT) + ); + } + + /// @notice An inverted COMPOSITE child is rejected: the invert flag must not let a + /// nested gate slip past the flat-tree invariant. + function test_createCompositePolicy_revert_invertedCompositeChild() public { + uint64 a = _createAllowlist(); + uint64 b = _createAllowlist(); + uint64 inner = policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(a, b)); + + uint64 c = _createAllowlist(); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, inner | INVERT_BIT)); + policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, inner | INVERT_BIT) + ); + } + + // ============================================================ + // GETTER STRIP SEMANTICS + // ============================================================ + + /// @notice policyExists(~id) mirrors policyExists(id): the inverse of a created policy + /// reports existing (so a token can store and re-validate ~id), and the inverse + /// of an unknown base reports non-existent. + function test_policyExists_success_invertMirrorsBase(uint56 counter) public { + vm.assume(counter > 1); + uint64 created = _createAllowlist(); + assertTrue(policyRegistry.policyExists(created | INVERT_BIT)); + + uint64 unknown = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); + assertEq(policyRegistry.policyExists(unknown | INVERT_BIT), policyRegistry.policyExists(unknown)); + } + + /// @notice policyAdmin(~id) resolves to the base's admin. + function test_policyAdmin_success_invertResolvesBaseAdmin(address policyAdmin) public { + vm.assume(policyAdmin != address(0)); + uint64 base = _createAllowlist(admin, policyAdmin); + assertEq(policyRegistry.policyAdmin(base | INVERT_BIT), policyAdmin); + } + + // ============================================================ + // invertPolicy() HELPER + // ============================================================ + + /// @notice invertPolicy toggles the invert flag and is involutive. + function test_invertPolicy_success_togglesAndRoundTrips(uint64 base) public pure { + uint64 inverted = B20Constants.invertPolicy(base); + assertEq(inverted, base ^ INVERT_BIT); + assertEq(B20Constants.invertPolicy(inverted), base); + } + + /// @notice The helper produces the same authorization result as setting the bit directly. + function test_invertPolicy_success_matchesRawBitOnAuthorization(address account) public { + uint64 base = _createAllowlist(); + _addAllowlistMember(base, account); + assertEq( + policyRegistry.isAuthorized(B20Constants.invertPolicy(base), account), + policyRegistry.isAuthorized(base | INVERT_BIT, account) + ); + } +} From d40d1443d1dfbb8aa9d5624fcb4f693cc2852362 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 10:35:09 -0400 Subject: [PATCH 02/12] feat(policy): add invertedPolicyId view to the registry Expose the invert (NOT) negation as an ABI-discoverable registry view so indexers, explorers, EOAs, and cross-codebase contracts can obtain the inverted form of a policy ID without knowing the bit layout or compiling against base-std. `invertedPolicyId(uint64)` is a pure toggle of the invert flag (`policyId ^ POLICY_INVERT_BIT`): never reverts, reads no state, and is involutive. It delegates to the on-chain `B20Constants.invertPolicy` helper so the view and the library can never disagree. Existence stays enforced where it matters -- isAuthorized is fail-closed on an inverted, non-existent base. - IPolicyRegistry: declare invertedPolicyId in POLICY QUERIES. - MockPolicyRegistry: implement it via B20Constants.invertPolicy. - Tests: toggle/involution, agreement with the library helper, and end-to-end negation of the authorization decision. Note: on the real precompile this is a new selector -- a follow-up must add it to the frozen ABI surface, the dispatch view-bypass list, and gate it at a hardfork. Scope here is the base-std interface + reference mock. Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 40 +++++++++---------- test/lib/mocks/MockPolicyRegistry.sol | 8 ++++ .../PolicyRegistry/isAuthorizedInvert.t.sol | 26 ++++++++++++ 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 3a290b1..3b78e42 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -5,15 +5,6 @@ pragma solidity >=0.8.20 <0.9.0; /// /// @notice Singleton registry of simple and composite policies. Policies are referenced by /// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. -/// -/// @dev Policy ID layout: bits `[63:56]` are the type byte, bits `[55:0]` the counter. -/// The `PolicyType` discriminant occupies the low two bits of the type byte; the top -/// bit (bit 63, `B20Constants.POLICY_INVERT_BIT`) is the **invert flag** and is -/// orthogonal to the type. When set, a query resolves the base policy -/// (`policyId & ~POLICY_INVERT_BIT`) and `isAuthorized` returns the opposite of the -/// base's decision — so one membership set can be evaluated as include or exclude -/// without a mirror list. The flag is fail-closed (see `isAuthorized`) and is not a -/// `PolicyType`; the enum is never extended to carry it. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES @@ -136,10 +127,8 @@ interface IPolicyRegistry { /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite /// and never a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK). The child-policy set is /// capped at 4. - /// @dev A child may carry the invert flag (`base | POLICY_INVERT_BIT`) to mean "NOT on this - /// list" — e.g. `INTERSECT[A, ~X]` reads as "on A and not on X". Validation resolves the - /// base (flag stripped); the base must still be an existing simple policy, so an inverted - /// composite child is rejected with `InvalidChildPolicy`. The child is stored verbatim. + /// @dev A child policy ID may be inverted. If so, the top bit of the type byte is flipped + /// (`POLICY_INVERT_BIT`) and the child is evaluated as the inverse of the base. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in @@ -240,14 +229,9 @@ interface IPolicyRegistry { /// BLOCKLIST -> true). /// /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. - /// @dev Invert flag: when bit 63 (`B20Constants.POLICY_INVERT_BIT`) is set, the base policy - /// `policyId & ~POLICY_INVERT_BIT` is evaluated and the result is negated — the inverse - /// of any policy, including a whole composite. The flag applies after a defined base - /// result, so it is **fail-closed**: an inverted ID whose base does not exist or is - /// malformed returns `false`, never allow-everyone. (This differs from the plain - /// empty-member-set semantics above, which are only reached without the flag.) An - /// inverted composite child (`base | POLICY_INVERT_BIT` inside a child set) negates - /// that leaf before the gate combines it. + /// @dev Invert flag (`POLICY_INVERT_BIT`): flipping bit 63 of the ID negates the + /// `isAuthorized` result of the base. Applies to every policy type, including a + /// whole composite. /// /// @param policyId Policy to query. /// @param account Account to check. @@ -318,4 +302,18 @@ interface IPolicyRegistry { /// /// @return Child policy IDs, or an empty array. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); + + /// @notice Returns the inverted form of `policyId` — its invert flag toggled + /// (`policyId ^ POLICY_INVERT_BIT`). Never reverts and reads no state. + /// + /// @dev Involutive: `invertedPolicyId(invertedPolicyId(id)) == id`. For an existing base, + /// `isAuthorized(invertedPolicyId(id), account) == !isAuthorized(id, account)`. The + /// returned ID is not validated here — an inverted ID over a non-existent or malformed + /// base is fail-closed only at `isAuthorized` time (returns false). This is the + /// ABI-discoverable counterpart to the on-chain `B20Constants.invertPolicy` helper. + /// + /// @param policyId Policy to invert. + /// + /// @return The policy ID with its invert flag toggled. + function invertedPolicyId(uint64 policyId) external view returns (uint64); } diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 574d2f0..ab2acc1 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -310,6 +310,14 @@ contract MockPolicyRegistry is IPolicyRegistry { return MockPolicyRegistryStorage.layout().children[policyId]; } + /// @inheritdoc IPolicyRegistry + /// @dev Delegates to the shared `B20Constants.invertPolicy` helper so the mock and the + /// library can never disagree on the invert bit. `pure` is a valid override of the + /// `view` interface declaration. + function invertedPolicyId(uint64 policyId) external pure returns (uint64) { + return B20Constants.invertPolicy(policyId); + } + // ============================================================ // INTERNAL HELPERS // ============================================================ diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol index f05aa87..a7a3cb7 100644 --- a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -214,4 +214,30 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { policyRegistry.isAuthorized(base | INVERT_BIT, account) ); } + + // ============================================================ + // invertedPolicyId() VIEW + // ============================================================ + + /// @notice The registry view toggles the invert flag, is involutive, and never reverts — + /// including for unknown/malformed IDs (it reads no state). + function test_invertedPolicyId_success_togglesAndRoundTrips(uint64 base) public view { + uint64 inverted = policyRegistry.invertedPolicyId(base); + assertEq(inverted, base ^ INVERT_BIT); + assertEq(policyRegistry.invertedPolicyId(inverted), base); + } + + /// @notice The view agrees with the on-chain `B20Constants.invertPolicy` helper. + function test_invertedPolicyId_success_matchesLibraryHelper(uint64 base) public view { + assertEq(policyRegistry.invertedPolicyId(base), B20Constants.invertPolicy(base)); + } + + /// @notice End-to-end: authorizing against the view's result negates the base decision. + function test_invertedPolicyId_success_negatesAuthorization(address account) public { + uint64 base = _createAllowlist(); + _addAllowlistMember(base, account); + uint64 inverted = policyRegistry.invertedPolicyId(base); + assertTrue(policyRegistry.isAuthorized(base, account)); + assertFalse(policyRegistry.isAuthorized(inverted, account)); + } } From 5f97889dfd218a8e64190354c9fe4534c8fa5831 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 10:39:36 -0400 Subject: [PATCH 03/12] docs(policy): clarify compositePolicyChildIds returns children verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reword the compositePolicyChildIds NatSpec (interface + mock) so it is unambiguous that only the queried composite's own invert flag is stripped — the child IDs are returned exactly as stored, so a child recorded with the invert flag comes back with the flag set. No behavior change. Add explicit read-side coverage: children returned verbatim (plain + inverted), the composite's inverse returns the identical child set, and the verbatim contract survives updateComposite. Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 19 +++---- test/lib/mocks/MockPolicyRegistry.sol | 7 +-- .../PolicyRegistry/isAuthorizedInvert.t.sol | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 3b78e42..8b186f7 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -229,9 +229,8 @@ interface IPolicyRegistry { /// BLOCKLIST -> true). /// /// @dev Callers that store policy IDs MUST validate `policyExists(policyId)` at write time. - /// @dev Invert flag (`POLICY_INVERT_BIT`): flipping bit 63 of the ID negates the - /// `isAuthorized` result of the base. Applies to every policy type, including a - /// whole composite. + /// @dev Invert: `isAuthorized(invertedPolicyId(id), account)` returns the negated + /// result of the base. Applies to every policy type. /// /// @param policyId Policy to query. /// @param account Account to check. @@ -255,9 +254,9 @@ interface IPolicyRegistry { /// @notice Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. /// - /// @dev The invert flag is stripped first, so an inverted ID resolves to its base: - /// `policyExists(base | POLICY_INVERT_BIT) == policyExists(base)`. A token may store an - /// inverted policy ID per scope and re-validate it here exactly as a plain one. + /// @dev `policyExists(invertedPolicyId(id)) == policyExists(id)`. Invert is not its + /// own record; a token can store an inverted ID and re-validate it here like a + /// plain one. /// /// @param policyId Policy to query. /// @@ -294,9 +293,11 @@ interface IPolicyRegistry { /// @dev An empty return unambiguously means "not a composite". /// @dev The registry preserves the caller's ordering verbatim and neither sorts nor /// de-duplicates. - /// @dev The invert flag on `policyId` is stripped first, so an inverted composite ID - /// resolves to the base composite's child set. Child IDs are returned verbatim, - /// including any per-child invert flag, so indexers can render `NOT` per child. + /// @dev Only the invert flag on the queried `policyId` is stripped, so a composite and its + /// inverse return the same set: `compositePolicyChildIds(id | POLICY_INVERT_BIT) == + /// compositePolicyChildIds(id)`. The child IDs themselves are NOT stripped — each is + /// returned exactly as stored, so a child recorded with the invert flag is returned + /// with the flag set, letting indexers render `NOT` per child. /// /// @param policyId Policy to query. /// diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index ab2acc1..604c627 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -300,9 +300,10 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @inheritdoc IPolicyRegistry - /// @dev An inverted composite ID resolves to the base composite's child set. Child - /// IDs are returned verbatim as stored, so any per-child invert flag remains - /// visible to indexers. + /// @dev Only the queried composite's own invert flag is stripped (so a composite and its + /// inverse return the same set). The child IDs are returned exactly as stored — a + /// child recorded with its invert flag comes back with the flag set — so any + /// per-child invert remains visible to indexers. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { policyId = policyId & ~INVERT_BIT; if (!_isWellFormed(policyId)) return new uint64[](0); diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol index a7a3cb7..c805e45 100644 --- a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -171,6 +171,56 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { ); } + // ============================================================ + // compositePolicyChildIds RETURNS CHILDREN VERBATIM + // ============================================================ + + /// @notice The read returns child IDs exactly as stored: a plain child is returned plain, + /// an inverted child is returned with its invert flag intact. + function test_compositePolicyChildIds_success_returnsChildrenVerbatim() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + ); + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[0], a, "plain child returned unchanged"); + assertEq(children[1], x | INVERT_BIT, "inverted child returned with flag set"); + } + + /// @notice Querying the composite's own inverse returns the identical child set (only the + /// queried ID's flag is stripped; the children are untouched). + function test_compositePolicyChildIds_success_invertedCompositeIdReturnsSameSet() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 composite = policyRegistry.createCompositePolicy( + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + ); + uint64[] memory viaBase = policyRegistry.compositePolicyChildIds(composite); + uint64[] memory viaInverse = policyRegistry.compositePolicyChildIds(composite | INVERT_BIT); + assertEq(viaInverse.length, viaBase.length); + for (uint256 i = 0; i < viaBase.length; ++i) { + assertEq(viaInverse[i], viaBase[i]); + } + } + + /// @notice updateComposite preserves the verbatim-return contract: after replacing the + /// child set, an inverted child still reads back with its flag set. + function test_compositePolicyChildIds_success_returnsInvertedChildVerbatimAfterUpdate() public { + uint64 a = _createAllowlist(); + uint64 x = _createAllowlist(); + uint64 y = _createAllowlist(); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x)); + + vm.prank(admin); + policyRegistry.updateComposite(composite, _childIds(a, y | INVERT_BIT)); + + uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); + assertEq(children[0], a); + assertEq(children[1], y | INVERT_BIT, "inverted child persists verbatim after update"); + } + // ============================================================ // GETTER STRIP SEMANTICS // ============================================================ From a3a59731a44f4c476967dda04a432e37d8010268 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 11:21:55 -0400 Subject: [PATCH 04/12] refactor(policy): move invert bit to PolicyRegistryConstants Consolidate the invert primitive now that negation is a first-class registry operation (invertedPolicyId). Remove POLICY_INVERT_BIT and the invertPolicy() helper from the B20-scoped B20Constants library and define a single INVERTED_POLICY_BIT in PolicyRegistryConstants alongside ALWAYS_ALLOW_ID / ALWAYS_BLOCK_ID, shared by the mock and its tests. - B20Constants: drop POLICY_INVERT_BIT and invertPolicy(). - PolicyRegistryConstants: add INVERTED_POLICY_BIT (single source of truth). - MockPolicyRegistry: source the bit from PolicyRegistryConstants; invertedPolicyId toggles it directly; internal uses renamed INVERT_BIT -> INVERTED_POLICY_BIT. - IPolicyRegistry: NatSpec no longer names a removed constant (invert flag, bit 63). - Tests: reference PolicyRegistryConstants.INVERTED_POLICY_BIT; drop the tests that exercised the removed library helper (registry-view coverage is retained). No behavior change. Co-Authored-By: Claude --- src/interfaces/IPolicyRegistry.sol | 34 +++----- src/lib/B20Constants.sol | 22 ----- test/lib/mocks/MockPolicyRegistry.sol | 55 ++++++------ .../PolicyRegistry/isAuthorizedInvert.t.sol | 83 +++++++------------ 4 files changed, 64 insertions(+), 130 deletions(-) diff --git a/src/interfaces/IPolicyRegistry.sol b/src/interfaces/IPolicyRegistry.sol index 8b186f7..ee3823c 100644 --- a/src/interfaces/IPolicyRegistry.sol +++ b/src/interfaces/IPolicyRegistry.sol @@ -5,6 +5,10 @@ pragma solidity >=0.8.20 <0.9.0; /// /// @notice Singleton registry of simple and composite policies. Policies are referenced by /// `uint64 policyId` and queried via `isAuthorized(policyId, account)`. +/// +/// @dev Invert (`invertedPolicyId`): all view functions see an inverted policy ID as an +/// extension of the base policy — same existence, admin, pending admin, and child +/// set as the base; `isAuthorized` returns the negated base result. interface IPolicyRegistry { /*////////////////////////////////////////////////////////////// TYPES @@ -127,8 +131,8 @@ interface IPolicyRegistry { /// @dev Child policies must be simple policies (ALLOWLIST or BLOCKLIST), never another composite /// and never a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK). The child-policy set is /// capped at 4. - /// @dev A child policy ID may be inverted. If so, the top bit of the type byte is flipped - /// (`POLICY_INVERT_BIT`) and the child is evaluated as the inverse of the base. + /// @dev A child policy ID may be inverted (its invert flag, bit 63, set via + /// `invertedPolicyId`), in which case the child is evaluated as the inverse of the base. /// @dev Reverts with `IncompatiblePolicyType` when `policyType` is not UNION or INTERSECT. /// @dev Reverts with `ZeroAddress` when `admin` is `address(0)`. /// @dev Reverts with `ChildPoliciesOutsideOfRange` when `childPolicyIds.length` is not in @@ -254,10 +258,6 @@ interface IPolicyRegistry { /// @notice Returns whether `policyId` is a built-in sentinel or a previously-assigned custom ID. Never reverts. /// - /// @dev `policyExists(invertedPolicyId(id)) == policyExists(id)`. Invert is not its - /// own record; a token can store an inverted ID and re-validate it here like a - /// plain one. - /// /// @param policyId Policy to query. /// /// @return Whether the policy exists. @@ -266,9 +266,6 @@ interface IPolicyRegistry { /// @notice Returns the current admin of `policyId`, or `address(0)` for built-in sentinels, /// renounced policies, unknown IDs, and malformed IDs. Never reverts. /// - /// @dev The invert flag is stripped first; an inverted ID has no record of its own and - /// resolves to its base's admin (`policyAdmin(base | POLICY_INVERT_BIT) == policyAdmin(base)`). - /// /// @param policyId Policy to query. /// /// @return Current admin, or `address(0)`. @@ -278,8 +275,6 @@ interface IPolicyRegistry { /// no transfer is in flight or for built-in sentinels, unknown IDs, and malformed IDs. /// Never reverts. /// - /// @dev The invert flag is stripped first; an inverted ID resolves to its base's pending admin. - /// /// @param policyId Policy to query. /// /// @return Pending admin, or `address(0)`. @@ -293,25 +288,18 @@ interface IPolicyRegistry { /// @dev An empty return unambiguously means "not a composite". /// @dev The registry preserves the caller's ordering verbatim and neither sorts nor /// de-duplicates. - /// @dev Only the invert flag on the queried `policyId` is stripped, so a composite and its - /// inverse return the same set: `compositePolicyChildIds(id | POLICY_INVERT_BIT) == - /// compositePolicyChildIds(id)`. The child IDs themselves are NOT stripped — each is - /// returned exactly as stored, so a child recorded with the invert flag is returned - /// with the flag set, letting indexers render `NOT` per child. + /// @dev Child IDs are returned as stored, including any per-child invert. /// /// @param policyId Policy to query. /// /// @return Child policy IDs, or an empty array. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); - /// @notice Returns the inverted form of `policyId` — its invert flag toggled - /// (`policyId ^ POLICY_INVERT_BIT`). Never reverts and reads no state. + /// @notice Returns `policyId` with its invert flag (bit 63) flipped. Never reverts + /// and reads no state. /// - /// @dev Involutive: `invertedPolicyId(invertedPolicyId(id)) == id`. For an existing base, - /// `isAuthorized(invertedPolicyId(id), account) == !isAuthorized(id, account)`. The - /// returned ID is not validated here — an inverted ID over a non-existent or malformed - /// base is fail-closed only at `isAuthorized` time (returns false). This is the - /// ABI-discoverable counterpart to the on-chain `B20Constants.invertPolicy` helper. + /// @dev This call does not check that `policyId` exists; a missing + /// or malformed base is denied later, at `isAuthorized`. /// /// @param policyId Policy to invert. /// diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 7741ded..1a27d1d 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -21,28 +21,6 @@ library B20Constants { bytes32 internal constant SEIZE_EXEMPT_POLICY = keccak256("SEIZE_EXEMPT_POLICY"); bytes32 internal constant SEIZE_RECEIVER_POLICY = keccak256("SEIZE_RECEIVER_POLICY"); - /// @notice High bit of a `uint64` policy ID that inverts the base policy's decision. - /// @dev `isAuthorized(base | POLICY_INVERT_BIT, account)` returns the opposite of - /// `isAuthorized(base, account)`, and is fail-closed: an inverted ID whose base - /// does not exist or is malformed returns `false` rather than authorizing - /// everyone. The other queries strip this bit and resolve to the base, so - /// `policyExists(base | POLICY_INVERT_BIT) == policyExists(base)` — a token can - /// store an inverted policy ID per scope and re-validate it like a plain one. - /// A live policy counter occupies only the low 56 bits (the type byte sits at - /// `[63:56]`), so bit 63 never collides with an issued ID. - uint64 internal constant POLICY_INVERT_BIT = uint64(1) << 63; - - /// @notice Returns the negated form of `policyId` by toggling the invert flag, so - /// `isAuthorized(invertPolicy(id), account) == !isAuthorized(id, account)` for an - /// existing base. Involutive: `invertPolicy(invertPolicy(id)) == id`. - /// @dev Pure bit math — no registry call. A composite child set uses this to express - /// "NOT on this list", e.g. `INTERSECT[A, invertPolicy(X)]`. - /// @param policyId The policy ID to negate. - /// @return The policy ID with its invert flag toggled. - function invertPolicy(uint64 policyId) internal pure returns (uint64) { - return policyId ^ POLICY_INVERT_BIT; - } - /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | SEIZE); 15 = 0b1111. uint8 internal constant ALL_FEATURES_PAUSED = 15; diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 604c627..c7b1d07 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; -import {B20Constants} from "base-std/lib/B20Constants.sol"; import {MockPolicyRegistryStorage} from "base-std-test/lib/mocks/MockPolicyRegistryStorage.sol"; @@ -23,6 +22,14 @@ library PolicyRegistryConstants { /// @dev Encodes as an ALLOWLIST at counter 1 (empty allowlist → block all). uint64 internal constant ALWAYS_BLOCK_ID = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | 1; + /// @notice High bit of a `uint64` policy ID that inverts the base policy. + /// @dev All view functions see an inverted ID as an extension of the base — + /// same existence, admin, pending admin, and child set; `isAuthorized` + /// returns the negated base result. A missing or malformed base is denied + /// later, at `isAuthorized`. The counter occupies only the low 56 bits + /// (type byte at `[63:56]`), so bit 63 never collides with an issued ID. + uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63; + /// @notice Number of built-in policies the registry initializes on /// first use. The global counter is advanced to this value /// once both sentinels are populated, so custom policies @@ -71,23 +78,10 @@ contract MockPolicyRegistry is IPolicyRegistry { // Policy ID encoding: top byte = uint8(PolicyType), low 56 bits = counter. uint64 internal constant POLICY_ID_TYPE_SHIFT = 56; - /// @notice Invert (NOT) flag carved from the high bit of the policy-ID type byte. - /// @dev Bits `[57:56]` hold the `PolicyType` discriminant (0..3); bits `[62:58]` - /// are unused. Bit 63 — the top bit of the type byte — is reserved as the - /// invert flag: when set, `isAuthorized` resolves the base policy - /// (`policyId & ~INVERT_BIT`) and returns the OPPOSITE of its decision. - /// The base's members are shared, never copied, so one membership set can be - /// evaluated as include or exclude without maintaining a mirror list. - /// - /// Fail-closed by construction: an inverted ID whose base does not exist or - /// is malformed denies (returns false), never flips an unknown-ID deny into - /// allow-everyone. See `_isAuthorized`. - /// - /// A live counter never reaches bit 63 (it is a 56-bit value under the type - /// byte), so no previously-issued ID collides with the invert encoding. - /// @dev Aliases `B20Constants.POLICY_INVERT_BIT` — the single source of truth shared - /// with consumers — so the mock and callers can never disagree on the bit. - uint64 internal constant INVERT_BIT = B20Constants.POLICY_INVERT_BIT; + /// @notice Invert flag on a policy ID: bit 63. + /// @dev Sourced from `PolicyRegistryConstants` so the mock and tests share one + /// definition of the bit. + uint64 internal constant INVERTED_POLICY_BIT = PolicyRegistryConstants.INVERTED_POLICY_BIT; /// @notice Per-call membership-batch limit. `createPolicyWithAccounts`, /// `updateAllowlist`, and `updateBlocklist` revert with @@ -268,7 +262,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// @dev An inverted ID has no record of its own; it resolves to its base's admin, /// matching `policyExists` (`policyAdmin(~id) == policyAdmin(id)`). function policyAdmin(uint64 policyId) external view returns (address) { - policyId = policyId & ~INVERT_BIT; + policyId = policyId & ~INVERTED_POLICY_BIT; if (!_isWellFormed(policyId)) return address(0); // No fast path for built-in IDs needed: lazy init writes them with // a zero admin, so the normal storage read returns address(0) for @@ -293,7 +287,7 @@ contract MockPolicyRegistry is IPolicyRegistry { // below would also return `address(0)` for built-ins in normal // operation (they never have a pending admin staged), but the // explicit branch removes that assumption from the trust boundary. - policyId = policyId & ~INVERT_BIT; + policyId = policyId & ~INVERTED_POLICY_BIT; if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return address(0); if (!_isWellFormed(policyId)) return address(0); return MockPolicyRegistryStorage.layout().pendingAdmins[policyId]; @@ -305,18 +299,17 @@ contract MockPolicyRegistry is IPolicyRegistry { /// child recorded with its invert flag comes back with the flag set — so any /// per-child invert remains visible to indexers. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { - policyId = policyId & ~INVERT_BIT; + policyId = policyId & ~INVERTED_POLICY_BIT; if (!_isWellFormed(policyId)) return new uint64[](0); if (!_isComposite(policyId)) return new uint64[](0); return MockPolicyRegistryStorage.layout().children[policyId]; } /// @inheritdoc IPolicyRegistry - /// @dev Delegates to the shared `B20Constants.invertPolicy` helper so the mock and the - /// library can never disagree on the invert bit. `pure` is a valid override of the - /// `view` interface declaration. + /// @dev Pure toggle of the invert flag; never reverts and reads no state. `pure` is a + /// valid override of the `view` interface declaration. function invertedPolicyId(uint64 policyId) external pure returns (uint64) { - return B20Constants.invertPolicy(policyId); + return policyId ^ INVERTED_POLICY_BIT; } // ============================================================ @@ -384,7 +377,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// fail-closed guard in `_isAuthorized`. Strips the invert flag first, so an /// inverted ID exists iff its base exists. Never reverts. function _policyExists(uint64 policyId) internal view returns (bool) { - policyId = policyId & ~INVERT_BIT; + policyId = policyId & ~INVERTED_POLICY_BIT; if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; if (!_isWellFormed(policyId)) return false; // Typed `policyExistsFromPacked` rather than a raw `packed != 0` test: identical @@ -410,8 +403,8 @@ contract MockPolicyRegistry is IPolicyRegistry { // allow-everyone — the one property that makes the invert flag safe on gated // mint / transfer / seize paths. The base's decision is only inverted once it is // known to resolve against a real policy. - if (policyId & INVERT_BIT != 0) { - uint64 base = policyId & ~INVERT_BIT; + if (policyId & INVERTED_POLICY_BIT != 0) { + uint64 base = policyId & ~INVERTED_POLICY_BIT; if (!_policyExists(base)) return false; return !_isAuthorized(base, account); } @@ -462,7 +455,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// `PolicyNotFound` takes precedence over `InvalidChildPolicy` across the whole /// set (matches the canonical revert order the Rust precompile mirrors). /// - /// A child may carry the invert flag (`base | INVERT_BIT`) to express + /// A child may carry the invert flag (`base | INVERTED_POLICY_BIT`) to express /// "NOT on this list" — e.g. `INTERSECT[A, ~X]` reads as "on A and not on X". /// Validation resolves the base (bit stripped): a composite base is still /// rejected, so the invert flag cannot smuggle a nested gate past the flat-tree @@ -472,11 +465,11 @@ contract MockPolicyRegistry is IPolicyRegistry { MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout(); // Pass 1: existence of the base (an inverted child references its base's members). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - if ($.policies[childPolicyIds[i] & ~INVERT_BIT] == 0) revert PolicyNotFound(); + if ($.policies[childPolicyIds[i] & ~INVERTED_POLICY_BIT] == 0) revert PolicyNotFound(); } // Pass 2: the base must be a simple policy (never a sentinel or a composite). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - uint64 base = childPolicyIds[i] & ~INVERT_BIT; + uint64 base = childPolicyIds[i] & ~INVERTED_POLICY_BIT; if (_isBuiltin(base) || _isComposite(base)) revert InvalidChildPolicy(childPolicyIds[i]); } } diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol index c805e45..b504d66 100644 --- a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -2,13 +2,12 @@ pragma solidity ^0.8.20; import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; -import {B20Constants} from "base-std/lib/B20Constants.sol"; import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; /// @notice Covers the invert (NOT) flag on the policy ID: `isAuthorized` resolves the -/// base policy (`policyId & ~INVERT_BIT`) and returns the opposite of its +/// base policy (`policyId & ~INVERTED_POLICY_BIT`) and returns the opposite of its /// decision, so one membership set can be evaluated as include or exclude /// without maintaining a mirror list. /// @@ -17,7 +16,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// gated mint / transfer / seize path. Those cases lead the suite. contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { /// @dev The shared invert flag (bit 63 of the ID); single source of truth. - uint64 internal constant INVERT_BIT = B20Constants.POLICY_INVERT_BIT; + uint64 internal constant INVERTED_POLICY_BIT = PolicyRegistryConstants.INVERTED_POLICY_BIT; function _addAllowlistMember(uint64 policyId, address account) internal { address[] memory accounts = new address[](1); @@ -43,7 +42,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { function test_isAuthorized_success_invertUnknownAllowlistBaseDenies(uint56 counter, address account) public view { vm.assume(counter > 1); uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); - assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } /// @notice Inverting an uncreated BLOCKLIST base also denies (fail-closed), even though @@ -54,14 +53,14 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { // Sanity: the plain unknown blocklist authorizes (empty-member-set semantics)... assertTrue(policyRegistry.isAuthorized(base, account)); // ...but its inverse must NOT become allow-everyone; the base does not exist. - assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } /// @notice Inverting a malformed base (type byte above the enum, after stripping the /// invert flag) denies. function test_isAuthorized_success_invertMalformedBaseDenies(uint64 seed, address account) public view { - uint64 base = _malformedPolicyId(seed) & ~INVERT_BIT; - assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + uint64 base = _malformedPolicyId(seed) & ~INVERTED_POLICY_BIT; + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } // ============================================================ @@ -73,14 +72,14 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 base = _createAllowlist(); _addAllowlistMember(base, account); assertTrue(policyRegistry.isAuthorized(base, account)); - assertFalse(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } /// @notice NOT(allowlist): a non-member of the base is authorized by the inverse. function test_isAuthorized_success_invertAllowlistNonMemberAuthorized(address account) public { uint64 base = _createAllowlist(); assertFalse(policyRegistry.isAuthorized(base, account)); - assertTrue(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } /// @notice NOT(blocklist): a blocked account (base denies) is authorized by the inverse. @@ -88,7 +87,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 base = _createBlocklist(); _addBlocklistMember(base, account); assertFalse(policyRegistry.isAuthorized(base, account)); - assertTrue(policyRegistry.isAuthorized(base | INVERT_BIT, account)); + assertTrue(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } // ============================================================ @@ -99,13 +98,13 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { function test_isAuthorized_success_invertAlwaysAllowDenies(address account) public { // Touch the registry so the built-ins are initialized before the query. _createAllowlist(); - assertFalse(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_ALLOW_ID | INVERT_BIT, account)); + assertFalse(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_ALLOW_ID | INVERTED_POLICY_BIT, account)); } /// @notice NOT(ALWAYS_BLOCK) authorizes every account. function test_isAuthorized_success_invertAlwaysBlockAuthorizes(address account) public { _createAllowlist(); - assertTrue(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_BLOCK_ID | INVERT_BIT, account)); + assertTrue(policyRegistry.isAuthorized(PolicyRegistryConstants.ALWAYS_BLOCK_ID | INVERTED_POLICY_BIT, account)); } // ============================================================ @@ -120,7 +119,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { _addAllowlistMember(a, account); uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) ); // account is on A and NOT on X -> authorized. @@ -140,10 +139,10 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) ); uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); - assertEq(children[1], x | INVERT_BIT); + assertEq(children[1], x | INVERTED_POLICY_BIT); } /// @notice An inverted child whose base does not exist reverts with PolicyNotFound — @@ -153,7 +152,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 missing = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(9999); vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, missing | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, missing | INVERTED_POLICY_BIT) ); } @@ -165,9 +164,11 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 inner = policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(a, b)); uint64 c = _createAllowlist(); - vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, inner | INVERT_BIT)); + vm.expectRevert( + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, inner | INVERTED_POLICY_BIT) + ); policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, inner | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, inner | INVERTED_POLICY_BIT) ); } @@ -181,11 +182,11 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) ); uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); assertEq(children[0], a, "plain child returned unchanged"); - assertEq(children[1], x | INVERT_BIT, "inverted child returned with flag set"); + assertEq(children[1], x | INVERTED_POLICY_BIT, "inverted child returned with flag set"); } /// @notice Querying the composite's own inverse returns the identical child set (only the @@ -194,10 +195,10 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERT_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) ); uint64[] memory viaBase = policyRegistry.compositePolicyChildIds(composite); - uint64[] memory viaInverse = policyRegistry.compositePolicyChildIds(composite | INVERT_BIT); + uint64[] memory viaInverse = policyRegistry.compositePolicyChildIds(composite | INVERTED_POLICY_BIT); assertEq(viaInverse.length, viaBase.length); for (uint256 i = 0; i < viaBase.length; ++i) { assertEq(viaInverse[i], viaBase[i]); @@ -214,11 +215,11 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x)); vm.prank(admin); - policyRegistry.updateComposite(composite, _childIds(a, y | INVERT_BIT)); + policyRegistry.updateComposite(composite, _childIds(a, y | INVERTED_POLICY_BIT)); uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); assertEq(children[0], a); - assertEq(children[1], y | INVERT_BIT, "inverted child persists verbatim after update"); + assertEq(children[1], y | INVERTED_POLICY_BIT, "inverted child persists verbatim after update"); } // ============================================================ @@ -231,38 +232,17 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { function test_policyExists_success_invertMirrorsBase(uint56 counter) public { vm.assume(counter > 1); uint64 created = _createAllowlist(); - assertTrue(policyRegistry.policyExists(created | INVERT_BIT)); + assertTrue(policyRegistry.policyExists(created | INVERTED_POLICY_BIT)); uint64 unknown = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); - assertEq(policyRegistry.policyExists(unknown | INVERT_BIT), policyRegistry.policyExists(unknown)); + assertEq(policyRegistry.policyExists(unknown | INVERTED_POLICY_BIT), policyRegistry.policyExists(unknown)); } /// @notice policyAdmin(~id) resolves to the base's admin. function test_policyAdmin_success_invertResolvesBaseAdmin(address policyAdmin) public { vm.assume(policyAdmin != address(0)); uint64 base = _createAllowlist(admin, policyAdmin); - assertEq(policyRegistry.policyAdmin(base | INVERT_BIT), policyAdmin); - } - - // ============================================================ - // invertPolicy() HELPER - // ============================================================ - - /// @notice invertPolicy toggles the invert flag and is involutive. - function test_invertPolicy_success_togglesAndRoundTrips(uint64 base) public pure { - uint64 inverted = B20Constants.invertPolicy(base); - assertEq(inverted, base ^ INVERT_BIT); - assertEq(B20Constants.invertPolicy(inverted), base); - } - - /// @notice The helper produces the same authorization result as setting the bit directly. - function test_invertPolicy_success_matchesRawBitOnAuthorization(address account) public { - uint64 base = _createAllowlist(); - _addAllowlistMember(base, account); - assertEq( - policyRegistry.isAuthorized(B20Constants.invertPolicy(base), account), - policyRegistry.isAuthorized(base | INVERT_BIT, account) - ); + assertEq(policyRegistry.policyAdmin(base | INVERTED_POLICY_BIT), policyAdmin); } // ============================================================ @@ -273,15 +253,10 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { /// including for unknown/malformed IDs (it reads no state). function test_invertedPolicyId_success_togglesAndRoundTrips(uint64 base) public view { uint64 inverted = policyRegistry.invertedPolicyId(base); - assertEq(inverted, base ^ INVERT_BIT); + assertEq(inverted, base ^ INVERTED_POLICY_BIT); assertEq(policyRegistry.invertedPolicyId(inverted), base); } - /// @notice The view agrees with the on-chain `B20Constants.invertPolicy` helper. - function test_invertedPolicyId_success_matchesLibraryHelper(uint64 base) public view { - assertEq(policyRegistry.invertedPolicyId(base), B20Constants.invertPolicy(base)); - } - /// @notice End-to-end: authorizing against the view's result negates the base decision. function test_invertedPolicyId_success_negatesAuthorization(address account) public { uint64 base = _createAllowlist(); From 4ad53604cc739b8941f22369b6e8d0125e567882 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 11:35:10 -0400 Subject: [PATCH 05/12] refactor(policy): extract _basePolicyId invert-strip helper Invert is query-time only; storage keys, type decode, and existence always resolve against the issued ID. Centralize the mask so getters and child validation share one strip. Co-authored-by: Cursor --- test/lib/mocks/MockPolicyRegistry.sol | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index c7b1d07..1256f63 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -262,7 +262,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// @dev An inverted ID has no record of its own; it resolves to its base's admin, /// matching `policyExists` (`policyAdmin(~id) == policyAdmin(id)`). function policyAdmin(uint64 policyId) external view returns (address) { - policyId = policyId & ~INVERTED_POLICY_BIT; + policyId = _basePolicyId(policyId); if (!_isWellFormed(policyId)) return address(0); // No fast path for built-in IDs needed: lazy init writes them with // a zero admin, so the normal storage read returns address(0) for @@ -287,7 +287,7 @@ contract MockPolicyRegistry is IPolicyRegistry { // below would also return `address(0)` for built-ins in normal // operation (they never have a pending admin staged), but the // explicit branch removes that assumption from the trust boundary. - policyId = policyId & ~INVERTED_POLICY_BIT; + policyId = _basePolicyId(policyId); if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return address(0); if (!_isWellFormed(policyId)) return address(0); return MockPolicyRegistryStorage.layout().pendingAdmins[policyId]; @@ -299,7 +299,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// child recorded with its invert flag comes back with the flag set — so any /// per-child invert remains visible to indexers. function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { - policyId = policyId & ~INVERTED_POLICY_BIT; + policyId = _basePolicyId(policyId); if (!_isWellFormed(policyId)) return new uint64[](0); if (!_isComposite(policyId)) return new uint64[](0); return MockPolicyRegistryStorage.layout().children[policyId]; @@ -377,7 +377,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// fail-closed guard in `_isAuthorized`. Strips the invert flag first, so an /// inverted ID exists iff its base exists. Never reverts. function _policyExists(uint64 policyId) internal view returns (bool) { - policyId = policyId & ~INVERTED_POLICY_BIT; + policyId = _basePolicyId(policyId); if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; if (!_isWellFormed(policyId)) return false; // Typed `policyExistsFromPacked` rather than a raw `packed != 0` test: identical @@ -403,8 +403,9 @@ contract MockPolicyRegistry is IPolicyRegistry { // allow-everyone — the one property that makes the invert flag safe on gated // mint / transfer / seize paths. The base's decision is only inverted once it is // known to resolve against a real policy. - if (policyId & INVERTED_POLICY_BIT != 0) { - uint64 base = policyId & ~INVERTED_POLICY_BIT; + bool isInverted = policyId & INVERTED_POLICY_BIT != 0; + if (isInverted) { + uint64 base = _basePolicyId(policyId); if (!_policyExists(base)) return false; return !_isAuthorized(base, account); } @@ -465,11 +466,11 @@ contract MockPolicyRegistry is IPolicyRegistry { MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout(); // Pass 1: existence of the base (an inverted child references its base's members). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - if ($.policies[childPolicyIds[i] & ~INVERTED_POLICY_BIT] == 0) revert PolicyNotFound(); + if ($.policies[_basePolicyId(childPolicyIds[i])] == 0) revert PolicyNotFound(); } // Pass 2: the base must be a simple policy (never a sentinel or a composite). for (uint256 i = 0; i < childPolicyIds.length; ++i) { - uint64 base = childPolicyIds[i] & ~INVERTED_POLICY_BIT; + uint64 base = _basePolicyId(childPolicyIds[i]); if (_isBuiltin(base) || _isComposite(base)) revert InvalidChildPolicy(childPolicyIds[i]); } } @@ -491,6 +492,13 @@ contract MockPolicyRegistry is IPolicyRegistry { return policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID; } + /// @dev Drops the invert flag so storage keys, type decode, and existence + /// resolve against the issued ID. Invert is query-time only; it is never + /// written as its own record. + function _basePolicyId(uint64 policyId) internal pure returns (uint64) { + return policyId & ~INVERTED_POLICY_BIT; + } + function _makeId(PolicyType policyType, uint56 counter) internal pure returns (uint64) { return (uint64(uint8(policyType)) << POLICY_ID_TYPE_SHIFT) | uint64(counter); } From 056775945f161b25502b8556b7a9a4e9e5a51f6d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 11:39:31 -0400 Subject: [PATCH 06/12] docs(policy): trim invert comments in the registry mock Drop natspec that restates _basePolicyId and the fail-closed invert path; keep the invert toggle note on invertedPolicyId. Co-authored-by: Cursor --- test/lib/mocks/MockPolicyRegistry.sol | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 1256f63..85d9cd0 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -294,10 +294,7 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @inheritdoc IPolicyRegistry - /// @dev Only the queried composite's own invert flag is stripped (so a composite and its - /// inverse return the same set). The child IDs are returned exactly as stored — a - /// child recorded with its invert flag comes back with the flag set — so any - /// per-child invert remains visible to indexers. + function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory) { policyId = _basePolicyId(policyId); if (!_isWellFormed(policyId)) return new uint64[](0); @@ -306,8 +303,7 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @inheritdoc IPolicyRegistry - /// @dev Pure toggle of the invert flag; never reverts and reads no state. `pure` is a - /// valid override of the `view` interface declaration. + /// @dev Pure toggle of the invert flag on a policy ID; never reverts and reads no state. function invertedPolicyId(uint64 policyId) external pure returns (uint64) { return policyId ^ INVERTED_POLICY_BIT; } @@ -374,16 +370,12 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @dev Existence predicate shared by the external `policyExists` view and the - /// fail-closed guard in `_isAuthorized`. Strips the invert flag first, so an - /// inverted ID exists iff its base exists. Never reverts. + /// fail-closed guard in `_isAuthorized`. function _policyExists(uint64 policyId) internal view returns (bool) { policyId = _basePolicyId(policyId); if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; if (!_isWellFormed(policyId)) return false; - // Typed `policyExistsFromPacked` rather than a raw `packed != 0` test: identical - // given the encoding invariant (the exists bit is always set when `_encode` - // writes the slot), but matches the Rust precompile's `packed.exists()` and - // survives a future encoding that adds bits above the admin lane. + return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); } @@ -396,13 +388,7 @@ contract MockPolicyRegistry is IPolicyRegistry { /// `_isAuthorized` per child, each of which resolves via the simple path /// (or a built-in short-circuit). function _isAuthorized(uint64 policyId, address account) internal view returns (bool) { - // Invert (NOT) handled before any other branch so it composes uniformly: a - // top-level inverted ID inverts its base, and an inverted composite child inverts - // that leaf as the recursion descends. FAIL-CLOSED: an inverted ID over an - // unknown or malformed base denies rather than flipping a would-be deny into - // allow-everyone — the one property that makes the invert flag safe on gated - // mint / transfer / seize paths. The base's decision is only inverted once it is - // known to resolve against a real policy. + bool isInverted = policyId & INVERTED_POLICY_BIT != 0; if (isInverted) { uint64 base = _basePolicyId(policyId); From 44770ce0ba34dbc4fb2d1a4b6ad1ebc0e82eb994 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 11:54:06 -0400 Subject: [PATCH 07/12] test(policy): name inverted child IDs and shorten invert comments Give inverted composite children a local so the flag is not inlined at every call site, and drop natspec that restates the tests. Co-authored-by: Cursor --- test/lib/mocks/MockPolicyRegistry.sol | 4 +- .../PolicyRegistry/isAuthorizedInvert.t.sol | 42 +++++++------------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 85d9cd0..7875b35 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -478,9 +478,7 @@ contract MockPolicyRegistry is IPolicyRegistry { return policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID; } - /// @dev Drops the invert flag so storage keys, type decode, and existence - /// resolve against the issued ID. Invert is query-time only; it is never - /// written as its own record. + /// @dev Strips the invert flag from the policy ID. function _basePolicyId(uint64 policyId) internal pure returns (uint64) { return policyId & ~INVERTED_POLICY_BIT; } diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol index b504d66..8f8187b 100644 --- a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -6,16 +6,8 @@ import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; import {PolicyRegistryTest} from "base-std-test/lib/PolicyRegistryTest.sol"; import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; -/// @notice Covers the invert (NOT) flag on the policy ID: `isAuthorized` resolves the -/// base policy (`policyId & ~INVERTED_POLICY_BIT`) and returns the opposite of its -/// decision, so one membership set can be evaluated as include or exclude -/// without maintaining a mirror list. -/// -/// @dev The load-bearing property is FAIL-CLOSED: an inverted ID over an unknown or -/// malformed base must deny, never flip a would-be deny into allow-everyone on a -/// gated mint / transfer / seize path. Those cases lead the suite. +/// @notice Covers the invert (NOT) flag on the policy ID. IsAuthrized evaluates the base policy and inverts the result. contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { - /// @dev The shared invert flag (bit 63 of the ID); single source of truth. uint64 internal constant INVERTED_POLICY_BIT = PolicyRegistryConstants.INVERTED_POLICY_BIT; function _addAllowlistMember(uint64 policyId, address account) internal { @@ -37,16 +29,13 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { // ============================================================ /// @notice Inverting an uncreated (unknown) base denies rather than allowing everyone. - /// @dev The whole reason the invert flag is gated on base existence. Without the gate - /// a garbage or typo'd ID with the bit set would authorize every account. function test_isAuthorized_success_invertUnknownAllowlistBaseDenies(uint56 counter, address account) public view { vm.assume(counter > 1); uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(counter); assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } - /// @notice Inverting an uncreated BLOCKLIST base also denies (fail-closed), even though - /// a plain unknown blocklist authorizes — existence is what gates the flip. + /// @notice Inverting an uncreated BLOCKLIST base also denies (fail-closed) function test_isAuthorized_success_invertUnknownBlocklistBaseDenies(uint56 counter, address account) public view { vm.assume(counter > 1); uint64 base = (uint64(uint8(IPolicyRegistry.PolicyType.BLOCKLIST)) << 56) | uint64(counter); @@ -56,8 +45,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); } - /// @notice Inverting a malformed base (type byte above the enum, after stripping the - /// invert flag) denies. + /// @notice Inverting a malformed base denies. function test_isAuthorized_success_invertMalformedBaseDenies(uint64 seed, address account) public view { uint64 base = _malformedPolicyId(seed) & ~INVERTED_POLICY_BIT; assertFalse(policyRegistry.isAuthorized(base | INVERTED_POLICY_BIT, account)); @@ -117,9 +105,9 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); _addAllowlistMember(a, account); - + uint64 invertedX = x | INVERTED_POLICY_BIT; uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX) ); // account is on A and NOT on X -> authorized. @@ -138,11 +126,12 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { function test_createCompositePolicy_success_invertedSimpleChild() public { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); + uint64 invertedX = x | INVERTED_POLICY_BIT; uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, x | INVERTED_POLICY_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX) ); uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); - assertEq(children[1], x | INVERTED_POLICY_BIT); + assertEq(children[1], invertedX); } /// @notice An inverted child whose base does not exist reverts with PolicyNotFound — @@ -150,9 +139,10 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { function test_createCompositePolicy_revert_invertedChildBaseNotFound() public { uint64 a = _createAllowlist(); uint64 missing = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(9999); + uint64 invertedMissing = missing | INVERTED_POLICY_BIT; vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, missing | INVERTED_POLICY_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedMissing) ); } @@ -162,13 +152,13 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 b = _createAllowlist(); uint64 inner = policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(a, b)); - + uint64 invertedInner = inner | INVERTED_POLICY_BIT; uint64 c = _createAllowlist(); vm.expectRevert( - abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, inner | INVERTED_POLICY_BIT) + abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, invertedInner) ); policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, inner | INVERTED_POLICY_BIT) + admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, invertedInner) ); } @@ -189,8 +179,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { assertEq(children[1], x | INVERTED_POLICY_BIT, "inverted child returned with flag set"); } - /// @notice Querying the composite's own inverse returns the identical child set (only the - /// queried ID's flag is stripped; the children are untouched). + /// @notice Querying the composite's own inverse returns the identical child set function test_compositePolicyChildIds_success_invertedCompositeIdReturnsSameSet() public { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); @@ -227,8 +216,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { // ============================================================ /// @notice policyExists(~id) mirrors policyExists(id): the inverse of a created policy - /// reports existing (so a token can store and re-validate ~id), and the inverse - /// of an unknown base reports non-existent. + /// reports existing function test_policyExists_success_invertMirrorsBase(uint56 counter) public { vm.assume(counter > 1); uint64 created = _createAllowlist(); From 45818bb23f2168293b42a5d8a1aad36d8d0c47e0 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 11:56:59 -0400 Subject: [PATCH 08/12] docs(policy): shorten INVERTED_POLICY_BIT natspec Keep the invert-bit notice without restating getter and fail-closed behavior already covered by the views. Co-authored-by: Cursor --- test/lib/mocks/MockPolicyRegistry.sol | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 7875b35..58c8db7 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -23,11 +23,7 @@ library PolicyRegistryConstants { uint64 internal constant ALWAYS_BLOCK_ID = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | 1; /// @notice High bit of a `uint64` policy ID that inverts the base policy. - /// @dev All view functions see an inverted ID as an extension of the base — - /// same existence, admin, pending admin, and child set; `isAuthorized` - /// returns the negated base result. A missing or malformed base is denied - /// later, at `isAuthorized`. The counter occupies only the low 56 bits - /// (type byte at `[63:56]`), so bit 63 never collides with an issued ID. + /// @dev All view functions see an inverted ID as an extension of the base — policy ID uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63; /// @notice Number of built-in policies the registry initializes on From 7b0cc31554fdbfbbb2473cbeefb59521ceee0065 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 12:00:18 -0400 Subject: [PATCH 09/12] docs(policy): note inverted IDs are valid composite children Restore the original simple-child @dev and add that an inverted valid policy ID still counts as a composite child. Co-authored-by: Cursor --- test/lib/mocks/MockPolicyRegistry.sol | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index 58c8db7..c59983c 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -433,17 +433,11 @@ contract MockPolicyRegistry is IPolicyRegistry { } /// @dev Requires every composite child to be a created, custom, SIMPLE policy: - /// its base must exist, must not be a built-in sentinel (ALWAYS_ALLOW / - /// ALWAYS_BLOCK), and must not itself be a composite. Two passes so - /// `PolicyNotFound` takes precedence over `InvalidChildPolicy` across the whole - /// set (matches the canonical revert order the Rust precompile mirrors). - /// - /// A child may carry the invert flag (`base | INVERTED_POLICY_BIT`) to express - /// "NOT on this list" — e.g. `INTERSECT[A, ~X]` reads as "on A and not on X". - /// Validation resolves the base (bit stripped): a composite base is still - /// rejected, so the invert flag cannot smuggle a nested gate past the flat-tree - /// invariant. The child is stored verbatim (flag intact); `_isAuthorized` - /// inverts that leaf during evaluation. + /// it must exist, must not be a built-in sentinel (ALWAYS_ALLOW / ALWAYS_BLOCK), + /// and must not itself be a composite. Two passes so `PolicyNotFound` takes + /// precedence over `InvalidChildPolicy` across the whole set (matches the + /// canonical revert order the Rust precompile mirrors). + /// @dev An inverted valid policy ID counts as a valid composite child. function _requireCreatedSimplePolicies(uint64[] calldata childPolicyIds) internal view { MockPolicyRegistryStorage.Layout storage $ = MockPolicyRegistryStorage.layout(); // Pass 1: existence of the base (an inverted child references its base's members). From 533de1bbf1d560e32dcd519dc01be4de048ce266 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 12:04:56 -0400 Subject: [PATCH 10/12] style(policy): forge fmt the invert mock and tests Reflow lines that exceeded the 120-char limit (composite-creation calls after the inverted-child locals were introduced). Formatting only, no logic change. Co-Authored-By: Claude --- test/lib/mocks/MockPolicyRegistry.sol | 3 +-- .../PolicyRegistry/isAuthorizedInvert.t.sol | 22 ++++++------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/test/lib/mocks/MockPolicyRegistry.sol b/test/lib/mocks/MockPolicyRegistry.sol index c59983c..e3e3072 100644 --- a/test/lib/mocks/MockPolicyRegistry.sol +++ b/test/lib/mocks/MockPolicyRegistry.sol @@ -371,7 +371,7 @@ contract MockPolicyRegistry is IPolicyRegistry { policyId = _basePolicyId(policyId); if (policyId == ALWAYS_ALLOW_ID || policyId == ALWAYS_BLOCK_ID) return true; if (!_isWellFormed(policyId)) return false; - + return MockPolicyRegistryStorage.policyExistsFromPacked(MockPolicyRegistryStorage.layout().policies[policyId]); } @@ -384,7 +384,6 @@ contract MockPolicyRegistry is IPolicyRegistry { /// `_isAuthorized` per child, each of which resolves via the simple path /// (or a built-in short-circuit). function _isAuthorized(uint64 policyId, address account) internal view returns (bool) { - bool isInverted = policyId & INVERTED_POLICY_BIT != 0; if (isInverted) { uint64 base = _basePolicyId(policyId); diff --git a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol index 8f8187b..b1e251b 100644 --- a/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol +++ b/test/unit/PolicyRegistry/isAuthorizedInvert.t.sol @@ -106,9 +106,8 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 x = _createAllowlist(); _addAllowlistMember(a, account); uint64 invertedX = x | INVERTED_POLICY_BIT; - uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX) - ); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX)); // account is on A and NOT on X -> authorized. assertTrue(policyRegistry.isAuthorized(composite, account)); @@ -127,9 +126,8 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 a = _createAllowlist(); uint64 x = _createAllowlist(); uint64 invertedX = x | INVERTED_POLICY_BIT; - uint64 composite = policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX) - ); + uint64 composite = + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedX)); uint64[] memory children = policyRegistry.compositePolicyChildIds(composite); assertEq(children[1], invertedX); } @@ -141,9 +139,7 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 missing = (uint64(uint8(IPolicyRegistry.PolicyType.ALLOWLIST)) << 56) | uint64(9999); uint64 invertedMissing = missing | INVERTED_POLICY_BIT; vm.expectRevert(IPolicyRegistry.PolicyNotFound.selector); - policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedMissing) - ); + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(a, invertedMissing)); } /// @notice An inverted COMPOSITE child is rejected: the invert flag must not let a @@ -154,12 +150,8 @@ contract PolicyRegistryIsAuthorizedInvertTest is PolicyRegistryTest { uint64 inner = policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.UNION, _childIds(a, b)); uint64 invertedInner = inner | INVERTED_POLICY_BIT; uint64 c = _createAllowlist(); - vm.expectRevert( - abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, invertedInner) - ); - policyRegistry.createCompositePolicy( - admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, invertedInner) - ); + vm.expectRevert(abi.encodeWithSelector(IPolicyRegistry.InvalidChildPolicy.selector, invertedInner)); + policyRegistry.createCompositePolicy(admin, IPolicyRegistry.PolicyType.INTERSECT, _childIds(c, invertedInner)); } // ============================================================ From da74a438a18d079258c5c58dfb17a4bb0952374f Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 11 Sep 2026 10:38:03 -0400 Subject: [PATCH 11/12] changelog(denim): add NOT / invert policy spec + concept-doc update Register Denim as hardfork ordinal 03 in changelog/README.md and add the full spec for query-time policy inversion (bit 63 of a uint64 policy ID): invertedPolicyId, isAuthorized/getter behavior on inverted IDs, composite-child invert handling, and rejected alternatives. Update docs/concepts/policies.md with the corresponding "Inverting a policy" section (renumbers 2.3+ by one). Co-Authored-By: Claude --- .../03_Denim_PolicyRegistry_not_policy.md | 183 ++++++++++++++++++ changelog/README.md | 10 + docs/concepts/policies.md | 94 ++++++++- 3 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 changelog/03_Denim_PolicyRegistry_not_policy.md diff --git a/changelog/03_Denim_PolicyRegistry_not_policy.md b/changelog/03_Denim_PolicyRegistry_not_policy.md new file mode 100644 index 0000000..a160e38 --- /dev/null +++ b/changelog/03_Denim_PolicyRegistry_not_policy.md @@ -0,0 +1,183 @@ +# NOT / Invert Policies + +- **Feature Name**: not_policy +- **Start Date**: 2026-09-09 +- **Authors**: Rayyan Alam +- **Title**: NOT / Invert Policies + +## Summary + +Issuers may want the inverse of a specific list without maintaining two lists. For example, they may authorize an account only when it is not on a sanctions blocklist. This change adds a query-time invert (NOT) flag in bit 63 of a `uint64` policy ID. When that bit is set, `isAuthorized` resolves the base policy and returns the opposite of that policy's decision. + +Members stay on the base policy and are shared, not copied, so an update to the base updates its inverse. Invert therefore creates no new record, no new create path, and no extra storage load (`SLOAD`). The flag applies to every policy type: `ALLOWLIST`, `BLOCKLIST`, and `UNION` / `INTERSECT` composites. + +## Motivation + +Issuers may want the inverse of a specific list, and the registry cannot express that. They may authorize an account only when it is not on a sanctions blocklist, or only when it is not Know Your Customer (KYC) verified. Composites often need the same negation: "allowed to transfer" is frequently "on list A and not on list B", where list B is a sanctions list, a blocked list, or a non-KYC'd list. There is no way to say "the opposite of this policy." + +The workaround is to maintain two mirror lists: an allowlist and a blocklist seeded with the same addresses. Every membership change must land on both lists. Any lag rejects valid accounts or admits invalid ones. Composite policies do not remove that second list. + +The goal is to let one membership set be evaluated as include or exclude, so issuers never maintain two policies for the same address group. + +## Background + +### Policy Registry + +The Policy Registry is a singleton precompile at `0x8453000000000000000000000000000000000002`. B20 tokens call it for pre-operation compliance checks on an address. + +B20 stores a `uint64` policy ID per scope (`TRANSFER_FROM`, `TRANSFER_TO`, `MINT_RECEIVER`, `SEIZE_EXEMPT`, and other scopes) and calls `isAuthorized(policyId, account)` before gated operations. + +`isAuthorized` never reverts. A malformed or unknown ID returns `false` (deny). + +### Policy ID layout + +A policy ID is a `uint64` value issued by the Policy Registry. It is the link between the registry and a token: the registry stores the policy, and the token stores only the ID, then passes that ID to `isAuthorized` for each gated operation. + +The structure of a policy ID is: + +```text + 63 56 55 0 ++------------------+-------------------------------+ +| PolicyType byte | unique counter | ++------------------+-------------------------------+ +``` + +- Bits `[0:55]` hold a unique counter value. The type is not stored in a slot. +- Bits `[56:63]` are reserved for `PolicyType`. Only four types are used today (`0–3`: `BLOCKLIST`, `ALLOWLIST`, `UNION`, `INTERSECT`), occupying bits `56–57`. Bits `58–63` are unused. + +Built-in sentinels are `ALWAYS_ALLOW` (id 0) and `ALWAYS_BLOCK` (id 1). The counter starts at 2. + + +## Specs + +### Interface Changes + +This change introduces `invertedPolicyId`, a view helper that returns the inverted version of a policy ID. Indexers, explorers, externally owned accounts (EOAs), and cross-codebase contracts can call it to obtain that form without knowing the bit layout. Bit 63 of a policy ID is now reserved as the invert bit, so the registry does not add a new create function. Consumers of the Policy Registry can set that bit themselves. + +```solidity +// New constant (single source of truth, in PolicyRegistryConstants) +uint64 internal constant INVERTED_POLICY_BIT = uint64(1) << 63; + +// Helper created for getting the inverted version of a policy ID +function invertedPolicyId(uint64 policyId) external view returns (uint64); +``` + +| Symbol | Selector / Topic0 | Status | Notes | +| ------ | ----------------- | ------ | ----- | +| `invertedPolicyId(uint64)` | `0x6b468933` | NEW (view) | Pure toggle of bit 63 (`policyId ^ INVERTED_POLICY_BIT`); never reverts, reads no state, involutive | +| `isAuthorized(uint64,address)` | (unchanged) | extended | An inverted ID resolves the base and returns the negated result; fail-closed on an unknown/malformed base | +| `policyExists(uint64)` | (unchanged) | extended | Strips to base: `policyExists(invertedPolicyId(id)) == policyExists(id)` | +| `policyAdmin(uint64)` | (unchanged) | extended | Strips to base: `policyAdmin(invertedPolicyId(id)) == policyAdmin(id)` | +| `pendingPolicyAdmin(uint64)` | (unchanged) | extended | Strips to base | +| `compositePolicyChildIds(uint64)` | (unchanged) | extended | Strips the queried composite's own flag; child IDs returned **verbatim**, including any per-child invert | +| `createCompositePolicy(address,uint8,uint64[])` | (unchanged) | extended | A child ID may carry the invert flag ("A AND NOT X"); validated against its base | +| `updateComposite(uint64,uint64[])` | (unchanged) | extended | Same per-child invert handling | + +`invertedPolicyId` does not check existence. A missing or malformed base is denied later, at `isAuthorized`. + +### Behavioural Changes + +#### Authorization + +`isAuthorized` gains a leading invert branch. All non-inverted paths are byte-identical to today. + +```text +isAuthorized(policyId, account): + if policyId has INVERTED_POLICY_BIT set: + base = policyId without the bit + if not policyExists(base): # fail-closed guard + return false + return not isAuthorized(base, account) + + ... existing ALLOWLIST / BLOCKLIST / UNION / INTERSECT dispatch ... +``` + +The invert applies to every policy type. Inverting a composite negates the composite's combined result. + +#### Getters strip to base + +Read views do not look up an inverted ID as its own policy. They strip bit 63 with a shared `_basePolicyId(id) = id & ~INVERTED_POLICY_BIT` helper and read the base. An inverted ID therefore has no record of its own: it mirrors the base's existence, admin, pending admin, and child set. A token can store an inverted policy ID and later re-validate it exactly as it would a plain one. + +```mermaid +flowchart TD + Q["read view(policyId)"] --> S["_basePolicyId: clear bit 63"] + S --> B["Load the base policy record"] + B --> R["Return the base field: exists, admin, pending admin, or child set"] +``` + +#### Composite children + +A composite child ID may carry the invert bit. The registry evaluates that child as the inverse of its base, and it checks existence and simple type against the base. An inverted simple child is valid. An inverted composite child is rejected (`InvalidChildPolicy`), which preserves the flat-tree invariant. Across the whole child set, `PolicyNotFound` still takes precedence over `InvalidChildPolicy`. + +```mermaid +flowchart TD + C["createCompositePolicy / updateComposite child"] --> S["_basePolicyId: clear bit 63"] + S --> E{"policyExists(base)?"} + E -->|no| NF["revert PolicyNotFound (whole set first)"] + E -->|yes| T{"base is ALLOWLIST or BLOCKLIST?"} + T -->|no, composite| IC["revert InvalidChildPolicy"] + T -->|yes| OK["Accept child ID as stored, invert bit kept"] +``` + +#### State / gas + +There are no new storage slots. Invert is query-time only. Storage keys, type decode, and existence always resolve against the issued (stripped) ID. + +Eval is the existing dispatch plus one boolean flip in memory. There is no extra `SLOAD`. + +### Examples + +Given a sanctions `BLOCKLIST` `sanctionsId` (authorized means not sanctioned): + +```solidity +uint64 notSanctions = policyRegistry.invertedPolicyId(sanctionsId); // sanctionsId ^ (1 << 63) +``` + +"Allowed to transfer = on `kycId` AND not on `sanctionsId`" via a composite with an inverted child: + +```solidity +uint64 notSanctions = policyRegistry.invertedPolicyId(sanctionsId); +policyRegistry.createCompositePolicy(admin, INTERSECT, [kycId, notSanctions]); +``` + +Fail-closed: for any never-created base, `isAuthorized(base | INVERTED_POLICY_BIT, account) == false`. The inverted unknown ID never becomes allow-everyone. + +Round-trip: `invertedPolicyId(invertedPolicyId(id)) == id` (involutive). `policyExists(notSanctions) == policyExists(sanctionsId)`. + +## Design Decisions & Alternatives Considered + +Three options were weighed. The team converged on Option 2 (invert bit on the ID), implemented as Option 2a: Option 2 plus a base-existence guard that makes it fail-closed. + +### Chosen: invert bit on the ID + +The chosen approach encodes NOT in bit 63 of the policy ID. `isAuthorized` strips the bit, runs the existing dispatch, and returns the opposite result. There is no new storage and no create path. Any policy, simple or composite, can be inverted on its own. + +This approach was chosen because: + +- There is no extra `SLOAD` for the common case of inverting an `ALLOWLIST` or `BLOCKLIST`. +- Performance matches Alternative 3, while a simple policy can be inverted standalone, which Alternative 3 cannot do. +- It aligns with treating membership as a single address list whose include/exclude polarity is chosen by the consumer, rather than baked into `ALLOWLIST` vs `BLOCKLIST`. + +Tradeoff: the flag occupies unused `PolicyType` bitspace, and every getter must strip it through `_basePolicyId`. + +### Alternative 1 — New `NOT` policy type + +`createNot(admin, base)` allocates a fresh record pointing at a base. A first-class NOT node wraps any policy, with the clearest explorer legibility. + +This option was rejected. Standalone NOT costs about 3 `SLOAD`s versus 1 for a mirror blocklist. "A AND NOT X" costs about 6 versus the bitmask's 4. The option also adds a new create path. As a composite child it deepens hot-path recursion. It was ruled out on performance. It would be preferable only if performance were a non-issue, for its structural consistency. + +### Alternative 3 — Per-child invert bitmask on the composite (doc's original recommendation) + +This option stores a ≤4-bit mask packed into the children length word. Bit `i` flips `children[i]` before the gate. `mask = 0` reproduces today's behavior, so existing composites need no migration. It keeps polarity off the policy IDs. + +This option was rejected. It only works inside a composite. There is no standalone referenceable inverse of an arbitrary policy. A simple policy cannot be inverted without wrapping it in a composite (minimum 2 children). It is better suited to a different problem, and could later compose on top of the invert bit. + +## Migration Steps + +This change is not breaking. All existing selectors, events, and errors are unchanged. Existing IDs have bit 63 unset, so behavior is identical. 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 an inverted 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 too, because existence resolves to the base. diff --git a/changelog/README.md b/changelog/README.md index 703cd9d..9d62418 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -16,11 +16,21 @@ See [AGENTS.md](AGENTS.md) for how to name and write a new entry. | --- | --- | --- | | `01` | Beryl | Live | | `02` | Cobalt | Upcoming | +| `03` | Denim | Upcoming | ## Index Grouped by hardfork, one collapsible section per hardfork, newest first. +
+Denim (upcoming) — ordinal 03 + +| Product(s) | Change | Affected interfaces | Entry | +| --- | --- | --- | --- | +| PolicyRegistry | NOT / invert policies | `src/interfaces/IPolicyRegistry.sol` | [03_Denim_PolicyRegistry_not_policy](03_Denim_PolicyRegistry_not_policy.md) | + +
+
Cobalt (upcoming) — ordinal 02 diff --git a/docs/concepts/policies.md b/docs/concepts/policies.md index 1459b6e..db269a4 100644 --- a/docs/concepts/policies.md +++ b/docs/concepts/policies.md @@ -61,7 +61,7 @@ A **composite** policy combines two to four existing simple policies. It does no | `INTERSECT` | Every child authorizes the account | -Children must be existing `ALLOWLIST` or `BLOCKLIST` policies. Another composite is not a valid child. The built-in sentinels in [§2.4](#24-built-in-sentinels) are not valid children either. Updating a child's members changes every composite that references it. There is no flatten-and-copy step. +Children must be existing `ALLOWLIST` or `BLOCKLIST` policies. Another composite is not a valid child. The built-in sentinels in [§2.5](#25-built-in-sentinels) are not valid children either. Updating a child's members changes every composite that references it. There is no flatten-and-copy step. Any of these types can also be inverted. See [§2.3](#23-inverting-a-policy). ```mermaid flowchart TD @@ -78,17 +78,38 @@ flowchart TD -### 2.3 Creating and updating +### 2.3 Inverting a policy + +An issuer may want the opposite of an existing policy without a second member set. Bit 63 of a policy ID is the invert (NOT) flag. That is not a new policy type and not a create path. Members stay on the base. An update to the base updates the inverse. + +Call `invertedPolicyId(policyId)` to set or clear that bit. You can also set bit 63 yourself. Bind the inverted ID to a token scope, or pass it as a composite child ("A AND NOT X"). The flag applies to every type: `ALLOWLIST`, `BLOCKLIST`, `UNION`, and `INTERSECT`. + +`isAuthorized` on an inverted ID returns the opposite of the base. If the base does not exist, the result is `false`. That fail-closed guard prevents a mistyped inverted ID from becoming allow-everyone. + +Read views strip bit 63 and load the base. `policyExists` and `policyAdmin` on an inverted ID match the base. An inverted ID has no record of its own. + +```mermaid +flowchart TD + Q["isAuthorized(policyId, account)"] --> Inv{"bit 63 set?"} + Inv -->|no| T[Dispatch on policy type] + Inv -->|yes| E{"policyExists(base)?"} + E -->|no| F[false] + E -->|yes| N["not isAuthorized(base)"] +``` + +A composite child ID may carry the invert bit. The registry checks existence and simple type against the base. An inverted simple child is valid. An inverted composite child reverts `InvalidChildPolicy`. Across the whole child set, `PolicyNotFound` still takes precedence over `InvalidChildPolicy`. + +### 2.4 Creating and updating Anyone can create a policy. The create call names a single `admin`. That address is the only one that can later change membership, replace a composite's children, transfer administration, or renounce. The creator does not have to be the admin. `admin` cannot be `address(0)`. You can also skip creation and reuse an existing policy. If another issuer already maintains the list you need, bind their policy ID to your token. You do not become that policy's admin by attaching it. -#### 2.3.1 Creating a policy +#### 2.4.1 Creating a policy A simple policy starts as an `ALLOWLIST` or a `BLOCKLIST`. Call `createPolicy(admin, ALLOWLIST)` or `createPolicy(admin, BLOCKLIST)`. The registry assigns a new policy ID and returns it. The member set is empty. `createPolicyWithAccounts(admin, policyType, accounts)` does the same and seeds the set in that call. Membership batches are capped at 64 accounts. -A composite starts from policies that already exist. Call `createCompositePolicy(admin, UNION | INTERSECT, childPolicyIds)`. The child count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`2` through `4`). The registry stores references, not a snapshot of the children's members. +A composite starts from policies that already exist. Call `createCompositePolicy(admin, UNION | INTERSECT, childPolicyIds)`. The child count must be in `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`2` through `4`). The registry stores references, not a snapshot of the children's members. A child ID may be inverted. The registry validates the base and stores the child ID with the invert bit set. See [§2.3](#23-inverting-a-policy). Both paths emit `PolicyCreated` and `PolicyAdminUpdated(policyId, address(0), admin)`. `policyAdmin(policyId)` then returns that admin. @@ -104,7 +125,7 @@ sequenceDiagram -#### 2.3.2 Updating a policy +#### 2.4.2 Updating a policy After creation, only the current admin can change the policy. Any other caller reverts `Unauthorized`. The update must match the policy's type or it reverts `IncompatiblePolicyType`. @@ -126,7 +147,7 @@ sequenceDiagram -#### 2.3.3 Changing the admin +#### 2.4.3 Changing the admin A policy has one admin at a time. To hand it off, the current admin calls `stageUpdateAdmin(policyId, newAdmin)`. That does not change who can update the policy yet. `policyAdmin` still returns the current admin. `pendingPolicyAdmin` returns `newAdmin`. Passing `address(0)` clears a nomination that has not been finalized. @@ -154,7 +175,7 @@ sequenceDiagram To freeze a policy instead of handing it off, the current admin calls `renounceAdmin(policyId)`. Administration is gone for good. Membership and child sets cannot change. `isAuthorized` keeps working. There is no call that assigns a new admin after renounce. -### 2.4 Built-in sentinels +### 2.5 Built-in sentinels Two policy IDs exist without being created: @@ -173,7 +194,7 @@ A scope is an identifier for the policy that runs on a specific function. It wor ### 3.1 Updating a scope -`updatePolicy(policyScope, newPolicyId)` binds a policy ID to a scope. It requires `DEFAULT_ADMIN_ROLE`. The ID must be a built-in sentinel or an existing registry policy. Otherwise the call reverts `PolicyNotFound`. An unknown `policyScope` reverts `UnsupportedPolicyType`. +`updatePolicy(policyScope, newPolicyId)` binds a policy ID to a scope. It requires `DEFAULT_ADMIN_ROLE`. The ID must be a built-in sentinel or an existing registry policy. Otherwise the call reverts `PolicyNotFound`. An inverted ID is valid when its base exists, because `policyExists` strips bit 63. The token treats the ID as an opaque `uint64`. An unknown `policyScope` reverts `UnsupportedPolicyType`. The write takes effect on the next call that hits that scope. It emits `PolicyUpdated`. Until you update a scope, it reads as `0` (`ALWAYS_ALLOW`), so the check passes for every address. The same policy ID can sit on more than one scope and on more than one token. `policyId(policyScope)` reads the current binding. @@ -206,7 +227,7 @@ Most scopes deny when `isAuthorized` is `false` and revert `PolicyForbids`. `SEI ## 4. Example -Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both. +Start with a receiver allowlist. Then combine it with a sanctions blocklist so a transfer requires both. Then invert an exclusion allowlist so the same gate can say "on KYC and not on that list" without a second member set. ### 4.1 One allowlist @@ -301,6 +322,61 @@ A later `updateBlocklist` that adds or removes Carol changes the composite on th If the issuer later needs the same KYC list or-ed with a token-specific partner allowlist, they create a `UNION` of those two allowlists instead. The token bind step is the same. +### 4.3 Invert: KYC and not on an exclusion allowlist + +Section 4.2 stores sanctioned addresses as a `BLOCKLIST`, so "not sanctioned" is already the blocklist's authorization result. Invert is for the other case: the exclusion list is an `ALLOWLIST` of addresses you want to keep out, and you need the opposite of that list without copying it into a blocklist. + +Create a KYC allowlist and an exclusion allowlist. Invert the exclusion ID. Pass both into an `INTERSECT` composite. + +```mermaid +flowchart TD + C["INTERSECT composite"] --> K[KYC ALLOWLIST] + C --> N["inverted exclusion ALLOWLIST"] + N --> X[exclusion ALLOWLIST] + K --> A1[Alice: member] + K --> A2[Bob: not a member] + K --> A3[Carol: member] + X --> B1[Alice: not listed] + X --> B2[Bob: not listed] + X --> B3[Carol: listed] +``` + +```mermaid +sequenceDiagram + participant Admin + participant Registry as Policy Registry + participant Token as B20 token + participant Alice + participant Dave + participant Carol + + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: kycId + Admin->>Registry: updateAllowlist(kycId, true, [Alice, Dave, Carol]) + Admin->>Registry: createPolicy(admin, ALLOWLIST) + Registry-->>Admin: exclusionId + Admin->>Registry: updateAllowlist(exclusionId, true, [Carol]) + Admin->>Registry: invertedPolicyId(exclusionId) + Registry-->>Admin: notExcluded + Admin->>Registry: createCompositePolicy(admin, INTERSECT, [kycId, notExcluded]) + Registry-->>Admin: gateId + Admin->>Token: updatePolicy(TRANSFER_RECEIVER_POLICY, gateId) + + Alice->>Token: transfer(Dave, amount) + Token->>Registry: isAuthorized(gateId, Dave) + Registry-->>Token: true + Token-->>Alice: allowed + + Alice->>Token: transfer(Carol, amount) + Token->>Registry: isAuthorized(gateId, Carol) + Registry-->>Token: false + Token-->>Alice: revert PolicyForbids(TRANSFER_RECEIVER_POLICY, gateId) +``` + +Alice and Dave are on the KYC list and not on the exclusion list. The inverted child authorizes them, so the `INTERSECT` returns `true`. Carol is KYC'd but on the exclusion list. The inverted child returns `false`, so the composite returns `false` and the transfer reverts. Bob is not on the KYC list, so he is denied even though he is not excluded. + +A later `updateAllowlist` that adds or removes Carol on `exclusionId` changes the inverted child on the next call. The token still holds `gateId`. The issuer does not call `updatePolicy` again. + ## Events and Errors ### Token From 882ca28c926ae966bee30a3d5b2fb294047f78b9 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 11 Sep 2026 10:48:42 -0400 Subject: [PATCH 12/12] changelog(denim): tighten NOT-policy summary/motivation wording Cover the "invert multiple children" case explicitly (NOT A AND NOT B) and trim a redundant blank line / sentinel-ID aside. Co-Authored-By: Claude --- changelog/03_Denim_PolicyRegistry_not_policy.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/changelog/03_Denim_PolicyRegistry_not_policy.md b/changelog/03_Denim_PolicyRegistry_not_policy.md index a160e38..a152273 100644 --- a/changelog/03_Denim_PolicyRegistry_not_policy.md +++ b/changelog/03_Denim_PolicyRegistry_not_policy.md @@ -7,17 +7,17 @@ ## Summary -Issuers may want the inverse of a specific list without maintaining two lists. For example, they may authorize an account only when it is not on a sanctions blocklist. This change adds a query-time invert (NOT) flag in bit 63 of a `uint64` policy ID. When that bit is set, `isAuthorized` resolves the base policy and returns the opposite of that policy's decision. +This change allows any policy ID to reference the opposite (NOT) of its original outcome at query time. When bit 63 is set, `isAuthorized` resolves the base policy and returns the opposite of that policy's decision. Members stay on the base policy and are shared, not copied, so an update to the base updates its inverse. Invert therefore creates no new record, no new create path, and no extra storage load (`SLOAD`). The flag applies to every policy type: `ALLOWLIST`, `BLOCKLIST`, and `UNION` / `INTERSECT` composites. ## Motivation -Issuers may want the inverse of a specific list, and the registry cannot express that. They may authorize an account only when it is not on a sanctions blocklist, or only when it is not Know Your Customer (KYC) verified. Composites often need the same negation: "allowed to transfer" is frequently "on list A and not on list B", where list B is a sanctions list, a blocked list, or a non-KYC'd list. There is no way to say "the opposite of this policy." +Issuers may want the inverse of a specific list, and the registry cannot express that. They may authorize an account only when it is not on a sanctions blocklist, or only when it is not Know Your Customer (KYC) verified. Composites often need the same negation: "allowed to transfer" is frequently "on list A and not on list B", where list B is a sanctions list, a blocked list, or a non-KYC'd list. They may also need both sides inverted: "not on list A and not on list B". There is no way to say "the opposite of this policy." -The workaround is to maintain two mirror lists: an allowlist and a blocklist seeded with the same addresses. Every membership change must land on both lists. Any lag rejects valid accounts or admits invalid ones. Composite policies do not remove that second list. +Without invert, the only way to get the opposite outcome is to create a second policy of the other type and copy the same addresses into it: an allowlist mirrored as a blocklist, or the reverse. Every membership change must then land on both policies. If one update lags, valid accounts are rejected or invalid ones are admitted. A composite that needs "NOT A" still has to point at that second, mirrored policy. It cannot reuse A. -The goal is to let one membership set be evaluated as include or exclude, so issuers never maintain two policies for the same address group. +The goal is to let one membership set be evaluated as include or exclude, so issuers never maintain two or more policies for the same address group. A composite can invert one child or several: "A AND NOT B", or "NOT A AND NOT B". ## Background @@ -45,9 +45,6 @@ The structure of a policy ID is: - Bits `[0:55]` hold a unique counter value. The type is not stored in a slot. - Bits `[56:63]` are reserved for `PolicyType`. Only four types are used today (`0–3`: `BLOCKLIST`, `ALLOWLIST`, `UNION`, `INTERSECT`), occupying bits `56–57`. Bits `58–63` are unused. -Built-in sentinels are `ALWAYS_ALLOW` (id 0) and `ALWAYS_BLOCK` (id 1). The counter starts at 2. - - ## Specs ### Interface Changes