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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions changelog/02_Cobalt_B20_seize.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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`.
Expand All @@ -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)`
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion script/smoke/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`. |

Expand Down
14 changes: 7 additions & 7 deletions script/smoke/journeys/seize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
12 changes: 7 additions & 5 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

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

Expand Down Expand Up @@ -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`.
///
Expand Down
11 changes: 6 additions & 5 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
Expand Down
32 changes: 25 additions & 7 deletions test/unit/B20/supply/seizeWithMemo.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading