Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions script/smoke/journeys/seize.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,16 @@ 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")
step(8, "zero source -> InvalidSender (seize is a reassignment, not a mint)")
c.expect_revert("InvalidSender", tok.functions.seizeWithMemo(config.ZERO, c.BOB, 1, MEMO), c.DEPLOYER)

step(9, "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(9, "seize ignores the receiver policy on `to`: block bob on TRANSFER_RECEIVER_POLICY, seize still lands")
step(10, "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)
Expand All @@ -135,7 +138,7 @@ def _decoupling(c: Chain, tok) -> None:


def _pause(c: Chain, tok) -> None:
step(10, "pause SEIZE: seizeWithMemo reverts ContractPaused; transfers are independent; unpause restores")
step(11, "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)")
Expand All @@ -154,13 +157,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(11, "SEIZE_RECEIVER_POLICY unset (default): seize to any destination is allowed")
step(12, "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(12, "configure SEIZE_RECEIVER_POLICY allowlist(bob): seize to bob (authorized) succeeds")
step(13, "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)
Expand All @@ -169,12 +172,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(13, "receiver policy forbids an unauthorized destination -> PolicyForbids(SEIZE_RECEIVER_POLICY)")
step(14, "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(14, "expected events emitted across the flow")
step(15, "expected events emitted across the flow")
c.assert_events_emitted(
"seize events",
"B20Created(address,uint8,string,string,uint8,bytes)",
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,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)` 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 `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` when `to` is not authorized under `SEIZE_RECEIVER_POLICY`.
/// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`.
Expand Down
6 changes: 4 additions & 2 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,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 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).
/// or `from == to`, and `InvalidSender` when `from == 0`. `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.
Expand All @@ -344,6 +345,7 @@ abstract contract MockB20 is IB20 {
onlyRole(SEIZE_ROLE)
{
if (to == address(0)) revert InvalidReceiver(to);
if (from == address(0)) revert InvalidSender(from);
if (from == to) revert InvalidReceiver(to);
_requireSeizable(from);
_requireSeizeReceiver(to);
Expand Down
12 changes: 12 additions & 0 deletions test/unit/B20/supply/seizeWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ contract B20SeizeWithMemoTest is B20Test {
token.seizeWithMemo(from, address(0), amount, bytes32(0));
}

/// @notice Reverts InvalidSender when `from == address(0)`. A non-default `SeizeHolder` can treat
/// the zero address as seizable; without this guard a zero-amount seize from the zero
/// address would emit a misleading `Transfer(0x0, to, 0)` that indexers read as a mint.
function test_seizeWithMemo_revert_zeroFrom(address to, uint256 amount) public {
_assumeValidActor(to);
_armSeize();

vm.prank(seizer);
vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0)));
token.seizeWithMemo(address(0), to, 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.
Expand Down
32 changes: 27 additions & 5 deletions test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr
/// @notice **Canonical order (Solidity reference):**
/// 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. 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`
/// 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`
/// 7. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`
/// 8. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance`
contract B20SeizeWithMemoRevertOrderTest is B20Test {
address internal seizer = makeAddr("seizer");

Expand Down Expand Up @@ -57,6 +58,27 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test {
token.seizeWithMemo(from, address(0), 1, bytes32(0));
}

/// @notice ZERO-RECEIVER beats ZERO-SENDER (`to == 0` reverts before the `from == 0` check).
function test_seizeWithMemo_revertOrder_zeroReceiver_beats_zeroSender() public {
_grantRole(B20Constants.SEIZE_ROLE, seizer);

vm.prank(seizer);
vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0)));
token.seizeWithMemo(address(0), address(0), 1, bytes32(0));
}

/// @notice ZERO-SENDER beats BLOCKED (`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 {
_assumeValidActor(to);
_grantRole(B20Constants.SEIZE_ROLE, seizer);
// SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → the zero address is NOT blocked (not seizable).

vm.prank(seizer);
vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0)));
token.seizeWithMemo(address(0), to, 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);
Expand Down
Loading