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
72 changes: 25 additions & 47 deletions src/interfaces/IB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ interface IB20 {
///
/// @param TRANSFER `transfer`, `transferFrom`, and memo'd variants.
/// @param MINT `mint` and `mintWithMemo`.
/// @param BURN `burn` and `burnWithMemo`.
/// @param SEIZE `burnBlocked`, `burnBlockedWithMemo`, and `transferFromSeizableWithMemo`
/// @param BURN `burn`, `burnWithMemo`, and the deprecated `burnBlocked`.
/// @param SEIZE `seizeWithMemo`.
enum PausableFeature {
TRANSFER,
MINT,
Expand Down Expand Up @@ -105,8 +105,12 @@ interface IB20 {
/// @notice `policyScope` is not a slot this token (or its variant) supports.
error UnsupportedPolicyType(bytes32 policyScope);

/// @notice A seize operation (`burnBlocked`, `burnBlockedWithMemo`, or `transferFromSeizableWithMemo`) was
/// called against a `from` that is currently authorized under `SEIZABLE_ACCOUNT_POLICY` (i.e. not blocked).
/// @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).
error AccountNotSeizable(address account);

/// @notice The deprecated `burnBlocked` was called against a `from` that is currently authorized under
/// `TRANSFER_SENDER_POLICY` (i.e. not blocked).
error AccountNotBlocked(address account);

/// @notice An EIP-2612 `permit` was submitted with a `deadline` strictly less than `block.timestamp`.
Expand Down Expand Up @@ -142,13 +146,14 @@ interface IB20 {
/// immediately after the underlying `Transfer` event. `caller` is the `msg.sender` of the memo'd call.
event Memo(address indexed caller, bytes32 indexed memo);

/// @notice Emitted by `burnBlocked` and `burnBlockedWithMemo` in addition to `Transfer(from, address(0), amount)`.
/// @notice Emitted by the deprecated `burnBlocked` in addition to `Transfer(from, address(0), amount)`.
/// `burnBlocked` is no longer part of this interface; the event is retained for the back-compat impl.
event BurnedBlocked(address indexed caller, address indexed from, uint256 amount);

/// @notice Emitted by `transferFromSeizableWithMemo` in addition to `Transfer(from, to, amount)` (and the
/// @notice Emitted by `seizeWithMemo` in addition to `Transfer(from, to, amount)` (and the
/// standard `Memo(caller, memo)`). Records a transfer-based seizure: `caller` is the `msg.sender`
/// (holder of `TRANSFER_FROM_SEIZABLE_ROLE`), `from` the seized account, `to` the destination.
event TransferredFromSeizable(address indexed caller, address indexed from, address indexed to, uint256 amount);
/// (holder of `SEIZE_ROLE`), `from` the seized account, `to` the destination.
event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount);

/// @notice Emitted when `account` is granted `role`. `sender` is the originating caller.
event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
Expand Down Expand Up @@ -207,13 +212,14 @@ interface IB20 {
/// @return Role constant.
function BURN_ROLE() external view returns (bytes32);

/// @notice Required to call `burnBlocked` and `burnBlockedWithMemo`.
/// @notice Required to call the deprecated `burnBlocked` (no longer part of this interface; retained for
/// the back-compat impl).
/// @return Role constant.
function BURN_BLOCKED_ROLE() external view returns (bytes32);

/// @notice Required to call `transferFromSeizableWithMemo`.
/// @notice Required to call `seizeWithMemo`.
/// @return Role constant.
function TRANSFER_FROM_SEIZABLE_ROLE() external view returns (bytes32);
function SEIZE_ROLE() external view returns (bytes32);

/// @notice Required to call `pause`.
/// @return Role constant.
Expand Down Expand Up @@ -257,12 +263,11 @@ interface IB20 {
/// @return Policy scope constant.
function MINT_RECEIVER_POLICY() external view returns (bytes32);

/// @notice Policy slot consulted against `from` by the seize operations.
/// @dev Consulted by `transferFromSeizableWithMemo`, `burnBlocked`, and `burnBlockedWithMemo`. A `from`
/// is seizable only when it is NOT authorized by this policy. An unset slot reads as `0`
/// @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.
/// @return Policy scope constant.
function SEIZABLE_ACCOUNT_POLICY() external view returns (bytes32);
function SEIZE_HOLDER_POLICY() external view returns (bytes32);

/*//////////////////////////////////////////////////////////////
ERC-20
Expand Down Expand Up @@ -427,42 +432,17 @@ interface IB20 {
/// @param memo Off-chain memo payload.
function burnWithMemo(uint256 amount, bytes32 memo) external;

/// @notice Destroys `amount` of `from`'s balance. Emits `Transfer(from, address(0), amount)` and
/// `BurnedBlocked(caller, from, amount)`. Part of the seize operation class.
///
/// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused.
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_BLOCKED_ROLE`.
/// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`.
/// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`.
///
/// @param from Account whose balance is being seized.
/// @param amount Amount to burn.
function burnBlocked(address from, uint256 amount) external;

/// @notice Same as `burnBlocked`, plus emits `Memo(caller, memo)` immediately after the `Transfer` event
/// (i.e. before `BurnedBlocked`). A memo of `bytes32(0)` is permitted.
///
/// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused.
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `BURN_BLOCKED_ROLE`.
/// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`.
/// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`.
///
/// @param from Account whose balance is being seized.
/// @param amount Amount to burn.
/// @param memo Memo payload.
function burnBlockedWithMemo(address from, uint256 amount, bytes32 memo) external;

/// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation.
/// Emits, in order, `Transfer(from, to, amount)`, `Memo(caller, memo)`, and
/// `TransferredFromSeizable(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted.
/// `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 `SEIZABLE_ACCOUNT_POLICY`.
/// `from` is blocked under `SEIZE_HOLDER_POLICY`.
/// @dev `to` is not policy-checked; the destination need not be allowlisted.
/// @dev Reverts with `ContractPaused(SEIZE)` when `SEIZE` is paused.
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `TRANSFER_FROM_SEIZABLE_ROLE`.
/// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `SEIZE_ROLE`.
/// @dev Reverts with `InvalidReceiver` when `to == address(0)`.
/// @dev Reverts with `AccountNotBlocked` when `from` is currently authorized under `SEIZABLE_ACCOUNT_POLICY`.
/// @dev Reverts with `AccountNotSeizable` when `from` is currently authorized under `SEIZE_HOLDER_POLICY`.
/// @dev Reverts with `InsufficientBalance` when `from`'s balance is below `amount`.
///
/// @param from Account whose balance is being seized.
Expand All @@ -471,9 +451,7 @@ interface IB20 {
/// @param memo Memo payload.
///
/// @return Always `true` on success.
function transferFromSeizableWithMemo(address from, address to, uint256 amount, bytes32 memo)
external
returns (bool);
function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool);

/*//////////////////////////////////////////////////////////////
ROLES
Expand Down
4 changes: 2 additions & 2 deletions src/lib/B20Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ library B20Constants {
bytes32 internal constant MINT_ROLE = keccak256("MINT_ROLE");
bytes32 internal constant BURN_ROLE = keccak256("BURN_ROLE");
bytes32 internal constant BURN_BLOCKED_ROLE = keccak256("BURN_BLOCKED_ROLE");
bytes32 internal constant TRANSFER_FROM_SEIZABLE_ROLE = keccak256("TRANSFER_FROM_SEIZABLE_ROLE");
bytes32 internal constant SEIZE_ROLE = keccak256("SEIZE_ROLE");
bytes32 internal constant PAUSE_ROLE = keccak256("PAUSE_ROLE");
bytes32 internal constant UNPAUSE_ROLE = keccak256("UNPAUSE_ROLE");
bytes32 internal constant METADATA_ROLE = keccak256("METADATA_ROLE");
Expand All @@ -18,7 +18,7 @@ library B20Constants {
bytes32 internal constant TRANSFER_RECEIVER_POLICY = keccak256("TRANSFER_RECEIVER_POLICY");
bytes32 internal constant TRANSFER_EXECUTOR_POLICY = keccak256("TRANSFER_EXECUTOR_POLICY");
bytes32 internal constant MINT_RECEIVER_POLICY = keccak256("MINT_RECEIVER_POLICY");
bytes32 internal constant SEIZABLE_ACCOUNT_POLICY = keccak256("SEIZABLE_ACCOUNT_POLICY");
bytes32 internal constant SEIZE_HOLDER_POLICY = keccak256("SEIZE_HOLDER_POLICY");

/// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | SEIZE); 15 = 0b1111.
uint8 internal constant ALL_FEATURES_PAUSED = 15;
Expand Down
4 changes: 2 additions & 2 deletions test/lib/B20Test.sol
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ contract B20Test is B20FactoryTest {
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.SEIZABLE_ACCOUNT_POLICY;
if (i == 3) return B20Constants.SEIZE_HOLDER_POLICY;
return B20Constants.MINT_RECEIVER_POLICY;
}

Expand All @@ -148,7 +148,7 @@ contract B20Test is B20FactoryTest {
/// base-token policy types.
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.SEIZABLE_ACCOUNT_POLICY
|| policyType == B20Constants.TRANSFER_EXECUTOR_POLICY || policyType == B20Constants.SEIZE_HOLDER_POLICY
|| policyType == B20Constants.MINT_RECEIVER_POLICY;
}

Expand Down
57 changes: 25 additions & 32 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ abstract contract MockB20 is IB20 {
bytes32 public constant MINT_ROLE = B20Constants.MINT_ROLE;
bytes32 public constant BURN_ROLE = B20Constants.BURN_ROLE;
bytes32 public constant BURN_BLOCKED_ROLE = B20Constants.BURN_BLOCKED_ROLE;
bytes32 public constant TRANSFER_FROM_SEIZABLE_ROLE = B20Constants.TRANSFER_FROM_SEIZABLE_ROLE;
bytes32 public constant SEIZE_ROLE = B20Constants.SEIZE_ROLE;
bytes32 public constant PAUSE_ROLE = B20Constants.PAUSE_ROLE;
bytes32 public constant UNPAUSE_ROLE = B20Constants.UNPAUSE_ROLE;
bytes32 public constant METADATA_ROLE = B20Constants.METADATA_ROLE;
Expand All @@ -95,7 +95,7 @@ abstract contract MockB20 is IB20 {
bytes32 public constant TRANSFER_RECEIVER_POLICY = B20Constants.TRANSFER_RECEIVER_POLICY;
bytes32 public constant TRANSFER_EXECUTOR_POLICY = B20Constants.TRANSFER_EXECUTOR_POLICY;
bytes32 public constant MINT_RECEIVER_POLICY = B20Constants.MINT_RECEIVER_POLICY;
bytes32 public constant SEIZABLE_ACCOUNT_POLICY = B20Constants.SEIZABLE_ACCOUNT_POLICY;
bytes32 public constant SEIZE_HOLDER_POLICY = B20Constants.SEIZE_HOLDER_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 @@ -308,36 +308,30 @@ abstract contract MockB20 is IB20 {
emit Memo(msg.sender, memo);
}

/// @notice DEPRECATED. Retained unchanged for back-compat but no longer part of the IB20
/// interface. Its blocked check reads `TRANSFER_SENDER_POLICY` and it gates on the `BURN`
/// pause vector. The recommended path is now `seizeWithMemo` to a treasury/self address
/// followed by a normal `burn`.
/// @dev Reads the transfer-sender policy id from the transfer-side packed slot and reverts
/// `AccountNotBlocked` if the target is still authorized. Enforced unconditionally, including
/// during the factory bootstrap window, matching the Rust precompile (no `privileged` exception).
function burnBlocked(address from, uint256 amount)
external
whenNotPaused(PausableFeature.SEIZE)
onlyRole(BURN_BLOCKED_ROLE)
{
// Part of the seize operation class: gated on SEIZABLE_ACCOUNT_POLICY (not the
// transfer-sender policy) and the SEIZE pause vector, same as
// burnBlockedWithMemo and transferFromSeizableWithMemo.
_requireSeizable(from);
_burnRaw(from, amount);
emit BurnedBlocked(msg.sender, from, amount);
}

function burnBlockedWithMemo(address from, uint256 amount, bytes32 memo)
external
whenNotPaused(PausableFeature.SEIZE)
whenNotPaused(PausableFeature.BURN)
onlyRole(BURN_BLOCKED_ROLE)
{
_requireSeizable(from);
uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender;
if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) {
revert AccountNotBlocked(from);
}
_burnRaw(from, amount);
// `Memo` must immediately follow the `Transfer` (emitted by `_burnRaw`),
// per the IB20 `Memo` invariant, so it precedes `BurnedBlocked`.
emit Memo(msg.sender, memo);
emit BurnedBlocked(msg.sender, from, amount);
}

function transferFromSeizableWithMemo(address from, address to, uint256 amount, bytes32 memo)
function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo)
external
whenNotPaused(PausableFeature.SEIZE)
onlyRole(TRANSFER_FROM_SEIZABLE_ROLE)
onlyRole(SEIZE_ROLE)
returns (bool)
{
// Admin seize: reassign a blocked account's balance. `to` must be
Expand All @@ -346,17 +340,17 @@ abstract contract MockB20 is IB20 {
// 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 SEIZABLE_ACCOUNT_POLICY. Deliberately does NOT reuse the
// 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);
_moveBalance(from, to, amount);
// `Memo` must immediately follow the `Transfer` (emitted by
// `_moveBalance`), per the IB20 `Memo` invariant, so it precedes
// `TransferredFromSeizable`.
// `Seized`.
emit Memo(msg.sender, memo);
emit TransferredFromSeizable(msg.sender, from, to, amount);
emit Seized(msg.sender, from, to, amount);
return true;
}

Expand Down Expand Up @@ -514,7 +508,7 @@ abstract contract MockB20 is IB20 {
if (policyScope == TRANSFER_SENDER_POLICY) return $.transferPolicyIds.sender;
if (policyScope == TRANSFER_RECEIVER_POLICY) return $.transferPolicyIds.receiver;
if (policyScope == TRANSFER_EXECUTOR_POLICY) return $.transferPolicyIds.executor;
if (policyScope == SEIZABLE_ACCOUNT_POLICY) return $.seizePolicyIds.seizable;
if (policyScope == SEIZE_HOLDER_POLICY) return $.seizePolicyIds.seizable;
if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver;
revert UnsupportedPolicyType(policyScope);
}
Expand All @@ -537,7 +531,7 @@ abstract contract MockB20 is IB20 {
$.transferPolicyIds.receiver = newPolicyId;
} else if (policyScope == TRANSFER_EXECUTOR_POLICY) {
$.transferPolicyIds.executor = newPolicyId;
} else if (policyScope == SEIZABLE_ACCOUNT_POLICY) {
} else if (policyScope == SEIZE_HOLDER_POLICY) {
$.seizePolicyIds.seizable = newPolicyId;
} else {
$.mintPolicyIds.receiver = newPolicyId;
Expand Down Expand Up @@ -790,14 +784,13 @@ abstract contract MockB20 is IB20 {
emit Transfer(from, to, amount);
}

/// @dev Seize gate: reverts `AccountNotBlocked(from)` unless `from` is
/// blocked under `SEIZABLE_ACCOUNT_POLICY` (i.e. NOT authorized). Enforced
/// unconditionally, including in the factory bootstrap window,
/// mirroring the `burnBlocked` sender-policy check.
/// @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.
function _requireSeizable(address from) internal view {
uint64 seizablePolicyId = MockB20Storage.layout().seizePolicyIds.seizable;
if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(seizablePolicyId, from)) {
revert AccountNotBlocked(from);
revert AccountNotSeizable(from);
}
}

Expand Down
9 changes: 4 additions & 5 deletions test/lib/mocks/MockB20Storage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,9 @@ library MockB20Storage {
uint64 executor;
}

/// @notice Seize policy IDs (read by the seize operations `transferFromSeizableWithMemo`,
/// `burnBlocked`, and `burnBlockedWithMemo`).
/// @notice Seize policy IDs (read by the seize operation `seizeWithMemo`).
/// @dev Bit layout:
/// bits 0.. 63 : seizable (`SEIZABLE_ACCOUNT_POLICY`)
/// bits 0.. 63 : seizable (`SEIZE_HOLDER_POLICY`)
/// bits 64..255 : reserved (implicit)
struct SeizePolicyIds {
uint64 seizable;
Expand Down Expand Up @@ -119,7 +118,7 @@ library MockB20Storage {
// shifts and mask operations.
//
// Transfer-side policies (read by `_transfer`, `transferFrom*`,
// and the seize check in `burnBlocked`).
// and the blocked check in the deprecated `burnBlocked`).
TransferPolicyIds transferPolicyIds;
// Mint-side policies (read by `_mint`). Only `MINT_RECEIVER_POLICY`
// is defined today; future granular mint-side policy types (e.g.
Expand Down Expand Up @@ -324,7 +323,7 @@ library MockB20Storage {
return uint256(senderId) | (uint256(receiverId) << 64) | (uint256(executorId) << 128);
}

/// @notice Extracts the SEIZABLE_ACCOUNT policy id (lane 0) from the seize packed slot.
/// @notice Extracts the seize-holder policy id (lane 0) from the seize packed slot.
function seizablePolicyId(uint256 packed) internal pure returns (uint64) {
return uint64(packed);
}
Expand Down
2 changes: 1 addition & 1 deletion test/regression/B20Removals.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ contract B20RemovalsTest is B20AssetTest {
}

/// @notice Verifies the `BURN_FROM_ROLE()` role constant no longer resolves
/// @dev `burnBlocked` (gated by `BURN_BLOCKED_ROLE`) is the remaining seize path.
/// @dev `seizeWithMemo` (gated by `SEIZE_ROLE`) is the seize path; `burnBlocked` remains a burn.
function test_burnFromRole_revert_selectorRemoved() public {
_assertSelectorRemoved(abi.encodeWithSignature("BURN_FROM_ROLE()"), "BURN_FROM_ROLE() must not resolve");
}
Expand Down
Loading
Loading