diff --git a/script/smoke/journeys/seize.py b/script/smoke/journeys/seize.py index d9474be..01a571d 100644 --- a/script/smoke/journeys/seize.py +++ b/script/smoke/journeys/seize.py @@ -116,9 +116,13 @@ def _edges(c: Chain, tok) -> None: step(7, "zero destination -> InvalidReceiver (seize is a reassignment, not a burn)") c.expect_revert("InvalidReceiver", tok.functions.seizeWithMemo(c.ALICE, config.ZERO, 1, MEMO), c.DEPLOYER) + step(8, "self-seize (from == to == alice, seizable) -> InvalidReceiver, balance untouched") + c.expect_revert("InvalidReceiver", tok.functions.seizeWithMemo(c.ALICE, c.ALICE, 1, MEMO), c.DEPLOYER) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(600, 18), "alice balance unchanged by rejected self-seize") + def _decoupling(c: Chain, tok) -> None: - step(8, "seize ignores the receiver policy on `to`: block bob on TRANSFER_RECEIVER_POLICY, seize still lands") + step(9, "seize ignores the receiver policy on `to`: block bob on TRANSFER_RECEIVER_POLICY, seize still lands") recv_pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_BLOCKLIST) c.send(tok.functions.updatePolicy(config.TRANSFER_RECEIVER_POLICY, recv_pid), c.deployer) c.send(c.policy.functions.updateBlocklist(recv_pid, True, [c.BOB]), c.deployer) @@ -131,7 +135,7 @@ def _decoupling(c: Chain, tok) -> None: def _pause(c: Chain, tok) -> None: - step(9, "pause SEIZE: seizeWithMemo reverts ContractPaused; transfers are independent; unpause restores") + step(10, "pause SEIZE: seizeWithMemo reverts ContractPaused; transfers are independent; unpause restores") c.send(tok.functions.pause([config.FEATURE_SEIZE]), c.deployer) c.assert_eq(tok.functions.isPaused(config.FEATURE_SEIZE).call(), True, "SEIZE paused") c.assert_eq(tok.functions.isPaused(config.FEATURE_TRANSFER).call(), False, "TRANSFER not paused (independent vector)") @@ -150,13 +154,13 @@ def _pause(c: Chain, tok) -> None: def _receiver_policy(c: Chain, tok) -> None: # SEIZE_RECEIVER_POLICY gates `to`, mirroring MINT_RECEIVER_POLICY: unset = allow-any, # configured = the destination must be authorized. Balances entering here: alice=410, bob=600. - step(10, "SEIZE_RECEIVER_POLICY unset (default): seize to any destination is allowed") + step(11, "SEIZE_RECEIVER_POLICY unset (default): seize to any destination is allowed") c.assert_eq(tok.functions.SEIZE_RECEIVER_POLICY().call(), config.SEIZE_RECEIVER_POLICY, "receiver scope getter") # Deployer is not on any allowlist; with the scope unset (ALWAYS_ALLOW) the seize still lands. c.send(tok.functions.seizeWithMemo(c.ALICE, c.DEPLOYER, config.amt(10, 18), MEMO), c.deployer) c.assert_eq(tok.functions.balanceOf(c.DEPLOYER).call(), config.amt(10, 18), "deployer received seize (unset receiver policy)") - step(11, "configure SEIZE_RECEIVER_POLICY allowlist(bob): seize to bob (authorized) succeeds") + step(12, "configure SEIZE_RECEIVER_POLICY allowlist(bob): seize to bob (authorized) succeeds") recv_pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST) c.send(tok.functions.updatePolicy(config.SEIZE_RECEIVER_POLICY, recv_pid), c.deployer) c.send(c.policy.functions.updateAllowlist(recv_pid, True, [c.BOB]), c.deployer) @@ -165,12 +169,12 @@ def _receiver_policy(c: Chain, tok) -> None: c.send(tok.functions.seizeWithMemo(c.ALICE, c.BOB, config.amt(100, 18), MEMO), c.deployer) c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(700, 18), "bob received seize (authorized receiver)") - step(12, "receiver policy forbids an unauthorized destination -> PolicyForbids(SEIZE_RECEIVER_POLICY)") + step(13, "receiver policy forbids an unauthorized destination -> PolicyForbids(SEIZE_RECEIVER_POLICY)") c.expect_revert("PolicyForbids", tok.functions.seizeWithMemo(c.ALICE, c.DEPLOYER, 1, MEMO), c.DEPLOYER) def _events(c: Chain) -> None: - step(13, "expected events emitted across the flow") + step(14, "expected events emitted across the flow") c.assert_events_emitted( "seize events", "B20Created(address,uint8,string,string,uint8,bytes)", diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index 49821d5..0a3a21b 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -462,7 +462,7 @@ interface IB20 { /// 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 `InvalidReceiver` when `to == address(0)` or `from == to`. /// @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`. diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index cb4d574..9d78a20 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -331,15 +331,9 @@ 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 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. + /// @dev Admin op: skips transfer policies and allowance. Reverts `InvalidReceiver` when `to == 0` + /// or `from == to`. `from` must be blocked 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. /// @param to Destination address for the seized balance. /// @param amount Amount to seize. @@ -350,6 +344,7 @@ abstract contract MockB20 is IB20 { onlyRole(SEIZE_ROLE) { if (to == address(0)) revert InvalidReceiver(to); + if (from == to) revert InvalidReceiver(to); _requireSeizable(from); _requireSeizeReceiver(to); _moveBalance(from, to, amount); diff --git a/test/unit/B20/supply/seizeWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol index af0326b..65a58bd 100644 --- a/test/unit/B20/supply/seizeWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -53,11 +53,28 @@ contract B20SeizeWithMemoTest is B20Test { token.seizeWithMemo(from, address(0), amount, bytes32(0)); } + /// @notice Reverts InvalidReceiver when `from == to`, even when both the seizable and receiver + /// checks would otherwise pass. A self-seize is a no-op balance move that would otherwise + /// still emit a misleading `Transfer`/`Memo`/`Seized`, polluting the compliance trail. + function test_seizeWithMemo_revert_selfSeize(address account, uint256 amount) public { + _assumeValidActor(account); + amount = bound(amount, 0, B20Constants.MAX_SUPPLY_CAP); + _mint(account, amount); + _armSeize(); + + vm.prank(seizer); + vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, account)); + token.seizeWithMemo(account, account, amount, bytes32(0)); + + 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 { _assumeValidActor(from); _assumeValidActor(to); + vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); vm.prank(seizer); @@ -124,6 +141,7 @@ contract B20SeizeWithMemoTest is B20Test { function test_seizeWithMemo_revert_receiverPolicyForbids(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); + vm.assume(from != to); _armSeize(); _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index 0670d5f..4e73d3b 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -13,9 +13,10 @@ 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`) → `AccountNotSeizable` -/// 5. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` -/// 6. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` +/// 4. SELF-SEIZE (`from == to`) → `InvalidReceiver` +/// 5. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotSeizable` +/// 6. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` +/// 7. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` contract B20SeizeWithMemoRevertOrderTest is B20Test { address internal seizer = makeAddr("seizer"); @@ -56,6 +57,31 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { token.seizeWithMemo(from, address(0), 1, bytes32(0)); } + /// @notice ROLE beats SELF-SEIZE (an unauthorized caller reverts before the `from == to` check). + function test_seizeWithMemo_revertOrder_role_beats_selfSeize(address caller, address account) public { + _assumeValidCaller(caller); + _assumeValidActor(account); + vm.assume(caller != admin); + + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.SEIZE_ROLE) + ); + token.seizeWithMemo(account, account, 1, bytes32(0)); + } + + /// @notice SELF-SEIZE beats BLOCKED (`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 { + _assumeValidActor(account); + _grantRole(B20Constants.SEIZE_ROLE, seizer); + // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `account` is NOT blocked (not seizable either). + + 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 { _assumeValidActor(from);