Skip to content

Add PolicyRegistry implementation#7

Draft
eric-ships wants to merge 16 commits into
mainfrom
impl/policy-registry
Draft

Add PolicyRegistry implementation#7
eric-ships wants to merge 16 commits into
mainfrom
impl/policy-registry

Conversation

@eric-ships
Copy link
Copy Markdown
Collaborator

@eric-ships eric-ships commented May 16, 2026

Motivation

Implements PolicyRegistry.sol (TIP-403 + TIP-1015 parity) with a fourth compound slot for redeem policy specification. Bit-packing logic is isolated in PolicySlot.sol.

Interface changes

  • Added redeemerPolicyId as a fourth slot on CompoundPolicyData
  • Updated createCompoundPolicy and compoundPolicyData signatures
  • Added isAuthorizedRedeemer query
  • Added ALWAYS_REJECT and ALWAYS_ALLOW to the PolicyType enum for built-in IDs

Design decisions

Single-slot storage. Every policy fits in one uint256. Low 8 bits are always the type tag; remaining bits depend on type:

  • WHITELIST/BLACKLIST: [type(8b) | admin(160b)] — 88 bits spare
  • COMPOUND: [type(8b) | sender(62b) | recipient(62b) | mintRecipient(62b) | redeemer(62b)] — exactly 256 bits

Any authorization check costs at most 2 SLOADs: one for the compound slot, one for the constituent's member set.

62-bit constituent IDs. Four 64-bit IDs plus an 8-bit type tag would be 264 bits. Trimming 2 bits per ID makes it fit exactly. The interface keeps uint64 throughout; truncation requires ~4.6e18 policies, which is not realistic.

No recursion. Constituents are validated as non-COMPOUND at creation time. The evaluation graph is always one level deep — no cycle detection or depth cap needed.

Built-in IDs. IDs 0 (always-reject) and 1 (always-allow) are handled as constants with no storage access. ALWAYS_REJECT and ALWAYS_ALLOW are added to the PolicyType enum so policyData() returns a meaningful type for built-ins rather than a misleading placeholder.

Implements IPolicyRegistry with a single-slot storage layout optimized
for lookup efficiency. Every policy lookup (simple or compound) resolves
in at most 2 SLOADs.

Also adds a fourth slot to CompoundPolicyData for redeem policy
specification, with matching updates to the interface signatures and
a new isAuthorizedRedeemer query.
@eric-ships eric-ships marked this pull request as draft May 16, 2026 02:41
eric-ships and others added 15 commits May 15, 2026 22:57
PolicyNotSimple -> ConstituentIsCompound, _createSimple -> _createPolicy,
_requireSimpleConstituent -> _requireConstituent. Updates interface NatSpec
and comments throughout to use WHITELIST/BLACKLIST/COMPOUND directly.
policyData() now returns the correct type for built-in IDs rather than
the misleading WHITELIST placeholder.
Each compound constituent field now carries both the constituent policy ID
(61 bits) and a single type bit (0=whitelist/built-in, 1=blacklist) in 62
bits total. _checkRole on a compound policy now reads only:
  1. the compound slot itself
  2. the relevant constituent's member set
The constituent's own policy slot is no longer loaded on the hot path,
matching the 'exactly 2 SLOADs per check' claim that the previous
implementation overstated.
The compound-policy packing format reserves 61 bits per constituent ID
(2.3e18 IDs), but the public API exposes uint64 IDs. _nextPolicyId now
reverts with PolicyIdOverflow before issuing an ID that would silently
truncate when stored in a compound slot. This enforces the packing
invariant at the source: every ID returned from a create function is
guaranteed safe to round-trip through encodeField / decodeField, so
downstream code can rely on it without per-call defensive checks.

Practically unreachable today (counter starts at 2 and only ever
increments by 1), but the alternative is an unchecked truncation hazard
hidden behind a comment.
Replaces single-step setPolicyAdmin (typo bricks a sanctions list) with
the OZ Ownable2Step shape:
  - beginPolicyAdminTransfer(policyId, newAdmin) records a pending admin
  - acceptPolicyAdminTransfer(policyId)          callable by pending only
  - cancelPolicyAdminTransfer(policyId)          aborts the in-flight transfer
  - pendingPolicyAdmin(policyId) view            inspects current pending

Adds freezePolicy(policyId) as a one-way switch that locks the policy's
membership AND admin permanently:
  - subsequent modifyPolicy* calls revert with PolicyFrozen
  - subsequent admin transfers revert with PolicyFrozen
  - isPolicyFrozen(policyId) view exposes the state

Frozen flag lives in bit 168 of the policy slot (currently unused space
above the 160-bit admin). Compound policies have no admin and cannot be
frozen; calls revert with IncompatiblePolicyType.

Why a freeze flag instead of letting admin be set to address(0):
the existence sentinel _policyData[id] == 0 means 'never created'. A
WHITELIST policy (type byte = 0) with admin = address(0) would pack to
0 exactly, colliding with the sentinel. The frozen bit avoids that
collision and gives a cleaner one-way switch with a distinct event.

Pending admin lives in its own mapping rather than the packed slot so
the authorization hot path isn't affected; rotation is cold so the
extra accept-time SLOAD is fine.
Correctness fixes on top of policy-registry impl
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants