From a959775721bf5b8a53a2b631c24f7568c9a5cd0d Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 2 Sep 2026 09:38:51 -0400 Subject: [PATCH 1/2] feat(b20): rename SEIZE_HOLDER_POLICY to SEIZE_EXEMPT_POLICY Authorized accounts cannot be seized, so the scope name now states that polarity instead of sounding like a seizable-holder list. Co-authored-by: Cursor --- CHANGELOG.md | 2 +- changelog/02_Cobalt_B20_seize.md | 34 +++++++++---------- ..._Cobalt_PolicyRegistry_composite_policy.md | 2 +- script/smoke/README.md | 2 +- script/smoke/config.py | 2 +- script/smoke/journeys/seize.py | 20 +++++------ src/interfaces/IB20.sol | 14 ++++---- src/lib/B20Constants.sol | 2 +- test/lib/B20Test.sol | 4 +-- test/lib/mocks/MockB20.sol | 16 ++++----- test/lib/mocks/MockB20Storage.sol | 4 +-- test/regression/B20Renames.t.sol | 25 ++++++++++++++ .../unit/B20/policy/policyTypeConstants.t.sol | 30 +++++++++++++--- test/unit/B20/policy/updatePolicy.t.sol | 4 +-- test/unit/B20/supply/seizeWithMemo.t.sol | 10 +++--- .../supply/seizeWithMemo_revertOrder.t.sol | 14 ++++---- test/unit/storage/B20FullLayout.t.sol | 4 +-- test/unit/storage/MockB20SlotHelpers.t.sol | 6 ++-- 18 files changed, 121 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6029635d..d4a0d09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ scheduled `updateUIMultiplier(uint256,uint256)`. 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`. +until the issuer sets `SEIZE_EXEMPT_POLICY`. #### PolicyRegistry integrators diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 8d356f3b..25530838 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -9,7 +9,7 @@ This change adds `seizeWithMemo` to the shared `IB20` interface so asset issuers have a dedicated seizing flow, decoupled from `burnBlocked`. The function reassigns a holder's balance to a destination in a single admin call and emits a dedicated `Seized` event after `Transfer` and `Memo`. Both B20 Asset and B20 Stablecoin inherit the function with no variant-specific logic. The `burnBlocked` function remains supported with unchanged behavior, but it is deprecated and has no committed removal date. -Two audiences are affected. Asset issuers can use `seizeWithMemo` for compliance seize workflows, gated by dedicated seize policies (`SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`). Indexers can use the `Seized` event to map which addresses were seized and where the balance moved. +Two audiences are affected. Asset issuers can use `seizeWithMemo` for compliance seize workflows, gated by dedicated seize policies (`SEIZE_EXEMPT_POLICY` and `SEIZE_RECEIVER_POLICY`). Indexers can use the `Seized` event to map which addresses were seized and where the balance moved. ## Motivation @@ -27,7 +27,7 @@ The workaround also couples who is seizable to who can transfer, because `burnBl `seizeWithMemo` addresses those gaps. That path has two steps: -1. Block the holder under `SEIZE_HOLDER_POLICY` (the holder must be not authorized). +1. Make the holder unauthorized under `SEIZE_EXEMPT_POLICY` (authorized accounts are seize-exempt). 2. Call `seizeWithMemo(from, to, amount, memo)`. The call emits `Transfer`, then `Memo`, then `Seized`. Holder balances move once from `from` to `to`. `totalSupply` does not change. Seize policy slots are independent of transfer policy. @@ -96,7 +96,7 @@ event Seized(address indexed caller, address indexed from, address indexed to, u function SEIZE_ROLE() external view returns (bytes32); -function SEIZE_HOLDER_POLICY() external view returns (bytes32); +function SEIZE_EXEMPT_POLICY() external view returns (bytes32); function SEIZE_RECEIVER_POLICY() external view returns (bytes32); /// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. @@ -124,7 +124,7 @@ function burnBlocked(address from, uint256 amount) external; | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | | `SEIZE_ROLE()` | `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) | -| `SEIZE_HOLDER_POLICY()` | `0xb279d311` (value `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`) | +| `SEIZE_EXEMPT_POLICY()` | `0xfeb346ec` (value `0xedb5da348cfb67af08746d3afd1be81034b50d5c8576f31aff688f39dfd540ed`) | | `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` (value `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`) | | `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | | `error AccountNotSeizable(address)` | `0x91dbbc8d` | @@ -139,7 +139,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 not authorized under `SEIZE_EXEMPT_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`. @@ -151,7 +151,7 @@ sequenceDiagram participant PolicyRegistry Caller->>B20: seizeWithMemo(from, to, amount, memo) - B20->>PolicyRegistry: isAuthorized(SEIZE_HOLDER_POLICY, from) + B20->>PolicyRegistry: isAuthorized(SEIZE_EXEMPT_POLICY, from) PolicyRegistry-->>B20: false Note right of B20: false means from is seizable B20->>PolicyRegistry: isAuthorized(SEIZE_RECEIVER_POLICY, to) @@ -165,9 +165,9 @@ sequenceDiagram **Policy polarity and defaults:** -- `SEIZE_HOLDER_POLICY` is inverted. The call proceeds only when `isAuthorized(SEIZE_HOLDER_POLICY, from)` is false. An unset slot is `0` (always-allow), so every account is authorized meaning that no account is seizable until the issuer attaches a policy. +- `SEIZE_EXEMPT_POLICY` is inverted. The call proceeds only when `isAuthorized(SEIZE_EXEMPT_POLICY, from)` is false. An unset slot is `0` (always-allow), so every account is authorized meaning that no account is seizable until the issuer attaches a policy. - `SEIZE_RECEIVER_POLICY` is a normal allow check. The call proceeds only when `isAuthorized(SEIZE_RECEIVER_POLICY, to)` is true. An unset slot is `0` (always-allow), so any destination can receive seized assets. -- `seizeWithMemo` checks `SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`. It does not check `TRANSFER_SENDER_POLICY` or `TRANSFER_RECEIVER_POLICY`. Blocking a holder from transferring does not make them seizable. Authorizing a transfer receiver does not authorize them as a seize destination. +- `seizeWithMemo` checks `SEIZE_EXEMPT_POLICY` and `SEIZE_RECEIVER_POLICY`. It does not check `TRANSFER_SENDER_POLICY` or `TRANSFER_RECEIVER_POLICY`. Blocking a holder from transferring does not make them seizable. Authorizing a transfer receiver does not authorize them as a seize destination. **Storage layout change:** A packed `seizePolicyIds` slot is added at offset 14 in the `base.b20` ERC-7201 namespace. The change is additive. Offsets 0–13 and `burnBlocked` storage are unchanged. The reserved lane in the transfer packed slot (offset 9, bits 192–255) is not used. @@ -179,7 +179,7 @@ The field is packed into a single 256-bit slot: | Bits | Lane | Field | Scope | | ------- | ---- | ---------- | ----------------------- | -| 0–63 | 0 | `seizable` | `SEIZE_HOLDER_POLICY` | +| 0–63 | 0 | `seizable` | `SEIZE_EXEMPT_POLICY` | | 64–127 | 1 | `receiver` | `SEIZE_RECEIVER_POLICY` | | 128–255 | 2–3 | reserved | unused, pinned to zero | @@ -222,9 +222,9 @@ sequenceDiagram **After (new, single call):** -The token's `SEIZE_HOLDER_POLICY` already points at a blocklist. Tokens move Alice → treasury. `totalSupply` does not change. +The token's `SEIZE_EXEMPT_POLICY` already points at a blocklist. Tokens move Alice → treasury. `totalSupply` does not change. -1. `updateBlocklist(SEIZE_HOLDER_POLICY, true, [Alice])` — add Alice to the blocklist. +1. `updateBlocklist(SEIZE_EXEMPT_POLICY, true, [Alice])` — add Alice to the blocklist. 2. Call `seizeWithMemo(Alice, Treasury, amount, memo)` — gated by `SEIZE_ROLE`. 3. Emits, in order: - `Transfer(Alice, Treasury, amount)` @@ -239,8 +239,8 @@ sequenceDiagram participant B20 participant Treasury - Admin->>PolicyRegistry: updateBlocklist(SEIZE_HOLDER_POLICY, true, [Alice]) - Note over Alice: not authorized under SEIZE_HOLDER_POLICY + Admin->>PolicyRegistry: updateBlocklist(SEIZE_EXEMPT_POLICY, true, [Alice]) + Note over Alice: not authorized under SEIZE_EXEMPT_POLICY Admin->>B20: seizeWithMemo(Alice, Treasury, amount, memo) B20-->>Alice: Transfer(Alice, Treasury, amount) @@ -260,9 +260,9 @@ sequenceDiagram `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` so that seizure eligibility and destinations are configured independently from transfer authorization. Changing a transfer policy therefore cannot implicitly make an account seizable or approve a seizure destination. +- `seizeWithMemo` uses the new `SEIZE_EXEMPT_POLICY` for `from` and `SEIZE_RECEIVER_POLICY` for `to` so that seizure eligibility and destinations are configured independently from transfer authorization. Changing a transfer policy therefore cannot implicitly make an account seizable or approve a seizure destination. - The new `SEIZE_ROLE` separates authority to seize from other administrative permissions. The new `PausableFeature.SEIZE` also makes seizure an independently pausable operation, so an issuer can stop seizures without pausing transfers or burns. -- `SEIZE_HOLDER_POLICY` intentionally inverts the allowlist-style check used by `transfer` and `transferFrom`. `seizeWithMemo` reverts when `isAuthorized(SEIZE_HOLDER_POLICY, from)` is true, so only accounts denied by the policy are seizable. Because an unset slot reads as `0` (always allow), no account is seizable until the issuer explicitly configures the policy. This polarity also matches the existing blocked-account model, which lets issuers apply the same denylist logic used for blocked transfer and burn restrictions. +- `SEIZE_EXEMPT_POLICY` intentionally inverts the allowlist-style check used by `transfer` and `transferFrom`. `seizeWithMemo` reverts when `isAuthorized(SEIZE_EXEMPT_POLICY, from)` is true, so only accounts denied by the policy are seizable. Because an unset slot reads as `0` (always allow), no account is seizable until the issuer explicitly configures the policy. This polarity also matches the existing blocked-account model, which lets issuers apply the same denylist logic used for blocked transfer and burn restrictions. - `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector because burning and seizing have different effects and must remain independently configurable. Keeping the existing controls unchanged also preserves current `burnBlocked` behavior. - `seizeWithMemo` gets its own packed `seizePolicyIds` slot because seizure is a rare, cold-path operation. The reserved lane in the transfer packed policy slot remains available for a future transfer-side policy, where packing another hot-path check into the existing slot could avoid a second `SLOAD`. @@ -277,7 +277,7 @@ sequenceDiagram This risk already exists. The deprecated but callable `burnBlocked` function lets an issuer block an address, burn its balance, and reissue the tokens to another address. `seizeWithMemo` does not expose additional accounts. Instead, it performs the operation in one call, transfers the balance instead of burning and reissuing it, and emits a dedicated `Seized` event. Both functions remain callable. -To assess current exposure, read the policy IDs assigned to `SEIZE_HOLDER_POLICY` and `TRANSFER_SENDER_POLICY` by calling `token.policyId(...)` (`IB20.policyId`, `src/interfaces/IB20.sol`). Then call the Policy Registry's `isAuthorized(policyId, account)` for each policy ID, using the pooling contract's address as the account. The issuer can seize the contract's balance if the contract is not authorized under the seize-holder policy. The issuer can burn the balance if the contract is blocked under the transfer-sender policy. This assessment applies only to the current configuration. An issuer can later change either policy with `updatePolicy`, so a balance that cannot be seized today might become seizable later. +To assess current exposure, read the policy IDs assigned to `SEIZE_EXEMPT_POLICY` and `TRANSFER_SENDER_POLICY` by calling `token.policyId(...)` (`IB20.policyId`, `src/interfaces/IB20.sol`). Then call the Policy Registry's `isAuthorized(policyId, account)` for each policy ID, using the pooling contract's address as the account. The issuer can seize the contract's balance if the contract is not authorized under the seize-exempt policy. The issuer can burn the balance if the contract is blocked under the transfer-sender policy. This assessment applies only to the current configuration. An issuer can later change either policy with `updatePolicy`, so a balance that cannot be seized today might become seizable later. ## Migration Steps @@ -288,7 +288,7 @@ To assess current exposure, read the policy IDs assigned to `SEIZE_HOLDER_POLICY **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_EXEMPT_POLICY` so the accounts you want seizable are NOT authorized under it. With no policy configured (unset = always-allow), no 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`. diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index 70141f43..68a8506d 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -41,7 +41,7 @@ flowchart TD ### Policy Registry -The Policy Registry is a singleton precompile that stores policies. B20 tokens consult it by calling `isAuthorized(policyId, account)` with the policy ID from the relevant scope, including `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. +The Policy Registry is a singleton precompile that stores policies. B20 tokens consult it by calling `isAuthorized(policyId, account)` with the policy ID from the relevant scope, including `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_EXEMPT`. Existing policies are simple `ALLOWLIST` and `BLOCKLIST` types, and they are the only valid children of a composite. diff --git a/script/smoke/README.md b/script/smoke/README.md index a579a5af..3181ea06 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -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_EXEMPT_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_EXEMPT_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/`. | diff --git a/script/smoke/config.py b/script/smoke/config.py index 5dd1531e..3dea390e 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -93,7 +93,7 @@ def _role(name: str) -> bytes: TRANSFER_RECEIVER_POLICY = _role("TRANSFER_RECEIVER_POLICY") TRANSFER_EXECUTOR_POLICY = _role("TRANSFER_EXECUTOR_POLICY") MINT_RECEIVER_POLICY = _role("MINT_RECEIVER_POLICY") -SEIZE_HOLDER_POLICY = _role("SEIZE_HOLDER_POLICY") +SEIZE_EXEMPT_POLICY = _role("SEIZE_EXEMPT_POLICY") SEIZE_RECEIVER_POLICY = _role("SEIZE_RECEIVER_POLICY") diff --git a/script/smoke/journeys/seize.py b/script/smoke/journeys/seize.py index 3777bf94..4be31a01 100644 --- a/script/smoke/journeys/seize.py +++ b/script/smoke/journeys/seize.py @@ -1,7 +1,7 @@ """B20 seize smoketest — the Cobalt `seizeWithMemo` path. 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 +`SEIZE_ROLE`, the `SEIZE_EXEMPT_POLICY` membership gate (an account is seizable when it is NOT 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, @@ -11,7 +11,7 @@ authorized). Fork-gated: the whole surface is Cobalt-only. The journey probes the -`SEIZE_HOLDER_POLICY()` getter and cleanly SKIPS on a pre-Cobalt chain (where the +`SEIZE_EXEMPT_POLICY()` getter and cleanly SKIPS on a pre-Cobalt chain (where the seize selectors do not exist) — the same "chain/fork state, not a contract defect" stance as the `multiplier` journey and the activation preflight. @@ -45,21 +45,21 @@ def _setup(c: Chain): def _is_cobalt(c: Chain, tok) -> bool: - """Cobalt probe: the `SEIZE_HOLDER_POLICY()` getter only resolves on the V2 wire surface. + """Cobalt probe: the `SEIZE_EXEMPT_POLICY()` getter only resolves on the V2 wire surface. On a pre-Cobalt (Beryl / V1) chain the selector is unknown and the call reverts, so the seize surface is absent and the journey opts out. """ try: - scope = tok.functions.SEIZE_HOLDER_POLICY().call() + scope = tok.functions.SEIZE_EXEMPT_POLICY().call() except (ContractLogicError, BadFunctionCallOutput): return False - return scope == config.SEIZE_HOLDER_POLICY + return scope == config.SEIZE_EXEMPT_POLICY def _journey(c: Chain, tok) -> None: - step(1, "getters: SEIZE_HOLDER_POLICY(), SEIZE_RECEIVER_POLICY() and SEIZE_ROLE() match the keccak constants") - c.assert_eq(tok.functions.SEIZE_HOLDER_POLICY().call(), config.SEIZE_HOLDER_POLICY, "SEIZE_HOLDER_POLICY scope") + step(1, "getters: SEIZE_EXEMPT_POLICY(), SEIZE_RECEIVER_POLICY() and SEIZE_ROLE() match the keccak constants") + c.assert_eq(tok.functions.SEIZE_EXEMPT_POLICY().call(), config.SEIZE_EXEMPT_POLICY, "SEIZE_EXEMPT_POLICY scope") c.assert_eq( tok.functions.SEIZE_RECEIVER_POLICY().call(), config.SEIZE_RECEIVER_POLICY, "SEIZE_RECEIVER_POLICY scope" ) @@ -71,9 +71,9 @@ 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)") + step(3, "seizable setup: blocklist policy on SEIZE_EXEMPT_POLICY, block alice (alice becomes seizable)") pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_BLOCKLIST) - c.send(tok.functions.updatePolicy(config.SEIZE_HOLDER_POLICY, pid), c.deployer) + c.send(tok.functions.updatePolicy(config.SEIZE_EXEMPT_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)") @@ -198,7 +198,7 @@ def run(c: Chain) -> None: log("seize: starting") tok = _setup(c) if not _is_cobalt(c, tok): - skip("Asset does not expose SEIZE_HOLDER_POLICY() — chain is pre-Cobalt (no seize surface)") + skip("Asset does not expose SEIZE_EXEMPT_POLICY() — chain is pre-Cobalt (no seize surface)") ok("chain is Cobalt-capable (seize surface present)") _journey(c, tok) _edges(c, tok) diff --git a/src/interfaces/IB20.sol b/src/interfaces/IB20.sol index d1d3c8d4..60d96619 100644 --- a/src/interfaces/IB20.sol +++ b/src/interfaces/IB20.sol @@ -106,7 +106,7 @@ interface IB20 { error UnsupportedPolicyType(bytes32 policyScope); /// @notice `seizeWithMemo` was called against a `from` account that is not seizable under - /// `SEIZE_HOLDER_POLICY`. + /// `SEIZE_EXEMPT_POLICY`. /// @dev A `from` is seizable only when `isAuthorized(policyId, from)` returns false. error AccountNotSeizable(address account); @@ -262,14 +262,14 @@ interface IB20 { /// @return Policy scope constant. function MINT_RECEIVER_POLICY() external view returns (bytes32); - /// @notice Policy slot consulted against `from` by `seizeWithMemo`. + /// @notice Policy slot consulted against `from` by `seizeWithMemo`. An authorized account is seize-exempt. /// @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 + /// @dev An unset slot reads as `0` (always-allow), so every account is seize-exempt until an issuer /// explicitly configures the slot. /// @return Policy scope constant. - function SEIZE_HOLDER_POLICY() external view returns (bytes32); + function SEIZE_EXEMPT_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 @@ -446,7 +446,7 @@ interface IB20 { /// Emits `Transfer(from, address(0), amount)` and `BurnedBlocked(caller, from, amount)`. /// /// @dev Gated by `BURN_BLOCKED_ROLE` and the `BURN` pause vector (not `SEIZE`). Its blocked check - /// reads `TRANSFER_SENDER_POLICY`, distinct from `seizeWithMemo`'s `SEIZE_HOLDER_POLICY`. + /// reads `TRANSFER_SENDER_POLICY`, distinct from `seizeWithMemo`'s `SEIZE_EXEMPT_POLICY`. /// @dev Reverts with `ContractPaused(BURN)` when `BURN` 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 `TRANSFER_SENDER_POLICY`. @@ -461,14 +461,14 @@ 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 not authorized under `SEIZE_EXEMPT_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)` or `from == to`. /// @dev Reverts with `InvalidSender` when `from == address(0)`. - /// @dev Reverts with `AccountNotSeizable` when `from` is authorized under `SEIZE_HOLDER_POLICY`. + /// @dev Reverts with `AccountNotSeizable` when `from` is authorized under `SEIZE_EXEMPT_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`. /// diff --git a/src/lib/B20Constants.sol b/src/lib/B20Constants.sol index 1b52946e..1a27d1d6 100644 --- a/src/lib/B20Constants.sol +++ b/src/lib/B20Constants.sol @@ -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 SEIZE_HOLDER_POLICY = keccak256("SEIZE_HOLDER_POLICY"); + bytes32 internal constant SEIZE_EXEMPT_POLICY = keccak256("SEIZE_EXEMPT_POLICY"); bytes32 internal constant SEIZE_RECEIVER_POLICY = keccak256("SEIZE_RECEIVER_POLICY"); /// @notice Bitmask with all `PausableFeature` bits set (TRANSFER | MINT | BURN | SEIZE); 15 = 0b1111. diff --git a/test/lib/B20Test.sol b/test/lib/B20Test.sol index e6d3cff3..cc517c96 100644 --- a/test/lib/B20Test.sol +++ b/test/lib/B20Test.sol @@ -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.SEIZE_HOLDER_POLICY; + if (i == 3) return B20Constants.SEIZE_EXEMPT_POLICY; if (i == 4) return B20Constants.SEIZE_RECEIVER_POLICY; return B20Constants.MINT_RECEIVER_POLICY; } @@ -149,7 +149,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.SEIZE_HOLDER_POLICY + || policyType == B20Constants.TRANSFER_EXECUTOR_POLICY || policyType == B20Constants.SEIZE_EXEMPT_POLICY || policyType == B20Constants.SEIZE_RECEIVER_POLICY || policyType == B20Constants.MINT_RECEIVER_POLICY; } diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 59dd7d54..9a0ab53d 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -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 SEIZE_HOLDER_POLICY = B20Constants.SEIZE_HOLDER_POLICY; + bytes32 public constant SEIZE_EXEMPT_POLICY = B20Constants.SEIZE_EXEMPT_POLICY; bytes32 public constant SEIZE_RECEIVER_POLICY = B20Constants.SEIZE_RECEIVER_POLICY; /// @notice Maximum value the supply cap may be set to. Because `mint` @@ -332,8 +332,8 @@ 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 - /// `SEIZE_HOLDER_POLICY`; `to` must be authorized under `SEIZE_RECEIVER_POLICY` (mirrors + /// or `from == to`, and `InvalidSender` when `from == 0`. `from` must be unauthorized under + /// `SEIZE_EXEMPT_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. /// @param to Destination address for the seized balance. @@ -511,7 +511,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 == SEIZE_HOLDER_POLICY) return $.seizePolicyIds.seizable; + if (policyScope == SEIZE_EXEMPT_POLICY) return $.seizePolicyIds.seizable; if (policyScope == SEIZE_RECEIVER_POLICY) return $.seizePolicyIds.receiver; if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver; revert UnsupportedPolicyType(policyScope); @@ -535,7 +535,7 @@ abstract contract MockB20 is IB20 { $.transferPolicyIds.receiver = newPolicyId; } else if (policyScope == TRANSFER_EXECUTOR_POLICY) { $.transferPolicyIds.executor = newPolicyId; - } else if (policyScope == SEIZE_HOLDER_POLICY) { + } else if (policyScope == SEIZE_EXEMPT_POLICY) { $.seizePolicyIds.seizable = newPolicyId; } else if (policyScope == SEIZE_RECEIVER_POLICY) { $.seizePolicyIds.receiver = newPolicyId; @@ -790,9 +790,9 @@ 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)` when `from` is + /// authorized under `SEIZE_EXEMPT_POLICY` (authorized = seize-exempt). + /// 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)) { diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index eef98601..7af39c87 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -63,7 +63,7 @@ library MockB20Storage { /// @notice Seize policy IDs (read by the seize operation `seizeWithMemo`). /// @dev Bit layout: - /// bits 0.. 63 : seizable (`SEIZE_HOLDER_POLICY`) + /// bits 0.. 63 : seizable (`SEIZE_EXEMPT_POLICY`) /// bits 64..127 : receiver (`SEIZE_RECEIVER_POLICY`) /// bits 128..255 : reserved (implicit) struct SeizePolicyIds { @@ -325,7 +325,7 @@ library MockB20Storage { return uint256(senderId) | (uint256(receiverId) << 64) | (uint256(executorId) << 128); } - /// @notice Extracts the seize-holder policy id (lane 0) from the seize packed slot. + /// @notice Extracts the seize-exempt policy id (lane 0) from the seize packed slot. function seizablePolicyId(uint256 packed) internal pure returns (uint64) { return uint64(packed); } diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index 637cf3f5..4171af47 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -220,6 +220,31 @@ contract B20RenamesTest is B20AssetTest { ); } + // ============================================================ + // SEIZE EXEMPT POLICY + // ============================================================ + + /// @notice Verifies the seize-from policy is exposed as `SEIZE_EXEMPT_POLICY`. + /// @dev The wire value (`keccak256("SEIZE_EXEMPT_POLICY")`) and library source-of-truth must agree. + function test_seizeExemptPolicy_success_renamedFromSeizeHolder() public view { + assertEq( + token.SEIZE_EXEMPT_POLICY(), + keccak256("SEIZE_EXEMPT_POLICY"), + "SEIZE_EXEMPT_POLICY must equal its keccak preimage" + ); + assertEq( + token.SEIZE_EXEMPT_POLICY(), B20Constants.SEIZE_EXEMPT_POLICY, "SEIZE_EXEMPT_POLICY must match B20Constants" + ); + } + + /// @notice Verifies the pre-rename `SEIZE_HOLDER_POLICY()` selector no longer resolves. + function test_seizeHolderPolicy_revert_selectorRemoved() public { + _assertSelectorRemoved( + abi.encodeWithSignature("SEIZE_HOLDER_POLICY()"), + "SEIZE_HOLDER_POLICY() must not resolve (renamed to SEIZE_EXEMPT_POLICY)" + ); + } + // ============================================================ // ACTIVATION FEATURE NAMESPACE // ============================================================ diff --git a/test/unit/B20/policy/policyTypeConstants.t.sol b/test/unit/B20/policy/policyTypeConstants.t.sol index 7487b850..f7aeb23a 100644 --- a/test/unit/B20/policy/policyTypeConstants.t.sol +++ b/test/unit/B20/policy/policyTypeConstants.t.sol @@ -4,10 +4,10 @@ pragma solidity ^0.8.20; import {B20Test} from "base-std-test/lib/B20Test.sol"; import {MockB20, B20Constants} from "base-std-test/lib/mocks/MockB20.sol"; -/// @notice Folds the four trivial policy-type constant readers -/// into one file since each is a one-stub assertion against a -/// fixed keccak digest. Substantive policy-related functions -/// (`policyId`, `updatePolicy`) live in their own files. +/// @notice Folds the trivial policy-type constant readers into one file +/// since each is a one-stub assertion against a fixed keccak digest. +/// Substantive policy-related functions (`policyId`, `updatePolicy`) +/// live in their own files. contract B20PolicyTypeConstantsTest is B20Test { /// @notice Verifies TRANSFER_SENDER_POLICY returns keccak256("TRANSFER_SENDER_POLICY") /// @dev Constant stability for off-chain consumers @@ -60,4 +60,26 @@ contract B20PolicyTypeConstantsTest is B20Test { ); assertEq(token.MINT_RECEIVER_POLICY(), B20Constants.MINT_RECEIVER_POLICY, "must match B20Test's local constant"); } + + /// @notice Verifies SEIZE_EXEMPT_POLICY returns keccak256("SEIZE_EXEMPT_POLICY") + /// @dev Constant stability for off-chain consumers. Authorized accounts are seize-exempt. + function test_SEIZE_EXEMPT_POLICY_success_matchesExpected() public view { + assertEq( + token.SEIZE_EXEMPT_POLICY(), keccak256("SEIZE_EXEMPT_POLICY"), "B20Constants.SEIZE_EXEMPT_POLICY digest" + ); + assertEq(token.SEIZE_EXEMPT_POLICY(), B20Constants.SEIZE_EXEMPT_POLICY, "must match B20Test's local constant"); + } + + /// @notice Verifies SEIZE_RECEIVER_POLICY returns keccak256("SEIZE_RECEIVER_POLICY") + /// @dev Constant stability for off-chain consumers + function test_SEIZE_RECEIVER_POLICY_success_matchesExpected() public view { + assertEq( + token.SEIZE_RECEIVER_POLICY(), + keccak256("SEIZE_RECEIVER_POLICY"), + "B20Constants.SEIZE_RECEIVER_POLICY digest" + ); + assertEq( + token.SEIZE_RECEIVER_POLICY(), B20Constants.SEIZE_RECEIVER_POLICY, "must match B20Test's local constant" + ); + } } diff --git a/test/unit/B20/policy/updatePolicy.t.sol b/test/unit/B20/policy/updatePolicy.t.sol index 28ea70c9..fdcc9ff7 100644 --- a/test/unit/B20/policy/updatePolicy.t.sol +++ b/test/unit/B20/policy/updatePolicy.t.sol @@ -16,7 +16,7 @@ contract B20UpdatePolicyTest is B20Test { /// slots: /// - `transferPolicyIds` (lane 0: SENDER, 1: RECEIVER, 2: EXECUTOR) /// - `mintPolicyIds` (lane 0: RECEIVER) - /// - `seizePolicyIds` (lane 0: SEIZE_HOLDER, 1: SEIZE_RECEIVER) + /// - `seizePolicyIds` (lane 0: SEIZE_EXEMPT, 1: SEIZE_RECEIVER) /// This helper routes to the right slot + lane decoder so /// tests can assert the slot reflects the surface /// `policyId(policyScope)` return. @@ -27,7 +27,7 @@ contract B20UpdatePolicyTest is B20Test { uint256(vm.load(address(token), MockB20Storage.mintPolicyIdsSlot())) ); } - if (policyScope == B20Constants.SEIZE_HOLDER_POLICY) { + if (policyScope == B20Constants.SEIZE_EXEMPT_POLICY) { return MockB20Storage.seizablePolicyId(uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot()))); } diff --git a/test/unit/B20/supply/seizeWithMemo.t.sol b/test/unit/B20/supply/seizeWithMemo.t.sol index ccb0083e..8f69ec05 100644 --- a/test/unit/B20/supply/seizeWithMemo.t.sol +++ b/test/unit/B20/supply/seizeWithMemo.t.sol @@ -12,11 +12,11 @@ import {MockPolicyRegistry, PolicyRegistryConstants} from "base-std-test/lib/moc 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 Sets SEIZE_EXEMPT_POLICY to ALWAYS_BLOCK (no account is seize-exempt) and grants + /// the seize role. Mirrors the setup every success path shares. function _armSeize() internal { _grantRole(B20Constants.SEIZE_ROLE, seizer); - _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); } /// @notice Reverts when caller lacks SEIZE_ROLE. @@ -81,8 +81,8 @@ 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. + /// @notice Reverts AccountNotSeizable when `from` is authorized under SEIZE_EXEMPT_POLICY. + /// @dev Default SEIZE_EXEMPT_POLICY is ALWAYS_ALLOW (0) → every account authorized → not seizable. function test_seizeWithMemo_revert_accountNotBlocked(address from, address to, uint256 amount) public { _assumeValidActor(from); _assumeValidActor(to); diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index 4732a26c..f65d1e23 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -51,7 +51,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { function test_seizeWithMemo_revertOrder_zeroActors_beats_blocked(address from) public { _assumeValidActor(from); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` would be "not blocked", but `to == 0` fires first. + // SEIZE_EXEMPT_POLICY left at ALWAYS_ALLOW → `from` would be "not blocked", but `to == 0` fires first. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, address(0))); @@ -72,7 +72,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { function test_seizeWithMemo_revertOrder_zeroSender_beats_blocked(address to) public { _assumeValidActor(to); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → the zero address is NOT blocked (not seizable). + // SEIZE_EXEMPT_POLICY left at ALWAYS_ALLOW → the zero address is NOT blocked (not seizable). vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidSender.selector, address(0))); @@ -97,7 +97,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { function test_seizeWithMemo_revertOrder_selfSeize_beats_blocked(address account) public { _assumeValidActor(account); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `account` is NOT blocked (not seizable either). + // SEIZE_EXEMPT_POLICY left at ALWAYS_ALLOW → `account` is NOT blocked (not seizable either). vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.InvalidReceiver.selector, account)); @@ -110,7 +110,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { _assumeValidActor(to); vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // Default SEIZE_HOLDER_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. + // Default SEIZE_EXEMPT_POLICY is ALWAYS_ALLOW → `from` is NOT blocked; zero balance too. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.AccountNotSeizable.selector, from)); @@ -123,7 +123,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { _assumeValidActor(to); vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` is NOT blocked (not seizable). + // SEIZE_EXEMPT_POLICY left at ALWAYS_ALLOW → `from` is NOT blocked (not seizable). _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); @@ -138,7 +138,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { 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_EXEMPT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); vm.prank(seizer); @@ -157,7 +157,7 @@ contract B20SeizeWithMemoRevertOrderTest is B20Test { vm.assume(from != to); _grantRole(B20Constants.SEIZE_ROLE, seizer); _pause(IB20.PausableFeature.SEIZE); - // SEIZE_HOLDER_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. + // SEIZE_EXEMPT_POLICY left at ALWAYS_ALLOW → `from` "not blocked", but pause fires first. vm.prank(seizer); vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.SEIZE)); diff --git a/test/unit/storage/B20FullLayout.t.sol b/test/unit/storage/B20FullLayout.t.sol index 4b9b3136..541a7fe5 100644 --- a/test/unit/storage/B20FullLayout.t.sol +++ b/test/unit/storage/B20FullLayout.t.sol @@ -216,7 +216,7 @@ contract B20FullLayoutTest is B20Test { // Its own per-operation packed slot, placed before the mock-only // `initialized` flag so the Rust precompile mirrors it without a filler. uint256 packedSeize = uint256(vm.load(tokenAddr, MockB20Storage.seizePolicyIdsSlot())); - assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(seizableMarker), "slot 14 bits 0..63: seize-holder lane"); + assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(seizableMarker), "slot 14 bits 0..63: seize-exempt lane"); assertEq( (packedSeize >> 64) & 0xFFFFFFFFFFFFFFFF, uint256(seizeReceiverMarker), @@ -290,7 +290,7 @@ contract B20FullLayoutTest is B20Test { _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, transferSenderMarker); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, transferReceiverMarker); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, transferExecutorMarker); - _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, seizableMarker); + _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, seizableMarker); _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, seizeReceiverMarker); _setPolicy(B20Constants.MINT_RECEIVER_POLICY, mintReceiverMarker); diff --git a/test/unit/storage/MockB20SlotHelpers.t.sol b/test/unit/storage/MockB20SlotHelpers.t.sol index b22961ac..cd184b56 100644 --- a/test/unit/storage/MockB20SlotHelpers.t.sol +++ b/test/unit/storage/MockB20SlotHelpers.t.sol @@ -226,10 +226,10 @@ contract MockB20SlotHelpersTest is B20Test { ); } - /// @notice Verifies `seizePolicyIdsSlot()` locates the seize-holder lane. - /// @dev Write to SEIZE_HOLDER_POLICY via `updatePolicy`; lane decoder reads back from its own slot. + /// @notice Verifies `seizePolicyIdsSlot()` locates the seize-exempt lane. + /// @dev Write to SEIZE_EXEMPT_POLICY via `updatePolicy`; lane decoder reads back from its own slot. function test_seizePolicyIdsSlot_success_decodesSeizableLane() public { - _setPolicy(B20Constants.SEIZE_HOLDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); uint256 packed = uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot())); assertEq( From 5c45bff921e9faa81e0142a64769b1cd8428683a Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Wed, 2 Sep 2026 11:28:45 -0400 Subject: [PATCH 2/2] fix(b20): rename seize-exempt lane from seizable to exempt The SEIZE_EXEMPT_POLICY lane and its accessors still used the pre-rename "seizable" name, which no longer matches the policy and reads backwards (exempt accounts are NOT seizable). Mirrors how the receiver lane already matches SEIZE_RECEIVER_POLICY. Addresses review comments on PR #214. Co-Authored-By: Claude --- changelog/02_Cobalt_B20_seize.md | 2 +- test/lib/mocks/MockB20.sol | 8 ++++---- test/lib/mocks/MockB20Storage.sol | 16 ++++++++-------- test/unit/B20/policy/updatePolicy.t.sol | 3 +-- .../B20/supply/seizeWithMemo_revertOrder.t.sol | 2 +- test/unit/storage/B20FullLayout.t.sol | 10 +++++----- test/unit/storage/MockB20SlotHelpers.t.sol | 12 ++++++------ 7 files changed, 26 insertions(+), 27 deletions(-) diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 25530838..660b0395 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -179,7 +179,7 @@ The field is packed into a single 256-bit slot: | Bits | Lane | Field | Scope | | ------- | ---- | ---------- | ----------------------- | -| 0–63 | 0 | `seizable` | `SEIZE_EXEMPT_POLICY` | +| 0–63 | 0 | `exempt` | `SEIZE_EXEMPT_POLICY` | | 64–127 | 1 | `receiver` | `SEIZE_RECEIVER_POLICY` | | 128–255 | 2–3 | reserved | unused, pinned to zero | diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 9a0ab53d..05142a2d 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -511,7 +511,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 == SEIZE_EXEMPT_POLICY) return $.seizePolicyIds.seizable; + if (policyScope == SEIZE_EXEMPT_POLICY) return $.seizePolicyIds.exempt; if (policyScope == SEIZE_RECEIVER_POLICY) return $.seizePolicyIds.receiver; if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver; revert UnsupportedPolicyType(policyScope); @@ -536,7 +536,7 @@ abstract contract MockB20 is IB20 { } else if (policyScope == TRANSFER_EXECUTOR_POLICY) { $.transferPolicyIds.executor = newPolicyId; } else if (policyScope == SEIZE_EXEMPT_POLICY) { - $.seizePolicyIds.seizable = newPolicyId; + $.seizePolicyIds.exempt = newPolicyId; } else if (policyScope == SEIZE_RECEIVER_POLICY) { $.seizePolicyIds.receiver = newPolicyId; } else { @@ -794,8 +794,8 @@ abstract contract MockB20 is IB20 { /// authorized under `SEIZE_EXEMPT_POLICY` (authorized = seize-exempt). /// 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)) { + uint64 exemptPolicyId = MockB20Storage.layout().seizePolicyIds.exempt; + if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(exemptPolicyId, from)) { revert AccountNotSeizable(from); } } diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 7af39c87..393ecadc 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -63,11 +63,11 @@ library MockB20Storage { /// @notice Seize policy IDs (read by the seize operation `seizeWithMemo`). /// @dev Bit layout: - /// bits 0.. 63 : seizable (`SEIZE_EXEMPT_POLICY`) + /// bits 0.. 63 : exempt (`SEIZE_EXEMPT_POLICY`) /// bits 64..127 : receiver (`SEIZE_RECEIVER_POLICY`) /// bits 128..255 : reserved (implicit) struct SeizePolicyIds { - uint64 seizable; + uint64 exempt; uint64 receiver; } @@ -145,7 +145,7 @@ library MockB20Storage { mapping(address owner => uint256 nonce) nonces; // ---------- Seize policies (PACKED, per-operation) ---------- // Placed before the mock-only `initialized` flag so the Rust precompile - // — which stores no `initialized` field — lands `seizable_policy_id` at + // — which stores no `initialized` field — lands `exempt_policy_id` at // this same offset with no filler slot. Distinct per-operation group // (seize is cold-path and spans transfer + burn), mirroring how mint // policies are grouped separately from transfer policies. @@ -194,7 +194,7 @@ library MockB20Storage { uint256 internal constant PAUSED_VECTORS_OFFSET = 11; uint256 internal constant SUPPLY_CAP_OFFSET = 12; uint256 internal constant NONCES_OFFSET = 13; - // Placed before the mock-only `initialized` flag so the Rust precompile lands its seizable + // Placed before the mock-only `initialized` flag so the Rust precompile lands its exempt // slot at this offset with no filler; see the field-level natspec above. uint256 internal constant SEIZE_POLICY_IDS_OFFSET = 14; // Mock-only bootstrap flag, kept last so the Rust precompile omits it. @@ -325,8 +325,8 @@ library MockB20Storage { return uint256(senderId) | (uint256(receiverId) << 64) | (uint256(executorId) << 128); } - /// @notice Extracts the seize-exempt policy id (lane 0) from the seize packed slot. - function seizablePolicyId(uint256 packed) internal pure returns (uint64) { + /// @notice Extracts the SEIZE_EXEMPT policy id (lane 0) from the seize packed slot. + function exemptPolicyId(uint256 packed) internal pure returns (uint64) { return uint64(packed); } @@ -337,8 +337,8 @@ library MockB20Storage { /// @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); + function packSeizePolicyIds(uint64 exemptId, uint64 receiverId) internal pure returns (uint256) { + return uint256(exemptId) | (uint256(receiverId) << 64); } /// @notice Extracts the MINT_RECEIVER policy id (lane 0) from the packed slot. diff --git a/test/unit/B20/policy/updatePolicy.t.sol b/test/unit/B20/policy/updatePolicy.t.sol index fdcc9ff7..b9db1600 100644 --- a/test/unit/B20/policy/updatePolicy.t.sol +++ b/test/unit/B20/policy/updatePolicy.t.sol @@ -28,8 +28,7 @@ contract B20UpdatePolicyTest is B20Test { ); } if (policyScope == B20Constants.SEIZE_EXEMPT_POLICY) { - return - MockB20Storage.seizablePolicyId(uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot()))); + return MockB20Storage.exemptPolicyId(uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot()))); } if (policyScope == B20Constants.SEIZE_RECEIVER_POLICY) { return diff --git a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol index f65d1e23..3704f70c 100644 --- a/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol +++ b/test/unit/B20/supply/seizeWithMemo_revertOrder.t.sol @@ -15,7 +15,7 @@ import {PolicyRegistryConstants} from "base-std-test/lib/mocks/MockPolicyRegistr /// 3. ZERO-RECEIVER (`to == address(0)`) → `InvalidReceiver` /// 4. ZERO-SENDER (`from == address(0)`) → `InvalidSender` /// 5. SELF-SEIZE (`from == to`) → `InvalidReceiver` -/// 6. BLOCKED (`isAuthorized(seizablePolicyId, from) == true`) → `AccountNotSeizable` +/// 6. BLOCKED (`isAuthorized(exemptPolicyId, from) == true`) → `AccountNotSeizable` /// 7. RECEIVER (`isAuthorized(seizeReceiverPolicyId, to) == false`) → `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)` /// 8. BALANCE (`fromBalance < amount` in `_moveBalance`) → `InsufficientBalance` contract B20SeizeWithMemoRevertOrderTest is B20Test { diff --git a/test/unit/storage/B20FullLayout.t.sol b/test/unit/storage/B20FullLayout.t.sol index 541a7fe5..5585d583 100644 --- a/test/unit/storage/B20FullLayout.t.sol +++ b/test/unit/storage/B20FullLayout.t.sol @@ -56,7 +56,7 @@ contract B20FullLayoutTest is B20Test { uint64 internal transferSenderMarker; uint64 internal transferReceiverMarker; uint64 internal transferExecutorMarker; - uint64 internal seizableMarker; + uint64 internal exemptMarker; uint64 internal seizeReceiverMarker; uint64 internal mintReceiverMarker; @@ -85,7 +85,7 @@ contract B20FullLayoutTest is B20Test { /// - 11: pausedVectors (TRANSFER + MINT bits) /// - 12: supplyCap /// - 13: nonces (advanced via permit) - /// - 14: seizePolicyIds (seizable + receiver lanes) + /// - 14: seizePolicyIds (exempt + receiver lanes) /// - 15: initialized (mock-only bootstrap flag, kept last) function test_b20Layout_success_populatedSnapshotMatchesAllSlots() public { // ---------- Populate ---------- @@ -216,7 +216,7 @@ contract B20FullLayoutTest is B20Test { // Its own per-operation packed slot, placed before the mock-only // `initialized` flag so the Rust precompile mirrors it without a filler. uint256 packedSeize = uint256(vm.load(tokenAddr, MockB20Storage.seizePolicyIdsSlot())); - assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(seizableMarker), "slot 14 bits 0..63: seize-exempt lane"); + assertEq(packedSeize & 0xFFFFFFFFFFFFFFFF, uint256(exemptMarker), "slot 14 bits 0..63: seize-exempt lane"); assertEq( (packedSeize >> 64) & 0xFFFFFFFFFFFFFFFF, uint256(seizeReceiverMarker), @@ -284,13 +284,13 @@ contract B20FullLayoutTest is B20Test { StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); transferExecutorMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); - seizableMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + exemptMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); seizeReceiverMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); mintReceiverMarker = StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, transferSenderMarker); _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, transferReceiverMarker); _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, transferExecutorMarker); - _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, seizableMarker); + _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, exemptMarker); _setPolicy(B20Constants.SEIZE_RECEIVER_POLICY, seizeReceiverMarker); _setPolicy(B20Constants.MINT_RECEIVER_POLICY, mintReceiverMarker); diff --git a/test/unit/storage/MockB20SlotHelpers.t.sol b/test/unit/storage/MockB20SlotHelpers.t.sol index cd184b56..ff3e6643 100644 --- a/test/unit/storage/MockB20SlotHelpers.t.sol +++ b/test/unit/storage/MockB20SlotHelpers.t.sol @@ -228,14 +228,14 @@ contract MockB20SlotHelpersTest is B20Test { /// @notice Verifies `seizePolicyIdsSlot()` locates the seize-exempt lane. /// @dev Write to SEIZE_EXEMPT_POLICY via `updatePolicy`; lane decoder reads back from its own slot. - function test_seizePolicyIdsSlot_success_decodesSeizableLane() public { + function test_seizePolicyIdsSlot_success_decodesExemptLane() public { _setPolicy(B20Constants.SEIZE_EXEMPT_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); uint256 packed = uint256(vm.load(address(token), MockB20Storage.seizePolicyIdsSlot())); assertEq( - MockB20Storage.seizablePolicyId(packed), + MockB20Storage.exemptPolicyId(packed), PolicyRegistryConstants.ALWAYS_BLOCK_ID, - "seizablePolicyId lane must reflect the policy write" + "exemptPolicyId lane must reflect the policy write" ); } @@ -265,9 +265,9 @@ contract MockB20SlotHelpersTest is B20Test { } /// @notice Verifies `packSeizePolicyIds` is the inverse of the seize lane decoders. - function test_packSeizePolicyIds_success_roundtrips(uint64 seizableId, uint64 receiverId) public pure { - uint256 packed = MockB20Storage.packSeizePolicyIds(seizableId, receiverId); - assertEq(MockB20Storage.seizablePolicyId(packed), seizableId); + function test_packSeizePolicyIds_success_roundtrips(uint64 exemptId, uint64 receiverId) public pure { + uint256 packed = MockB20Storage.packSeizePolicyIds(exemptId, receiverId); + assertEq(MockB20Storage.exemptPolicyId(packed), exemptId); assertEq(MockB20Storage.seizeReceiverPolicyId(packed), receiverId); }