Skip to content

Sync IB20 + IPolicyRegistry interfaces with finalized PRD#14

Merged
amiecorso merged 3 commits into
mainfrom
amie/interfaces-prd-sync
May 19, 2026
Merged

Sync IB20 + IPolicyRegistry interfaces with finalized PRD#14
amiecorso merged 3 commits into
mainfrom
amie/interfaces-prd-sync

Conversation

@amiecorso
Copy link
Copy Markdown
Collaborator

Summary

Brings IPolicyRegistry, IB20, and IB20Security into spec with the
finalized PRD decisions in
PRD: Native ERC20 ("B20").
Two atomic commits, build clean throughout. Interface changes only — no
implementation in this PR.

Supersedes the direction in #11 (compound policies are gone) and #7
(implementation work depends on the registry shape settling here first).
Some content overlaps with #13 but goes further: this PR adds the
2-step admin transfer + renounce path on the registry side, and on the
token side adopts the PRD's generic 5-slot policy mapping instead of
explicit transferPolicyId / mintPolicyId / redeemPolicyId fields.

Commits

1. Overhaul IPolicyRegistry to PRD spec (4f63440)

Type system:

  • WHITELIST / BLACKLISTALLOWLIST / BLOCKLIST
  • COMPOUND removed entirely. Per-role asymmetric authorization
    moves to the token layer (see commit 2); the registry stops at
    flat membership checks.

Authorization:

  • isAuthorizedSender / isAuthorizedRecipient /
    isAuthorizedMintRecipient collapsed into a single
    isAuthorized(policyId, account). With no on-registry composition,
    role distinctions have no meaning here.

Membership updates (batch only):

  • modifyPolicyWhitelist / modifyPolicyBlacklist
    updateAllowlist(policyId, bool, address[]) /
    updateBlocklist(policyId, bool, address[]). Single-account update
    is a batch of one. One event per batch.

Admin model (2-step + renounce):

  • setPolicyAdmin (single-step) → stageUpdateAdmin +
    finalizeUpdateAdmin. Two-step defends against typos and key
    compromise: the staged candidate must actively claim the role.
  • New renounceAdmin (single-step) permanently freezes the policy's
    member set by clearing the admin slot. After renunciation the
    policy still answers isAuthorized but cannot be mutated.
  • New pendingPolicyAdmin(policyId) view exposes the in-flight state.

Read surface:

  • policyData() split into policyType() + policyAdmin() +
    new pendingPolicyAdmin().
  • policyIdCounternextPolicyId (clearer naming).

Future-deferred (documented inline, not in this PR):

  • Union / intersect policies. Path: append enum values
    (UNION_ALLOWLIST, INTERSECT_ALLOWLIST, blocklist counterparts)
    • sibling createUnionPolicy / createIntersectPolicy creators.
      Backward-compatible since enum extension preserves existing values.

2. Refactor IB20 + IB20Security per PRD (4cda0a3)

IB20 (default token):

  • New BURN_BLOCKED_ROLE + burnBlocked(from, amount) for
    sanctions seizure. Requires the target to be NOT authorized under
    the active TRANSFER_SENDER policy (otherwise reverts with
    AccountNotBlocked(from)). Emits standard Transfer plus a
    distinct BurnedBlocked(caller, from, amount) event so indexers
    can distinguish compliance seizure from regular burn. Tokens
    following "freeze, never seize" simply never grant the role.

  • Replace explicit transferPolicyId / changeTransferPolicyId with
    the PRD's generic policy mapping: policyId(bytes32) +
    updatePolicy(bytes32, uint64). Five standard policy-type
    constants (keccak256-hashed names per OZ AccessControl convention):

    Constant Checked against
    TRANSFER_SENDER from on every transfer
    TRANSFER_RECEIVER to on every transfer
    TRANSFER_EXECUTOR msg.sender on transferFrom (when distinct from from)
    MINT_RECEIVER to on every mint
    REDEEMER_SENDER msg.sender on redeem (used by variants that ship redeem)

    Asymmetric per-role configuration is expressed by pointing
    different slots at different policies (e.g. sanctions BLOCKLIST on
    TRANSFER_SENDER, unrestricted always-allow on MINT_RECEIVER).
    All slots default to ID 0 (always-reject): newly minted tokens
    cannot move balance until admin configures their compliance regime.

  • Remove redeem surface (relocated to IB20Security). The
    REDEEMER_SENDER policy-type constant stays on IB20 so all B-20
    tokens share a common policy-type vocabulary.

  • TransferPolicyUpdated event replaced by generic
    PolicyUpdated(bytes32 indexed policyType, uint64 oldPolicyId, uint64 newPolicyId).

  • PolicyForbids error now carries both policyType (which slot
    failed) and policyId (which registry entry it pointed at).

IB20Security:

  • Hosts the full redeem surface (redeem, redeemWithMemo,
    minimumRedeemable, setMinimumRedeemable, Redeemed event,
    MinimumRedeemableUpdated event, MinimumRedeemableNotMet error).
    redeem checks the inherited REDEEMER_SENDER policy slot — no
    separate redeemPolicyId field; it's just a normal slot in the
    generic mapping.

  • Brokerage-allowlist story documented as "point REDEEMER_SENDER
    at an ALLOWLIST policy admin'd by Coinbase, which adds addresses
    as users complete KYC + brokerage account connection."

  • Operational guidance refreshed: security issuers typically do not
    grant MINT_ROLE (use create / adminMint instead) and do not
    grant BURN_ROLE (holders use redeem; admins use adminBurn).

Design calls worth a second look

Two judgment calls beyond the literal PRD text:

  1. BurnedBlocked(caller, from, amount) event added even though
    the PRD doesn't explicitly require one. Parity with Redeemed
    (both are burn variants that mean something specific to compliance
    or settlement). Indexers need a way to distinguish compliance
    seizure from regular burn without inspecting role membership
    offchain. Open to dropping if you'd rather stay strictly
    PRD-minimal.

  2. AccountNotBlocked(address) error for the case where
    burnBlocked is called against an authorized address. PRD says
    "Requires !isAuthorized(transferSenderPolicy, sender)" — I
    interpreted this as "must revert if the target is authorized."
    Could alternatively reuse PolicyForbids(TRANSFER_SENDER, policyId) but that's semantically backwards (the policy isn't
    forbidding anything; the operation requires the policy to forbid).
    New dedicated error is clearer.

Out of scope

  • Implementation work (separate PR once interfaces are settled).
  • IB20Stablecoin (no PRD changes affect it).
  • Capabilities.sol (may want a follow-up pass to align bits with
    the new generic policy system and BURN_BLOCKED_ROLE; not in this
    PR).
  • Tests.

amiecorso added 3 commits May 18, 2026 14:26
Major simplification of the policy registry interface per the finalized
PRD decisions:

Type system:
- WHITELIST/BLACKLIST -> ALLOWLIST/BLOCKLIST (terminology)
- COMPOUND removed entirely. Per-role asymmetric authorization moves
  to the token layer (multiple policy IDs per token, one per role
  slot); the registry stops at flat membership checks.

Authorization:
- isAuthorizedSender/Recipient/MintRecipient -> single isAuthorized.
  With no on-registry composition, role distinctions have no meaning
  here; tokens consult the registry per-role using different policy IDs.

Membership updates (batch only):
- modifyPolicyWhitelist/Blacklist -> updateAllowlist/Blocklist taking
  address[] of accounts plus a single bool. Single-account update is
  expressed as a batch of one. Emits a single event per batch.

Admin model (2-step + renounce):
- setPolicyAdmin (single-step) -> stageUpdateAdmin + finalizeUpdateAdmin.
  Two-step transfer defends against typos and key compromise: the
  staged candidate must actively claim the role.
- New renounceAdmin (single-step) permanently freezes the policy's
  member set by clearing the admin slot. After renunciation the
  policy still answers isAuthorized but cannot be mutated.
- Pending admin lives in its own mapping; pendingPolicyAdmin view
  exposes the in-flight state.

Read surface:
- policyData() (combined) -> policyType() + policyAdmin() (separate).
- New pendingPolicyAdmin() view.
- policyIdCounter -> nextPolicyId (clearer naming for 'the next ID
  that will be assigned').

Dropped from interface entirely (no longer relevant):
- PolicyData struct, CompoundPolicyData struct
- createCompoundPolicy, compoundPolicyData
- PolicyNotSimple error, CompoundPolicyCreated event
- All compound natspec

Documented as future-deferred (not in v1):
- Union/intersect policies (path: append PolicyType enum values + add
  createUnionPolicy/createIntersectPolicy creators in a future hardfork;
  backward-compatible since enum extension preserves existing values).
IB20 (default token):
- Add BURN_BLOCKED_ROLE + burnBlocked(from, amount) for sanctions
  seizure. Function requires the target to be NOT authorized under the
  active TRANSFER_SENDER policy (i.e. on the sender-side blocklist);
  rejects with AccountNotBlocked(from) otherwise. Emits Transfer plus
  a distinct BurnedBlocked(caller, from, amount) event so indexers can
  distinguish compliance seizure from regular burn. Tokens following
  'freeze, never seize' simply never grant the role.

- Replace explicit transferPolicyId / changeTransferPolicyId with a
  generic policy system: policyId(bytes32) + updatePolicy(bytes32, uint64).
  Five standard policy-type constants (keccak256-hashed names per OZ
  AccessControl convention):
    TRANSFER_SENDER   — checked against from on every transfer
    TRANSFER_RECEIVER — checked against to on every transfer
    TRANSFER_EXECUTOR — checked against msg.sender on transferFrom
                         when distinct from from
    MINT_RECEIVER     — checked against to on every mint
    REDEEMER_SENDER   — checked against msg.sender on redeem (used by
                         variants that ship redeem, e.g. IB20Security)
  Asymmetric per-role configuration is now token-side (multiple policy
  IDs, one per slot) rather than registry-side (compound policies).
  All slots default to ID 0 (always-reject); tokens cannot move balance
  until admin configures their compliance regime.

- Remove redeem surface (redeem, redeemWithMemo, minimumRedeemable,
  setMinimumRedeemable, Redeemed event, MinimumRedeemableUpdated
  event, MinimumRedeemableNotMet error) — relocated to IB20Security
  where it belongs semantically. The REDEEMER_SENDER policy-type
  constant stays on IB20 so all B-20 tokens share a common policy-type
  vocabulary.

- Replace TransferPolicyUpdated event with generic
  PolicyUpdated(bytes32 policyType, uint64 oldPolicyId, uint64 newPolicyId).

- Update transfer / transferFrom / mint natspec to describe per-slot
  policy checks (TRANSFER_SENDER on from, TRANSFER_RECEIVER on to,
  TRANSFER_EXECUTOR on msg.sender when distinct from from,
  MINT_RECEIVER on mint to).

- Refine PolicyForbids error to carry both the policy type (which slot)
  and the policy ID (which registry entry).

IB20Security:
- Host the full redeem surface (redeem, redeemWithMemo, minimumRedeemable,
  setMinimumRedeemable, Redeemed event, MinimumRedeemableUpdated event,
  MinimumRedeemableNotMet error). Redeem now uses the inherited
  REDEEMER_SENDER policy slot from IB20's generic policy system
  (rather than the compound-policy redeemer slot we previously
  documented). Tokens that do not offer redemption point that slot at
  policy ID 0 (always-reject).

- Refresh natspec to reflect the inherited generic policy system: the
  brokerage allowlist is just the policy at REDEEMER_SENDER, configured
  by the admin via updatePolicy. No separate redeemPolicyId field on
  the security token; it is a normal policy slot in the generic mapping.

- Document the role separation more explicitly: 'security issuers
  typically do not grant MINT_ROLE; create / adminMint are the
  canonical issuance paths' and analogous notes for BURN_ROLE.
REDEEMER_SENDER is only consulted by the redeem path, which lives on
IB20Security. Exposing it on the base IB20 surface implied all B-20
tokens share that vocabulary, but in practice nothing on Default ever
references the slot. Move the constant to where the function that uses
it lives.

The underlying policyId(bytes32) mapping on IB20 still accepts any
bytes32 key, so this is a pure interface relocation: no change to the
generic policy storage shape, no impact on how the registry is called,
no breaking change for callers that read the slot via the inherited
generic accessor.
@amiecorso amiecorso marked this pull request as ready for review May 19, 2026 00:02
@amiecorso amiecorso merged commit bb9bbcd into main May 19, 2026
1 check passed
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.

1 participant