diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 0960e3f..af0054c 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -269,6 +269,12 @@ interface IB20 { /// @return Policy scope constant. function SEIZE_HOLDER_POLICY() external view returns (bytes32); + /// @notice Policy slot consulted against `to` by `seizeWithMemo`. + /// @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. + /// @return Policy scope constant. + function SEIZE_RECEIVER_POLICY() external view returns (bytes32); + /*////////////////////////////////////////////////////////////// ERC-20 //////////////////////////////////////////////////////////////*/ @@ -436,13 +442,15 @@ interface IB20 { /// Emits, in order, `Transfer(from, to, amount)`, `Memo(caller, memo)`, and /// `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 `SEIZE_HOLDER_POLICY`. - /// @dev `to` is not policy-checked; the destination need not be allowlisted. + /// @dev Admin operation: skips allowance and the transfer policies. The membership checks are that + /// `from` is blocked under `SEIZE_HOLDER_POLICY` and `to` is authorized under `SEIZE_RECEIVER_POLICY`. + /// @dev `to` is gated by `SEIZE_RECEIVER_POLICY`, which defaults to always-allow when unset, so an + /// unconfigured token may seize to any destination (a treasury need not be allowlisted). /// @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 `AccountNotSeizable` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. + /// @dev Reverts with `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` when `to` is not authorized under `SEIZE_RECEIVER_POLICY`. /// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`. /// /// @param from Account whose balance is being seized. diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 92d0620..1b52946 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -19,6 +19,7 @@ library B20Constants { bytes32 internal constant TRANSFER_EXECUTOR_POLICY = keccak256("TRANSFER_EXECUTOR_POLICY"); bytes32 internal constant MINT_RECEIVER_POLICY = keccak256("MINT_RECEIVER_POLICY"); bytes32 internal constant SEIZE_HOLDER_POLICY = keccak256("SEIZE_HOLDER_POLICY"); + bytes32 internal constant SEIZE_RECEIVER_POLICY = keccak256("SEIZE_RECEIVER_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 39a6ce8..e6d3cff 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -134,11 +134,12 @@ contract B20Test is B20FactoryTest { /// extends the codomain when they add variant-specific /// policy slots. function _knownPolicyType(uint8 idx) internal pure returns (bytes32) { - uint8 i = idx % 5; + uint8 i = idx % 6; 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.SEIZE_HOLDER_POLICY; + if (i == 4) return B20Constants.SEIZE_RECEIVER_POLICY; return B20Constants.MINT_RECEIVER_POLICY; } @@ -149,7 +150,7 @@ contract B20Test is B20FactoryTest { 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.SEIZE_HOLDER_POLICY - || policyType == B20Constants.MINT_RECEIVER_POLICY; + || policyType == B20Constants.SEIZE_RECEIVER_POLICY || policyType == B20Constants.MINT_RECEIVER_POLICY; } /// @notice Pauses a single `PausableFeature`, lazily granting `PAUSE_ROLE` diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index c8fa89d..6b3d9fe 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -96,6 +96,7 @@ abstract contract MockB20 is IB20 { bytes32 public constant TRANSFER_EXECUTOR_POLICY = B20Constants.TRANSFER_EXECUTOR_POLICY; bytes32 public constant MINT_RECEIVER_POLICY = B20Constants.MINT_RECEIVER_POLICY; bytes32 public constant SEIZE_HOLDER_POLICY = B20Constants.SEIZE_HOLDER_POLICY; + bytes32 public constant SEIZE_RECEIVER_POLICY = B20Constants.SEIZE_RECEIVER_POLICY; /// @notice Maximum value the supply cap may be set to. Because `mint` /// rejects any `totalSupply` above the cap, this also bounds @@ -328,23 +329,31 @@ abstract contract MockB20 is IB20 { emit BurnedBlocked(msg.sender, from, amount); } + /// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation, + /// emitting `Transfer`, `Memo`, then `Seized` (in that order). + /// @dev Admin seize: reassign a blocked account's balance. `to` must be 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. + /// @param from Account whose balance is being seized. + /// @param to Destination address for the seized balance. + /// @param amount Amount to seize. + /// @param memo Memo payload emitted via `Memo`. + /// @return Always `true` on success. function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external whenNotPaused(PausableFeature.SEIZE) onlyRole(SEIZE_ROLE) returns (bool) { - // Admin seize: reassign a blocked account's balance. `to` must be - // non-zero (otherwise this would be a burn), but — unlike a normal - // transfer — no sender/receiver/executor 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 only membership check is that `from` is - // 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); _requireSeizable(from); + _requireSeizeReceiver(to); _moveBalance(from, to, amount); // `Memo` must immediately follow the `Transfer` (emitted by // `_moveBalance`), per the IB20 `Memo` invariant, so it precedes @@ -509,6 +518,7 @@ abstract contract MockB20 is IB20 { if (policyScope == TRANSFER_RECEIVER_POLICY) return $.transferPolicyIds.receiver; if (policyScope == TRANSFER_EXECUTOR_POLICY) return $.transferPolicyIds.executor; if (policyScope == SEIZE_HOLDER_POLICY) return $.seizePolicyIds.seizable; + if (policyScope == SEIZE_RECEIVER_POLICY) return $.seizePolicyIds.receiver; if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver; revert UnsupportedPolicyType(policyScope); } @@ -533,6 +543,8 @@ abstract contract MockB20 is IB20 { $.transferPolicyIds.executor = newPolicyId; } else if (policyScope == SEIZE_HOLDER_POLICY) { $.seizePolicyIds.seizable = newPolicyId; + } else if (policyScope == SEIZE_RECEIVER_POLICY) { + $.seizePolicyIds.receiver = newPolicyId; } else { $.mintPolicyIds.receiver = newPolicyId; } @@ -794,6 +806,18 @@ abstract contract MockB20 is IB20 { } } + /// @dev Seize receiver gate: reverts `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` + /// unless `to` is authorized under `SEIZE_RECEIVER_POLICY`. Mirrors the + /// `_mint` receiver check: enforced unconditionally, and an unset slot + /// reads as `ALWAYS_ALLOW_ID` so an unconfigured token may seize to any + /// destination (a treasury need not be allowlisted). + function _requireSeizeReceiver(address to) internal view { + uint64 seizeReceiverPolicyId = MockB20Storage.layout().seizePolicyIds.receiver; + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(seizeReceiverPolicyId, to)) { + revert PolicyForbids(SEIZE_RECEIVER_POLICY, seizeReceiverPolicyId); + } + } + /// @dev Pure mechanics: policy + supply cap + effects. Pause, role, /// and the zero-receiver check are enforced upstream by `mint` /// / `mintWithMemo`. The asset variant's `batchMint` carries diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 0fd3c7f..eef9860 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -64,9 +64,11 @@ library MockB20Storage { /// @notice Seize policy IDs (read by the seize operation `seizeWithMemo`). /// @dev Bit layout: /// bits 0.. 63 : seizable (`SEIZE_HOLDER_POLICY`) - /// bits 64..255 : reserved (implicit) + /// bits 64..127 : receiver (`SEIZE_RECEIVER_POLICY`) + /// bits 128..255 : reserved (implicit) struct SeizePolicyIds { uint64 seizable; + uint64 receiver; } /// @notice Mint-side policy IDs (read by `_mint`). Only the @@ -328,10 +330,15 @@ library MockB20Storage { return uint64(packed); } - /// @notice Composes the seize packed slot from its single defined lane. - /// @dev Lanes 1..3 are reserved and pinned to zero. - function packSeizePolicyIds(uint64 seizableId) internal pure returns (uint256) { - return uint256(seizableId); + /// @notice Extracts the SEIZE_RECEIVER policy id (lane 1) from the seize packed slot. + function seizeReceiverPolicyId(uint256 packed) internal pure returns (uint64) { + return uint64(packed >> 64); + } + + /// @notice Composes the seize packed slot from its two defined lanes. + /// @dev Lanes 2..3 are reserved and pinned to zero. + function packSeizePolicyIds(uint64 seizableId, uint64 receiverId) internal pure returns (uint256) { + return uint256(seizableId) | (uint256(receiverId) << 64); } /// @notice Extracts the MINT_RECEIVER policy id (lane 0) from the packed slot. diff --git a/test/unit/B20/policy/updatePolicy.t.sol b/test/unit/B20/policy/updatePolicy.t.sol index e452f59..28ea70c 100644 --- a/test/unit/B20/policy/updatePolicy.t.sol +++ b/test/unit/B20/policy/updatePolicy.t.sol @@ -12,10 +12,11 @@ contract B20UpdatePolicyTest is B20Test { /// @notice Reads the policy id stored in the slot lane that /// corresponds to `policyScope`, via raw `vm.load` and the /// per-lane decoder helpers on `MockB20Storage`. - /// @dev The four base-token policy types are packed across two + /// @dev The base-token policy types are packed across three /// slots: /// - `transferPolicyIds` (lane 0: SENDER, 1: RECEIVER, 2: EXECUTOR) /// - `mintPolicyIds` (lane 0: RECEIVER) + /// - `seizePolicyIds` (lane 0: SEIZE_HOLDER, 1: SEIZE_RECEIVER) /// This helper routes to the right slot + lane decoder so /// tests can assert the slot reflects the surface /// `policyId(policyScope)` return. @@ -30,6 +31,12 @@ contract B20UpdatePolicyTest is B20Test { return MockB20Storage.seizablePolicyId(uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot()))); } + if (policyScope == B20Constants.SEIZE_RECEIVER_POLICY) { + return + MockB20Storage.seizeReceiverPolicyId( + uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot())) + ); + } uint256 transferPacked = uint256(vm.load(address(token), MockB20Storage.transferPolicyIdsSlot())); if (policyScope == B20Constants.TRANSFER_SENDER_POLICY) { return MockB20Storage.transferSenderPolicyId(transferPacked); diff --git a/test/unit/B20/supply/seizeWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol index 2b03083..af0326b 100644 --- a/test/unit/B20/supply/seizeWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -119,6 +119,61 @@ contract B20SeizeWithMemoTest is B20Test { assertEq(token.balanceOf(to), amount, "seize must succeed regardless of receiver policy on `to`"); } + /// @notice Reverts PolicyForbids(SEIZE_RECEIVER_POLICY, ...) when `to` is not authorized under it. + /// @dev The receiver gate mirrors MINT_RECEIVER_POLICY and fires after the seizable check on `from`. + function test_seizeWithMemo_revert_receiverPolicyForbids(address from, address to, uint256 amount) public { + _assumeValidActor(from); + _assumeValidActor(to); + _armSeize(); + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(seizer); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.seizeWithMemo(from, to, amount, bytes32(0)); + } + + /// @notice An unset SEIZE_RECEIVER_POLICY (default ALWAYS_ALLOW) lets seize send to any destination. + function test_seizeWithMemo_success_unsetReceiverPolicyAllowsAnyDestination( + address from, + address to, + uint256 amount + ) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); + _mint(from, amount); + _armSeize(); + // SEIZE_RECEIVER_POLICY left unset (0 = ALWAYS_ALLOW). + + vm.prank(seizer); + token.seizeWithMemo(from, to, amount, bytes32(0)); + + assertEq(token.balanceOf(to), amount, "unset receiver policy must allow any destination"); + } + + /// @notice A configured-allow SEIZE_RECEIVER_POLICY authorizes the destination and seize succeeds. + function test_seizeWithMemo_success_configuredReceiverPolicyAllows(address from, address to, uint256 amount) + public + { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + amount = bound(amount, 1, B20Constants.MAX_SUPPLY_CAP); + _mint(from, amount); + _armSeize(); + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_ALLOW_ID); + + vm.prank(seizer); + token.seizeWithMemo(from, to, amount, bytes32(0)); + + assertEq(token.balanceOf(to), amount, "authorized destination must receive the seized amount"); + } + /// @notice Requires no allowance from the seized account: seize skips allowance accounting. function test_seizeWithMemo_success_noAllowanceRequired(address from, address to, uint256 amount) public { _assumeValidActor(from); diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index dcf5482..0670d5f 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -14,7 +14,8 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 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`) → `AccountNotSeizable` -/// 5. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` +/// 5. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` +/// 6. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` contract B20SeizeWithMemoRevertOrderTest is B20Test { address internal seizer = makeAddr("seizer"); @@ -68,6 +69,39 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(from, to, 1, bytes32(0)); } + /// @notice BLOCKED beats RECEIVER (`from` not blocked wins over a forbidding receiver policy on `to`). + function test_seizeWithMemo_revertOrder_blocked_beats_receiver(address from, address to) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + _grantRole(B20Constants.SEIZE_ROLE, seizer); + // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` is NOT blocked (not seizable). + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(seizer); + vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); + token.seizeWithMemo(from, to, 1, bytes32(0)); + } + + /// @notice RECEIVER beats BALANCE (`to` forbidden by the receiver policy wins over zero balance). + function test_seizeWithMemo_revertOrder_receiver_beats_balance(address from, address to) public { + _assumeValidActor(from); + _assumeValidActor(to); + vm.assume(from != to); + _grantRole(B20Constants.SEIZE_ROLE, seizer); + // `from` IS seizable (blocked), `to` IS forbidden by the receiver policy, and balance is zero. + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + vm.prank(seizer); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + token.seizeWithMemo(from, to, 1, bytes32(0)); + } + /// @notice PAUSE beats BLOCKED. function test_seizeWithMemo_revertOrder_pause_beats_blocked(address from, address to) public { _assumeValidActor(from); diff --git a/test/unit/storage/B20FullLayout.t.sol b/test/unit/storage/B20FullLayout.t.sol index eb93276..4b9b313 100644 --- a/test/unit/storage/B20FullLayout.t.sol +++ b/test/unit/storage/B20FullLayout.t.sol @@ -57,6 +57,7 @@ contract B20FullLayoutTest is B20Test { uint64 internal transferReceiverMarker; uint64 internal transferExecutorMarker; uint64 internal seizableMarker; + uint64 internal seizeReceiverMarker; uint64 internal mintReceiverMarker; /// @notice Cross-cuts every field of MockB20Storage.Layout in a single @@ -84,7 +85,7 @@ contract B20FullLayoutTest is B20Test { /// - 11: pausedVectors (TRANSFER + MINT bits) /// - 12: supplyCap /// - 13: nonces (advanced via permit) - /// - 14: seizePolicyIds (seizable lane) + /// - 14: seizePolicyIds (seizable + receiver lanes) /// - 15: initialized (mock-only bootstrap flag, kept last) function test_b20Layout_success_populatedSnapshotMatchesAllSlots() public { // ---------- Populate ---------- @@ -216,7 +217,12 @@ contract B20FullLayoutTest is B20Test { // `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: seize-holder lane"); - assertEq(packedSeize >> 64, 0, "slot 14 bits 64..255: three reserved lanes must be zero"); + assertEq( + (packedSeize >> 64) & 0xFFFFFFFFFFFFFFFF, + uint256(seizeReceiverMarker), + "slot 14 bits 64..127: seize-receiver lane" + ); + assertEq(packedSeize >> 128, 0, "slot 14 bits 128..255: two reserved lanes must be zero"); // ---------- initialized ---------- // Mock world: the mock-only bootstrap flag, kept last in its own slot @@ -279,11 +285,13 @@ contract B20FullLayoutTest is B20Test { transferExecutorMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); seizableMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + seizeReceiverMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); mintReceiverMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, transferSenderMarker); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, transferReceiverMarker); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, transferExecutorMarker); _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, seizableMarker); + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, seizeReceiverMarker); _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 ced2541..b22961a 100644 --- a/test/unit/storage/MockB20SlotHelpers.t.sol +++ b/test/unit/storage/MockB20SlotHelpers.t.sol @@ -239,6 +239,19 @@ contract MockB20SlotHelpersTest is B20Test { ); } + /// @notice Verifies `seizePolicyIdsSlot()` locates the seize-receiver lane (lane 1). + /// @dev Write to SEIZE_RECEIVER_POLICY via `updatePolicy`; lane decoder reads back from its own slot. + function test_seizePolicyIdsSlot_success_decodesReceiverLane() public { + _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + + uint256 packed = uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot())); + assertEq( + MockB20Storage.seizeReceiverPolicyId(packed), + PolicyRegistryConstants.ALWAYS_BLOCK_ID, + "seizeReceiverPolicyId lane must reflect the policy write" + ); + } + /// @notice Verifies `packTransferPolicyIds` is the inverse of the lane decoders. /// @dev Round-trip: pack three uint64s, decode, expect the inputs back. function test_packTransferPolicyIds_success_roundtrips(uint64 senderId, uint64 receiverId, uint64 executorId) @@ -251,9 +264,11 @@ contract MockB20SlotHelpersTest is B20Test { assertEq(MockB20Storage.transferExecutorPolicyId(packed), executorId); } - /// @notice Verifies `packSeizePolicyIds` is the inverse of `seizablePolicyId`. - function test_packSeizePolicyIds_success_roundtrips(uint64 seizableId) public pure { - assertEq(MockB20Storage.seizablePolicyId(MockB20Storage.packSeizePolicyIds(seizableId)), seizableId); + /// @notice Verifies `packSeizePolicyIds` is the inverse of the seize lane decoders. + function test_packSeizePolicyIds_success_roundtrips(uint64 seizableId, uint64 receiverId) public pure { + uint256 packed = MockB20Storage.packSeizePolicyIds(seizableId, receiverId); + assertEq(MockB20Storage.seizablePolicyId(packed), seizableId); + assertEq(MockB20Storage.seizeReceiverPolicyId(packed), receiverId); } /// @notice Verifies `packMintPolicyIds` is the inverse of `mintReceiverPolicyId`.