From 011c26142d2dc1fcb9709411d6572c1bad3663f4 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers Date: Tue, 4 Aug 2026 14:47:18 +0200 Subject: [PATCH] test(BOP-494): cover SEIZE_RECEIVER_POLICY in the seize smoke journey Extends the Cobalt seize journey to exercise the new receiver-side gate on `to`: the SEIZE_RECEIVER_POLICY() getter, the unset default (allow-any), a configured allowlist authorizing the destination, and the PolicyForbids revert for an unauthorized destination. Still fork-gated (skips pre-Cobalt via the SEIZE_HOLDER_POLICY() probe). - config: add SEIZE_RECEIVER_POLICY constant. - seize journey: getter assertion + new receiver-policy section (unset allows any destination; allowlist(bob) lets the seize land; unauthorized `to` reverts PolicyForbids); assert AllowlistUpdated in the emitted-events set. - README: note the SEIZE_RECEIVER_POLICY coverage in the seize row. Co-authored-by: OpenCode --- script/smoke/README.md | 2 +- script/smoke/config.py | 1 + script/smoke/journeys/seize.py | 37 ++++++++++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/script/smoke/README.md b/script/smoke/README.md index 21b9261..a9d56f2 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 `MultiplierUpdated` vs Cobalt `UIMultiplierUpdated`). | | `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `ScheduleOverlap`), `cancelScheduledMultiplier` (+ `NoScheduledMultiplier`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `MultiplierUpdateCancelled`, *not* `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 receiver policy on `to`, and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | +| `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the transfer receiver policy on `to`, the `SEIZE_RECEIVER_POLICY` gate on `to` (unset = allow-any, configured = destination must be authorized, else `PolicyForbids`), and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | | `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | | `invariants` | EVM-context invariants a precompile must implement explicitly: payable rejection, unknown-selector revert, strict ABI decode, dirty-bit canonicalization, `STATICCALL` read-only enforcement, returndata fidelity, OOG containment, revert atomicity, and gas independence from a force-fed balance. Uses the `PrecompileProbe` + `ForceFeeder` helpers under `test/lib/`. | diff --git a/script/smoke/config.py b/script/smoke/config.py index aabdeab..8fbd45b 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -94,6 +94,7 @@ def _role(name: str) -> bytes: TRANSFER_EXECUTOR_POLICY = _role("TRANSFER_EXECUTOR_POLICY") MINT_RECEIVER_POLICY = _role("MINT_RECEIVER_POLICY") SEIZE_HOLDER_POLICY = _role("SEIZE_HOLDER_POLICY") +SEIZE_RECEIVER_POLICY = _role("SEIZE_RECEIVER_POLICY") @dataclass(frozen=True) diff --git a/script/smoke/journeys/seize.py b/script/smoke/journeys/seize.py index 8799534..d9474be 100644 --- a/script/smoke/journeys/seize.py +++ b/script/smoke/journeys/seize.py @@ -5,8 +5,10 @@ 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, -`InvalidReceiver`, `ContractPaused`) and the admin-op decoupling from the receiver -policy on `to`. +`InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the *transfer* +receiver policy on `to`, and the `SEIZE_RECEIVER_POLICY` gate on `to` (mirrors +`MINT_RECEIVER_POLICY`: unset = allow-any, configured = the destination must be +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 @@ -56,8 +58,11 @@ def _is_cobalt(c: Chain, tok) -> bool: def _journey(c: Chain, tok) -> None: - step(1, "getters: SEIZE_HOLDER_POLICY() and SEIZE_ROLE() match the keccak constants") + 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") + c.assert_eq( + tok.functions.SEIZE_RECEIVER_POLICY().call(), config.SEIZE_RECEIVER_POLICY, "SEIZE_RECEIVER_POLICY scope" + ) c.assert_eq(tok.functions.SEIZE_ROLE().call(), config.SEIZE_ROLE, "SEIZE_ROLE id") step(2, "mint(alice, 1000); mint(deployer, 10)") @@ -142,8 +147,30 @@ def _pause(c: Chain, tok) -> None: c.assert_eq(tok.functions.totalSupply().call(), config.amt(1010, 18), "total supply still unchanged across all seizes") +def _receiver_policy(c: Chain, tok) -> None: + # SEIZE_RECEIVER_POLICY gates `to`, mirroring MINT_RECEIVER_POLICY: unset = allow-any, + # configured = the destination must be authorized. Balances entering here: alice=410, bob=600. + step(10, "SEIZE_RECEIVER_POLICY unset (default): seize to any destination is allowed") + c.assert_eq(tok.functions.SEIZE_RECEIVER_POLICY().call(), config.SEIZE_RECEIVER_POLICY, "receiver scope getter") + # Deployer is not on any allowlist; with the scope unset (ALWAYS_ALLOW) the seize still lands. + c.send(tok.functions.seizeWithMemo(c.ALICE, c.DEPLOYER, config.amt(10, 18), MEMO), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.DEPLOYER).call(), config.amt(10, 18), "deployer received seize (unset receiver policy)") + + step(11, "configure SEIZE_RECEIVER_POLICY allowlist(bob): seize to bob (authorized) succeeds") + recv_pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST) + c.send(tok.functions.updatePolicy(config.SEIZE_RECEIVER_POLICY, recv_pid), c.deployer) + c.send(c.policy.functions.updateAllowlist(recv_pid, True, [c.BOB]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(recv_pid, c.BOB).call(), True, "bob authorized as seize receiver") + c.assert_eq(c.policy.functions.isAuthorized(recv_pid, c.DEPLOYER).call(), False, "deployer not an authorized receiver") + c.send(tok.functions.seizeWithMemo(c.ALICE, c.BOB, config.amt(100, 18), MEMO), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(700, 18), "bob received seize (authorized receiver)") + + step(12, "receiver policy forbids an unauthorized destination -> PolicyForbids(SEIZE_RECEIVER_POLICY)") + c.expect_revert("PolicyForbids", tok.functions.seizeWithMemo(c.ALICE, c.DEPLOYER, 1, MEMO), c.DEPLOYER) + + def _events(c: Chain) -> None: - step(10, "expected events emitted across the flow") + step(13, "expected events emitted across the flow") c.assert_events_emitted( "seize events", "B20Created(address,uint8,string,string,uint8,bytes)", @@ -153,6 +180,7 @@ def _events(c: Chain) -> None: "Seized(address,address,address,uint256)", "PolicyCreated(uint64,address,uint8)", "BlocklistUpdated(uint64,address,bool,address[])", + "AllowlistUpdated(uint64,address,bool,address[])", "PolicyUpdated(bytes32,uint64,uint64)", "Paused(address,uint8[])", "Unpaused(address,uint8[])", @@ -169,5 +197,6 @@ def run(c: Chain) -> None: _edges(c, tok) _decoupling(c, tok) _pause(c, tok) + _receiver_policy(c, tok) _events(c) log("seize: OK")