feat(BOP-440): add transferFromSeizableWithMemo and burnBlockedWithMemo seize surface - #177
Conversation
Interface Coverage✅ All interface functions have test coverage. |
📊 Forge Coverage (
|
| 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% | 100.00% | 100.00% | 100.00% |
| 🟢 MockPolicyRegistryStorage.sol | 100.00% | 100.00% | 100.00% | 100.00% |
| Total | 96.72% | 97.32% | 98.67% | 96.44% |
Full report: download artifact. To browse locally: make coverage (runs forge coverage + genhtml + opens the HTML report).
| /// bits 192..255 : reserved (implicit, no field declared) | ||
| /// bits 192..255 : seizable (`SEIZABLE_POLICY`) |
There was a problem hiding this comment.
does adding seizable here use up the reserved bits? no more reserved bits after adding it?
There was a problem hiding this comment.
AI: Yes — this fills the slot. TransferPolicyIds is one 256-bit slot = 4 × uint64 lanes; sender/receiver/executor used lanes 0–2, and seizable takes lane 3 (bits 192..255), which was the last reserved lane. No reserved bits remain in this slot. A future transfer-side policy would need a new appended slot. I picked this lane specifically because it avoids shifting any existing field offset on the already-shipped layout.
There was a problem hiding this comment.
These bits were reserved for exactly this kind of purpose. Good to flag that we won't have any headroom after this lands, but it's correct to consume them like this.
There was a problem hiding this comment.
@rayyan224 another candidate for automated agent fuzzing would be any changes to storage layout (including appending)
There was a problem hiding this comment.
I'm a bit concerned to take the last space here. Main thing on my mind is if we have some other idea that definitely needs to be on the hotpath transfer. The seizing operations are cold-path and we won't be using any of the other policies here in its execution, so I'm not seeing as clear of a benefit tbh. I'd opt to make a new slot for seize policy entirely by default.
We also recognize seize as an operation applies to burning too. Placing this policy in the "transfer policies" group is not consistent with our separation of "mint policies" as a distinct group by operation.
There was a problem hiding this comment.
aligned with moving to a dedicated slot
|
ilikesymmetry
left a comment
There was a problem hiding this comment.
Seeking alignment on policy name, storage location, and revisiting behavior of only supporting specific policy scopes.
| /// `burnBlocked`, and `burnBlockedWithMemo`). A distinct per-operation group — seize is | ||
| /// cold-path and applies to burns too, so it does not belong in the transfer slot. |
There was a problem hiding this comment.
remove the "A distinct per-operation group..." note
|
@ilikesymmetry implemented the following Namingpolicy: Policy slotNew slot Policy scope support behaviorConcern to be investigated separately |
| /// @notice Policy slot consulted against `from` by the seize operations (`transferFromSeizableWithMemo`, | ||
| /// `burnBlockedWithMemo`). A `from` is seizable only when it is NOT authorized by this policy | ||
| /// (mirroring the `burnBlocked` "blocked" semantics). An unset slot reads as `0` (always-allow), | ||
| /// which makes no account seizable until an issuer configures the slot. | ||
| /// @return Policy scope constant. |
There was a problem hiding this comment.
Should probably split into a notice and dev block to move the verbosity into the appropriate field.
There was a problem hiding this comment.
Also missing burnBlocked in @noice enumeration
| // ---------- Seize policies (PACKED, per-operation) ---------- | ||
| // Appended after `initialized` so no pre-existing field offset shifts | ||
| // on already-deployed tokens. Distinct per-operation group (seize is | ||
| // cold-path and spans transfer + burn), mirroring how mint policies are | ||
| // grouped separately from transfer policies. | ||
| SeizePolicyIds seizePolicyIds; |
There was a problem hiding this comment.
| // ---------- Seize policies (PACKED, per-operation) ---------- | |
| // Appended after `initialized` so no pre-existing field offset shifts | |
| // on already-deployed tokens. Distinct per-operation group (seize is | |
| // cold-path and spans transfer + burn), mirroring how mint policies are | |
| // grouped separately from transfer policies. | |
| SeizePolicyIds seizePolicyIds; | |
| // ---------- Seize policies (PACKED, per-operation) ---------- | |
| // Appended after `initialized` so no pre-existing field offset shifts | |
| // on already-deployed tokens. | |
| SeizePolicyIds seizePolicyIds; |
…o seize surface Adds the additive seize operation class to the base-std reference and ABI (source of truth for fork tests), per the BOP-439 PPS: - transferFromBlockedWithMemo(from, to, amount, memo): admin seize that reassigns a blocked account's balance in one call. Skips allowance and the sender/receiver/executor transfer policies; the only membership check is that `from` is blocked under the new SEIZABLE_POLICY. `to` is not policy-checked (admin op). Emits Transfer + TransferredFromBlocked + Memo. - burnBlockedWithMemo(from, amount, memo): memo variant of burn-based seizure, gated on SEIZABLE_POLICY and the new SEIZE pause vector. - New SEIZABLE_POLICY scope (packed into the transfer slot's reserved lane 3, no storage-offset disruption), TRANSFER_FROM_BLOCKED_ROLE, and SEIZE PausableFeature. Reference impl uses an explicit guard sequence and a shared _moveBalance primitive; it does NOT reuse the factory-bootstrap privileged path. Out of scope: repointing the legacy burnBlocked to SEIZABLE_POLICY (BOP-471). Tests: 25 new (happy + revert-order for both functions); layout-pin, slot codec, and removals regression tests updated for the new lane/feature. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
…TURES_PAUSED Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
…emo natspec Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Folds the legacy burnBlocked into the seize operation class: its blocked check now uses SEIZABLE_POLICY (was TRANSFER_SENDER_POLICY) and it gates on the SEIZE pause vector (was BURN), matching burnBlockedWithMemo and transferFromBlockedWithMemo. Breaking change, acceptable because burnBlocked is not yet in use. Simplifies the AccountNotBlocked error to a single seizable-policy statement. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
…k on seize - Reorder seize emissions so Memo immediately follows Transfer (per the IB20 Memo invariant): Transfer -> Memo -> BurnedBlocked/TransferredFromBlocked. - transferFromBlockedWithMemo: drop the from==address(0) check (keep to!=0), matching the burn-blocked family; a zero/empty from fails the seizable or balance check anyway. Removes the now-obsolete InvalidSender test. Addresses review from @ilikesymmetry. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
… SEIZABLE_ACCOUNT_POLICY Renames the transfer-based seize entrypoint and its policy/role/event to the 'seizable' vocabulary (the seize precondition is membership in the seizable scope, not an enforced transfer block): - SEIZABLE_POLICY -> SEIZABLE_ACCOUNT_POLICY - transferFromBlockedWithMemo -> transferFromSeizableWithMemo - TRANSFER_FROM_BLOCKED_ROLE -> TRANSFER_FROM_SEIZABLE_ROLE - TransferredFromBlocked -> TransferredFromSeizable Burn-blocked family (burnBlocked/burnBlockedWithMemo/BURN_BLOCKED_ROLE/ BurnedBlocked) keeps its established naming for selector continuity. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
…e slot Per review, seize is a distinct cold-path operation that spans transfer + burn, so its policy id no longer shares the transfer slot's reserved lane. Adds a dedicated SeizePolicyIds slot appended after `initialized` (offset 15) so no existing field offset shifts on already-deployed tokens; the transfer slot's lane 3 is reserved again. Updates codecs, _read/_writePolicyId, and the layout-pin + slot-helper tests. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
…t comment Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
a6288ac to
53b1f56
Compare
The `initialized` flag is a mock-only field: the native precompile checks factory-init via deployed marker bytecode, not a storage slot, so base/base's B20CoreStorage has no such field. Appending seizePolicyIds after it forced the Rust mirror to reserve an unused hole at offset 14 to keep seize at 15. Keep `initialized` trailing and place seizePolicyIds before it, so seize sits at offset 14 on both sides and the Rust impl omits the trailing mock-only slot for free (no filler). Non-backward-compatible mock layout change, which is fine. Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
|
@ilikesymmetry going to merge - happy to follow up with changes |
Summary
Adds the seize operation class to the base-std reference + ABI (the source of truth the fork harness cross-validates against base/base), per the BOP-439 PPS. Implements BOP-440 and, per review, also folds in the BOP-471
burnBlockedrepoint (base-std portion).New surface:
transferFromSeizableWithMemo(address from, address to, uint256 amount, bytes32 memo)— admin seize that reassigns a blocked account's balance in one call. Skips allowance and the transfer policies; the only membership check is thatfromis blocked under the newSEIZABLE_ACCOUNT_POLICY.tois not policy-checked (admin op). EmitsTransfer+Memo+TransferredFromSeizable.burnBlockedWithMemo(address from, uint256 amount, bytes32 memo)— memo variant of burn-based seizure.SEIZABLE_ACCOUNT_POLICYscope,TRANSFER_FROM_SEIZABLE_ROLE, andSEIZEPausableFeature.Seize operation class (all three gate uniformly on
SEIZABLE_ACCOUNT_POLICY+ theSEIZEpause vector):burnBlockedrepointed fromTRANSFER_SENDER_POLICY/BURNpause toSEIZABLE_ACCOUNT_POLICY/SEIZEpause. This is a breaking change, acceptable becauseburnBlockedis not yet in use; intent is a single uniform seize gate rather than a transient split.Design notes
SeizePolicyIdsslot placed before the mock-onlyinitializedflag (which stays trailing at offset 15). This matters for the Rust mirror: the native precompile checks factory-init via deployed marker bytecode and stores noinitializedfield, so it omits that trailing mock-only slot and landsseizable_policy_idat offset 14 — with no filler on either side. Offsets 0–13 are untouched, so already-deployed tokens are undisturbed. Seize is a distinct per-operation group (cold-path, spanning transfer + burn), mirroring how mint policies are grouped separately from transfer policies.SEIZEpause → role →SEIZABLE_ACCOUNT_POLICYblock check) and moves balance via a shared_moveBalanceprimitive extracted fromtransfer_inner; it does not reuse the factory-bootstrapprivilegedpath.Memois emitted immediately afterTransfer(beforeTransferredFromSeizable/BurnedBlocked), per the IB20Memoinvariant.Testing
forge test(non-fork): all pass (715+),forge fmt --checkclean.burnBlockedtests updated to the seize gating; new happy-path + revert-order suites for both new functions.B20FullLayout(seize at slot 14,initializedat slot 15) andMockB20SlotHelpersround-trip tests;B20Removalsregression updated for the newSEIZEfeature (ALL_FEATURES_PAUSED7 → 15, enum boundary index 4).