From dde868131e22df272cab97d22428f18de391d5ac Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 28 Jul 2026 12:30:13 -0400 Subject: [PATCH 1/5] test(smoke): composite policy journey steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the policy-registry journey to 35 steps against the latest deployment, which is the lane that defines what must actually work on-chain. Covers UNION/INTERSECT creation, both evaluation semantics, live re-evaluation when a child's membership changes (no call against the composite), full-replace on updateComposite, the composite creator's own input guards (ZeroAddress, IncompatiblePolicyType), child-set validation across all four branches (range / non-existent / built-in / composite child) including the PolicyNotFound-outranks-InvalidChildPolicy precedence, unauthorized update, and end-to-end token enforcement through a composite. precompile_invariants: the two new write selectors were absent from the NonPayable checks, which only ever covered createPolicy. Adds both to _payable_rejected and _value_forwarding_rejected. error_name is pinned for the new cases because expect_raw_revert treats ANY non-ContractLogicError exception as a pass — without it a node-level rejection would masquerade as NonPayable. No composite-support gating needed: the value guard is the first check in dispatch, ahead of version resolution and selector matching alike. chain.py: adds create_composite_policy(), and composite_children_from(receipt) because assert_events_emitted is presence-only — it gathers topic0 across all recorded txs and never inspects the payload, so a CompositePolicyUpdated carrying the wrong child array would pass it. Steps 33-35 are detectors for known divergences and are EXPECTED TO FAIL until base/base is reconciled. They are error-type and precedence mismatches, not missing guards: PolicyType::is_valid() in policy/abi.rs is hand-narrowed to BLOCKLIST|ALLOWLIST, so the simple creators DO reject a composite gate — with Panic(0x21) rather than the documented IncompatiblePolicyType. Step 35 (D3) is a separate precedence bug: the live impl checks the type guard before the zero-admin guard, inverting the order the interface and the mock both pin. They sit after the event check so the harness's fail-fast die() does not mask everything before them, and all are eth_call simulations that mutate nothing. Not executed end-to-end: no RPC available. Verified by encoding every function, event and error binding against the forge artifacts in out/. Generated with Claude Code Co-Authored-By: Claude --- script/smoke/chain.py | 29 ++ script/smoke/config.py | 11 +- script/smoke/journeys/policy_registry.py | 299 +++++++++++++++++- .../smoke/journeys/precompile_invariants.py | 33 +- 4 files changed, 368 insertions(+), 4 deletions(-) diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 550e7f10..6aa509ee 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -560,12 +560,41 @@ def create_policy_with_accounts(self, admin: ChecksumAddress, ptype: int, accoun receipt = self.send(self.policy.functions.createPolicyWithAccounts(admin, ptype, accounts), self.deployer) return self._policy_id_from(receipt) + def create_composite_policy(self, admin: ChecksumAddress, ptype: int, child_ids: list[int]) -> int: + """Create a UNION/INTERSECT composite over existing simple policies; return the new id. + + Composites are minted through the same `_create` path as simple policies and emit the + canonical `PolicyCreated(policyId, creator, policyType)` (verified against + `IPolicyRegistry`/`MockPolicyRegistry`), so `_policy_id_from` needs no composite-specific + branch. They additionally emit `CompositePolicyUpdated` carrying the full child set — + see `composite_children_from` to read it back off the receipt. + """ + receipt = self.send(self.policy.functions.createCompositePolicy(admin, ptype, child_ids), self.deployer) + return self._policy_id_from(receipt) + def _policy_id_from(self, receipt: TxReceipt) -> int: events = self.policy.events.PolicyCreated().process_receipt(receipt, errors=DISCARD) if not events: die("PolicyCreated event not found in receipt") return int(events[0]["args"]["policyId"]) + def composite_children_from(self, receipt: TxReceipt, policy_id: int | None = None) -> list[int]: + """Decode `CompositePolicyUpdated` from a SPECIFIC receipt and return its `childPolicyIds`. + + `assert_events_emitted` is presence-only (topic0 seen somewhere across the run), which + cannot distinguish "the child set was replaced with exactly X" from "some composite event + fired". This decodes the payload of one tx so a journey can assert the exact array. + Pass `policy_id` to select the event for a specific composite when a receipt carries + several; otherwise the first (only) one is returned. + """ + events = self.policy.events.CompositePolicyUpdated().process_receipt(receipt, errors=DISCARD) + if policy_id is not None: + events = [e for e in events if int(e["args"]["policyId"]) == policy_id] + if not events: + suffix = f" for policyId={policy_id}" if policy_id is not None else "" + die(f"CompositePolicyUpdated event not found in receipt{suffix}") + return [int(child) for child in events[0]["args"]["childPolicyIds"]] + def _norm(v: object) -> object: """Normalize for comparison: lowercase hex/addresses so checksums match.""" diff --git a/script/smoke/config.py b/script/smoke/config.py index 33a7b33b..95f7952c 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -36,9 +36,18 @@ def amt(whole: int, decimals: int) -> int: VARIANT_ASSET = 0 VARIANT_STABLECOIN = 1 -# PolicyType enum (IPolicyRegistry). +# PolicyType enum (IPolicyRegistry). BLOCKLIST/ALLOWLIST are "simple" policies (decide from an +# address set); UNION/INTERSECT are "composite" gates over 2..4 simple children. POLICY_TYPE_BLOCKLIST = 0 POLICY_TYPE_ALLOWLIST = 1 +POLICY_TYPE_UNION = 2 +POLICY_TYPE_INTERSECT = 3 + +# Composite child-set bounds. Outside this range the registry reverts +# ChildPoliciesOutsideOfRange(MIN_CHILD_POLICIES, MAX_CHILD_POLICIES). Distinct from the +# 64-account membership batch limit (BatchSizeTooLarge). +MIN_CHILD_POLICIES = 2 +MAX_CHILD_POLICIES = 4 # Built-in policy IDs: ALWAYS_ALLOW = 0, ALWAYS_BLOCK = (uint64(ALLOWLIST) << 56) | 1. ALWAYS_ALLOW_ID = 0 diff --git a/script/smoke/journeys/policy_registry.py b/script/smoke/journeys/policy_registry.py index 9b809c31..084174cf 100644 --- a/script/smoke/journeys/policy_registry.py +++ b/script/smoke/journeys/policy_registry.py @@ -4,10 +4,18 @@ admin lifecycle, and — the part that matters most — a token actually enforcing a policy (PolicyForbids on transfer + mint). Edges cover the registry's reverts and the token-side write-time validation, then a flow-level event check. + +`_composite` covers the UNION/INTERSECT composite gates: construction, live +gate evaluation over child membership, full-replacement `updateComposite` +semantics, the child-set validation reverts, and token enforcement through a +composite. `_known_divergences` runs LAST and is expected to fail against the +current live deployment (see the function docstring). """ from __future__ import annotations +import sys + from .. import config from ..chain import Chain, log, step from ..codec import AssetCreateParams, init_call @@ -100,14 +108,206 @@ def _edges(c: Chain, tok, pid_r: int, pid_b: int) -> None: c.expect_revert("PolicyNotFound", tok.functions.updatePolicy(config.TRANSFER_SENDER_POLICY, 999999), c.DEPLOYER) +def _composite(c: Chain, pid_b: int) -> None: + """Composite (UNION / INTERSECT) policies: construction, evaluation, update, edges, enforcement. + + Spec IDs are from brains/composite_policy/SMOKE_TEST_SPECS.md. + """ + carol = c.cfg.new_addr("carol") + dave = c.cfg.new_addr("dave") + erin = c.cfg.new_addr("erin") + + step(17, "[CC-1/CC-2] seed two simple children, then create a UNION and an INTERSECT over them") + # childX allows {alice, dave}; childY allows {bob, dave}. dave is the only account in BOTH. + pid_x = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.ALICE, dave]) + pid_y = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.BOB, dave]) + pid_union = c.create_composite_policy(c.DEPLOYER, config.POLICY_TYPE_UNION, [pid_x, pid_y]) + pid_inter = c.create_composite_policy(c.DEPLOYER, config.POLICY_TYPE_INTERSECT, [pid_x, pid_y]) + c.assert_eq(c.policy.functions.policyExists(pid_union).call(), True, "UNION composite exists") + c.assert_eq(c.policy.functions.policyAdmin(pid_union).call(), c.DEPLOYER, "UNION admin == deployer") + c.assert_eq(c.policy.functions.policyExists(pid_inter).call(), True, "INTERSECT composite exists") + c.assert_eq(c.policy.functions.policyAdmin(pid_inter).call(), c.DEPLOYER, "INTERSECT admin == deployer") + + step(18, "[CE-1/CE-2] UNION authorizes an account in ANY child; denies one in none") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.ALICE).call(), True, "UNION: alice (childX only) allowed") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.BOB).call(), True, "UNION: bob (childY only) allowed") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, dave).call(), True, "UNION: dave (both children) allowed") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, carol).call(), False, "UNION: carol (no child) denied") + + step(19, "[CE-3/CE-4] INTERSECT authorizes only an account in EVERY child") + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, dave).call(), True, "INTERSECT: dave (every child) allowed") + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, c.ALICE).call(), False, "INTERSECT: alice missing childY -> denied") + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, c.BOB).call(), False, "INTERSECT: bob missing childX -> denied") + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol (no child) denied") + + step(20, "[CE-7] live evaluation: mutate a CHILD's membership, composite verdict flips (no call on the composite)") + c.send(c.policy.functions.updateAllowlist(pid_x, True, [carol]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid_union, carol).call(), True, "UNION: carol allowed after childX add") + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol still denied (childY missing)") + c.send(c.policy.functions.updateAllowlist(pid_y, True, [carol]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), True, "INTERSECT: carol allowed once in every child") + c.send(c.policy.functions.updateAllowlist(pid_x, False, [carol]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol denied again after childX removal") + + step(21, "[CU-1/EV-7] updateComposite REPLACES the child set (no merge); event carries the exact new set") + pid_c1 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [erin]) + pid_c2 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [erin]) + c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.ALICE).call(), True, "pre-update: alice allowed via childX") + receipt = c.send(c.policy.functions.updateComposite(pid_union, [pid_c1, pid_c2]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.ALICE).call(), False, "post-update: alice denied (old children dropped, not merged)") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.BOB).call(), False, "post-update: bob denied (old children dropped)") + c.assert_eq(c.policy.functions.isAuthorized(pid_union, erin).call(), True, "post-update: erin allowed via the new children") + # Payload-level check: assert_events_emitted is presence-only, so decode this specific receipt. + c.assert_eq( + c.composite_children_from(receipt, pid_union), + [pid_c1, pid_c2], + "CompositePolicyUpdated payload == exactly the new child ids", + ) + + step(22, "[CX-3/CX-4] child count outside [2,4] -> ChildPoliciesOutsideOfRange") + too_few = [pid_x] + too_many = [pid_x, pid_y, pid_c1, pid_c2, pid_b] + c.assert_eq(len(too_few) < config.MIN_CHILD_POLICIES, True, "fixture: 1 child is below MIN_CHILD_POLICIES") + c.assert_eq(len(too_many) > config.MAX_CHILD_POLICIES, True, "fixture: 5 children is above MAX_CHILD_POLICIES") + c.expect_revert( + "ChildPoliciesOutsideOfRange", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, too_few), + c.DEPLOYER, + ) + c.expect_revert( + "ChildPoliciesOutsideOfRange", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, too_many), + c.DEPLOYER, + ) + c.expect_revert("ChildPoliciesOutsideOfRange", c.policy.functions.updateComposite(pid_union, too_few), c.DEPLOYER) + c.expect_revert("ChildPoliciesOutsideOfRange", c.policy.functions.updateComposite(pid_union, too_many), c.DEPLOYER) + + step(23, "[CX-6] a built-in sentinel as a child -> InvalidChildPolicy") + c.expect_revert( + "InvalidChildPolicy", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [config.ALWAYS_ALLOW_ID, pid_x]), + c.DEPLOYER, + ) + c.expect_revert( + "InvalidChildPolicy", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [config.ALWAYS_BLOCK_ID, pid_x]), + c.DEPLOYER, + ) + c.expect_revert( + "InvalidChildPolicy", c.policy.functions.updateComposite(pid_union, [config.ALWAYS_ALLOW_ID, pid_x]), c.DEPLOYER + ) + + step(24, "[CX-7] a composite as a child -> InvalidChildPolicy") + c.expect_revert( + "InvalidChildPolicy", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_INTERSECT, [pid_inter, pid_x]), + c.DEPLOYER, + ) + c.expect_revert("InvalidChildPolicy", c.policy.functions.updateComposite(pid_union, [pid_inter, pid_x]), c.DEPLOYER) + + step(25, "[CU-6] non-admin updateComposite -> Unauthorized") + c.expect_revert("Unauthorized", c.policy.functions.updateComposite(pid_union, [pid_x, pid_y]), c.USER2) + + step(26, "[CX-1/CX-2/CU-5] composite creator input guards: ZeroAddress, IncompatiblePolicyType") + # ZeroAddress outranks every later check, so the child set here is deliberately valid. + c.expect_revert( + "ZeroAddress", + c.policy.functions.createCompositePolicy(config.ZERO, config.POLICY_TYPE_UNION, [pid_x, pid_y]), + c.DEPLOYER, + ) + # A composite must be UNION or INTERSECT; a simple gate is rejected by the composite creator. + for simple in (config.POLICY_TYPE_ALLOWLIST, config.POLICY_TYPE_BLOCKLIST): + c.expect_revert( + "IncompatiblePolicyType", + c.policy.functions.createCompositePolicy(c.DEPLOYER, simple, [pid_x, pid_y]), + c.DEPLOYER, + ) + # Mirror: updateComposite must reject a SIMPLE target, and the membership mutators must reject a + # COMPOSITE target. Together with steps 31-32 this closes the type-guard matrix in both directions, + # so a red step 31/32 reads as "the two known creator-side defects" and not "the type byte is + # ignored everywhere". + c.expect_revert("IncompatiblePolicyType", c.policy.functions.updateComposite(pid_x, [pid_x, pid_y]), c.DEPLOYER) + c.expect_revert( + "IncompatiblePolicyType", c.policy.functions.updateAllowlist(pid_union, True, [c.BOB]), c.DEPLOYER + ) + c.expect_revert( + "IncompatiblePolicyType", c.policy.functions.updateBlocklist(pid_inter, True, [c.BOB]), c.DEPLOYER + ) + + step(27, "[CX-5/CU-8/CX-8] non-existent child -> PolicyNotFound, and it outranks InvalidChildPolicy") + # A well-formed but never-created id. Type byte 0 (BLOCKLIST) — note this is exactly the shape whose + # read-side semantics are permissive (an empty blocklist authorizes everyone), so child-existence + # validation is the only thing standing between a permissionless caller and a composite that + # authorizes every account while looking like a legitimate admin-owned gate. + ghost = 999999 + c.assert_eq(c.policy.functions.policyExists(ghost).call(), False, "fixture: ghost child id does not exist") + c.expect_revert( + "PolicyNotFound", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [pid_x, ghost]), + c.DEPLOYER, + ) + # Ghost in FIRST position too: an impl that only validates childPolicyIds[0] passes the case above. + c.expect_revert( + "PolicyNotFound", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [ghost, pid_x]), + c.DEPLOYER, + ) + c.expect_revert("PolicyNotFound", c.policy.functions.updateComposite(pid_union, [pid_x, ghost]), c.DEPLOYER) + # CX-8 precedence: existence is checked across the WHOLE set before validity. The invalid child is + # placed FIRST and the ghost LAST, so a per-element validator would answer InvalidChildPolicy. + c.expect_revert( + "PolicyNotFound", + c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [pid_inter, ghost]), + c.DEPLOYER, + ) + + step(28, "[CT-1] create ASSET token wired to a UNION composite on TRANSFER_RECEIVER + MINT_RECEIVER") + # Dedicated children so the token's gate is unaffected by the mutations above. + pid_t1 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.ALICE]) + pid_t2 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.DEPLOYER]) + pid_tok = c.create_composite_policy(c.DEPLOYER, config.POLICY_TYPE_UNION, [pid_t1, pid_t2]) + c.assert_eq(c.policy.functions.isAuthorized(pid_tok, c.ALICE).call(), True, "gate: alice authorized (child 1)") + c.assert_eq(c.policy.functions.isAuthorized(pid_tok, c.DEPLOYER).call(), True, "gate: deployer authorized (child 2)") + c.assert_eq(c.policy.functions.isAuthorized(pid_tok, c.BOB).call(), False, "gate: bob denied (neither child)") + + salt = c.cfg.salt_for("composite-enforce") + params = AssetCreateParams("Composite Gated Asset", "CGATE", c.DEPLOYER, config.ASSET_DECIMALS).encode() + init_calls = [ + init_call(c.asset_abi, "updatePolicy", config.TRANSFER_RECEIVER_POLICY, pid_tok), + init_call(c.asset_abi, "updatePolicy", config.MINT_RECEIVER_POLICY, pid_tok), + init_call(c.asset_abi, "grantRole", config.MINT_ROLE, c.DEPLOYER), + ] + tok_addr = c.predict_b20(config.VARIANT_ASSET, salt) + c.create_b20(config.VARIANT_ASSET, salt, params, init_calls) + ctok = c.asset_at(tok_addr) + c.assert_eq(ctok.functions.policyId(config.MINT_RECEIVER_POLICY).call(), pid_tok, "MINT_RECEIVER_POLICY == composite") + c.assert_eq( + ctok.functions.policyId(config.TRANSFER_RECEIVER_POLICY).call(), pid_tok, "TRANSFER_RECEIVER_POLICY == composite" + ) + + step(29, "[CT-2] composite-authorized receivers: mint + transfer succeed") + c.send(ctok.functions.mint(c.ALICE, config.amt(100, 18)), c.deployer) + c.assert_eq(ctok.functions.balanceOf(c.ALICE).call(), config.amt(100, 18), "alice minted (authorized via child 1)") + c.send(ctok.functions.mint(c.DEPLOYER, config.amt(100, 18)), c.deployer) + c.send(ctok.functions.transfer(c.ALICE, config.amt(1, 18)), c.deployer) + c.assert_eq(ctok.functions.balanceOf(c.ALICE).call(), config.amt(101, 18), "transfer to composite-authorized receiver") + + step(30, "[CT-3] composite-denied receiver on transfer -> PolicyForbids") + c.expect_revert("PolicyForbids", ctok.functions.transfer(c.BOB, config.amt(1, 18)), c.DEPLOYER) + + step(31, "[CT-4] composite-denied receiver on mint -> PolicyForbids") + c.expect_revert("PolicyForbids", ctok.functions.mint(c.BOB, config.amt(1, 18)), c.DEPLOYER) + + def _events(c: Chain) -> None: - step(17, "expected events emitted across the flow") + step(32, "expected events emitted across the flow") c.assert_events_emitted( "policy events", "PolicyCreated(uint64,address,uint8)", "AllowlistUpdated(uint64,address,bool,address[])", "PolicyAdminStaged(uint64,address,address)", "PolicyAdminUpdated(uint64,address,address)", + "CompositePolicyUpdated(uint64,address,uint64[])", "B20Created(address,uint8,string,string,uint8,bytes)", "PolicyUpdated(bytes32,uint64,uint64)", "Transfer(address,address,uint256)", @@ -115,10 +315,107 @@ def _events(c: Chain) -> None: ) +def _d1_create_policy(c: Chain) -> None: + """createPolicy(admin, UNION) must revert IncompatiblePolicyType.""" + c.expect_revert( + "IncompatiblePolicyType", c.policy.functions.createPolicy(c.DEPLOYER, config.POLICY_TYPE_UNION), c.DEPLOYER + ) + + +def _d2_create_policy_with_accounts(c: Chain) -> None: + """createPolicyWithAccounts(admin, UNION, accts) must revert IncompatiblePolicyType.""" + c.expect_revert( + "IncompatiblePolicyType", + c.policy.functions.createPolicyWithAccounts(c.DEPLOYER, config.POLICY_TYPE_UNION, [c.ALICE]), + c.DEPLOYER, + ) + + +def _d3_zero_admin_precedence(c: Chain) -> None: + """A zero admin must outrank the policy-type guard in both simple creators.""" + c.expect_revert( + "ZeroAddress", c.policy.functions.createPolicy(config.ZERO, config.POLICY_TYPE_UNION), c.DEPLOYER + ) + c.expect_revert( + "ZeroAddress", + c.policy.functions.createPolicyWithAccounts(config.ZERO, config.POLICY_TYPE_UNION, [c.ALICE]), + c.DEPLOYER, + ) + + +# Conformance checks where base/base does not yet match the `IPolicyRegistry` contract that base-std +# defines. There are no ACCEPTED divergences between the two — every entry here is a base/base bug to +# fix, not a behaviour to live with. They are listed rather than inlined only so one failure cannot +# hide the others. +CONFORMANCE_DETECTORS = [ + ("D1 createPolicy(admin, UNION) -> IncompatiblePolicyType", _d1_create_policy), + ("D2 createPolicyWithAccounts(admin, UNION, accts) -> IncompatiblePolicyType", _d2_create_policy_with_accounts), + ("D3 createPolicy(0, UNION) -> ZeroAddress outranks the type guard", _d3_zero_admin_precedence), +] + + +def _conformance(c: Chain) -> None: + """Assert base/base matches the `IPolicyRegistry` contract. Currently RED — see below. + + base-std and base/base must not diverge: base-std's interface is the contract and the Rust + precompile is the implementation of it. Every check here is a base/base bug to fix. + + Known cause of the current failures: `PolicyType::is_valid()` (base/base `policy/abi.rs`) is + hand-narrowed to BLOCKLIST|ALLOWLIST, and `validate_create_policy_inputs` (`policy/logic/v2.rs`) + consults it BEFORE the zero-admin guard. So the simple creators do reject a composite gate, but + with `Panic(0x21)` (EnumConversionError) instead of the documented `IncompatiblePolicyType`, and + a zero-admin composite-typed call reports the type problem instead of `ZeroAddress`. Semantically + `Panic(0x21)` is the wrong answer regardless: UNION/INTERSECT ARE valid `PolicyType` + discriminants, so the enum conversion succeeds — nothing is out of range. + + These are NOT regressions. Before composites, `PolicyType` had only BLOCKLIST|ALLOWLIST, so a + composite discriminant was out of enum range and also produced `Panic(0x21)`. The wire behaviour + is unchanged; what changed is that the interface now documents `IncompatiblePolicyType` for this + input. Once base/base is reconciled these become ordinary regression tests that must stay green. + + COLLECT-ALL, mirroring `precompile_invariants.run`: the harness's `die()` raises SystemExit, so a + plain sequence would abort at the first detector and the rest would never execute — failing + silently. Each detector is run and reported independently, then the run exits non-zero with the + full list. + + Diagnosing the output: `expect_revert` resolves names from the interface ABI's error set and + `Panic(uint256)` is not in it, so these read `got=None want=IncompatiblePolicyType`. That is the + signature of `Panic(0x21)` — not of a call that failed to revert. + + Runs LAST so the rest of the journey reports first. All are `eth_call` simulations; none mutates + chain state. + """ + findings: list[tuple[str, str]] = [] + for i, (name, fn) in enumerate(CONFORMANCE_DETECTORS, 33): + step(i, f"[conformance | RED until base/base is fixed] {name}") + try: + fn(c) + except SystemExit as exc: + detail = str(exc).replace("[smoke] ERROR: ", "") + print(f" \u2717 FINDING: {detail}", file=sys.stderr) + findings.append((name, detail)) + except Exception as exc: # noqa: BLE001 - harness/RPC error, surface as a finding + detail = f"{type(exc).__name__}: {exc}" + print(f" \u2717 ERROR: {detail}", file=sys.stderr) + findings.append((name, detail)) + + held = len(CONFORMANCE_DETECTORS) - len(findings) + log(f"policy-registry conformance: {held}/{len(CONFORMANCE_DETECTORS)} checks held") + for name, detail in findings: + log(f" \u2717 {name} \u2014 {detail}") + if findings: + raise SystemExit( + f"[smoke] policy-registry: {len(findings)} base/base conformance finding(s) need fixing" + ) + + def run(c: Chain) -> None: log("policy-registry: starting") pid_b = _journey(c) tok, pid_r = _enforcement(c) _edges(c, tok, pid_r, pid_b) + _composite(c, pid_b) _events(c) + # Last: base/base conformance. Collect-all, so no detector can be hidden by an earlier one. + _conformance(c) log("policy-registry: OK") diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index 3570cef9..9878f668 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -33,6 +33,7 @@ FACTORY = config.B20_FACTORY POLICY = config.POLICY_REGISTRY ALLOWLIST = config.POLICY_TYPE_ALLOWLIST +UNION = config.POLICY_TYPE_UNION # Check names that are known/accepted divergences: reported, but do not fail the run. INFORMATIONAL: set[str] = set() @@ -52,9 +53,31 @@ def _addr_word(address: str) -> bytes: # ── raw calldata edges (no helper contract; web3 emits bytes Solidity wouldn't) ──────────────────── +def _composite_write_calldata(c: Chain) -> list[tuple[str, bytes]]: + """Canonical calldata for the two composite (V2) write selectors. + + Argument values are immaterial: `msg.value != 0` is the FIRST check in policy dispatch, ahead of + calldata-gas accounting, hardfork/version resolution and selector matching alike. That ordering is + also why these two need no composite-support gating — on a pre-Cobalt node the selectors are + unknown, but the value guard still fires before dispatch ever looks at them. Payloads are canonical + anyway so a failure can never be blamed on ABI decoding. + """ + children = [(ALLOWLIST << 56) | 2, (ALLOWLIST << 56) | 3] + return [ + ("createCompositePolicy", _clean(c.policy, "createCompositePolicy", c.DEPLOYER, UNION, children)), + ("updateComposite", _clean(c.policy, "updateComposite", (UNION << 56) | 4, children)), + ] + + def _payable_rejected(c: Chain, _probe) -> None: create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) c.expect_raw_revert("value on createPolicy", POLICY, create_data, value=1) + # Every policy-registry selector is nonpayable (IPolicyRegistry: the check runs at the top of + # dispatch), but only createPolicy was ever covered. `error_name` is pinned for these two because + # `expect_raw_revert` treats ANY non-ContractLogicError exception as a pass — without it, a + # node-level rejection (insufficient funds, bad gas estimate) would masquerade as NonPayable. + for name, data in _composite_write_calldata(c): + c.expect_raw_revert(f"value on {name}", POLICY, data, value=1, error_name="NonPayable") def _unknown_selector_reverts(c: Chain, _probe) -> None: @@ -93,11 +116,17 @@ def _staticcall_read_only(c: Chain, probe) -> None: def _value_forwarding_rejected(c: Chain, probe) -> None: - create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) overrides = {"from": c.DEPLOYER, "value": 1} + create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) fn = probe.functions.probeCall(POLICY, create_data) res = fn.call(overrides) c.assert_eq(res[0], False, "createPolicy rejects forwarded value", repro_fn=fn, repro_overrides=overrides) + # Same guard, reached from a real contract frame, for the two composite write selectors. + for name, data in _composite_write_calldata(c): + fn = probe.functions.probeCall(POLICY, data) + c.assert_eq( + fn.call(overrides)[0], False, f"{name} rejects forwarded value", repro_fn=fn, repro_overrides=overrides + ) def _returndata_fidelity(c: Chain, probe) -> None: @@ -170,7 +199,7 @@ def _create_gas_independent_of_prefunded_balance(c: Chain, _probe) -> None: # Ordered audit checklist: (name, fn). `name` doubles as the INFORMATIONAL downgrade key. CHECKS: list[tuple[str, Callable[[Chain, object], None]]] = [ - ("payable rejected (value on non-payable createPolicy)", _payable_rejected), + ("payable rejected (value on non-payable createPolicy + composite writes)", _payable_rejected), ("unknown selector reverts (no silent fallthrough)", _unknown_selector_reverts), ("empty calldata reverts (no implicit receive/fallback)", _empty_calldata_reverts), ("truncated args revert (strict ABI decode)", _truncated_args_revert), From e8337ceb73045a0c1160ca2b25190e33b89c2af3 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 28 Jul 2026 14:23:22 -0400 Subject: [PATCH 2/5] test(smoke): trim chain.py helper docstrings to one-liners Addresses review on #178: both new helpers carried multi-paragraph docstrings where the surrounding class uses one-liners or none at all (create_policy and _policy_id_from have no docstring). Keeps just the summary line. Generated with Claude Code Co-Authored-By: Claude --- script/smoke/chain.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 6aa509ee..6610acc6 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -561,14 +561,7 @@ def create_policy_with_accounts(self, admin: ChecksumAddress, ptype: int, accoun return self._policy_id_from(receipt) def create_composite_policy(self, admin: ChecksumAddress, ptype: int, child_ids: list[int]) -> int: - """Create a UNION/INTERSECT composite over existing simple policies; return the new id. - - Composites are minted through the same `_create` path as simple policies and emit the - canonical `PolicyCreated(policyId, creator, policyType)` (verified against - `IPolicyRegistry`/`MockPolicyRegistry`), so `_policy_id_from` needs no composite-specific - branch. They additionally emit `CompositePolicyUpdated` carrying the full child set — - see `composite_children_from` to read it back off the receipt. - """ + """Create a UNION/INTERSECT composite over existing simple policies; return the new id.""" receipt = self.send(self.policy.functions.createCompositePolicy(admin, ptype, child_ids), self.deployer) return self._policy_id_from(receipt) @@ -579,14 +572,7 @@ def _policy_id_from(self, receipt: TxReceipt) -> int: return int(events[0]["args"]["policyId"]) def composite_children_from(self, receipt: TxReceipt, policy_id: int | None = None) -> list[int]: - """Decode `CompositePolicyUpdated` from a SPECIFIC receipt and return its `childPolicyIds`. - - `assert_events_emitted` is presence-only (topic0 seen somewhere across the run), which - cannot distinguish "the child set was replaced with exactly X" from "some composite event - fired". This decodes the payload of one tx so a journey can assert the exact array. - Pass `policy_id` to select the event for a specific composite when a receipt carries - several; otherwise the first (only) one is returned. - """ + """Decode `CompositePolicyUpdated` from a SPECIFIC receipt and return its `childPolicyIds`.""" events = self.policy.events.CompositePolicyUpdated().process_receipt(receipt, errors=DISCARD) if policy_id is not None: events = [e for e in events if int(e["args"]["policyId"]) == policy_id] From 89387c236af91247d94025a26bcb9bd13507a530 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 28 Jul 2026 15:42:13 -0400 Subject: [PATCH 3/5] test(smoke): drop unresolvable spec refs, trim doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #178. The step descriptions carried tags like [CX-8] and [CC-1/CC-2] that referenced brains/composite_policy/SMOKE_TEST_SPECS.md — a doc that does not live in this repo, so nobody reading base-std could resolve them. Stripped all 15 from the step() descriptions and removed the docstring pointer to that path; the descriptions now stand on their own. Also fixes a cross-reference that went stale when the steps were renumbered ("steps 31-32" for what are now the conformance checks), and trims the two remaining oversized docstrings — _conformance 30 lines -> 12, keeping only the cause, the collect-all rationale and the got=None diagnostic hint; _composite_write_calldata 7 -> 4. Generated with Claude Code Co-Authored-By: Claude --- script/smoke/journeys/policy_registry.py | 84 +++++++------------ .../smoke/journeys/precompile_invariants.py | 9 +- 2 files changed, 35 insertions(+), 58 deletions(-) diff --git a/script/smoke/journeys/policy_registry.py b/script/smoke/journeys/policy_registry.py index 084174cf..700a3631 100644 --- a/script/smoke/journeys/policy_registry.py +++ b/script/smoke/journeys/policy_registry.py @@ -109,15 +109,12 @@ def _edges(c: Chain, tok, pid_r: int, pid_b: int) -> None: def _composite(c: Chain, pid_b: int) -> None: - """Composite (UNION / INTERSECT) policies: construction, evaluation, update, edges, enforcement. - - Spec IDs are from brains/composite_policy/SMOKE_TEST_SPECS.md. - """ + """Composite (UNION / INTERSECT) policies: construction, evaluation, update, edges, enforcement.""" carol = c.cfg.new_addr("carol") dave = c.cfg.new_addr("dave") erin = c.cfg.new_addr("erin") - step(17, "[CC-1/CC-2] seed two simple children, then create a UNION and an INTERSECT over them") + step(17, "seed two simple children, then create a UNION and an INTERSECT over them") # childX allows {alice, dave}; childY allows {bob, dave}. dave is the only account in BOTH. pid_x = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.ALICE, dave]) pid_y = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.BOB, dave]) @@ -128,19 +125,19 @@ def _composite(c: Chain, pid_b: int) -> None: c.assert_eq(c.policy.functions.policyExists(pid_inter).call(), True, "INTERSECT composite exists") c.assert_eq(c.policy.functions.policyAdmin(pid_inter).call(), c.DEPLOYER, "INTERSECT admin == deployer") - step(18, "[CE-1/CE-2] UNION authorizes an account in ANY child; denies one in none") + step(18, "UNION authorizes an account in ANY child; denies one in none") c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.ALICE).call(), True, "UNION: alice (childX only) allowed") c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.BOB).call(), True, "UNION: bob (childY only) allowed") c.assert_eq(c.policy.functions.isAuthorized(pid_union, dave).call(), True, "UNION: dave (both children) allowed") c.assert_eq(c.policy.functions.isAuthorized(pid_union, carol).call(), False, "UNION: carol (no child) denied") - step(19, "[CE-3/CE-4] INTERSECT authorizes only an account in EVERY child") + step(19, "INTERSECT authorizes only an account in EVERY child") c.assert_eq(c.policy.functions.isAuthorized(pid_inter, dave).call(), True, "INTERSECT: dave (every child) allowed") c.assert_eq(c.policy.functions.isAuthorized(pid_inter, c.ALICE).call(), False, "INTERSECT: alice missing childY -> denied") c.assert_eq(c.policy.functions.isAuthorized(pid_inter, c.BOB).call(), False, "INTERSECT: bob missing childX -> denied") c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol (no child) denied") - step(20, "[CE-7] live evaluation: mutate a CHILD's membership, composite verdict flips (no call on the composite)") + step(20, "live evaluation: mutate a CHILD's membership, composite verdict flips (no call on the composite)") c.send(c.policy.functions.updateAllowlist(pid_x, True, [carol]), c.deployer) c.assert_eq(c.policy.functions.isAuthorized(pid_union, carol).call(), True, "UNION: carol allowed after childX add") c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol still denied (childY missing)") @@ -149,7 +146,7 @@ def _composite(c: Chain, pid_b: int) -> None: c.send(c.policy.functions.updateAllowlist(pid_x, False, [carol]), c.deployer) c.assert_eq(c.policy.functions.isAuthorized(pid_inter, carol).call(), False, "INTERSECT: carol denied again after childX removal") - step(21, "[CU-1/EV-7] updateComposite REPLACES the child set (no merge); event carries the exact new set") + step(21, "updateComposite REPLACES the child set (no merge); event carries the exact new set") pid_c1 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [erin]) pid_c2 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [erin]) c.assert_eq(c.policy.functions.isAuthorized(pid_union, c.ALICE).call(), True, "pre-update: alice allowed via childX") @@ -164,7 +161,7 @@ def _composite(c: Chain, pid_b: int) -> None: "CompositePolicyUpdated payload == exactly the new child ids", ) - step(22, "[CX-3/CX-4] child count outside [2,4] -> ChildPoliciesOutsideOfRange") + step(22, "child count outside [2,4] -> ChildPoliciesOutsideOfRange") too_few = [pid_x] too_many = [pid_x, pid_y, pid_c1, pid_c2, pid_b] c.assert_eq(len(too_few) < config.MIN_CHILD_POLICIES, True, "fixture: 1 child is below MIN_CHILD_POLICIES") @@ -182,7 +179,7 @@ def _composite(c: Chain, pid_b: int) -> None: c.expect_revert("ChildPoliciesOutsideOfRange", c.policy.functions.updateComposite(pid_union, too_few), c.DEPLOYER) c.expect_revert("ChildPoliciesOutsideOfRange", c.policy.functions.updateComposite(pid_union, too_many), c.DEPLOYER) - step(23, "[CX-6] a built-in sentinel as a child -> InvalidChildPolicy") + step(23, "a built-in sentinel as a child -> InvalidChildPolicy") c.expect_revert( "InvalidChildPolicy", c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_UNION, [config.ALWAYS_ALLOW_ID, pid_x]), @@ -197,7 +194,7 @@ def _composite(c: Chain, pid_b: int) -> None: "InvalidChildPolicy", c.policy.functions.updateComposite(pid_union, [config.ALWAYS_ALLOW_ID, pid_x]), c.DEPLOYER ) - step(24, "[CX-7] a composite as a child -> InvalidChildPolicy") + step(24, "a composite as a child -> InvalidChildPolicy") c.expect_revert( "InvalidChildPolicy", c.policy.functions.createCompositePolicy(c.DEPLOYER, config.POLICY_TYPE_INTERSECT, [pid_inter, pid_x]), @@ -205,10 +202,10 @@ def _composite(c: Chain, pid_b: int) -> None: ) c.expect_revert("InvalidChildPolicy", c.policy.functions.updateComposite(pid_union, [pid_inter, pid_x]), c.DEPLOYER) - step(25, "[CU-6] non-admin updateComposite -> Unauthorized") + step(25, "non-admin updateComposite -> Unauthorized") c.expect_revert("Unauthorized", c.policy.functions.updateComposite(pid_union, [pid_x, pid_y]), c.USER2) - step(26, "[CX-1/CX-2/CU-5] composite creator input guards: ZeroAddress, IncompatiblePolicyType") + step(26, "composite creator input guards: ZeroAddress, IncompatiblePolicyType") # ZeroAddress outranks every later check, so the child set here is deliberately valid. c.expect_revert( "ZeroAddress", @@ -223,9 +220,9 @@ def _composite(c: Chain, pid_b: int) -> None: c.DEPLOYER, ) # Mirror: updateComposite must reject a SIMPLE target, and the membership mutators must reject a - # COMPOSITE target. Together with steps 31-32 this closes the type-guard matrix in both directions, - # so a red step 31/32 reads as "the two known creator-side defects" and not "the type byte is - # ignored everywhere". + # COMPOSITE target. Together with the conformance checks at the end of the journey this closes the + # type-guard matrix in both directions, so a red conformance check reads as "the two creator-side + # bugs" rather than "the type byte is ignored everywhere". c.expect_revert("IncompatiblePolicyType", c.policy.functions.updateComposite(pid_x, [pid_x, pid_y]), c.DEPLOYER) c.expect_revert( "IncompatiblePolicyType", c.policy.functions.updateAllowlist(pid_union, True, [c.BOB]), c.DEPLOYER @@ -234,7 +231,7 @@ def _composite(c: Chain, pid_b: int) -> None: "IncompatiblePolicyType", c.policy.functions.updateBlocklist(pid_inter, True, [c.BOB]), c.DEPLOYER ) - step(27, "[CX-5/CU-8/CX-8] non-existent child -> PolicyNotFound, and it outranks InvalidChildPolicy") + step(27, "non-existent child -> PolicyNotFound, and it outranks InvalidChildPolicy") # A well-formed but never-created id. Type byte 0 (BLOCKLIST) — note this is exactly the shape whose # read-side semantics are permissive (an empty blocklist authorizes everyone), so child-existence # validation is the only thing standing between a permissionless caller and a composite that @@ -253,7 +250,7 @@ def _composite(c: Chain, pid_b: int) -> None: c.DEPLOYER, ) c.expect_revert("PolicyNotFound", c.policy.functions.updateComposite(pid_union, [pid_x, ghost]), c.DEPLOYER) - # CX-8 precedence: existence is checked across the WHOLE set before validity. The invalid child is + # Precedence: existence is checked across the WHOLE set before validity. The invalid child is # placed FIRST and the ghost LAST, so a per-element validator would answer InvalidChildPolicy. c.expect_revert( "PolicyNotFound", @@ -261,7 +258,7 @@ def _composite(c: Chain, pid_b: int) -> None: c.DEPLOYER, ) - step(28, "[CT-1] create ASSET token wired to a UNION composite on TRANSFER_RECEIVER + MINT_RECEIVER") + step(28, "create ASSET token wired to a UNION composite on TRANSFER_RECEIVER + MINT_RECEIVER") # Dedicated children so the token's gate is unaffected by the mutations above. pid_t1 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.ALICE]) pid_t2 = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.DEPLOYER]) @@ -285,17 +282,17 @@ def _composite(c: Chain, pid_b: int) -> None: ctok.functions.policyId(config.TRANSFER_RECEIVER_POLICY).call(), pid_tok, "TRANSFER_RECEIVER_POLICY == composite" ) - step(29, "[CT-2] composite-authorized receivers: mint + transfer succeed") + step(29, "composite-authorized receivers: mint + transfer succeed") c.send(ctok.functions.mint(c.ALICE, config.amt(100, 18)), c.deployer) c.assert_eq(ctok.functions.balanceOf(c.ALICE).call(), config.amt(100, 18), "alice minted (authorized via child 1)") c.send(ctok.functions.mint(c.DEPLOYER, config.amt(100, 18)), c.deployer) c.send(ctok.functions.transfer(c.ALICE, config.amt(1, 18)), c.deployer) c.assert_eq(ctok.functions.balanceOf(c.ALICE).call(), config.amt(101, 18), "transfer to composite-authorized receiver") - step(30, "[CT-3] composite-denied receiver on transfer -> PolicyForbids") + step(30, "composite-denied receiver on transfer -> PolicyForbids") c.expect_revert("PolicyForbids", ctok.functions.transfer(c.BOB, config.amt(1, 18)), c.DEPLOYER) - step(31, "[CT-4] composite-denied receiver on mint -> PolicyForbids") + step(31, "composite-denied receiver on mint -> PolicyForbids") c.expect_revert("PolicyForbids", ctok.functions.mint(c.BOB, config.amt(1, 18)), c.DEPLOYER) @@ -355,35 +352,18 @@ def _d3_zero_admin_precedence(c: Chain) -> None: def _conformance(c: Chain) -> None: - """Assert base/base matches the `IPolicyRegistry` contract. Currently RED — see below. - - base-std and base/base must not diverge: base-std's interface is the contract and the Rust - precompile is the implementation of it. Every check here is a base/base bug to fix. - - Known cause of the current failures: `PolicyType::is_valid()` (base/base `policy/abi.rs`) is - hand-narrowed to BLOCKLIST|ALLOWLIST, and `validate_create_policy_inputs` (`policy/logic/v2.rs`) - consults it BEFORE the zero-admin guard. So the simple creators do reject a composite gate, but - with `Panic(0x21)` (EnumConversionError) instead of the documented `IncompatiblePolicyType`, and - a zero-admin composite-typed call reports the type problem instead of `ZeroAddress`. Semantically - `Panic(0x21)` is the wrong answer regardless: UNION/INTERSECT ARE valid `PolicyType` - discriminants, so the enum conversion succeeds — nothing is out of range. - - These are NOT regressions. Before composites, `PolicyType` had only BLOCKLIST|ALLOWLIST, so a - composite discriminant was out of enum range and also produced `Panic(0x21)`. The wire behaviour - is unchanged; what changed is that the interface now documents `IncompatiblePolicyType` for this - input. Once base/base is reconciled these become ordinary regression tests that must stay green. - - COLLECT-ALL, mirroring `precompile_invariants.run`: the harness's `die()` raises SystemExit, so a - plain sequence would abort at the first detector and the rest would never execute — failing - silently. Each detector is run and reported independently, then the run exits non-zero with the - full list. - - Diagnosing the output: `expect_revert` resolves names from the interface ABI's error set and - `Panic(uint256)` is not in it, so these read `got=None want=IncompatiblePolicyType`. That is the - signature of `Panic(0x21)` — not of a call that failed to revert. - - Runs LAST so the rest of the journey reports first. All are `eth_call` simulations; none mutates - chain state. + """Assert base/base matches the `IPolicyRegistry` contract. Currently RED; each is a base/base bug. + + Cause: `PolicyType::is_valid()` (`policy/abi.rs`) is narrowed to BLOCKLIST|ALLOWLIST and + `validate_create_policy_inputs` (`policy/logic/v2.rs`) consults it before the zero-admin guard, so + composite gates get `Panic(0x21)` instead of `IncompatiblePolicyType`, and a zero-admin composite + call reports the type problem instead of `ZeroAddress`. + + Collect-all, mirroring `precompile_invariants.run`: `die()` raises SystemExit, so a plain sequence + would abort at the first check and hide the rest. Runs last; all are `eth_call`, none mutate state. + + Reading a failure: `Panic(uint256)` is not in the interface's error set, so these report + `got=None want=IncompatiblePolicyType` — that is `Panic(0x21)`, not a missing revert. """ findings: list[tuple[str, str]] = [] for i, (name, fn) in enumerate(CONFORMANCE_DETECTORS, 33): diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index 9878f668..7fddddae 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -54,13 +54,10 @@ def _addr_word(address: str) -> bytes: # ── raw calldata edges (no helper contract; web3 emits bytes Solidity wouldn't) ──────────────────── def _composite_write_calldata(c: Chain) -> list[tuple[str, bytes]]: - """Canonical calldata for the two composite (V2) write selectors. + """Canonical calldata for the two composite write selectors. - Argument values are immaterial: `msg.value != 0` is the FIRST check in policy dispatch, ahead of - calldata-gas accounting, hardfork/version resolution and selector matching alike. That ordering is - also why these two need no composite-support gating — on a pre-Cobalt node the selectors are - unknown, but the value guard still fires before dispatch ever looks at them. Payloads are canonical - anyway so a failure can never be blamed on ABI decoding. + `msg.value != 0` is the first check in dispatch, ahead of version resolution and selector + matching, so arg values are immaterial and no pre-Cobalt gating is needed. """ children = [(ALLOWLIST << 56) | 2, (ALLOWLIST << 56) | 3] return [ From e28e498ac88599e700c4a01c58d031ef1d1c6786 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 28 Jul 2026 15:48:39 -0400 Subject: [PATCH 4/5] test(smoke): share run_collected, rename to regressions, drop magic step number Addresses the three open review threads on #178. - Renamed _conformance -> _regressions and CONFORMANCE_DETECTORS -> REGRESSION_CHECKS. - Extracted the collect-all loop into chain.run_collected and routed BOTH journeys through it. precompile_invariants had the same loop inline with slightly different error handling; that copy and its _detail helper are gone, so the two lanes now report failures identically. The helper takes (name, thunk) pairs, honours an `informational` set, and raises once at the end with every finding rather than aborting at the first. - Replaced the hardcoded step-33 start with a step_prefix, so the regression checks number R1..R3 as their own series. They no longer depend on how many steps precede them, which is what made the earlier "steps 31-32" comment go stale when steps were inserted above. Verified run_collected behaviourally: every check runs when an earlier one fails, die()'s SystemExit reports as FINDING while an arbitrary exception reports as ERROR, and an informational name is reported without failing the run. Generated with Claude Code Co-Authored-By: Claude --- script/smoke/chain.py | 41 +++++++++++++ script/smoke/journeys/policy_registry.py | 59 +++++++------------ .../smoke/journeys/precompile_invariants.py | 39 +++--------- 3 files changed, 69 insertions(+), 70 deletions(-) diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 6610acc6..8551ec1c 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -18,6 +18,7 @@ import time import urllib.error import urllib.request +from collections.abc import Callable from eth_account import Account from eth_account.signers.local import LocalAccount @@ -40,6 +41,46 @@ def log(msg: str) -> None: print(f"[smoke] {msg}", file=sys.stderr) +def run_collected( + checks: "list[tuple[str, Callable[[], None]]]", + *, + label: str, + informational: "frozenset[str] | set[str]" = frozenset(), + step_prefix: str = "", +) -> None: + """Run every check, recording failures instead of aborting at the first. + + `die()` raises SystemExit, so a plain sequence stops at the first failure and the remaining + checks never execute. Each `(name, thunk)` is isolated and its failure recorded. Names in + `informational` are reported but do not fail the run. Raises SystemExit at the end if any + required check failed. + """ + findings: list[tuple[str, str, bool]] = [] + for i, (name, thunk) in enumerate(checks, 1): + step(f"{step_prefix}{i}", name) + required = name not in informational + try: + thunk() + except SystemExit as exc: + detail = str(exc.code or "").replace("[smoke] ERROR: ", "") + print(f" ✗ {'FINDING' if required else 'info'}: {detail}", file=sys.stderr) + findings.append((name, detail, required)) + except Exception as exc: # noqa: BLE001 - harness/RPC error, surface as a finding + detail = f"{type(exc).__name__}: {exc}" + print(f" ✗ ERROR: {detail}", file=sys.stderr) + findings.append((name, detail, required)) + + required_fail = [f for f in findings if f[2]] + info_only = [f for f in findings if not f[2]] + log(f"{label}: {len(checks) - len(findings)}/{len(checks)} checks held") + for name, detail, _ in findings: + log(f" ✗ {name} — {detail}") + if info_only: + log(f"({len(info_only)} accepted divergence(s) reported as informational)") + if required_fail: + raise SystemExit(f"[smoke] {label}: {len(required_fail)} finding(s) need triage") + + def step(n: object, desc: str) -> None: print(f" \u2192 [{n}] {desc}", file=sys.stderr) diff --git a/script/smoke/journeys/policy_registry.py b/script/smoke/journeys/policy_registry.py index 700a3631..e30a528b 100644 --- a/script/smoke/journeys/policy_registry.py +++ b/script/smoke/journeys/policy_registry.py @@ -8,16 +8,16 @@ `_composite` covers the UNION/INTERSECT composite gates: construction, live gate evaluation over child membership, full-replacement `updateComposite` semantics, the child-set validation reverts, and token enforcement through a -composite. `_known_divergences` runs LAST and is expected to fail against the +composite. `_regressions` runs LAST and is expected to fail against the current live deployment (see the function docstring). """ from __future__ import annotations -import sys +from functools import partial from .. import config -from ..chain import Chain, log, step +from ..chain import Chain, log, run_collected, step from ..codec import AssetCreateParams, init_call @@ -340,18 +340,17 @@ def _d3_zero_admin_precedence(c: Chain) -> None: ) -# Conformance checks where base/base does not yet match the `IPolicyRegistry` contract that base-std -# defines. There are no ACCEPTED divergences between the two — every entry here is a base/base bug to -# fix, not a behaviour to live with. They are listed rather than inlined only so one failure cannot -# hide the others. -CONFORMANCE_DETECTORS = [ - ("D1 createPolicy(admin, UNION) -> IncompatiblePolicyType", _d1_create_policy), - ("D2 createPolicyWithAccounts(admin, UNION, accts) -> IncompatiblePolicyType", _d2_create_policy_with_accounts), - ("D3 createPolicy(0, UNION) -> ZeroAddress outranks the type guard", _d3_zero_admin_precedence), +# Checks where base/base does not yet match the `IPolicyRegistry` contract that base-std defines. +# There are no accepted divergences between the two — every entry is a base/base bug to fix, not a +# behaviour to live with. Listed rather than inlined so one failure cannot hide the others. +REGRESSION_CHECKS = [ + ("createPolicy(admin, UNION) -> IncompatiblePolicyType", _d1_create_policy), + ("createPolicyWithAccounts(admin, UNION, accts) -> IncompatiblePolicyType", _d2_create_policy_with_accounts), + ("createPolicy(0, UNION) -> ZeroAddress outranks the type guard", _d3_zero_admin_precedence), ] -def _conformance(c: Chain) -> None: +def _regressions(c: Chain) -> None: """Assert base/base matches the `IPolicyRegistry` contract. Currently RED; each is a base/base bug. Cause: `PolicyType::is_valid()` (`policy/abi.rs`) is narrowed to BLOCKLIST|ALLOWLIST and @@ -359,34 +358,18 @@ def _conformance(c: Chain) -> None: composite gates get `Panic(0x21)` instead of `IncompatiblePolicyType`, and a zero-admin composite call reports the type problem instead of `ZeroAddress`. - Collect-all, mirroring `precompile_invariants.run`: `die()` raises SystemExit, so a plain sequence - would abort at the first check and hide the rest. Runs last; all are `eth_call`, none mutate state. + Runs last, collect-all so one failure cannot hide the others. All are `eth_call`; none mutate + state. Numbered `R1..` rather than continuing the journey's sequence, so inserting a step above + cannot silently renumber these. Reading a failure: `Panic(uint256)` is not in the interface's error set, so these report `got=None want=IncompatiblePolicyType` — that is `Panic(0x21)`, not a missing revert. """ - findings: list[tuple[str, str]] = [] - for i, (name, fn) in enumerate(CONFORMANCE_DETECTORS, 33): - step(i, f"[conformance | RED until base/base is fixed] {name}") - try: - fn(c) - except SystemExit as exc: - detail = str(exc).replace("[smoke] ERROR: ", "") - print(f" \u2717 FINDING: {detail}", file=sys.stderr) - findings.append((name, detail)) - except Exception as exc: # noqa: BLE001 - harness/RPC error, surface as a finding - detail = f"{type(exc).__name__}: {exc}" - print(f" \u2717 ERROR: {detail}", file=sys.stderr) - findings.append((name, detail)) - - held = len(CONFORMANCE_DETECTORS) - len(findings) - log(f"policy-registry conformance: {held}/{len(CONFORMANCE_DETECTORS)} checks held") - for name, detail in findings: - log(f" \u2717 {name} \u2014 {detail}") - if findings: - raise SystemExit( - f"[smoke] policy-registry: {len(findings)} base/base conformance finding(s) need fixing" - ) + run_collected( + [(name, partial(fn, c)) for name, fn in REGRESSION_CHECKS], + label="policy-registry regressions", + step_prefix="R", + ) def run(c: Chain) -> None: @@ -396,6 +379,6 @@ def run(c: Chain) -> None: _edges(c, tok, pid_r, pid_b) _composite(c, pid_b) _events(c) - # Last: base/base conformance. Collect-all, so no detector can be hidden by an earlier one. - _conformance(c) + # Last: base/base conformance. Collect-all, so no check can be hidden by an earlier one. + _regressions(c) log("policy-registry: OK") diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index 7fddddae..39dd1544 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -19,15 +19,15 @@ from __future__ import annotations -import sys from collections.abc import Callable +from functools import partial from hexbytes import HexBytes from web3 import Web3 from .. import config from ..abis import forcefeeder_artifact, probe_artifact -from ..chain import Chain, log, ok, step +from ..chain import Chain, log, ok, run_collected, step from ..codec import AssetCreateParams, init_call FACTORY = config.B20_FACTORY @@ -211,10 +211,6 @@ def _create_gas_independent_of_prefunded_balance(c: Chain, _probe) -> None: ] -def _detail(exc: SystemExit) -> str: - return str(exc.code or "").replace("[smoke] ERROR: ", "") - - def _setup(c: Chain): step("setup", "deploy PrecompileProbe helper") abi, bytecode = probe_artifact() @@ -226,30 +222,9 @@ def _setup(c: Chain): def run(c: Chain) -> None: log("precompile-invariants: starting (collect-all; findings reported at the end)") probe = _setup(c) - - findings: list[tuple[str, str, bool]] = [] # (name, detail, required) - for i, (name, fn) in enumerate(CHECKS, 1): - step(i, name) - required = name not in INFORMATIONAL - try: - fn(c, probe) - except SystemExit as exc: # assertion/expectation failed inside a check - detail = _detail(exc) - tag = "FINDING" if required else "info" - print(f" \u2717 {tag}: {detail}", file=sys.stderr) - findings.append((name, detail, required)) - except Exception as exc: # noqa: BLE001 - harness/RPC error, surface as a finding - detail = f"{type(exc).__name__}: {exc}" - print(f" \u2717 ERROR: {detail}", file=sys.stderr) - findings.append((name, detail, required)) - - required_fail = [f for f in findings if f[2]] - info_only = [f for f in findings if not f[2]] - log(f"precompile-invariants: {len(CHECKS) - len(findings)}/{len(CHECKS)} invariants held") - for name, detail, _ in findings: - log(f" \u2717 {name} \u2014 {detail}") - if info_only: - log(f"({len(info_only)} accepted divergence(s) reported as informational)") - if required_fail: - raise SystemExit(f"[smoke] precompile-invariants: {len(required_fail)} finding(s) need triage") + run_collected( + [(name, partial(fn, c, probe)) for name, fn in CHECKS], + label="precompile-invariants", + informational=INFORMATIONAL, + ) log("precompile-invariants: OK") From b7ae8cc137b36756dd12501a5e7394c2224f0951 Mon Sep 17 00:00:00 2001 From: Rayyan Alam Date: Tue, 28 Jul 2026 16:23:45 -0400 Subject: [PATCH 5/5] test(smoke): drop the expected-to-fail conformance checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the three checks that asserted base-std's documented behaviour against what base/base currently does (composite policyType in the simple creators, and zero-admin precedence over the type guard). They were expected to fail, and a test that is expected to fail is not a useful signal in a journey — the fix belongs in base/base, not in a detector that encodes the gap. Also removes the surrounding commentary that framed them as such. chain.run_collected stays: precompile_invariants uses it, which was the point of extracting it from that journey's inline loop. The journey now runs steps 1-32 and is expected to pass end to end. Generated with Claude Code Co-Authored-By: Claude --- script/smoke/journeys/policy_registry.py | 69 +----------------------- 1 file changed, 2 insertions(+), 67 deletions(-) diff --git a/script/smoke/journeys/policy_registry.py b/script/smoke/journeys/policy_registry.py index e30a528b..b19cf991 100644 --- a/script/smoke/journeys/policy_registry.py +++ b/script/smoke/journeys/policy_registry.py @@ -8,16 +8,13 @@ `_composite` covers the UNION/INTERSECT composite gates: construction, live gate evaluation over child membership, full-replacement `updateComposite` semantics, the child-set validation reverts, and token enforcement through a -composite. `_regressions` runs LAST and is expected to fail against the -current live deployment (see the function docstring). +composite. """ from __future__ import annotations -from functools import partial - from .. import config -from ..chain import Chain, log, run_collected, step +from ..chain import Chain, log, step from ..codec import AssetCreateParams, init_call @@ -312,66 +309,6 @@ def _events(c: Chain) -> None: ) -def _d1_create_policy(c: Chain) -> None: - """createPolicy(admin, UNION) must revert IncompatiblePolicyType.""" - c.expect_revert( - "IncompatiblePolicyType", c.policy.functions.createPolicy(c.DEPLOYER, config.POLICY_TYPE_UNION), c.DEPLOYER - ) - - -def _d2_create_policy_with_accounts(c: Chain) -> None: - """createPolicyWithAccounts(admin, UNION, accts) must revert IncompatiblePolicyType.""" - c.expect_revert( - "IncompatiblePolicyType", - c.policy.functions.createPolicyWithAccounts(c.DEPLOYER, config.POLICY_TYPE_UNION, [c.ALICE]), - c.DEPLOYER, - ) - - -def _d3_zero_admin_precedence(c: Chain) -> None: - """A zero admin must outrank the policy-type guard in both simple creators.""" - c.expect_revert( - "ZeroAddress", c.policy.functions.createPolicy(config.ZERO, config.POLICY_TYPE_UNION), c.DEPLOYER - ) - c.expect_revert( - "ZeroAddress", - c.policy.functions.createPolicyWithAccounts(config.ZERO, config.POLICY_TYPE_UNION, [c.ALICE]), - c.DEPLOYER, - ) - - -# Checks where base/base does not yet match the `IPolicyRegistry` contract that base-std defines. -# There are no accepted divergences between the two — every entry is a base/base bug to fix, not a -# behaviour to live with. Listed rather than inlined so one failure cannot hide the others. -REGRESSION_CHECKS = [ - ("createPolicy(admin, UNION) -> IncompatiblePolicyType", _d1_create_policy), - ("createPolicyWithAccounts(admin, UNION, accts) -> IncompatiblePolicyType", _d2_create_policy_with_accounts), - ("createPolicy(0, UNION) -> ZeroAddress outranks the type guard", _d3_zero_admin_precedence), -] - - -def _regressions(c: Chain) -> None: - """Assert base/base matches the `IPolicyRegistry` contract. Currently RED; each is a base/base bug. - - Cause: `PolicyType::is_valid()` (`policy/abi.rs`) is narrowed to BLOCKLIST|ALLOWLIST and - `validate_create_policy_inputs` (`policy/logic/v2.rs`) consults it before the zero-admin guard, so - composite gates get `Panic(0x21)` instead of `IncompatiblePolicyType`, and a zero-admin composite - call reports the type problem instead of `ZeroAddress`. - - Runs last, collect-all so one failure cannot hide the others. All are `eth_call`; none mutate - state. Numbered `R1..` rather than continuing the journey's sequence, so inserting a step above - cannot silently renumber these. - - Reading a failure: `Panic(uint256)` is not in the interface's error set, so these report - `got=None want=IncompatiblePolicyType` — that is `Panic(0x21)`, not a missing revert. - """ - run_collected( - [(name, partial(fn, c)) for name, fn in REGRESSION_CHECKS], - label="policy-registry regressions", - step_prefix="R", - ) - - def run(c: Chain) -> None: log("policy-registry: starting") pid_b = _journey(c) @@ -379,6 +316,4 @@ def run(c: Chain) -> None: _edges(c, tok, pid_r, pid_b) _composite(c, pid_b) _events(c) - # Last: base/base conformance. Collect-all, so no check can be hidden by an earlier one. - _regressions(c) log("policy-registry: OK")