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
3 changes: 2 additions & 1 deletion 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. The membership is inverted: an account is seizable when it is **not** authorized under this policy. This is distinct from the allowlist-style checks used by `transfer` and `transferFrom`, where `isAuthorized(...) == true` allows the operation. `SEIZE_HOLDER_POLICY` uses the inverse result so it can target accounts those checks deny. 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_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 Down Expand Up @@ -133,6 +133,7 @@ Seize is a transfer, not a burn. The balance moves from `from` to `to` and `tota
**Final shipped shape:** `seizeWithMemo` and `burnBlocked` use fully independent policy slots and pause vectors.

- `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`.
- `SEIZE_HOLDER_POLICY` is intentionally different from the allowlist-style checks used by `transfer` and `transferFrom`. In those flows, `isAuthorized(...) == true` permits the operation. In `seizeWithMemo`, the same check is interpreted inversely: the call reverts when `isAuthorized(...) == true`, so only accounts denied by the policy are seizable. This preserves the safe default, because an unset slot reads as `0` (always allow), which means no account is seizable until the issuer explicitly configures the policy. It also lets seize semantics align with the existing "blocked account" policy model already used by `transferFrom`-style restrictions.
- `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector unchanged.
- Seize operations are rare, so the reserved lane in the transfer packed policy slot was not reused for seize. That lane is kept open for a possible future transfer-side optimization where another hot-path transfer policy could be packed into the existing transfer slot without adding a second `SLOAD`. Because seize is a cold-path/rare-path operation, it instead gets its own packed `seizePolicyIds` slot.

Expand Down
14 changes: 9 additions & 5 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ 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
/// `SEIZE_HOLDER_POLICY` (i.e. not a member of the seize-holder set).
/// @notice `seizeWithMemo` was called against a `from` account that is not seizable under
/// `SEIZE_HOLDER_POLICY`.
/// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false.
error AccountNotSeizable(address account);

/// @notice The deprecated `burnBlocked` was called against a `from` that is currently authorized under
Expand Down Expand Up @@ -262,8 +263,11 @@ 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 `isAuthorized(policyId, from)` returns false.
/// @dev This uses the inverse of the normal transfer-style gating: accounts are seizable when
/// `isAuthorized(...)` returns false, not true.
/// @dev An unset slot reads as `0` (always-allow), so no account is seizable until an issuer
/// explicitly configures the slot.
/// @return Policy scope constant.
function SEIZE_HOLDER_POLICY() external view returns (bytes32);

Expand Down Expand Up @@ -464,7 +468,7 @@ interface IB20 {
/// @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 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
Loading