feat(policy): add NOT/invert policy semantics to reference - #222
feat(policy): add NOT/invert policy semantics to reference#222rayyan224 wants to merge 12 commits into
Conversation
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 <noreply@anthropic.com>
Interface Coverage✅ All interface functions have test coverage. |
📊 Forge Coverage (
|
| File | Lines | Stmts | Branches | Funcs |
|---|---|---|---|---|
| 🟡 B20FactoryLib.sol | 97.70% | 98.00% | 100.00% | 95.00% |
| 🔴 test/lib/ForceFeeder.sol | 0.00% | 0.00% | 100.00% | 0.00% |
| 🔴 test/lib/PrecompileProbe.sol | 0.00% | 0.00% | 0.00% | 0.00% |
| 🟢 MockActivationRegistry.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockActivationRegistryStorage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20Asset.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟡 MockB20Factory.sol | 98.96% | 99.10% | 100.00% | 100.00% |
| 🟢 MockB20Stablecoin.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟢 MockB20Storage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| 🟡 MockPolicyRegistry.sol | 100.00% | 99.58% | 97.78% | 100.00% |
| 🟢 MockPolicyRegistryStorage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| Total | 97.12% | 97.57% | 98.18% | 97.03% |
Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).
|
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <cursoragent@cursor.com>
Drop natspec that restates _basePolicyId and the fail-closed invert path; keep the invert toggle note on invertedPolicyId. Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Keep the invert-bit notice without restating getter and fail-closed behavior already covered by the views. Co-authored-by: Cursor <cursoragent@cursor.com>
Restore the original simple-child @dev and add that an inverted valid policy ID still counts as a composite child. Co-authored-by: Cursor <cursoragent@cursor.com>
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 <noreply@anthropic.com>
Register Denim as hardfork ordinal 03 in changelog/README.md and add the full spec for query-time policy inversion (bit 63 of a uint64 policy ID): invertedPolicyId, isAuthorized/getter behavior on inverted IDs, composite-child invert handling, and rejected alternatives. Update docs/concepts/policies.md with the corresponding "Inverting a policy" section (renumbers 2.3+ by one). Co-Authored-By: Claude <noreply@anthropic.com>
Cover the "invert multiple children" case explicitly (NOT A AND NOT B) and trim a redundant blank line / sentinel-ID aside. Co-Authored-By: Claude <noreply@anthropic.com>
| /// or malformed base is denied later, at `isAuthorized`. | ||
| /// | ||
| /// @param policyId Policy to invert. | ||
| /// |
There was a problem hiding this comment.
Add Nat spec to give the NAT spec that inversion twice results in the same
| /// @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 |
There was a problem hiding this comment.
Move those to each view functions.
There was a problem hiding this comment.
Motivation: feature level natspec should be as close to the feature code as possible
| /// `_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; |
There was a problem hiding this comment.
Move this to 399
| T -->|yes| OK["Accept child ID as stored, invert bit kept"] | ||
| ``` | ||
|
|
||
| #### State / gas |
There was a problem hiding this comment.
Make heading the same as Interfaces and seperate
What
Adds an invert (NOT) flag on the high bit of the
uint64policy ID so one membership set can be evaluated as include or exclude without maintaining a mirror list. When the bit (B20Constants.POLICY_INVERT_BIT, bit 63) is set,isAuthorizedresolves the base policy (id & ~POLICY_INVERT_BIT) and returns the opposite of its decision. The base's members are shared, never copied — updating the base updates the inverse.This is Option 2a (invert bit on the ID) with a base-existence check. No new functions or selectors are added; the invert bit reinterprets the existing ID argument.
Why fail-closed matters
An inverted ID over an unknown or malformed base returns
false, never allow-everyone. Without this guard, a garbage/typo'd ID with the bit set would authorize every account — a mint/transfer/seize bypass. The flip is only applied after the base resolves against a real policy.Changes
src/lib/B20Constants.sol—POLICY_INVERT_BIT(single source of truth) + a pureinvertPolicy()helper. Negation is pure bit math, so no registry selector is added (kept off the frozen ABI surface).src/interfaces/IPolicyRegistry.sol— NatSpec only (no signature changes): the ID layout + invert flag,isAuthorized's fail-closed rule, strip-to-base semantics on the read getters (policyExists(~id) == policyExists(id), etc.), and the composite create/update contract (a child may carry the flag for "A AND NOT X"; an inverted composite child is rejected to preserve the flat-tree invariant).test/lib/mocks/MockPolicyRegistry.sol— invert handling in_isAuthorized(fail-closed flip), strip-to-base in the getters, composite-child validation on the base. Non-inverted paths are byte-identical.test/unit/PolicyRegistry/isAuthorizedInvert.t.sol— new suite, fail-closed invariants first, then simple/built-in truth tables,INTERSECT[A, ~X], child validation, getter strip semantics, and the helper.Scope
base-std reference mock only; the Rust precompile is unchanged. Per AGENTS.md the mock must mirror the Rust impl slot-for-slot — the Rust side does not implement NOT yet, so live-precompile (
base-forge test) will diverge for the new inverted-ID cases until the precompile lands. Existing behavior stays in parity.Testing
forge buildclean;forge fmt --checkclean.forge test— 751 passed, 0 failed, 4 skipped (adds 16 invert cases).python3 script/check-coverage.py— all interface functions covered.