Skip to content

feat(BOP-491): add SEIZE_RECEIVER_POLICY seize destination gate - #188

Merged
stephancill merged 3 commits into
mainfrom
stephancilliers/bop-491-seize-receiver-policy
Aug 3, 2026
Merged

feat(BOP-491): add SEIZE_RECEIVER_POLICY seize destination gate#188
stephancill merged 3 commits into
mainfrom
stephancilliers/bop-491-seize-receiver-policy

Conversation

@stephancill

Copy link
Copy Markdown
Contributor

What

Adds a receiver-side seize policy, SEIZE_RECEIVER_POLICY, mirroring MINT_RECEIVER_POLICY exactly. seizeWithMemo now gates the destination to under this policy, in addition to the existing SEIZE_HOLDER_POLICY check on from.

An unset slot reads as ALWAYS_ALLOW (0), so an unconfigured token can still seize to any destination — a treasury need not be allowlisted, preserving the merged default. Issuers who configure it can constrain where seized funds land, derisking a runaway SEIZE_ROLE (symmetric to how MINT_RECEIVER_POLICY constrains mint destinations).

This is a scoped reversal of the original "seize enforces no policy on to" decision — to is now policy-gated but defaults open. Cobalt (V2) surface; base-std is the source of truth for the base/base mirror (BOP-492).

The new receiver id packs into the same seize slot (slot 14) as the seize-holder id — lane 1, bits 64..127 — so no new slot and no offset drift.

Changes

  • B20Constants / IB20: SEIZE_RECEIVER_POLICY constant + getter; seizeWithMemo natspec updated for the new gate/revert.
  • MockB20: _requireSeizeReceiver(to) enforced after _requireSeizable(from); _readPolicyId/_writePolicyId arms.
  • MockB20Storage: SeizePolicyIds.receiver lane + seizeReceiverPolicyId extractor and updated packSeizePolicyIds.

Guard order

SEIZE pause -> SEIZE_ROLE -> to != 0 -> AccountNotSeizable(from) -> PolicyForbids(SEIZE_RECEIVER_POLICY, to) -> InsufficientBalance.

Testing

forge test — 719 passed, 4 skipped (fork). New/updated:

  • seizeWithMemo.t.sol: receiver forbids revert, unset-allows-any default, configured-allow success.
  • seizeWithMemo_revertOrder.t.sol: blocked_beats_receiver, receiver_beats_balance.
  • B20FullLayout.t.sol + MockB20SlotHelpers.t.sol: slot-14 lane-1 pin + codec roundtrip.
  • updatePolicy.t.sol + B20Test: new scope routed through the supported-type set.
  • forge fmt --check clean.

Ticket: https://linear.app/coinbase/issue/BOP-491

Adds a receiver-side seize policy mirroring MINT_RECEIVER_POLICY: seizeWithMemo
now gates the destination "to" under SEIZE_RECEIVER_POLICY (unset = ALWAYS_ALLOW,
so a treasury need not be allowlisted by default). Lets issuers constrain where
seized funds may land, derisking a runaway SEIZE_ROLE. Cobalt (V2) surface.

- B20Constants + IB20: SEIZE_RECEIVER_POLICY constant and getter; seize natspec.
- MockB20: _requireSeizeReceiver gate after the seizable check; read/write arms.
- MockB20Storage: pack the receiver id as lane 1 of the seize slot (slot 14,
  bits 64..127); add codec + extractor.
- Tests: receiver gate unit + revert-order (blocked>receiver, receiver>balance),
  layout pin, slot-helper roundtrip, updatePolicy scope.

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@linear

linear Bot commented Aug 3, 2026

Copy link
Copy Markdown

BOP-491

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Interface Coverage

✅ All interface functions have test coverage.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

📊 Forge Coverage (src/lib/)

🟡 ≥95% across all metrics — some metrics below 99%.

File Lines Stmts Branches Funcs
🔴 B20FactoryLib.sol 95.40% 96.00% 100.00% 90.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.54% 97.67% 100.00%
🟢 MockPolicyRegistryStorage.sol 100.00% 100.00% 100.00% 100.00%
Total 96.79% 97.29% 98.14% 96.49%

Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).

Comment thread src/interfaces/IB20.sol Outdated
Comment on lines +273 to +276
/// @dev Mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot reads
/// as `0` (always-allow), so seize may send anywhere until an issuer configures the slot — a
/// treasury need not be allowlisted by default. Setting it lets an issuer constrain where seized
/// funds may land, derisking a runaway `SEIZE_ROLE`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// @dev Mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot reads
/// as `0` (always-allow), so seize may send anywhere until an issuer configures the slot — a
/// treasury need not be allowlisted by default. Setting it lets an issuer constrain where seized
/// funds may land, derisking a runaway `SEIZE_ROLE`.
/// @dev Mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot reads
/// as `0` (always-allow), so seize may send anywhere until an issuer configures the slot.

Comment thread test/lib/mocks/MockB20.sol Outdated
Comment on lines +345 to +349
// receiver policy); every skip here is explicit.
// non-zero (otherwise this would be a burn), and — unlike a normal
// transfer — no sender/receiver/executor transfer policy is consulted,
// no allowance is spent, and `from` is not zero-checked (consistent
// with the burn-blocked family; a zero/empty `from` fails the seizable
// or balance check anyway). The membership checks are that `from` is
// blocked under SEIZE_HOLDER_POLICY and `to` is authorized under
// SEIZE_RECEIVER_POLICY (mirroring MINT_RECEIVER_POLICY; an unset slot
// is always-allow, so a treasury need not be allowlisted by default).
// Deliberately does NOT reuse the factory-bootstrap privileged path
// (which would silently skip the receiver policy); every skip here is
// explicit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should all be natspec not inline.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ Fork tests: 20 failed, 699 passed

These failures indicate divergences where base/base needs to catch up to the base-std spec. This check is advisory and does not block merging.

Failing tests
  • test_policyId_success_reflectsUpdatePolicy(uint8,uint64): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0xa21d943e000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000002aa714 args=[10, 2795284 [2.795e6]]
  • test_policyId_success_zeroByDefault(uint8): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0x11a5c8f40000000000000000000000000000000000000000000000000000000000000064 args=[100]
  • test_seizeWithMemo_revertOrder_blocked_beats_receiver(address,address): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0x95d54d9400000000000000000000000000000000000000000000000000000000000009b50000000000000000000000000000000000000000000000000000000000000629 args=[0x00000000000000000000000000000000000009b5, 0x0000000000000000000000000000000000000629]
  • test_seizeWithMemo_revertOrder_receiver_beats_balance(address,address): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0xc375ac63000000000000000000000000b3b7c34a1b34241602571edc511a5911fd4847f0000000000000000000000000bc25691789f022577254b98577e542b1d639eabf args=[0xb3b7c34a1B34241602571eDC511A5911FD4847F0, 0xBc25691789f022577254B98577e542b1d639eAbF]
  • test_seizeWithMemo_revert_receiverPolicyForbids(address,address,uint256): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0x05a3631f00000000000000000000000006c51e4d6607c32dbafad4a36bffbea96a19f3f30000000000000000000000002b7eaa4a33d12ecfd5d9f8a2179bd9777dd451c200000000000000000000000000000000000000000000000000000000037701c1 args=[0x06C51e4D6607c32dBAFAD4a36BFFBEA96A19f3F3, 0x2b7eAa4a33D12ECFd5D9F8a2179bd9777dD451C2, 58130881 [5.813e7]]
  • test_seizeWithMemo_success_configuredReceiverPolicyAllows(address,address,uint256): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0xd1038b0d0000000000000000000000000000000000000000000000000000000000000bdd000000000000000000000000000000000000000000000000000000000000044d00000000000000000000000000000000000000000000000000000000f10e8e44 args=[0x0000000000000000000000000000000000000Bdd, 0x000000000000000000000000000000000000044d, 4044262980 [4.044e9]]
  • test_updatePolicy_success_builtinReject(uint8): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0x56f5b9080000000000000000000000000000000000000000000000000000000000000082 args=[130]
  • test_updatePolicy_success_writesSlot(uint8,uint64): UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d); counterexample: calldata=0x9746e8e10000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000019baa21851fa491 args=[40, 115873276372165777 [1.158e17]]
    [FAIL: UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d)] test_b20Layout_success_populatedSnapshotMatchesAllSlots() (gas: 643689)
    [FAIL: UnsupportedPolicyType(0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d)] test_seizePolicyIdsSlot_success_decodesReceiverLane() (gas: 12548)

… getter doc

- Move the seizeWithMemo admin-seize explanation from an inline // block into
  /// natspec on the function (stevieraykatz).
- Trim the SEIZE_RECEIVER_POLICY() getter natspec to match SEIZE_HOLDER_POLICY's
  concise form (stevieraykatz suggestion).

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
emit BurnedBlocked(msg.sender, from, amount);
}

/// @dev Admin seize: reassign a blocked account's balance. `to` must be non-zero (otherwise this

@stevieraykatz stevieraykatz Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @notice, @params and @returns.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm i'm seeing that we weren't consistent with our natspecs in this contract generally. i'd be ok deferring this for a separate doc cleanup pass

emit BurnedBlocked(msg.sender, from, amount);
}

/// @dev Admin seize: reassign a blocked account's balance. `to` must be non-zero (otherwise this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm i'm seeing that we weren't consistent with our natspecs in this contract generally. i'd be ok deferring this for a separate doc cleanup pass

…return)

Addresses review (stevieraykatz): the mock override's natspec was @dev-only;
add @notice, per-argument @param, and @return.

Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
@stephancill
stephancill merged commit 8f88bf3 into main Aug 3, 2026
10 checks passed
@stephancill
stephancill deleted the stephancilliers/bop-491-seize-receiver-policy branch August 3, 2026 20:15
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.

3 participants