From fc7f439337962a543a2d47d3040b25f6a2846830 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Fri, 21 Aug 2026 16:40:47 -0400 Subject: [PATCH] fix(b20): make SEIZE_HOLDER_POLICY inclusive (authorized = seizable) Align seize-holder membership with the standard allowlist pattern used by mint/seize receiver policies. An account is seizable when authorized under SEIZE_HOLDER_POLICY; unset defaults to always-allow. Co-authored-by: Cursor --- CHANGELOG.md | 5 +- changelog/02_Cobalt_B20_seize.md | 8 ++-- script/smoke/README.md | 2 +- script/smoke/journeys/seize.py | 14 +++--- src/interfaces/IB20.sol | 12 +++-- test/lib/mocks/MockB20.sol | 11 +++-- test/unit/B20/supply/seizeWithMemo.t.sol | 32 ++++++++++--- .../supply/seizeWithMemo_revertOrder.t.sol | 46 ++++++++++--------- 8 files changed, 78 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6029635..44f60f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,9 @@ scheduled `updateUIMultiplier(uint256,uint256)`. #### B20 Asset and Stablecoin: `burnBlocked` callers Replace administrative balance removal with `seizeWithMemo(from, treasury, amount, memo)`, then call -`burn(amount)` if you still need to destroy supply. Seize is opt-in per token: it has no effect -until the issuer sets `SEIZE_HOLDER_POLICY`. +`burn(amount)` if you still need to destroy supply. Seize is gated by `SEIZE_ROLE` and +`SEIZE_HOLDER_POLICY`: an account is seizable when authorized under that policy (unset defaults +to always-allow, so issuers that want a restricted seize set must configure an allowlist). #### PolicyRegistry integrators diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index a32350a..5120db2 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -83,7 +83,7 @@ function burnBlocked(address from, uint256 amount) external; **Policy semantics:** -- `SEIZE_HOLDER_POLICY` gates who is seizable. The membership is inverted: an account is seizable when it is **not** authorized under this policy. This mirrors the blocklist semantics of `burnBlocked`'s `TRANSFER_SENDER_POLICY` so the "blocked = seizable" model carries over. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer configures the slot. This is a safe default. +- `SEIZE_HOLDER_POLICY` gates who is seizable. Membership is inclusive: an account is seizable when it is **authorized** under this policy. This matches the standard allowlist pattern used by `MINT_RECEIVER_POLICY` and `SEIZE_RECEIVER_POLICY`. An unset slot reads as `0` (always-allow), so every account is seizable until an issuer configures the slot (for example, with an allowlist of seizable holders). - `SEIZE_RECEIVER_POLICY` gates the seize destination. It mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot defaults to always-allow, so an unconfigured token may seize to any destination (a treasury need not be allowlisted). @@ -95,7 +95,7 @@ function burnBlocked(address from, uint256 amount) external; 2. Check that the caller holds `SEIZE_ROLE`; else revert `AccessControlUnauthorizedAccount`. 3. Reject zero or self destinations; else revert `InvalidReceiver`. 4. Reject zero source; else revert `InvalidSender`. -5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`; else revert `AccountNotSeizable`. +5. Require `from` to be authorized under `SEIZE_HOLDER_POLICY`; else revert `AccountNotSeizable`. 6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`; else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. 7. Check balance; else revert `InsufficientBalance`. 8. Emit `Transfer`, then `Memo`, then `Seized`. @@ -121,7 +121,7 @@ Seize is a transfer, not a burn. The balance moves from `from` to `to` and `tota **After (new, single call):** -1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e., blocked). +1. Configure `from` as authorized under `SEIZE_HOLDER_POLICY` (for example, as an allowlist member). 2. Call `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE`. 3. Emits, in order: - `Transfer(from, treasury, amount)` @@ -151,7 +151,7 @@ The name `transferFromBlockedWithMemo` was considered and rejected. `seizeWithMe **To adopt `seizeWithMemo`:** 1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize. With no `SEIZE_ROLE` holders, no one can seize. -2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under it. With no policy configured (unset = always-allow), no account is seizable. +2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are authorized under it (for example, an allowlist of seizable holders). With no policy configured (unset = always-allow), every account is seizable. 3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow (for example, an unallowlisted treasury still works). **To reproduce `burnBlocked`'s destroy-supply outcome with seize:** `seizeWithMemo` alone does not reduce `totalSupply`. Seize to a treasury or self address, then call `burn(amount)` from that address if you want the supply destroyed. diff --git a/script/smoke/README.md b/script/smoke/README.md index a579a5a..03388cd 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -112,7 +112,7 @@ Seven "journeys", run as a whole suite (a single journey can still be run via th | `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). The rebase event is fork-aware: V1 emits `MultiplierUpdated`; Cobalt (AssetV2) emits both `MultiplierUpdated` and `UIMultiplierUpdated`. | | `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `updateUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `UIMultiplierUpdateExists`), `cancelUIMultiplierUpdate` (+ `UIMultiplierUpdateDoesNotExist`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled` + the deprecated `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | | `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | -| `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the transfer receiver policy on `to`, the `SEIZE_RECEIVER_POLICY` gate on `to` (unset = allow-any, configured = destination must be authorized, else `PolicyForbids`), and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | +| `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate (inclusive: `from` must be authorized) + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the transfer receiver policy on `to`, the `SEIZE_RECEIVER_POLICY` gate on `to` (unset = allow-any, configured = destination must be authorized, else `PolicyForbids`), and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | | `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | | `invariants` | EVM-context invariants a precompile must implement explicitly: payable rejection, unknown-selector revert, strict ABI decode, dirty-bit canonicalization, `STATICCALL` read-only enforcement, returndata fidelity, OOG containment, revert atomicity, and gas independence from a force-fed balance. Uses the `PrecompileProbe` + `ForceFeeder` helpers under `test/lib/`. | diff --git a/script/smoke/journeys/seize.py b/script/smoke/journeys/seize.py index 3777bf9..307fb9a 100644 --- a/script/smoke/journeys/seize.py +++ b/script/smoke/journeys/seize.py @@ -2,7 +2,7 @@ Exercises the transfer-based seize surface added at Cobalt (V2): the dedicated `SEIZE_ROLE`, the `SEIZE_HOLDER_POLICY` membership gate (an account is seizable when -it is NOT authorized by that policy), `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, +it is authorized by that policy), `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving because seize is a reassignment, not a burn), and the `SEIZE` pause vector — plus the gates that must reject (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the *transfer* @@ -71,12 +71,12 @@ def _journey(c: Chain, tok) -> None: c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1000, 18), "alice balance") c.assert_eq(tok.functions.totalSupply().call(), config.amt(1010, 18), "total supply") - step(3, "seizable setup: blocklist policy on SEIZE_HOLDER_POLICY, block alice (alice becomes seizable)") - pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_BLOCKLIST) + step(3, "seizable setup: allowlist policy on SEIZE_HOLDER_POLICY, allow alice (alice becomes seizable)") + pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST) c.send(tok.functions.updatePolicy(config.SEIZE_HOLDER_POLICY, pid), c.deployer) - c.send(c.policy.functions.updateBlocklist(pid, True, [c.ALICE]), c.deployer) - c.assert_eq(c.policy.functions.isAuthorized(pid, c.ALICE).call(), False, "alice not authorized (seizable)") - c.assert_eq(c.policy.functions.isAuthorized(pid, c.BOB).call(), True, "bob authorized (not seizable)") + c.send(c.policy.functions.updateAllowlist(pid, True, [c.ALICE]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid, c.ALICE).call(), True, "alice authorized (seizable)") + c.assert_eq(c.policy.functions.isAuthorized(pid, c.BOB).call(), False, "bob not authorized (not seizable)") step(4, "seizeWithMemo(alice, bob, 400, memo): Transfer -> Memo -> Seized; supply unchanged") receipt = c.send(tok.functions.seizeWithMemo(c.ALICE, c.BOB, config.amt(400, 18), MEMO), c.deployer) @@ -103,7 +103,7 @@ def _journey(c: Chain, tok) -> None: def _edges(c: Chain, tok) -> None: - step(5, "seize an account that is NOT seizable (bob authorized) -> AccountNotSeizable") + step(5, "seize an account that is NOT seizable (bob not authorized) -> AccountNotSeizable") c.expect_revert("AccountNotSeizable", tok.functions.seizeWithMemo(c.BOB, c.DEPLOYER, 1, MEMO), c.DEPLOYER) step(6, "role gate: user2 (no SEIZE_ROLE) -> AccessControlUnauthorizedAccount") diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index bc9302a..28d90c6 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -105,7 +105,7 @@ interface IB20 { /// @notice `policyScope` is not a slot this token (or its variant) supports. error UnsupportedPolicyType(bytes32 policyScope); - /// @notice `seizeWithMemo` was called against a `from` that is currently authorized under + /// @notice `seizeWithMemo` was called against a `from` that is not currently authorized under /// `SEIZE_HOLDER_POLICY` (i.e. not a member of the seize-holder set). error AccountNotSeizable(address account); @@ -262,8 +262,8 @@ interface IB20 { function MINT_RECEIVER_POLICY() external view returns (bytes32); /// @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. + /// @dev A `from` is seizable only when it is authorized by this policy. An unset slot reads as `0` + /// (always-allow), so every account is seizable until an issuer configures the slot. /// @return Policy scope constant. function SEIZE_HOLDER_POLICY() external view returns (bytes32); @@ -457,14 +457,16 @@ interface IB20 { /// `Seized(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted. /// /// @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`. + /// `from` is authorized under `SEIZE_HOLDER_POLICY` and `to` is authorized under `SEIZE_RECEIVER_POLICY`. + /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`, which defaults to always-allow when unset, so an + /// unconfigured token may seize from any account (a holder need not be allowlisted). /// @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)` or `from == to`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. - /// @dev Reverts with `AccountNotSeizable` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`. + /// @dev Reverts with `AccountNotSeizable` when `from` is not 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`. /// diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 59dd7d5..b372dfa 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -332,7 +332,7 @@ abstract contract MockB20 is IB20 { /// @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 op: skips transfer policies and allowance. Reverts `InvalidReceiver` when `to == 0` - /// or `from == to`, and `InvalidSender` when `from == 0`. `from` must be blocked under + /// or `from == to`, and `InvalidSender` when `from == 0`. `from` must be authorized under /// `SEIZE_HOLDER_POLICY`; `to` must be authorized under `SEIZE_RECEIVER_POLICY` (mirrors /// `MINT_RECEIVER_POLICY`: unset slot = always-allow). /// @param from Account whose balance is being seized. @@ -790,12 +790,13 @@ abstract contract MockB20 is IB20 { emit Transfer(from, to, amount); } - /// @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. + /// @dev Seize gate: reverts `AccountNotSeizable(from)` unless `from` is + /// authorized under `SEIZE_HOLDER_POLICY`. Enforced unconditionally, + /// including in the factory bootstrap window. An unset slot reads as + /// `ALWAYS_ALLOW_ID`, so every account is seizable until configured. function _requireSeizable(address from) internal view { uint64 seizablePolicyId = MockB20Storage.layout().seizePolicyIds.seizable; - if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(seizablePolicyId, from)) { + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(seizablePolicyId, from)) { revert AccountNotSeizable(from); } } diff --git a/test/unit/B20/supply/seizeWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol index ccb0083..416bf70 100644 --- a/test/unit/B20/supply/seizeWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -6,17 +6,16 @@ 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"; +import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; /// @title Unit tests for `seizeWithMemo` (transfer-based seize). contract B20SeizeWithMemoTest is B20Test { address internal seizer = makeAddr("seizer"); - /// @dev Blocks `from` under SEIZE_HOLDER_POLICY and grants the seize role. Mirrors the - /// setup every success path shares. + /// @dev Grants the seize role. Default `SEIZE_HOLDER_POLICY` is ALWAYS_ALLOW, + /// so every account is authorized (seizable) until the slot is reconfigured. function _armSeize() internal { _grantRole(B20Constants.SEIZE_ROLE, seizer); - _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); } /// @notice Reverts when caller lacks SEIZE_ROLE. @@ -81,13 +80,14 @@ contract B20SeizeWithMemoTest is B20Test { assertEq(token.balanceOf(account), amount, "balance must be unchanged"); } - /// @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 { + /// @notice Reverts AccountNotSeizable when `from` is not authorized under SEIZE_HOLDER_POLICY. + /// @dev ALWAYS_BLOCK means every account is unauthorized → not seizable. + function test_seizeWithMemo_revert_accountNotSeizable(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); @@ -186,6 +186,24 @@ contract B20SeizeWithMemoTest is B20Test { assertEq(token.balanceOf(to), amount, "unset receiver policy must allow any destination"); } + /// @notice An unset SEIZE_HOLDER_POLICY (default ALWAYS_ALLOW) lets seize from any account. + function test_seizeWithMemo_success_unsetHolderPolicyAllowsAnySource(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_HOLDER_POLICY left unset (0 = ALWAYS_ALLOW). + + vm.prank(seizer); + token.seizeWithMemo(from, to, amount, bytes32(0)); + + assertEq(token.balanceOf(to), amount, "unset holder policy must allow any source"); + } + /// @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 diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index 4732a26..290fd03 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -15,7 +15,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 3. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 4. ZERO-SENDER (`from == address(0)`) → `InvalidSender` /// 5. SELF-SEIZE (`from == to`) → `InvalidReceiver` -/// 6. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotSeizable` +/// 6. HOLDER (`isAuthorized(seizablePolicyId, from) == false`) → `AccountNotSeizable` /// 7. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` /// 8. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` contract B20SeizeWithMemoRevertOrderTest is B20Test { @@ -47,11 +47,13 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(from, address(0), 1, bytes32(0)); } - /// @notice ZERO-ACTORS beats BLOCKED (`to == 0` reverts before the seizable check on `from`). - function test_seizeWithMemo_revertOrder_zeroActors_beats_blocked(address from) public { + /// @notice ZERO-ACTORS beats HOLDER (`to == 0` reverts before the seizable check on `from`). + function test_seizeWithMemo_revertOrder_zeroActors_beats_holder(address from) public { _assumeValidActor(from); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` would be "not blocked", but `to == 0` fires first. + // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` is seizable, but `to == 0` fires first. + // Force the holder gate to fail as well so both conditions are live. + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0))); @@ -67,12 +69,13 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(address(0), address(0), 1, bytes32(0)); } - /// @notice ZERO-SENDER beats BLOCKED (`from == 0` reverts even though the zero address would also + /// @notice ZERO-SENDER beats HOLDER (`from == 0` reverts even though the zero address would also /// fail the seizable check, i.e. the guard is unconditional, not gated on policy state). - function test_seizeWithMemo_revertOrder_zeroSender_beats_blocked(address to) public { + function test_seizeWithMemo_revertOrder_zeroSender_beats_holder(address to) public { _assumeValidActor(to); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → the zero address is NOT blocked (not seizable). + // ALWAYS_BLOCK → the zero address is also not authorized (not seizable). + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0))); @@ -92,38 +95,39 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(account, account, 1, bytes32(0)); } - /// @notice SELF-SEIZE beats BLOCKED (`from == to` reverts even though `from` would also fail the + /// @notice SELF-SEIZE beats HOLDER (`from == to` reverts even though `from` would also fail the /// seizable check, i.e. the self-seize guard is unconditional, not gated on policy state). - function test_seizeWithMemo_revertOrder_selfSeize_beats_blocked(address account) public { + function test_seizeWithMemo_revertOrder_selfSeize_beats_holder(address account) public { _assumeValidActor(account); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `account` is NOT blocked (not seizable either). + // ALWAYS_BLOCK → `account` is not authorized (not seizable either). + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, account)); token.seizeWithMemo(account, account, 1, bytes32(0)); } - /// @notice BLOCKED beats BALANCE (`from` not blocked and zero balance → AccountNotSeizable wins). - function test_seizeWithMemo_revertOrder_blocked_beats_balance(address from, address to) public { + /// @notice HOLDER beats BALANCE (`from` not authorized and zero balance → AccountNotSeizable wins). + function test_seizeWithMemo_revertOrder_holder_beats_balance(address from, address to) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); 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 { + /// @notice HOLDER beats RECEIVER (`from` not authorized wins over a forbidding receiver policy on `to`). + function test_seizeWithMemo_revertOrder_holder_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_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); @@ -137,8 +141,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { _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); + // `from` IS seizable (ALWAYS_ALLOW default), `to` IS forbidden by the receiver policy, and balance is zero. _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); @@ -150,14 +153,15 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(from, to, 1, bytes32(0)); } - /// @notice PAUSE beats BLOCKED. - function test_seizeWithMemo_revertOrder_pause_beats_blocked(address from, address to) public { + /// @notice PAUSE beats HOLDER. + function test_seizeWithMemo_revertOrder_pause_beats_holder(address from, address to) public { _assumeValidActor(from); _assumeValidActor(to); vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); _pause(IB20.PausableFeature.SEIZE); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. + // ALWAYS_BLOCK → `from` not authorized, but pause fires first. + _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE));