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
14 changes: 11 additions & 3 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/lib/B20Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions test/lib/B20Test.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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`
Expand Down
42 changes: 33 additions & 9 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

@stevieraykatz stevieraykatz Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @notice, @params and @returns.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm i'm seeing that we weren't consistent with our natspecs in this contract generally. i'd be ok deferring this for a separate doc cleanup pass

/// 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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions test/lib/mocks/MockB20Storage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion test/unit/B20/policy/updatePolicy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions test/unit/B20/supply/seizeWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 35 additions & 1 deletion test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");

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