From 65a84a36248af7ca6c182daa41244b100989bca2 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 24 Aug 2026 11:27:44 -0400 Subject: [PATCH 1/5] docs(IB20): clarify inverted seize-holder semantics Make the SEIZE_HOLDER_POLICY NatSpec explicit that seize uses inverted policy semantics: a holder is seizable only when the policy does not authorize it, and an unset slot remains safe by default. Co-authored-by: Cursor --- src/interfaces/IB20.sol | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index bc9302a..de62767 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -105,8 +105,10 @@ 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 `SEIZE_HOLDER_POLICY` uses inverted semantics: a `from` is seizable only when the + /// policy does not authorize it. error AccountNotSeizable(address account); /// @notice The deprecated `burnBlocked` was called against a `from` that is currently authorized under @@ -262,8 +264,10 @@ 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 This scope uses inverted semantics: a `from` is seizable only when + /// `isAuthorized(policyId, from)` returns false. + /// @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); @@ -456,15 +460,19 @@ 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 membership checks are that - /// `from` is blocked under `SEIZE_HOLDER_POLICY` and `to` is authorized under `SEIZE_RECEIVER_POLICY`. + /// @dev Admin operation: skips allowance and the transfer policies. + /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`, which uses inverted semantics: + /// `from` is seizable only when `isAuthorized(policyId, from)` returns false. + /// @dev An unset `SEIZE_HOLDER_POLICY` slot reads as `0` (always-allow), so no account is + /// seizable until an issuer explicitly configures the slot. + /// @dev `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)` 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`. /// From 4597afd1dae02e5645ac0f041b43a0c44e7c1921 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 24 Aug 2026 11:32:57 -0400 Subject: [PATCH 2/5] docs(IB20): simplify seize-holder NatSpec wording Replace the "inverted semantics" phrasing with direct `isAuthorized(...)` language so the seize-holder gate reads more concretely in the interface docs. Co-authored-by: Cursor --- src/interfaces/IB20.sol | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index de62767..bd8aa80 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -107,8 +107,7 @@ interface IB20 { /// @notice `seizeWithMemo` was called against a `from` account that is not seizable under /// `SEIZE_HOLDER_POLICY`. - /// @dev `SEIZE_HOLDER_POLICY` uses inverted semantics: a `from` is seizable only when the - /// policy does not authorize it. + /// @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 @@ -264,8 +263,7 @@ interface IB20 { function MINT_RECEIVER_POLICY() external view returns (bytes32); /// @notice Policy slot consulted against `from` by `seizeWithMemo`. - /// @dev This scope uses inverted semantics: a `from` is seizable only when - /// `isAuthorized(policyId, from)` returns false. + /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. /// @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. @@ -461,8 +459,8 @@ interface IB20 { /// `Seized(caller, from, to, amount)`. A memo of `bytes32(0)` is permitted. /// /// @dev Admin operation: skips allowance and the transfer policies. - /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`, which uses inverted semantics: - /// `from` is seizable only when `isAuthorized(policyId, from)` returns false. + /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`. + /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. /// @dev An unset `SEIZE_HOLDER_POLICY` slot reads as `0` (always-allow), so no account is /// seizable until an issuer explicitly configures the slot. /// @dev `to` is authorized under `SEIZE_RECEIVER_POLICY`. From 39967966ece86454af8a8e6a7403b410eec10ff4 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 24 Aug 2026 11:56:43 -0400 Subject: [PATCH 3/5] docs: clarify seize-holder as inverse of allowlist gates Explain in the IB20 NatSpec and seize changelog that SEIZE_HOLDER_POLICY is distinct from the allowlist-style checks used by transfer and transferFrom, and intentionally uses the inverse authorization result. Co-authored-by: Cursor --- changelog/02_Cobalt_B20_seize.md | 2 +- src/interfaces/IB20.sol | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index a32350a..9aa4f1c 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -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). diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index bd8aa80..f3c7e38 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -264,6 +264,9 @@ interface IB20 { /// @notice Policy slot consulted against `from` by `seizeWithMemo`. /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. + /// @dev 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 that those checks deny. /// @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. @@ -461,6 +464,9 @@ interface IB20 { /// @dev Admin operation: skips allowance and the transfer policies. /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`. /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. + /// @dev 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 that those checks deny. /// @dev An unset `SEIZE_HOLDER_POLICY` slot reads as `0` (always-allow), so no account is /// seizable until an issuer explicitly configures the slot. /// @dev `to` is authorized under `SEIZE_RECEIVER_POLICY`. From 582eae54f97cd6076fba9892f91958f4990d7965 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 24 Aug 2026 11:58:56 -0400 Subject: [PATCH 4/5] docs(changelog): record seize-holder rationale in design decisions Add the design rationale that SEIZE_HOLDER_POLICY intentionally uses the inverse of the allowlist-style transfer checks so the seize path can target denied accounts while preserving the safe unset default. Co-authored-by: Cursor --- changelog/02_Cobalt_B20_seize.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 9aa4f1c..ffabbd1 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -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 distinct from the allowlist-style checks used by `transfer` and `transferFrom`. Those flows allow the operation when `isAuthorized(...) == true`; seize uses the inverse result so it can target accounts those checks deny. This also 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 slot. - `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. From d4ef393f2881cb87339bab906bf239ba5e2d8fe5 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Mon, 24 Aug 2026 12:10:55 -0400 Subject: [PATCH 5/5] docs(IB20): clarify seize-holder policy semantics Explain the inverted `SEIZE_HOLDER_POLICY` behavior in plainer technical English so the interface docs and changelog more clearly describe the default behavior and blocked-account model. Co-authored-by: Cursor --- changelog/02_Cobalt_B20_seize.md | 2 +- src/interfaces/IB20.sol | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index ffabbd1..dc19c30 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -133,7 +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 distinct from the allowlist-style checks used by `transfer` and `transferFrom`. Those flows allow the operation when `isAuthorized(...) == true`; seize uses the inverse result so it can target accounts those checks deny. This also 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 slot. +- `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. diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index f3c7e38..d1d3c8d 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -264,9 +264,8 @@ interface IB20 { /// @notice Policy slot consulted against `from` by `seizeWithMemo`. /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. - /// @dev 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 that those checks deny. + /// @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. @@ -461,15 +460,8 @@ 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. - /// @dev `from` is gated by `SEIZE_HOLDER_POLICY`. - /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. - /// @dev 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 that those checks deny. - /// @dev An unset `SEIZE_HOLDER_POLICY` slot reads as `0` (always-allow), so no account is - /// seizable until an issuer explicitly configures the slot. - /// @dev `to` is authorized under `SEIZE_RECEIVER_POLICY`. + /// @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.