From 683daf03e28dea7406be694d7dea24987bbce729 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Thu, 10 Sep 2026 10:11:28 -0400 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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)); } // ============================================================