Skip to content
Open
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
16 changes: 10 additions & 6 deletions script/smoke/journeys/seize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)")
Expand All @@ -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)
Expand All @@ -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)",
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 4 additions & 9 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions test/unit/B20/supply/seizeWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
32 changes: 29 additions & 3 deletions test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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);
Expand Down
Loading