From 01fa0c87b2bba823349caf88e510aad01ca7646f Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 31 Jul 2026 11:46:49 -0400 Subject: [PATCH 1/5] refactor(BOP-440): rename seize surface; revert & drop burnBlocked changes Finalizes the seize surface naming and decouples burnBlocked, per the updated BOP-439 decisions (supersedes the #177 design): - Rename across interface + reference impl + tests: - transferFromSeizableWithMemo -> seizeWithMemo - TRANSFER_FROM_SEIZABLE_ROLE -> SEIZE_ROLE - SEIZABLE_ACCOUNT_POLICY -> SEIZE_HOLDER_POLICY - event TransferredFromSeizable -> Seized (role/policy keccak strings change accordingly.) - Drop burnBlockedWithMemo entirely (no burn-with-memo variant). - Revert burnBlocked to its pre-#177 behavior: blocked check on TRANSFER_SENDER_POLICY and the BURN pause vector (no longer part of the seize class). Behavior is otherwise unchanged. - Remove burnBlocked from the IB20 interface as a deprecation signal; the reference impl keeps the selector working for back-compat. Formal deprecation/removal is announced for the Denim hardfork. Retained burnBlocked reference tests call it via a concrete MockB20 cast. SEIZE pause vector now governs only seizeWithMemo. The dedicated seize storage slot (offset 14) is unchanged. All unit tests pass; fmt clean. Co-authored-by: OpenCode --- src/interfaces/IB20.sol | 69 ++++------- src/lib/B20Constants.sol | 4 +- test/lib/B20Test.sol | 4 +- test/lib/mocks/MockB20.sol | 55 +++++---- test/lib/mocks/MockB20Storage.sol | 9 +- test/regression/B20Removals.t.sol | 2 +- test/unit/B20/policy/updatePolicy.t.sol | 2 +- test/unit/B20/supply/burnBlocked.t.sol | 42 +++---- .../unit/B20/supply/burnBlockedWithMemo.t.sol | 107 ------------------ .../burnBlockedWithMemo_revertOrder.t.sol | 68 ----------- .../B20/supply/burnBlocked_revertOrder.t.sol | 42 +++---- ...ableWithMemo.t.sol => seizeWithMemo.t.sol} | 80 +++++-------- ....t.sol => seizeWithMemo_revertOrder.t.sol} | 44 ++++--- test/unit/storage/B20FullLayout.t.sol | 4 +- test/unit/storage/MockB20SlotHelpers.t.sol | 6 +- 15 files changed, 156 insertions(+), 382 deletions(-) delete mode 100644 test/unit/B20/supply/burnBlockedWithMemo.t.sol delete mode 100644 test/unit/B20/supply/burnBlockedWithMemo_revertOrder.t.sol rename test/unit/B20/supply/{transferFromSeizableWithMemo.t.sol => seizeWithMemo.t.sol} (61%) rename test/unit/B20/supply/{transferFromSeizableWithMemo_revertOrder.t.sol => seizeWithMemo_revertOrder.t.sol} (54%) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index f5e6a64..203c59d 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -15,8 +15,8 @@ interface IB20 { /// /// @param TRANSFER `transfer`, `transferFrom`, and memo'd variants. /// @param MINT `mint` and `mintWithMemo`. - /// @param BURN `burn` and `burnWithMemo`. - /// @param SEIZE `burnBlocked`, `burnBlockedWithMemo`, and `transferFromSeizableWithMemo` + /// @param BURN `burn`, `burnWithMemo`, and the deprecated `burnBlocked`. + /// @param SEIZE `seizeWithMemo`. enum PausableFeature { TRANSFER, MINT, @@ -105,8 +105,9 @@ interface IB20 { /// @notice `policyScope` is not a slot this token (or its variant) supports. error UnsupportedPolicyType(bytes32 policyScope); - /// @notice A seize operation (`burnBlocked`, `burnBlockedWithMemo`, or `transferFromSeizableWithMemo`) was - /// called against a `from` that is currently authorized under `SEIZABLE_ACCOUNT_POLICY` (i.e. not blocked). + /// @notice A seize operation (`seizeWithMemo`) was called against a `from` that is currently + /// authorized under `SEIZE_HOLDER_POLICY` (i.e. not eligible to be seized from). Also used by the + /// deprecated `burnBlocked`, which checks `TRANSFER_SENDER_POLICY`. error AccountNotBlocked(address account); /// @notice An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`. @@ -142,13 +143,14 @@ interface IB20 { /// immediately after the underlying `Transfer` event. `caller` is the `msg.sender` of the memo'd call. event Memo(address indexed caller, bytes32 indexed memo); - /// @notice Emitted by `burnBlocked` and `burnBlockedWithMemo` in addition to `Transfer(from, address(0), amount)`. + /// @notice Emitted by the deprecated `burnBlocked` in addition to `Transfer(from, address(0), amount)`. + /// `burnBlocked` is no longer part of this interface; the event is retained for the back-compat impl. event BurnedBlocked(address indexed caller, address indexed from, uint256 amount); - /// @notice Emitted by `transferFromSeizableWithMemo` in addition to `Transfer(from, to, amount)` (and the + /// @notice Emitted by `seizeWithMemo` in addition to `Transfer(from, to, amount)` (and the /// standard `Memo(caller, memo)`). Records a transfer-based seizure: `caller` is the `msg.sender` - /// (holder of `TRANSFER_FROM_SEIZABLE_ROLE`), `from` the seized account, `to` the destination. - event TransferredFromSeizable(address indexed caller, address indexed from, address indexed to, uint256 amount); + /// (holder of `SEIZE_ROLE`), `from` the seized account, `to` the destination. + event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); /// @notice Emitted when `account` is granted `role`. `sender` is the originating caller. event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); @@ -207,13 +209,14 @@ interface IB20 { /// @return Role constant. function BURN_ROLE() external view returns (bytes32); - /// @notice Required to call `burnBlocked` and `burnBlockedWithMemo`. + /// @notice Required to call the deprecated `burnBlocked` (no longer part of this interface; retained for + /// the back-compat impl, with formal removal announced for Denim). /// @return Role constant. function BURN_BLOCKED_ROLE() external view returns (bytes32); - /// @notice Required to call `transferFromSeizableWithMemo`. + /// @notice Required to call `seizeWithMemo`. /// @return Role constant. - function TRANSFER_FROM_SEIZABLE_ROLE() external view returns (bytes32); + function SEIZE_ROLE() external view returns (bytes32); /// @notice Required to call `pause`. /// @return Role constant. @@ -257,12 +260,11 @@ interface IB20 { /// @return Policy scope constant. function MINT_RECEIVER_POLICY() external view returns (bytes32); - /// @notice Policy slot consulted against `from` by the seize operations. - /// @dev Consulted by `transferFromSeizableWithMemo`, `burnBlocked`, and `burnBlockedWithMemo`. A `from` - /// is seizable only when it is NOT authorized by this policy. An unset slot reads as `0` + /// @notice Policy slot consulted against `from` by `seizeWithMemo`. + /// @dev A `from` is seizable only when it is NOT authorized by this policy. An unset slot reads as `0` /// (always-allow), so no account is seizable until an issuer configures the slot. /// @return Policy scope constant. - function SEIZABLE_ACCOUNT_POLICY() external view returns (bytes32); + function SEIZE_HOLDER_POLICY() external view returns (bytes32); /*////////////////////////////////////////////////////////////// ERC-20 @@ -427,42 +429,17 @@ interface IB20 { /// @param memo Off-chain memo payload. function burnWithMemo(uint256 amount, bytes32 memo) external; - /// @notice Destroys `amount` of `from`'s balance. Emits `Transfer(from, address(0), amount)` and - /// `BurnedBlocked(caller, from, amount)`. Part of the seize operation class. - /// - /// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused. - /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_BLOCKED_ROLE`. - /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`. - /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. - /// - /// @param from Account whose balance is being seized. - /// @param amount Amount to burn. - function burnBlocked(address from, uint256 amount) external; - - /// @notice Same as `burnBlocked`, plus emits `Memo(caller, memo)` immediately after the `Transfer` event - /// (i.e. before `BurnedBlocked`). A memo of `bytes32(0)` is permitted. - /// - /// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused. - /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_BLOCKED_ROLE`. - /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`. - /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. - /// - /// @param from Account whose balance is being seized. - /// @param amount Amount to burn. - /// @param memo Memo payload. - function burnBlockedWithMemo(address from, uint256 amount, bytes32 memo) external; - /// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. /// Emits, in order, `Transfer(from, to, amount)`, `Memo(caller, memo)`, and - /// `TransferredFromSeizable(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted. + /// `Seized(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted. /// /// @dev Admin operation: skips allowance and the transfer policies. The only membership check is that - /// `from` is blocked under `SEIZABLE_ACCOUNT_POLICY`. + /// `from` is blocked under `SEIZE_HOLDER_POLICY`. /// @dev `to` is not policy-checked; the destination need not be allowlisted. /// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused. - /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `TRANSFER_FROM_SEIZABLE_ROLE`. + /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SEIZE_ROLE`. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. - /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`. + /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. /// /// @param from Account whose balance is being seized. @@ -471,9 +448,7 @@ interface IB20 { /// @param memo Memo payload. /// /// @return Always `true` on success. - function transferFromSeizableWithMemo(address from, address to, uint256 amount, bytes32 memo) - external - returns (bool); + function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool); /*////////////////////////////////////////////////////////////// ROLES diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 55e27b2..92d0620 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -8,7 +8,7 @@ library B20Constants { bytes32 internal constant MINT_ROLE = keccak256("MINT_ROLE"); bytes32 internal constant BURN_ROLE = keccak256("BURN_ROLE"); bytes32 internal constant BURN_BLOCKED_ROLE = keccak256("BURN_BLOCKED_ROLE"); - bytes32 internal constant TRANSFER_FROM_SEIZABLE_ROLE = keccak256("TRANSFER_FROM_SEIZABLE_ROLE"); + bytes32 internal constant SEIZE_ROLE = keccak256("SEIZE_ROLE"); bytes32 internal constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); bytes32 internal constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE"); bytes32 internal constant METADATA_ROLE = keccak256("METADATA_ROLE"); @@ -18,7 +18,7 @@ library B20Constants { bytes32 internal constant TRANSFER_RECEIVER_POLICY = keccak256("TRANSFER_RECEIVER_POLICY"); bytes32 internal constant TRANSFER_EXECUTOR_POLICY = keccak256("TRANSFER_EXECUTOR_POLICY"); bytes32 internal constant MINT_RECEIVER_POLICY = keccak256("MINT_RECEIVER_POLICY"); - bytes32 internal constant SEIZABLE_ACCOUNT_POLICY = keccak256("SEIZABLE_ACCOUNT_POLICY"); + bytes32 internal constant SEIZE_HOLDER_POLICY = keccak256("SEIZE_HOLDER_POLICY"); /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | SEIZE); 15 = 0b1111. uint8 internal constant ALL_FEATURES_PAUSED = 15; diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index cc0aa00..39a6ce8 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -138,7 +138,7 @@ contract B20Test is B20FactoryTest { if (i == 0) return B20Constants.TRANSFER_SENDER_POLICY; if (i == 1) return B20Constants.TRANSFER_RECEIVER_POLICY; if (i == 2) return B20Constants.TRANSFER_EXECUTOR_POLICY; - if (i == 3) return B20Constants.SEIZABLE_ACCOUNT_POLICY; + if (i == 3) return B20Constants.SEIZE_HOLDER_POLICY; return B20Constants.MINT_RECEIVER_POLICY; } @@ -148,7 +148,7 @@ contract B20Test is B20FactoryTest { /// base-token policy types. function _isKnownPolicyType(bytes32 policyType) internal pure returns (bool) { return policyType == B20Constants.TRANSFER_SENDER_POLICY || policyType == B20Constants.TRANSFER_RECEIVER_POLICY - || policyType == B20Constants.TRANSFER_EXECUTOR_POLICY || policyType == B20Constants.SEIZABLE_ACCOUNT_POLICY + || policyType == B20Constants.TRANSFER_EXECUTOR_POLICY || policyType == B20Constants.SEIZE_HOLDER_POLICY || policyType == B20Constants.MINT_RECEIVER_POLICY; } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index e2c642c..8f50498 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -85,7 +85,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant MINT_ROLE = B20Constants.MINT_ROLE; bytes32 public constant BURN_ROLE = B20Constants.BURN_ROLE; bytes32 public constant BURN_BLOCKED_ROLE = B20Constants.BURN_BLOCKED_ROLE; - bytes32 public constant TRANSFER_FROM_SEIZABLE_ROLE = B20Constants.TRANSFER_FROM_SEIZABLE_ROLE; + bytes32 public constant SEIZE_ROLE = B20Constants.SEIZE_ROLE; bytes32 public constant PAUSE_ROLE = B20Constants.PAUSE_ROLE; bytes32 public constant UNPAUSE_ROLE = B20Constants.UNPAUSE_ROLE; bytes32 public constant METADATA_ROLE = B20Constants.METADATA_ROLE; @@ -95,7 +95,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant TRANSFER_RECEIVER_POLICY = B20Constants.TRANSFER_RECEIVER_POLICY; bytes32 public constant TRANSFER_EXECUTOR_POLICY = B20Constants.TRANSFER_EXECUTOR_POLICY; bytes32 public constant MINT_RECEIVER_POLICY = B20Constants.MINT_RECEIVER_POLICY; - bytes32 public constant SEIZABLE_ACCOUNT_POLICY = B20Constants.SEIZABLE_ACCOUNT_POLICY; + bytes32 public constant SEIZE_HOLDER_POLICY = B20Constants.SEIZE_HOLDER_POLICY; /// @notice Maximum value the supply cap may be set to. Because `mint` /// rejects any `totalSupply` above the cap, this also bounds @@ -308,36 +308,35 @@ abstract contract MockB20 is IB20 { emit Memo(msg.sender, memo); } + // Deprecated: `burnBlocked` is retained unchanged for back-compat but is no + // longer part of the IB20 interface. Its blocked check reads + // `TRANSFER_SENDER_POLICY` and it gates on the `BURN` pause vector — it is NOT + // part of the seize operation class (that is `seizeWithMemo`). Formal removal + // is announced for the Denim hardfork. function burnBlocked(address from, uint256 amount) external - whenNotPaused(PausableFeature.SEIZE) - onlyRole(BURN_BLOCKED_ROLE) - { - // Part of the seize operation class: gated on SEIZABLE_ACCOUNT_POLICY (not the - // transfer-sender policy) and the SEIZE pause vector, same as - // burnBlockedWithMemo and transferFromSeizableWithMemo. - _requireSeizable(from); - _burnRaw(from, amount); - emit BurnedBlocked(msg.sender, from, amount); - } - - function burnBlockedWithMemo(address from, uint256 amount, bytes32 memo) - external - whenNotPaused(PausableFeature.SEIZE) + whenNotPaused(PausableFeature.BURN) onlyRole(BURN_BLOCKED_ROLE) { - _requireSeizable(from); + // The point of burnBlocked is to seize from policy-blocked + // accounts. Read the transfer-sender policy ID out of the + // transfer-side packed slot and reject if the target is + // currently authorized. Enforced unconditionally — including + // for factory-originated calls during the bootstrap window — + // matching the Rust precompile, which carves no `privileged` + // exception for this guard. + uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender; + if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { + revert AccountNotBlocked(from); + } _burnRaw(from, amount); - // `Memo` must immediately follow the `Transfer` (emitted by `_burnRaw`), - // per the IB20 `Memo` invariant, so it precedes `BurnedBlocked`. - emit Memo(msg.sender, memo); emit BurnedBlocked(msg.sender, from, amount); } - function transferFromSeizableWithMemo(address from, address to, uint256 amount, bytes32 memo) + function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external whenNotPaused(PausableFeature.SEIZE) - onlyRole(TRANSFER_FROM_SEIZABLE_ROLE) + onlyRole(SEIZE_ROLE) returns (bool) { // Admin seize: reassign a blocked account's balance. `to` must be @@ -346,7 +345,7 @@ abstract contract MockB20 is IB20 { // 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 only membership check is that `from` is - // blocked under SEIZABLE_ACCOUNT_POLICY. Deliberately does NOT reuse the + // blocked under SEIZE_HOLDER_POLICY. Deliberately does NOT reuse the // factory-bootstrap privileged path (which would silently skip the // receiver policy); every skip here is explicit. if (to == address(0)) revert InvalidReceiver(to); @@ -354,9 +353,9 @@ abstract contract MockB20 is IB20 { _moveBalance(from, to, amount); // `Memo` must immediately follow the `Transfer` (emitted by // `_moveBalance`), per the IB20 `Memo` invariant, so it precedes - // `TransferredFromSeizable`. + // `Seized`. emit Memo(msg.sender, memo); - emit TransferredFromSeizable(msg.sender, from, to, amount); + emit Seized(msg.sender, from, to, amount); return true; } @@ -514,7 +513,7 @@ abstract contract MockB20 is IB20 { if (policyScope == TRANSFER_SENDER_POLICY) return $.transferPolicyIds.sender; if (policyScope == TRANSFER_RECEIVER_POLICY) return $.transferPolicyIds.receiver; if (policyScope == TRANSFER_EXECUTOR_POLICY) return $.transferPolicyIds.executor; - if (policyScope == SEIZABLE_ACCOUNT_POLICY) return $.seizePolicyIds.seizable; + if (policyScope == SEIZE_HOLDER_POLICY) return $.seizePolicyIds.seizable; if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver; revert UnsupportedPolicyType(policyScope); } @@ -537,7 +536,7 @@ abstract contract MockB20 is IB20 { $.transferPolicyIds.receiver = newPolicyId; } else if (policyScope == TRANSFER_EXECUTOR_POLICY) { $.transferPolicyIds.executor = newPolicyId; - } else if (policyScope == SEIZABLE_ACCOUNT_POLICY) { + } else if (policyScope == SEIZE_HOLDER_POLICY) { $.seizePolicyIds.seizable = newPolicyId; } else { $.mintPolicyIds.receiver = newPolicyId; @@ -791,7 +790,7 @@ abstract contract MockB20 is IB20 { } /// @dev Seize gate: reverts `AccountNotBlocked(from)` unless `from` is - /// blocked under `SEIZABLE_ACCOUNT_POLICY` (i.e. NOT authorized). Enforced + /// blocked under `SEIZE_HOLDER_POLICY` (i.e. NOT authorized). Enforced /// unconditionally, including in the factory bootstrap window, /// mirroring the `burnBlocked` sender-policy check. function _requireSeizable(address from) internal view { diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index abc7e6b..0fd3c7f 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -61,10 +61,9 @@ library MockB20Storage { uint64 executor; } - /// @notice Seize policy IDs (read by the seize operations `transferFromSeizableWithMemo`, - /// `burnBlocked`, and `burnBlockedWithMemo`). + /// @notice Seize policy IDs (read by the seize operation `seizeWithMemo`). /// @dev Bit layout: - /// bits 0.. 63 : seizable (`SEIZABLE_ACCOUNT_POLICY`) + /// bits 0.. 63 : seizable (`SEIZE_HOLDER_POLICY`) /// bits 64..255 : reserved (implicit) struct SeizePolicyIds { uint64 seizable; @@ -119,7 +118,7 @@ library MockB20Storage { // shifts and mask operations. // // Transfer-side policies (read by `_transfer`, `transferFrom*`, - // and the seize check in `burnBlocked`). + // and the blocked check in the deprecated `burnBlocked`). TransferPolicyIds transferPolicyIds; // Mint-side policies (read by `_mint`). Only `MINT_RECEIVER_POLICY` // is defined today; future granular mint-side policy types (e.g. @@ -324,7 +323,7 @@ library MockB20Storage { return uint256(senderId) | (uint256(receiverId) << 64) | (uint256(executorId) << 128); } - /// @notice Extracts the SEIZABLE_ACCOUNT policy id (lane 0) from the seize packed slot. + /// @notice Extracts the seize-holder policy id (lane 0) from the seize packed slot. function seizablePolicyId(uint256 packed) internal pure returns (uint64) { return uint64(packed); } diff --git a/test/regression/B20Removals.t.sol b/test/regression/B20Removals.t.sol index 160799e..c101574 100644 --- a/test/regression/B20Removals.t.sol +++ b/test/regression/B20Removals.t.sol @@ -111,7 +111,7 @@ contract B20RemovalsTest is B20AssetTest { } /// @notice Verifies the `BURN_FROM_ROLE()` role constant no longer resolves - /// @dev `burnBlocked` (gated by `BURN_BLOCKED_ROLE`) is the remaining seize path. + /// @dev `seizeWithMemo` (gated by `SEIZE_ROLE`) is the seize path; `burnBlocked` remains a burn. function test_burnFromRole_revert_selectorRemoved() public { _assertSelectorRemoved(abi.encodeWithSignature("BURN_FROM_ROLE()"), "BURN_FROM_ROLE() must not resolve"); } diff --git a/test/unit/B20/policy/updatePolicy.t.sol b/test/unit/B20/policy/updatePolicy.t.sol index 3ba02c0..e452f59 100644 --- a/test/unit/B20/policy/updatePolicy.t.sol +++ b/test/unit/B20/policy/updatePolicy.t.sol @@ -26,7 +26,7 @@ contract B20UpdatePolicyTest is B20Test { uint256(vm.load(address(token), MockB20Storage.mintPolicyIdsSlot())) ); } - if (policyScope == B20Constants.SEIZABLE_ACCOUNT_POLICY) { + if (policyScope == B20Constants.SEIZE_HOLDER_POLICY) { return MockB20Storage.seizablePolicyId(uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot()))); } diff --git a/test/unit/B20/supply/burnBlocked.t.sol b/test/unit/B20/supply/burnBlocked.t.sol index 564f9f2..731b5a2 100644 --- a/test/unit/B20/supply/burnBlocked.t.sol +++ b/test/unit/B20/supply/burnBlocked.t.sol @@ -21,35 +21,35 @@ contract B20BurnBlockedTest is B20Test { IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.BURN_BLOCKED_ROLE ) ); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } - /// @notice Verifies burnBlocked reverts when SEIZE feature is paused - /// @dev Pause guard; checks ContractPaused(SEIZE) error - function test_burnBlocked_revert_whenSeizePaused(address from, uint256 amount) public { + /// @notice Verifies burnBlocked reverts when BURN feature is paused + /// @dev Pause guard; checks ContractPaused(BURN) error + function test_burnBlocked_revert_whenBurnPaused(address from, uint256 amount) public { _assumeValidActor(from); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - // We also need SEIZABLE_ACCOUNT_POLICY set to ALWAYS_BLOCK_ID so the from-not-authorized + // We also need TRANSFER_SENDER_POLICY set to ALWAYS_BLOCK_ID so the from-not-authorized // path is satisfied; otherwise the policy check inside burnBlocked fires // with AccountNotBlocked first. - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _pause(IB20.PausableFeature.SEIZE); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _pause(IB20.PausableFeature.BURN); vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlocked(from, amount); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.BURN)); + MockB20(address(token)).burnBlocked(from, amount); } - /// @notice Verifies burnBlocked reverts when the target is authorized under SEIZABLE_ACCOUNT_POLICY + /// @notice Verifies burnBlocked reverts when the target is authorized under TRANSFER_SENDER_POLICY /// @dev Seizure is only permitted against policy-blocked addresses; checks AccountNotBlocked(from) function test_burnBlocked_revert_accountNotBlocked(address from, uint256 amount) public { _assumeValidActor(from); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - // Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW_ID (0), so every address is "authorized" → not blocked. + // Default TRANSFER_SENDER_POLICY is ALWAYS_ALLOW_ID (0), so every address is "authorized" → not blocked. vm.prank(burnBlocker); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } /// @notice Verifies burnBlocked reverts when target balance is insufficient @@ -58,12 +58,12 @@ contract B20BurnBlockedTest is B20Test { _assumeValidActor(from); amount = bound(amount, 1, type(uint256).max); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); // from is policy-blocked + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); // from is policy-blocked // from has zero balance. vm.prank(burnBlocker); vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientBalance.selector, from, 0, amount)); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } /// @notice Verifies burnBlocked debits the target balance by amount @@ -74,12 +74,12 @@ contract B20BurnBlockedTest is B20Test { amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); // Mint while no policy is set so the mint isn't blocked. _mint(from, amount); - // Now block from via SEIZABLE_ACCOUNT_POLICY policy, then seize. - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + // Now block from via TRANSFER_SENDER_POLICY policy, then seize. + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); vm.prank(burnBlocker); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); assertEq(token.balanceOf(from), 0, "target balance must be zero after seizure"); assertEq( uint256(vm.load(address(token), MockB20Storage.balanceSlot(from))), @@ -95,12 +95,12 @@ contract B20BurnBlockedTest is B20Test { _assumeValidActor(from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); uint256 before = token.totalSupply(); vm.prank(burnBlocker); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); assertEq(token.totalSupply(), before - amount, "totalSupply must decrease by seized amount"); assertEq( uint256(vm.load(address(token), MockB20Storage.totalSupplySlot())), @@ -115,7 +115,7 @@ contract B20BurnBlockedTest is B20Test { _assumeValidActor(from); amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); _mint(from, amount); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); vm.expectEmit(true, true, false, true, address(token)); @@ -123,6 +123,6 @@ contract B20BurnBlockedTest is B20Test { vm.expectEmit(true, true, false, true, address(token)); emit IB20.BurnedBlocked(burnBlocker, from, amount); vm.prank(burnBlocker); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } } diff --git a/test/unit/B20/supply/burnBlockedWithMemo.t.sol b/test/unit/B20/supply/burnBlockedWithMemo.t.sol deleted file mode 100644 index a5aaf65..0000000 --- a/test/unit/B20/supply/burnBlockedWithMemo.t.sol +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IB20} from "base-std/interfaces/IB20.sol"; - -import {B20Test} from "base-std-test/lib/B20Test.sol"; -import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; -import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; -import {MockPolicyRegistry, PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; - -/// @title Unit tests for `burnBlockedWithMemo` (burn-based seize with memo). -/// -/// @notice Unlike the legacy `burnBlocked`, this variant is part of the seize operation class: -/// it gates on the SEIZE pause vector and the SEIZABLE_ACCOUNT_POLICY (not BURN / TRANSFER_SENDER). -contract B20BurnBlockedWithMemoTest is B20Test { - /// @notice Reverts when caller lacks BURN_BLOCKED_ROLE. - function test_burnBlockedWithMemo_revert_unauthorized(address caller, address from, uint256 amount) public { - _assumeValidCaller(caller); - vm.assume(caller != admin); - - vm.prank(caller); - vm.expectRevert( - abi.encodeWithSelector( - IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.BURN_BLOCKED_ROLE - ) - ); - token.burnBlockedWithMemo(from, amount, bytes32(0)); - } - - /// @notice Reverts when the SEIZE feature is paused (not BURN). - function test_burnBlockedWithMemo_revert_whenSeizePaused(address from, uint256 amount) public { - _assumeValidActor(from); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _pause(IB20.PausableFeature.SEIZE); - - vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlockedWithMemo(from, amount, bytes32(0)); - } - - /// @notice Reverts AccountNotBlocked when `from` is authorized under SEIZABLE_ACCOUNT_POLICY. - function test_burnBlockedWithMemo_revert_accountNotBlocked(address from, uint256 amount) public { - _assumeValidActor(from); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - // Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW (0) → every address authorized → not blocked. - - vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.burnBlockedWithMemo(from, amount, bytes32(0)); - } - - /// @notice Reverts InsufficientBalance when the target balance is below `amount`. - function test_burnBlockedWithMemo_revert_insufficientBalance(address from, uint256 amount) public { - _assumeValidActor(from); - amount = bound(amount, 1, type(uint256).max); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - - vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientBalance.selector, from, 0, amount)); - token.burnBlockedWithMemo(from, amount, bytes32(0)); - } - - /// @notice Debits the target and decreases totalSupply by `amount`. - function test_burnBlockedWithMemo_success_debitsAndDecreasesSupply(address from, uint256 amount) public { - _assumeValidActor(from); - amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _mint(from, amount); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - uint256 before = token.totalSupply(); - - vm.prank(burnBlocker); - token.burnBlockedWithMemo(from, amount, bytes32(0)); - - assertEq(token.balanceOf(from), 0, "target balance must be zero after seizure"); - assertEq(token.totalSupply(), before - amount, "totalSupply must decrease by seized amount"); - assertEq( - uint256(vm.load(address(token), MockB20Storage.totalSupplySlot())), - before - amount, - "totalSupply slot must reflect the seizure" - ); - } - - /// @notice Emits Transfer(from, 0, amount), then Memo(caller, memo), then BurnedBlocked(caller, from, amount). - function test_burnBlockedWithMemo_success_emitsTransferBurnedBlockedAndMemo( - address from, - uint256 amount, - bytes32 memo - ) public { - _assumeValidActor(from); - amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); - _mint(from, amount); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - - vm.expectEmit(true, true, false, true, address(token)); - emit IB20.Transfer(from, address(0), amount); - vm.expectEmit(true, true, false, true, address(token)); - emit IB20.Memo(burnBlocker, memo); - vm.expectEmit(true, true, false, true, address(token)); - emit IB20.BurnedBlocked(burnBlocker, from, amount); - vm.prank(burnBlocker); - token.burnBlockedWithMemo(from, amount, memo); - } -} diff --git a/test/unit/B20/supply/burnBlockedWithMemo_revertOrder.t.sol b/test/unit/B20/supply/burnBlockedWithMemo_revertOrder.t.sol deleted file mode 100644 index 4a06269..0000000 --- a/test/unit/B20/supply/burnBlockedWithMemo_revertOrder.t.sol +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IB20} from "base-std/interfaces/IB20.sol"; - -import {B20Test} from "base-std-test/lib/B20Test.sol"; -import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; -import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; - -/// @title Differential check-order tests for `burnBlockedWithMemo`. -/// -/// @notice **Canonical order (Solidity reference):** -/// 1. PAUSE (`whenNotPaused(SEIZE)` modifier) → `ContractPaused` -/// 2. ROLE (`onlyRole(BURN_BLOCKED_ROLE)` modifier) → `AccessControlUnauthorizedAccount` -/// 3. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotBlocked` -/// 4. BALANCE (`fromBalance < amount` in `_burnRaw`) → `InsufficientBalance` -contract B20BurnBlockedWithMemoRevertOrderTest is B20Test { - /// @notice PAUSE beats ROLE. - function test_burnBlockedWithMemo_revertOrder_pause_beats_role(address caller, address from) public { - _assumeValidCaller(caller); - _assumeValidActor(from); - vm.assume(caller != admin); - _pause(IB20.PausableFeature.SEIZE); - - vm.prank(caller); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlockedWithMemo(from, 1, bytes32(0)); - } - - /// @notice ROLE beats BLOCKED. - function test_burnBlockedWithMemo_revertOrder_role_beats_blocked(address caller, address from) public { - _assumeValidCaller(caller); - _assumeValidActor(from); - vm.assume(caller != admin); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW → `from` is "not blocked". No role granted. - - vm.prank(caller); - vm.expectRevert( - abi.encodeWithSelector( - IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.BURN_BLOCKED_ROLE - ) - ); - token.burnBlockedWithMemo(from, 1, bytes32(0)); - } - - /// @notice PAUSE beats BLOCKED. - function test_burnBlockedWithMemo_revertOrder_pause_beats_blocked(address from) public { - _assumeValidActor(from); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _pause(IB20.PausableFeature.SEIZE); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. - - vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlockedWithMemo(from, 1, bytes32(0)); - } - - /// @notice BLOCKED beats BALANCE. - function test_burnBlockedWithMemo_revertOrder_blocked_beats_balance(address from) public { - _assumeValidActor(from); - _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - // Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW → `from` NOT blocked; zero balance too. - - vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.burnBlockedWithMemo(from, 1, bytes32(0)); - } -} diff --git a/test/unit/B20/supply/burnBlocked_revertOrder.t.sol b/test/unit/B20/supply/burnBlocked_revertOrder.t.sol index 05232f1..a85ce8b 100644 --- a/test/unit/B20/supply/burnBlocked_revertOrder.t.sol +++ b/test/unit/B20/supply/burnBlocked_revertOrder.t.sol @@ -10,14 +10,14 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// @title Differential check-order tests for `burnBlocked`. /// /// @notice **Canonical order (Solidity reference):** -/// 1. PAUSE (`whenNotPaused(SEIZE)` modifier) → `ContractPaused` +/// 1. PAUSE (`whenNotPaused(BURN)` modifier) → `ContractPaused` /// 2. ROLE (`onlyRole(BURN_BLOCKED_ROLE)` modifier) → `AccessControlUnauthorizedAccount` -/// 3. BLOCKED (`isAuthorized(seizablePolicyId, from) == true` reverts) → `AccountNotBlocked` +/// 3. BLOCKED (`isAuthorized(senderPolicyId, from) == true` reverts) → `AccountNotBlocked` /// 4. BALANCE (`fromBalance < amount` in `_burnRaw`) → `InsufficientBalance` /// /// Note on BLOCKED semantics: `burnBlocked` is a clawback function — it /// only succeeds when `from` is currently NOT authorized by the -/// seizable policy. A test triggers the BLOCKED-precondition +/// transfer-sender policy. A test triggers the BLOCKED-precondition /// violation by setting the policy to `ALWAYS_ALLOW` so every account is /// authorized, which makes the function revert `AccountNotBlocked`. contract B20BurnBlockedRevertOrderTest is B20Test { @@ -29,12 +29,12 @@ contract B20BurnBlockedRevertOrderTest is B20Test { _assumeValidCaller(caller); _assumeValidActor(from); vm.assume(caller != admin); - _pause(IB20.PausableFeature.SEIZE); - // No BURN_BLOCKED_ROLE granted AND SEIZE is paused — pause fires first. + _pause(IB20.PausableFeature.BURN); + // No BURN_BLOCKED_ROLE granted AND BURN is paused — pause fires first. vm.prank(caller); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlocked(from, amount); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.BURN)); + MockB20(address(token)).burnBlocked(from, amount); } // --- Pairs where ROLE wins (PAUSE not violated) --- @@ -44,7 +44,7 @@ contract B20BurnBlockedRevertOrderTest is B20Test { _assumeValidCaller(caller); _assumeValidActor(from); vm.assume(caller != admin); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW default → `from` is "not blocked". + // TRANSFER_SENDER_POLICY left at ALWAYS_ALLOW default → `from` is "not blocked". // No BURN_BLOCKED_ROLE granted. vm.prank(caller); @@ -53,7 +53,7 @@ contract B20BurnBlockedRevertOrderTest is B20Test { IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.BURN_BLOCKED_ROLE ) ); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } /// @notice ROLE beats BALANCE. @@ -70,7 +70,7 @@ contract B20BurnBlockedRevertOrderTest is B20Test { IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.BURN_BLOCKED_ROLE ) ); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } // --- Pairs where PAUSE wins --- @@ -79,12 +79,12 @@ contract B20BurnBlockedRevertOrderTest is B20Test { function test_burnBlocked_revertOrder_pause_beats_blocked(address from, uint256 amount) public { _assumeValidActor(from); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _pause(IB20.PausableFeature.SEIZE); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW → `from` is "not blocked". + _pause(IB20.PausableFeature.BURN); + // TRANSFER_SENDER_POLICY left at ALWAYS_ALLOW → `from` is "not blocked". vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlocked(from, amount); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.BURN)); + MockB20(address(token)).burnBlocked(from, amount); } /// @notice PAUSE beats BALANCE. @@ -92,13 +92,13 @@ contract B20BurnBlockedRevertOrderTest is B20Test { _assumeValidActor(from); amount = bound(amount, 1, type(uint128).max); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - _pause(IB20.PausableFeature.SEIZE); - // Need `from` blocked so BLOCKED would pass; set seizable policy to ALWAYS_BLOCK. - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _pause(IB20.PausableFeature.BURN); + // Need `from` blocked so BLOCKED would pass; set transfer-sender policy to ALWAYS_BLOCK. + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(burnBlocker); - vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.burnBlocked(from, amount); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.BURN)); + MockB20(address(token)).burnBlocked(from, amount); } // --- Pair where BLOCKED wins --- @@ -109,11 +109,11 @@ contract B20BurnBlockedRevertOrderTest is B20Test { _assumeValidActor(from); amount = bound(amount, 1, type(uint128).max); _grantRole(B20Constants.BURN_BLOCKED_ROLE, burnBlocker); - // Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW → `from` is NOT blocked. + // Default TRANSFER_SENDER_POLICY is ALWAYS_ALLOW → `from` is NOT blocked. // `from` has zero balance → balance check WOULD fail if BLOCKED didn't fire first. vm.prank(burnBlocker); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.burnBlocked(from, amount); + MockB20(address(token)).burnBlocked(from, amount); } } diff --git a/test/unit/B20/supply/transferFromSeizableWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol similarity index 61% rename from test/unit/B20/supply/transferFromSeizableWithMemo.t.sol rename to test/unit/B20/supply/seizeWithMemo.t.sol index 9e514e8..afb6910 100644 --- a/test/unit/B20/supply/transferFromSeizableWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -8,38 +8,31 @@ import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; import {MockPolicyRegistry, PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; -/// @title Unit tests for `transferFromSeizableWithMemo` (transfer-based seize). -contract B20TransferFromSeizableWithMemoTest is B20Test { +/// @title Unit tests for `seizeWithMemo` (transfer-based seize). +contract B20SeizeWithMemoTest is B20Test { address internal seizer = makeAddr("seizer"); - /// @dev Blocks `from` under SEIZABLE_ACCOUNT_POLICY and grants the seize role. Mirrors the + /// @dev Blocks `from` under SEIZE_HOLDER_POLICY and grants the seize role. Mirrors the /// setup every success path shares. function _armSeize() internal { - _grantRole(B20Constants.TRANSFER_FROM_SEIZABLE_ROLE, seizer); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _grantRole(B20Constants.SEIZE_ROLE, seizer); + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); } - /// @notice Reverts when caller lacks TRANSFER_FROM_SEIZABLE_ROLE. - function test_transferFromSeizableWithMemo_revert_unauthorized( - address caller, - address from, - address to, - uint256 amount - ) public { + /// @notice Reverts when caller lacks SEIZE_ROLE. + function test_seizeWithMemo_revert_unauthorized(address caller, address from, address to, uint256 amount) public { _assumeValidCaller(caller); vm.assume(caller != admin); vm.prank(caller); vm.expectRevert( - abi.encodeWithSelector( - IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.TRANSFER_FROM_SEIZABLE_ROLE - ) + abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.SEIZE_ROLE) ); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); } /// @notice Reverts when the SEIZE feature is paused. - function test_transferFromSeizableWithMemo_revert_whenSeizePaused(address from, address to, uint256 amount) public { + function test_seizeWithMemo_revert_whenSeizePaused(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); _armSeize(); @@ -47,37 +40,33 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); } /// @notice Reverts with InvalidReceiver when `to == address(0)`. - function test_transferFromSeizableWithMemo_revert_invalidReceiver(address from, uint256 amount) public { + function test_seizeWithMemo_revert_invalidReceiver(address from, uint256 amount) public { _assumeValidActor(from); _armSeize(); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0))); - token.transferFromSeizableWithMemo(from, address(0), amount, bytes32(0)); + token.seizeWithMemo(from, address(0), amount, bytes32(0)); } - /// @notice Reverts AccountNotBlocked when `from` is authorized under SEIZABLE_ACCOUNT_POLICY. - /// @dev Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW (0) → every account authorized → not seizable. - function test_transferFromSeizableWithMemo_revert_accountNotBlocked(address from, address to, uint256 amount) - public - { + /// @notice Reverts AccountNotBlocked when `from` is authorized under SEIZE_HOLDER_POLICY. + /// @dev Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW (0) → every account authorized → not seizable. + function test_seizeWithMemo_revert_accountNotBlocked(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); - _grantRole(B20Constants.TRANSFER_FROM_SEIZABLE_ROLE, seizer); + _grantRole(B20Constants.SEIZE_ROLE, seizer); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); } /// @notice Reverts InsufficientBalance when the seized account's balance is below `amount`. - function test_transferFromSeizableWithMemo_revert_insufficientBalance(address from, address to, uint256 amount) - public - { + function test_seizeWithMemo_revert_insufficientBalance(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -86,11 +75,11 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientBalance.selector, from, 0, amount)); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); } /// @notice Moves the seized balance from `from` to `to` and leaves totalSupply unchanged. - function test_transferFromSeizableWithMemo_success_movesBalance(address from, address to, uint256 amount) public { + function test_seizeWithMemo_success_movesBalance(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -100,7 +89,7 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { uint256 supplyBefore = token.totalSupply(); vm.prank(seizer); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); assertEq(token.balanceOf(from), 0, "seized account must be drained"); assertEq(token.balanceOf(to), amount, "destination must receive the seized amount"); @@ -114,9 +103,7 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { /// @notice Succeeds even when `to` is blocked by TRANSFER_RECEIVER_POLICY: seize is an admin /// operation and does not consult the receiver policy on the destination. - function test_transferFromSeizableWithMemo_success_ignoresReceiverPolicy(address from, address to, uint256 amount) - public - { + function test_seizeWithMemo_success_ignoresReceiverPolicy(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -127,15 +114,13 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); assertEq(token.balanceOf(to), amount, "seize must succeed regardless of receiver policy on `to`"); } /// @notice Requires no allowance from the seized account: seize skips allowance accounting. - function test_transferFromSeizableWithMemo_success_noAllowanceRequired(address from, address to, uint256 amount) - public - { + function test_seizeWithMemo_success_noAllowanceRequired(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -145,19 +130,14 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { // No approve() from `from` to `seizer`; a normal transferFrom would revert InsufficientAllowance. vm.prank(seizer); - token.transferFromSeizableWithMemo(from, to, amount, bytes32(0)); + token.seizeWithMemo(from, to, amount, bytes32(0)); assertEq(token.balanceOf(to), amount, "seize must not require an allowance"); assertEq(token.allowance(from, seizer), 0, "no allowance should be consumed"); } - /// @notice Emits, in order, Transfer, Memo (immediately after Transfer per the IB20 invariant), then TransferredFromSeizable. - function test_transferFromSeizableWithMemo_success_emitsEvents( - address from, - address to, - uint256 amount, - bytes32 memo - ) public { + /// @notice Emits, in order, Transfer, Memo (immediately after Transfer per the IB20 invariant), then Seized. + function test_seizeWithMemo_success_emitsEvents(address from, address to, uint256 amount, bytes32 memo) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); @@ -170,8 +150,8 @@ contract B20TransferFromSeizableWithMemoTest is B20Test { vm.expectEmit(true, true, false, true, address(token)); emit IB20.Memo(seizer, memo); vm.expectEmit(true, true, true, true, address(token)); - emit IB20.TransferredFromSeizable(seizer, from, to, amount); + emit IB20.Seized(seizer, from, to, amount); vm.prank(seizer); - token.transferFromSeizableWithMemo(from, to, amount, memo); + token.seizeWithMemo(from, to, amount, memo); } } diff --git a/test/unit/B20/supply/transferFromSeizableWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol similarity index 54% rename from test/unit/B20/supply/transferFromSeizableWithMemo_revertOrder.t.sol rename to test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index f834d08..533de42 100644 --- a/test/unit/B20/supply/transferFromSeizableWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -7,21 +7,19 @@ import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; -/// @title Differential check-order tests for `transferFromSeizableWithMemo`. +/// @title Differential check-order tests for `seizeWithMemo`. /// /// @notice **Canonical order (Solidity reference):** /// 1. PAUSE (`whenNotPaused(SEIZE)` modifier) → `ContractPaused` -/// 2. ROLE (`onlyRole(TRANSFER_FROM_SEIZABLE_ROLE)` modifier) → `AccessControlUnauthorizedAccount` +/// 2. ROLE (`onlyRole(SEIZE_ROLE)` modifier) → `AccessControlUnauthorizedAccount` /// 3. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` (`from` is not zero-checked) /// 4. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotBlocked` /// 5. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` -contract B20TransferFromSeizableWithMemoRevertOrderTest is B20Test { +contract B20SeizeWithMemoRevertOrderTest is B20Test { address internal seizer = makeAddr("seizer"); /// @notice PAUSE beats ROLE. - function test_transferFromSeizableWithMemo_revertOrder_pause_beats_role(address caller, address from, address to) - public - { + function test_seizeWithMemo_revertOrder_pause_beats_role(address caller, address from, address to) public { _assumeValidCaller(caller); _assumeValidActor(from); _assumeValidActor(to); @@ -30,59 +28,57 @@ contract B20TransferFromSeizableWithMemoRevertOrderTest is B20Test { vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.transferFromSeizableWithMemo(from, to, 1, bytes32(0)); + token.seizeWithMemo(from, to, 1, bytes32(0)); } /// @notice ROLE beats ZERO-ACTORS (unauthorized caller reverts before the `to == 0` check). - function test_transferFromSeizableWithMemo_revertOrder_role_beats_zeroActors(address caller, address from) public { + function test_seizeWithMemo_revertOrder_role_beats_zeroActors(address caller, address from) public { _assumeValidCaller(caller); _assumeValidActor(from); vm.assume(caller != admin); vm.prank(caller); vm.expectRevert( - abi.encodeWithSelector( - IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.TRANSFER_FROM_SEIZABLE_ROLE - ) + abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.SEIZE_ROLE) ); - token.transferFromSeizableWithMemo(from, address(0), 1, bytes32(0)); + token.seizeWithMemo(from, address(0), 1, bytes32(0)); } /// @notice ZERO-ACTORS beats BLOCKED (`to == 0` reverts before the seizable check on `from`). - function test_transferFromSeizableWithMemo_revertOrder_zeroActors_beats_blocked(address from) public { + function test_seizeWithMemo_revertOrder_zeroActors_beats_blocked(address from) public { _assumeValidActor(from); - _grantRole(B20Constants.TRANSFER_FROM_SEIZABLE_ROLE, seizer); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW → `from` would be "not blocked", but `to == 0` fires first. + _grantRole(B20Constants.SEIZE_ROLE, seizer); + // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` would be "not blocked", but `to == 0` fires first. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0))); - token.transferFromSeizableWithMemo(from, address(0), 1, bytes32(0)); + token.seizeWithMemo(from, address(0), 1, bytes32(0)); } /// @notice BLOCKED beats BALANCE (`from` not blocked and zero balance → AccountNotBlocked wins). - function test_transferFromSeizableWithMemo_revertOrder_blocked_beats_balance(address from, address to) public { + function test_seizeWithMemo_revertOrder_blocked_beats_balance(address from, address to) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - _grantRole(B20Constants.TRANSFER_FROM_SEIZABLE_ROLE, seizer); - // Default SEIZABLE_ACCOUNT_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. + _grantRole(B20Constants.SEIZE_ROLE, seizer); + // Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); - token.transferFromSeizableWithMemo(from, to, 1, bytes32(0)); + token.seizeWithMemo(from, to, 1, bytes32(0)); } /// @notice PAUSE beats BLOCKED. - function test_transferFromSeizableWithMemo_revertOrder_pause_beats_blocked(address from, address to) public { + function test_seizeWithMemo_revertOrder_pause_beats_blocked(address from, address to) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); - _grantRole(B20Constants.TRANSFER_FROM_SEIZABLE_ROLE, seizer); + _grantRole(B20Constants.SEIZE_ROLE, seizer); _pause(IB20.PausableFeature.SEIZE); - // SEIZABLE_ACCOUNT_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. + // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); - token.transferFromSeizableWithMemo(from, to, 1, bytes32(0)); + token.seizeWithMemo(from, to, 1, bytes32(0)); } } diff --git a/test/unit/storage/B20FullLayout.t.sol b/test/unit/storage/B20FullLayout.t.sol index 361bc40..eb93276 100644 --- a/test/unit/storage/B20FullLayout.t.sol +++ b/test/unit/storage/B20FullLayout.t.sol @@ -215,7 +215,7 @@ contract B20FullLayoutTest is B20Test { // Its own per-operation packed slot, placed before the mock-only // `initialized` flag so the Rust precompile mirrors it without a filler. uint256 packedSeize = uint256(vm.load(tokenAddr, MockB20Storage.seizePolicyIdsSlot())); - assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(seizableMarker), "slot 14 bits 0..63: SEIZABLE_ACCOUNT lane"); + assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(seizableMarker), "slot 14 bits 0..63: seize-holder lane"); assertEq(packedSeize >> 64, 0, "slot 14 bits 64..255: three reserved lanes must be zero"); // ---------- initialized ---------- @@ -283,7 +283,7 @@ contract B20FullLayoutTest is B20Test { _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, transferSenderMarker); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, transferReceiverMarker); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, transferExecutorMarker); - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, seizableMarker); + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, seizableMarker); _setPolicy(B20Constants.MINT_RECEIVER_POLICY, mintReceiverMarker); // ---------- Pause vectors ---------- diff --git a/test/unit/storage/MockB20SlotHelpers.t.sol b/test/unit/storage/MockB20SlotHelpers.t.sol index 93b3e88..ced2541 100644 --- a/test/unit/storage/MockB20SlotHelpers.t.sol +++ b/test/unit/storage/MockB20SlotHelpers.t.sol @@ -226,10 +226,10 @@ contract MockB20SlotHelpersTest is B20Test { ); } - /// @notice Verifies `seizePolicyIdsSlot()` locates the SEIZABLE_ACCOUNT lane. - /// @dev Write to SEIZABLE_ACCOUNT_POLICY via `updatePolicy`; lane decoder reads back from its own slot. + /// @notice Verifies `seizePolicyIdsSlot()` locates the seize-holder lane. + /// @dev Write to SEIZE_HOLDER_POLICY via `updatePolicy`; lane decoder reads back from its own slot. function test_seizePolicyIdsSlot_success_decodesSeizableLane() public { - _setPolicy(B20Constants.SEIZABLE_ACCOUNT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); uint256 packed = uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot())); assertEq( From 892383bb29fdbbee55fad634d6a83e9017ca6846 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 31 Jul 2026 11:51:51 -0400 Subject: [PATCH 2/5] refactor(BOP-440): use AccountNotSeizable for seize; keep AccountNotBlocked for burnBlocked Splits the shared error so each gate has an accurate name: - seizeWithMemo now reverts AccountNotSeizable(from) (gate: SEIZE_HOLDER_POLICY) - deprecated burnBlocked keeps AccountNotBlocked(from) (gate: TRANSFER_SENDER_POLICY) Membership in SEIZE_HOLDER_POLICY marks who CAN be seized from, so "not blocked" was semantically wrong for the seize path. Co-authored-by: OpenCode --- src/interfaces/IB20.sol | 12 ++++++++---- test/lib/mocks/MockB20.sol | 9 ++++----- test/unit/B20/supply/seizeWithMemo.t.sol | 4 ++-- test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol | 6 +++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 203c59d..9ef89f1 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -105,9 +105,13 @@ interface IB20 { /// @notice `policyScope` is not a slot this token (or its variant) supports. error UnsupportedPolicyType(bytes32 policyScope); - /// @notice A seize operation (`seizeWithMemo`) was called against a `from` that is currently - /// authorized under `SEIZE_HOLDER_POLICY` (i.e. not eligible to be seized from). Also used by the - /// deprecated `burnBlocked`, which checks `TRANSFER_SENDER_POLICY`. + /// @notice `seizeWithMemo` was called against a `from` that is currently authorized under + /// `SEIZE_HOLDER_POLICY` (i.e. not a member of the seize-holder set, so not eligible to be + /// seized from). + error AccountNotSeizable(address account); + + /// @notice The deprecated `burnBlocked` was called against a `from` that is currently authorized under + /// `TRANSFER_SENDER_POLICY` (i.e. not blocked). error AccountNotBlocked(address account); /// @notice An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`. @@ -439,7 +443,7 @@ interface IB20 { /// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused. /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SEIZE_ROLE`. /// @dev Reverts with `InvalidReceiver` when `to == address(0)`. - /// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. + /// @dev Reverts with `AccountNotSeizable` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. /// /// @param from Account whose balance is being seized. diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 8f50498..506a751 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -789,14 +789,13 @@ abstract contract MockB20 is IB20 { emit Transfer(from, to, amount); } - /// @dev Seize gate: reverts `AccountNotBlocked(from)` unless `from` is - /// blocked under `SEIZE_HOLDER_POLICY` (i.e. NOT authorized). Enforced - /// unconditionally, including in the factory bootstrap window, - /// mirroring the `burnBlocked` sender-policy check. + /// @dev Seize gate: reverts `AccountNotSeizable(from)` unless `from` is a + /// member of `SEIZE_HOLDER_POLICY` (i.e. NOT authorized). Enforced + /// unconditionally, including in the factory bootstrap window. function _requireSeizable(address from) internal view { uint64 seizablePolicyId = MockB20Storage.layout().seizePolicyIds.seizable; if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(seizablePolicyId, from)) { - revert AccountNotBlocked(from); + revert AccountNotSeizable(from); } } diff --git a/test/unit/B20/supply/seizeWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol index afb6910..2b03083 100644 --- a/test/unit/B20/supply/seizeWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -53,7 +53,7 @@ contract B20SeizeWithMemoTest is B20Test { token.seizeWithMemo(from, address(0), amount, bytes32(0)); } - /// @notice Reverts AccountNotBlocked when `from` is authorized under SEIZE_HOLDER_POLICY. + /// @notice Reverts AccountNotSeizable when `from` is authorized under SEIZE_HOLDER_POLICY. /// @dev Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW (0) → every account authorized → not seizable. function test_seizeWithMemo_revert_accountNotBlocked(address from, address to, uint256 amount) public { _assumeValidActor(from); @@ -61,7 +61,7 @@ contract B20SeizeWithMemoTest is B20Test { _grantRole(B20Constants.SEIZE_ROLE, seizer); vm.prank(seizer); - vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); + vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); token.seizeWithMemo(from, to, amount, bytes32(0)); } diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index 533de42..dcf5482 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -13,7 +13,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 1. PAUSE (`whenNotPaused(SEIZE)` modifier) → `ContractPaused` /// 2. ROLE (`onlyRole(SEIZE_ROLE)` modifier) → `AccessControlUnauthorizedAccount` /// 3. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` (`from` is not zero-checked) -/// 4. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotBlocked` +/// 4. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotSeizable` /// 5. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` contract B20SeizeWithMemoRevertOrderTest is B20Test { address internal seizer = makeAddr("seizer"); @@ -55,7 +55,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(from, address(0), 1, bytes32(0)); } - /// @notice BLOCKED beats BALANCE (`from` not blocked and zero balance → AccountNotBlocked wins). + /// @notice BLOCKED beats BALANCE (`from` not blocked and zero balance → AccountNotSeizable wins). function test_seizeWithMemo_revertOrder_blocked_beats_balance(address from, address to) public { _assumeValidActor(from); _assumeValidActor(to); @@ -64,7 +64,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { // Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. vm.prank(seizer); - vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotBlocked.selector, from)); + vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); token.seizeWithMemo(from, to, 1, bytes32(0)); } From 7cb9310f8e8dd66ea7778efa7a2e1cef9e039918 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 31 Jul 2026 12:00:32 -0400 Subject: [PATCH 3/5] docs(BOP-440): clarify burnBlocked deprecation note (recommend seize-then-burn) Co-authored-by: OpenCode --- test/lib/mocks/MockB20.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 506a751..97d47bc 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -310,9 +310,9 @@ abstract contract MockB20 is IB20 { // Deprecated: `burnBlocked` is retained unchanged for back-compat but is no // longer part of the IB20 interface. Its blocked check reads - // `TRANSFER_SENDER_POLICY` and it gates on the `BURN` pause vector — it is NOT - // part of the seize operation class (that is `seizeWithMemo`). Formal removal - // is announced for the Denim hardfork. + // `TRANSFER_SENDER_POLICY` and it gates on the `BURN` pause vector. The + // recommended path is now `seizeWithMemo` to a treasury/self address followed + // by a normal `burn`. Formal removal is announced for the Denim hardfork. function burnBlocked(address from, uint256 amount) external whenNotPaused(PausableFeature.BURN) From d80e21544ab7dc23b2b415a95f86a0b12724d34c Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 31 Jul 2026 13:42:02 -0400 Subject: [PATCH 4/5] docs(BOP-440): make burnBlocked deprecation a @notice; drop Denim commitment Addresses review: use all-caps DEPRECATED @notice natspec, move the guard rationale into @dev instead of an inline impl block, and remove the comment stating what will land in Denim. Co-authored-by: OpenCode --- test/lib/mocks/MockB20.sol | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 97d47bc..c8fa89d 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -308,23 +308,18 @@ abstract contract MockB20 is IB20 { emit Memo(msg.sender, memo); } - // Deprecated: `burnBlocked` is retained unchanged for back-compat but is no - // longer part of the IB20 interface. Its blocked check reads - // `TRANSFER_SENDER_POLICY` and it gates on the `BURN` pause vector. The - // recommended path is now `seizeWithMemo` to a treasury/self address followed - // by a normal `burn`. Formal removal is announced for the Denim hardfork. + /// @notice DEPRECATED. Retained unchanged for back-compat but no longer part of the IB20 + /// interface. Its blocked check reads `TRANSFER_SENDER_POLICY` and it gates on the `BURN` + /// pause vector. The recommended path is now `seizeWithMemo` to a treasury/self address + /// followed by a normal `burn`. + /// @dev Reads the transfer-sender policy id from the transfer-side packed slot and reverts + /// `AccountNotBlocked` if the target is still authorized. Enforced unconditionally, including + /// during the factory bootstrap window, matching the Rust precompile (no `privileged` exception). function burnBlocked(address from, uint256 amount) external whenNotPaused(PausableFeature.BURN) onlyRole(BURN_BLOCKED_ROLE) { - // The point of burnBlocked is to seize from policy-blocked - // accounts. Read the transfer-sender policy ID out of the - // transfer-side packed slot and reject if the target is - // currently authorized. Enforced unconditionally — including - // for factory-originated calls during the bootstrap window — - // matching the Rust precompile, which carves no `privileged` - // exception for this guard. uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender; if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { revert AccountNotBlocked(from); From 71ebc7e3f582ccf3c0631cb6ea5e74333dfba85b Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Fri, 31 Jul 2026 14:20:18 -0400 Subject: [PATCH 5/5] docs(BOP-440): trim AccountNotSeizable natspec; drop Denim mention on BURN_BLOCKED_ROLE Addresses review: apply the suggested shorter AccountNotSeizable wording, and remove the remaining Denim commitment from the BURN_BLOCKED_ROLE getter. Co-authored-by: OpenCode --- src/interfaces/IB20.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 9ef89f1..0960e3f 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -106,8 +106,7 @@ interface IB20 { error UnsupportedPolicyType(bytes32 policyScope); /// @notice `seizeWithMemo` was called against a `from` that is currently authorized under - /// `SEIZE_HOLDER_POLICY` (i.e. not a member of the seize-holder set, so not eligible to be - /// seized from). + /// `SEIZE_HOLDER_POLICY` (i.e. not a member of the seize-holder set). error AccountNotSeizable(address account); /// @notice The deprecated `burnBlocked` was called against a `from` that is currently authorized under @@ -214,7 +213,7 @@ interface IB20 { function BURN_ROLE() external view returns (bytes32); /// @notice Required to call the deprecated `burnBlocked` (no longer part of this interface; retained for - /// the back-compat impl, with formal removal announced for Denim). + /// the back-compat impl). /// @return Role constant. function BURN_BLOCKED_ROLE() external view returns (bytes32);