From 656b516ddf12ff4abe264e3d7024b28fa235bc10 Mon Sep 17 00:00:00 2001 From: katzman Date: Mon, 8 Jun 2026 13:18:11 -0700 Subject: [PATCH 1/8] first pass stub --- Makefile | 19 ++- {scripts => script}/check-coverage.py | 0 script/smoke/asset-lifecycle.sh | 71 ++++++++++ script/smoke/factory.sh | 49 +++++++ script/smoke/policy-registry.sh | 77 +++++++++++ script/smoke/smoke-lib.sh | 178 ++++++++++++++++++++++++++ script/smoke/stablecoin-lifecycle.sh | 52 ++++++++ 7 files changed, 445 insertions(+), 1 deletion(-) rename {scripts => script}/check-coverage.py (100%) create mode 100755 script/smoke/asset-lifecycle.sh create mode 100755 script/smoke/factory.sh create mode 100755 script/smoke/policy-registry.sh create mode 100755 script/smoke/smoke-lib.sh create mode 100755 script/smoke/stablecoin-lifecycle.sh diff --git a/Makefile b/Makefile index 14b72a0..25a9213 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: coverage +.PHONY: coverage smoke smoke-factory smoke-asset smoke-stablecoin smoke-policy # Generate an lcov coverage report and open it in the browser. # Scoped to src/ and test/lib/mocks/ (excludes test runner files). @@ -6,3 +6,20 @@ coverage: forge coverage --no-match-coverage "(\.t\.sol|Test\.sol)$$" --report lcov genhtml lcov.info --branch-coverage -o coverage --dark-mode --ignore-errors inconsistent,corrupt open coverage/index.html + +# b20 precompile bring-up smoketest. cast-driven; sends real txs to $RPC_URL. +# Requires env: RPC_URL, DEPLOYER_PK, USER2_PK (see script/smoke/smoke-lib.sh). +# `make smoke` runs every journey fail-fast; the per-journey targets run one. +smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy + +smoke-factory: + ./script/smoke/factory.sh + +smoke-asset: + ./script/smoke/asset-lifecycle.sh + +smoke-stablecoin: + ./script/smoke/stablecoin-lifecycle.sh + +smoke-policy: + ./script/smoke/policy-registry.sh diff --git a/scripts/check-coverage.py b/script/check-coverage.py similarity index 100% rename from scripts/check-coverage.py rename to script/check-coverage.py diff --git a/script/smoke/asset-lifecycle.sh b/script/smoke/asset-lifecycle.sh new file mode 100755 index 0000000..ef10082 --- /dev/null +++ b/script/smoke/asset-lifecycle.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# asset-lifecycle.sh — B20 Asset variant smoketest. +# +# STUB: step-level scaffold for scope review. No executable bodies yet. +# Flexes the full operator lifecycle of an Asset token (decimals 18): issuance, +# transfers + memo, delegated spend, announcements (batchMint + rebase), metadata, +# burn — then the gates that must reject (cap, pause, role, announce-id reuse). +# +# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./asset-lifecycle.sh +# (or `make smoke-asset`) + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=script/smoke/smoke-lib.sh +source "$HERE/smoke-lib.sh" + +# Golden path. +asset_journey() { + # Setup. createB20(ASSET, salt_for asset, params, initCalls) where initCalls + # bundles (via encode_init_calls): grantRole(MINT/BURN/BURN_BLOCKED/PAUSE/ + # UNPAUSE/METADATA/OPERATOR, deployer) + updateSupplyCap(CAP). admin=deployer, + # decimals=ASSET_DECIMALS. Policies stay ALWAYS_ALLOW (default). + # assert isB20Initialized(tok) == true; assert decimals() == 18. + # + # 1. mint: mint(alice, 1000e18) + # assert balanceOf(alice) == 1000e18; assert totalSupply() == 1000e18 + # 2. mint to deployer (so it can send): mint(deployer, 500e18) + # 3. transfer: transfer(bob, 200e18) from deployer + # assert balanceOf(bob) == 200e18; assert balanceOf(deployer) == 300e18 + # 4. transferWithMemo: transferWithMemo(bob, 1e18, memo) from deployer + # assert_log_order tx Transfer Memo (Memo immediately follows Transfer) + # 5. delegated spend (distinct executor): approve(user2, 50e18) from deployer; + # fund_user2; transferFrom(deployer, bob, 50e18) signed by USER2 + # assert allowance(deployer, user2) == 0; assert balances moved + # 6. announce + batchMint: announce(internalCalls=[call_batch_mint([alice,bob], + # [10e18,20e18])], id="smoke-batch-1", desc, uri) from deployer + # assert Announcement→EndAnnouncement bracket for the id; + # assert isAnnouncementIdUsed("smoke-batch-1") == true; assert balances + # 7. announce + rebase: announce([call_update_multiplier(2e18)], id="smoke-rebase-1", …) + # assert multiplier() == 2e18; assert scaledBalanceOf(alice) == 2 * balanceOf(alice) + # assert toRawBalance(toScaledBalance(x)) within 1 ULP of x + # 8. extra metadata: updateExtraMetadata("category","rwa") + # assert extraMetadata("category") == "rwa"; + # then updateExtraMetadata("category","") -> assert extraMetadata == "" + # 9. metadata: updateName("Asset Two"); assert name() == "Asset Two" + # updateSymbol("AST2"); assert symbol() == "AST2" + # 10. burn: burn(100e18) from deployer; assert totalSupply() down by 100e18 + : +} + +# Critical edges. +asset_edges() { + # 11. supply cap: mint(alice, CAP - totalSupply + 1) -> SupplyCapExceeded(cap,attempted) + # 12. pause: pause([TRANSFER]); assert isPaused(TRANSFER) == true + # transfer(bob, 1) -> ContractPaused(TRANSFER) + # unpause([TRANSFER]) to restore; assert transfer(bob,1) succeeds again + # 13. role gate: mint(alice, 1) signed by USER2 (no MINT_ROLE) + # -> AccessControlUnauthorizedAccount(user2, MINT_ROLE) + # 14. announce id reuse: announce([], id="smoke-batch-1", …) -> AnnouncementIdAlreadyUsed + : +} + +main() { + preflight + log "asset-lifecycle: starting" + asset_journey + asset_edges + log "asset-lifecycle: OK" +} + +main "$@" diff --git a/script/smoke/factory.sh b/script/smoke/factory.sh new file mode 100755 index 0000000..2c167b7 --- /dev/null +++ b/script/smoke/factory.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# factory.sh — B20Factory precompile smoketest. +# +# STUB: step-level scaffold for scope review. No executable bodies yet. +# Flexes deterministic creation + address prediction + the variant/identity +# query surface, then the factory's creation-time reverts. +# +# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./factory.sh (or `make smoke-factory`) + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=script/smoke/smoke-lib.sh +source "$HERE/smoke-lib.sh" + +# Golden path: deterministic creation + identity queries for both variants. +factory_journey() { + # 1. predict: addrA = getB20Address(ASSET, deployer, salt_for asset) + # assert isB20(addrA) == true (prefix recoverable, no storage read) + # assert isB20Initialized(addrA) == false (not created yet) + # 2. create: tok = createB20(ASSET, salt, encode_asset_params(...), []) + # assert tok == addrA (prediction matches actual) + # assert isB20Initialized(tok) == true (flips exactly once on completion) + # 3. predict + create STABLECOIN at salt_for stablecoin + # assert returned addr == getB20Address(STABLECOIN, deployer, salt) + # assert isB20(addrS) == true + # 4. assert isB20(some random non-b20 address) == false + : +} + +# Critical edges: creation-time reverts (match the exact selector). +factory_edges() { + # 5. dup salt: createB20(ASSET, same salt as step 2) -> TokenAlreadyExists(address) + # 6. decimals out of range: createB20(ASSET, decimals=5) -> InvalidDecimals(5) + # createB20(ASSET, decimals=19) -> InvalidDecimals(19) + # 7. bad currency: createB20(STABLECOIN, currency="usd") -> InvalidCurrency("usd") + # createB20(STABLECOIN, currency="") -> MissingRequiredField("currency") + # 8. bad variant: createB20(variant=2, ...) -> InvalidVariant() + : +} + +main() { + preflight + log "factory: starting" + factory_journey + factory_edges + log "factory: OK" +} + +main "$@" diff --git a/script/smoke/policy-registry.sh b/script/smoke/policy-registry.sh new file mode 100755 index 0000000..9b0cb26 --- /dev/null +++ b/script/smoke/policy-registry.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# policy-registry.sh — PolicyRegistry precompile smoketest. +# +# STUB: step-level scaffold for scope review. No executable bodies yet. +# Flexes policy creation (both types), membership, the built-in sentinels, the +# two-step 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. +# +# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./policy-registry.sh +# (or `make smoke-policy`) + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=script/smoke/smoke-lib.sh +source "$HERE/smoke-lib.sh" + +# Golden path: registry mechanics. +policy_journey() { + # 1. create allowlist: pidA = createPolicy(deployer, ALLOWLIST) + # assert policyExists(pidA) == true; assert policyAdmin(pidA) == deployer + # 2. create seeded blocklist: pidB = createPolicyWithAccounts(deployer, BLOCKLIST, [bob]) + # assert isAuthorized(pidB, bob) == false (blocked) + # assert isAuthorized(pidB, alice) == true (blocklist default-allow) + # 3. membership: updateAllowlist(pidA, true, [alice]) + # assert isAuthorized(pidA, alice) == true; isAuthorized(pidA, bob) == false + # 4. built-ins: assert isAuthorized(ALWAYS_ALLOW_ID, anyone) == true + # assert isAuthorized(ALWAYS_BLOCK_ID, anyone) == false + # 5. two-step admin transfer: stageUpdateAdmin(pidA, user2) from deployer + # assert pendingPolicyAdmin(pidA) == user2; fund_user2; + # finalizeUpdateAdmin(pidA) signed by USER2 + # assert policyAdmin(pidA) == user2; assert pendingPolicyAdmin(pidA) == 0 + # 6. renounce: renounceAdmin(pidA) signed by USER2 + # assert policyAdmin(pidA) == 0; assert policyExists(pidA) == true (frozen, still queryable) + : +} + +# Golden path: a token enforcing a policy end-to-end. +policy_enforcement() { + # 7. fresh allowlist with alice as the only member: + # pidR = createPolicyWithAccounts(deployer, ALLOWLIST, [alice]) + # 8. create an ASSET token wired to it via initCalls: + # updatePolicy(TRANSFER_RECEIVER_POLICY, pidR) + updatePolicy(MINT_RECEIVER_POLICY, pidR) + # + grant MINT_ROLE to deployer. admin=deployer. + # 9. allowed paths: mint(alice, 100e18) succeeds (alice ∈ allowlist) + # mint(deployer, 100e18) requires deployer ∈ allowlist — add deployer to pidR first, + # then transfer(alice, 1e18) from deployer succeeds. + # 10. denied receiver on transfer: transfer(bob, 1e18) from deployer + # -> PolicyForbids(TRANSFER_RECEIVER_POLICY, pidR) (bob ∉ allowlist) + # 11. denied receiver on mint: mint(bob, 1e18) + # -> PolicyForbids(MINT_RECEIVER_POLICY, pidR) + : +} + +# Critical edges. +policy_edges() { + # 12. wrong-type mutation: updateBlocklist(pidA /* an ALLOWLIST */, …) + # -> IncompatiblePolicyType() + # 13. non-admin mutation: updateAllowlist(pidR, …) signed by USER2 (not admin) + # -> Unauthorized() + # 14. zero admin: createPolicy(address(0), ALLOWLIST) -> ZeroAddress() + # 15. no pending: finalizeUpdateAdmin(pidB) with nothing staged -> NoPendingAdmin() + # 16. token write-time validation: updatePolicy(TRANSFER_SENDER_POLICY, ) + # on the token -> PolicyNotFound(id) (consumer must validate at write time) + : +} + +main() { + preflight + log "policy-registry: starting" + policy_journey + policy_enforcement + policy_edges + log "policy-registry: OK" +} + +main "$@" diff --git a/script/smoke/smoke-lib.sh b/script/smoke/smoke-lib.sh new file mode 100755 index 0000000..bace178 --- /dev/null +++ b/script/smoke/smoke-lib.sh @@ -0,0 +1,178 @@ +# shellcheck shell=bash +# smoke-lib.sh — shared helpers for the b20 precompile bring-up smoketest. +# +# STUB: this is a scaffold for scope review. Function bodies are intentionally +# unimplemented (marked TODO). Sourced by each journey script; not run directly. +# +# The smoketest flexes the b20 precompiles (B20Factory, PolicyRegistry, +# ActivationRegistry, and the per-token precompiles) on a freshly-cut chain by +# sending real transactions with `cast` and asserting read-backs. It assumes the +# b20 features are already activated on the target chain and that $DEPLOYER_PK is +# funded in genesis. Everything is driven through env vars so no chain identity +# lands in this OSS repo. +# +# Required env: +# RPC_URL RPC endpoint of the target chain (e.g. a freshly-cut net). +# DEPLOYER_PK Funded private key. Privileged actor: token admin + every role, +# primary holder, and policy admin #1. +# USER2_PK Second signer (need not be pre-funded; the deployer sends it a +# gas float at runtime). The distinct `transferFrom` executor and +# policy admin #2 (finalizes the two-step admin transfer). +# Optional env: +# SMOKE_SALT Suffix mixed into every createB20 salt so the suite can be +# re-run on a chain that already holds the default-salt tokens. +# GAS_FLOAT Wei sent to USER2 before it signs (default: a small fixed float). + +# ────────────────────────────────────────────────────────────────────────────── +# Precompile addresses (from StdPrecompiles.sol — public, stable singletons). +# ────────────────────────────────────────────────────────────────────────────── +readonly B20_FACTORY=0xB20f000000000000000000000000000000000000 +readonly POLICY_REGISTRY=0x8453000000000000000000000000000000000002 +readonly ACTIVATION_REGISTRY=0x8453000000000000000000000000000000000001 + +# B20Variant enum (IB20Factory). +readonly VARIANT_ASSET=0 +readonly VARIANT_STABLECOIN=1 + +# PolicyType enum (IPolicyRegistry). +readonly POLICY_TYPE_BLOCKLIST=0 +readonly POLICY_TYPE_ALLOWLIST=1 + +# Built-in policy IDs (PolicyRegistry README): ALWAYS_ALLOW = 0, +# ALWAYS_BLOCK = (ALLOWLIST << 56) | 1. +readonly ALWAYS_ALLOW_ID=0 +# TODO: ALWAYS_BLOCK_ID — compute (uint64(1) << 56 | 1) as a decimal/hex literal. + +# Asset decimals used by the asset journey (in [6,18]). +readonly ASSET_DECIMALS=18 +readonly STABLECOIN_DECIMALS=6 + +# ────────────────────────────────────────────────────────────────────────────── +# Derived constants (filled at runtime via cast; declared here for reference). +# Role hashes are keccak256("MINT_ROLE") etc. (B20Constants); DEFAULT_ADMIN_ROLE +# is bytes32(0). Policy scopes are keccak256("TRANSFER_SENDER_POLICY") etc. +# ────────────────────────────────────────────────────────────────────────────── +# TODO: populate via role_hash / scope_hash helpers (cast keccak), e.g. +# MINT_ROLE=$(role_hash MINT_ROLE) +# TRANSFER_SENDER_POLICY=$(scope_hash TRANSFER_SENDER_POLICY) + +# ────────────────────────────────────────────────────────────────────────────── +# Logging / control +# ────────────────────────────────────────────────────────────────────────────── + +# log MSG — narrate a phase to stderr. +log() { echo "[smoke] $*" >&2; } + +# die MSG — abort the whole run with a nonzero exit (CI signal). +die() { echo "[smoke] ERROR: $*" >&2; exit 1; } + +# step N DESC — narrate a numbered step within a journey. +step() { :; } # TODO: echo " → [$1] $2" >&2 + +# ok DESC — mark the most recent step as passed (✓). +ok() { :; } # TODO: echo " ✓ $*" >&2 + +# ────────────────────────────────────────────────────────────────────────────── +# Preflight +# ────────────────────────────────────────────────────────────────────────────── + +# preflight — validate required bins (cast) and env (RPC_URL/DEPLOYER_PK/USER2_PK) +# are present, that RPC_URL answers eth_chainId, and that the b20 features are +# activated (isActivated on ACTIVATION_REGISTRY). die() on any failure. +preflight() { :; } # TODO + +# ────────────────────────────────────────────────────────────────────────────── +# Actors / addresses +# ────────────────────────────────────────────────────────────────────────────── + +# deployer_addr — echo the address for DEPLOYER_PK (cast wallet address). +deployer_addr() { :; } # TODO + +# user2_addr — echo the address for USER2_PK. +user2_addr() { :; } # TODO + +# fund_user2 — send USER2 a gas float from the deployer (idempotent enough: only +# tops up if USER2 balance < GAS_FLOAT). Call before any USER2-signed step. +fund_user2() { :; } # TODO + +# new_addr LABEL — echo a fresh keyless address (deterministic from LABEL) used as +# a token recipient / policy-list member / seize target. Never signs. +new_addr() { :; } # TODO: cast wallet address for keccak(LABEL+SMOKE_SALT), or a fixed table. + +# ────────────────────────────────────────────────────────────────────────────── +# Salt / hashing helpers +# ────────────────────────────────────────────────────────────────────────────── + +# salt_for JOURNEY — echo the deterministic createB20 salt for a journey, +# keccak("base-std.smoke." + SMOKE_SALT). Stable per fresh chain; +# override SMOKE_SALT to re-run on a chain that already has the tokens. +salt_for() { :; } # TODO: cast keccak "base-std.smoke.$1${SMOKE_SALT:-}" + +# role_hash NAME — echo keccak256(NAME) for a role constant (e.g. MINT_ROLE). +role_hash() { :; } # TODO: cast keccak "$1" (DEFAULT_ADMIN_ROLE is bytes32(0)) + +# scope_hash NAME — echo keccak256(NAME) for a policy scope (e.g. TRANSFER_SENDER_POLICY). +scope_hash() { :; } # TODO: cast keccak "$1" + +# ────────────────────────────────────────────────────────────────────────────── +# ABI encoding (cast-only) — the gnarly createB20 inputs and friends. +# ────────────────────────────────────────────────────────────────────────────── + +# encode_asset_params NAME SYMBOL ADMIN DECIMALS — echo the ABI-encoded +# B20AssetCreateParams blob (version byte = 1) for createB20's `params` arg. +encode_asset_params() { :; } +# TODO: cast abi-encode 'x((uint8,string,string,address,uint8))' \ +# "(1,\"$1\",\"$2\",$3,$4)" + +# encode_stablecoin_params NAME SYMBOL ADMIN CURRENCY — echo the ABI-encoded +# B20StablecoinCreateParams blob (version byte = 1). +encode_stablecoin_params() { :; } +# TODO: cast abi-encode 'x((uint8,string,string,address,string))' \ +# "(1,\"$1\",\"$2\",$3,\"$4\")" + +# encode_init_calls CALLDATA... — echo the ABI-encoded bytes[] of bootstrap +# initCalls from a list of pre-encoded calldata blobs. +encode_init_calls() { :; } # TODO: cast abi-encode 'x(bytes[])' "[$(join , "$@")]" + +# call_grant_role ROLE ACCOUNT — echo `cast calldata grantRole(bytes32,address)`. +# call_update_supply_cap CAP — echo calldata updateSupplyCap(uint256). +# call_update_policy SCOPE POLICYID — echo calldata updatePolicy(bytes32,uint64). +# call_batch_mint RECIPS AMTS — echo calldata batchMint(address[],uint256[]). +# call_update_multiplier M — echo calldata updateMultiplier(uint256). +# (These wrap `cast calldata` for use inside encode_init_calls / announce().) +call_grant_role() { :; } # TODO +call_update_supply_cap() { :; } # TODO +call_update_policy() { :; } # TODO +call_batch_mint() { :; } # TODO +call_update_multiplier() { :; } # TODO + +# ────────────────────────────────────────────────────────────────────────────── +# Send / read / assert +# ────────────────────────────────────────────────────────────────────────────── + +# send PK TO SIG ARGS... — cast send from PK; die() if status != 1. Echoes the +# tx hash so callers can inspect logs. +send() { :; } # TODO: cast send --rpc-url "$RPC_URL" --private-key "$PK" "$TO" "$SIG" "$@" + +# call TO SIG ARGS... — cast call (read); echo the decoded return value. +call() { :; } # TODO: cast call --rpc-url "$RPC_URL" "$TO" "$SIG" "$@" + +# assert_eq GOT WANT DESC — die() unless GOT == WANT. +assert_eq() { :; } # TODO + +# assert_call TO SIG ARGS... -- WANT DESC — call() then assert_eq the result. +assert_call() { :; } # TODO + +# expect_revert SELECTOR -- PK TO SIG ARGS... — send the (expected-to-fail) call +# and assert it reverts with the EXACT custom-error SELECTOR (e.g. the 4-byte +# sig of PolicyForbids / SupplyCapExceeded). Parse cast's revert output; die() +# if it succeeds or reverts with a different selector. +expect_revert() { :; } # TODO + +# selector SIG — echo the 4-byte selector for an error/function signature, e.g. +# selector 'PolicyForbids(bytes32,uint64)' (cast sig). +selector() { :; } # TODO: cast sig "$1" + +# assert_log_order TXHASH SIG_A SIG_B DESC — assert event SIG_A is logged +# immediately before SIG_B in TXHASH's receipt (e.g. Transfer then Memo). +assert_log_order() { :; } # TODO: cast receipt --json | parse topics[0] diff --git a/script/smoke/stablecoin-lifecycle.sh b/script/smoke/stablecoin-lifecycle.sh new file mode 100755 index 0000000..df73215 --- /dev/null +++ b/script/smoke/stablecoin-lifecycle.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# stablecoin-lifecycle.sh — B20 Stablecoin variant smoketest. +# +# STUB: step-level scaffold for scope review. No executable bodies yet. +# Flexes the Stablecoin deltas (fixed 6 decimals, immutable currency) plus the +# regulated-issuer freeze-and-seize path (blocklist + burnBlocked). +# +# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./stablecoin-lifecycle.sh +# (or `make smoke-stablecoin`) + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=script/smoke/smoke-lib.sh +source "$HERE/smoke-lib.sh" + +# Golden path. +stablecoin_journey() { + # Setup. createB20(STABLECOIN, salt_for stablecoin, encode_stablecoin_params( + # "USD Coin","USDC",deployer,"USD"), initCalls) where initCalls grants + # MINT/BURN/BURN_BLOCKED/PAUSE/UNPAUSE/METADATA to deployer. admin=deployer. + # assert isB20Initialized(tok) == true. + # + # 1. variant identity: assert currency() == "USD"; assert decimals() == 6 + # 2. mint: mint(alice, 1000e6); assert balanceOf(alice) == 1000e6 + # 3. mint to deployer (500e6) then transfer(bob, 200e6); assert balances + # 4. freeze setup: pid = createPolicy(deployer, BLOCKLIST); + # updatePolicy(TRANSFER_SENDER_POLICY, pid); + # updateBlocklist(pid, true, [alice]); assert isAuthorized(pid, alice) == false + # 5. seize: burnBlocked(alice, 400e6) from deployer (holds BURN_BLOCKED_ROLE) + # assert balanceOf(alice) == 600e6; assert totalSupply down by 400e6 + # assert_log_order tx Transfer BurnedBlocked (Transfer(alice,0) then BurnedBlocked) + : +} + +# Critical edges. +stablecoin_edges() { + # 6. seize an unblocked account: burnBlocked(bob, 1) where bob ∉ blocklist + # -> AccountNotBlocked(bob) + # 7. role gate: mint(alice, 1) signed by USER2 (no MINT_ROLE) + # -> AccessControlUnauthorizedAccount(user2, MINT_ROLE) + : +} + +main() { + preflight + log "stablecoin-lifecycle: starting" + stablecoin_journey + stablecoin_edges + log "stablecoin-lifecycle: OK" +} + +main "$@" From 270a1d8781a82a252d96102b157afe469ec6c444 Mon Sep 17 00:00:00 2001 From: katzman Date: Mon, 8 Jun 2026 17:13:08 -0700 Subject: [PATCH 2/8] refactor to python --- .github/workflows/ci.yml | 2 +- .gitignore | 8 +- Makefile | 39 +- script/check-coverage.py | 2 +- script/smoke/__init__.py | 1 + script/smoke/__main__.py | 36 + script/smoke/abi/IB20Asset.json | 1805 +++++++++++++++++ script/smoke/abi/IB20Factory.json | 222 ++ script/smoke/abi/IB20Stablecoin.json | 1467 ++++++++++++++ script/smoke/abi/IPolicyRegistry.json | 399 ++++ script/smoke/abis.py | 26 + script/smoke/asset-lifecycle.sh | 71 - script/smoke/chain.py | 191 ++ script/smoke/codec.py | 70 + script/smoke/config.py | 107 + script/smoke/errors.py | 31 + script/smoke/factory.sh | 49 - script/smoke/journeys/__init__.py | 1 + script/smoke/journeys/asset_lifecycle.py | 162 ++ script/smoke/journeys/factory.py | 79 + script/smoke/journeys/policy_registry.py | 124 ++ script/smoke/journeys/stablecoin_lifecycle.py | 97 + script/smoke/policy-registry.sh | 77 - script/smoke/requirements.txt | 1 + script/smoke/smoke-lib.sh | 178 -- script/smoke/stablecoin-lifecycle.sh | 52 - 26 files changed, 4859 insertions(+), 438 deletions(-) create mode 100644 script/smoke/__init__.py create mode 100644 script/smoke/__main__.py create mode 100644 script/smoke/abi/IB20Asset.json create mode 100644 script/smoke/abi/IB20Factory.json create mode 100644 script/smoke/abi/IB20Stablecoin.json create mode 100644 script/smoke/abi/IPolicyRegistry.json create mode 100644 script/smoke/abis.py delete mode 100755 script/smoke/asset-lifecycle.sh create mode 100644 script/smoke/chain.py create mode 100644 script/smoke/codec.py create mode 100644 script/smoke/config.py create mode 100644 script/smoke/errors.py delete mode 100755 script/smoke/factory.sh create mode 100644 script/smoke/journeys/__init__.py create mode 100644 script/smoke/journeys/asset_lifecycle.py create mode 100644 script/smoke/journeys/factory.py create mode 100644 script/smoke/journeys/policy_registry.py create mode 100644 script/smoke/journeys/stablecoin_lifecycle.py delete mode 100755 script/smoke/policy-registry.sh create mode 100644 script/smoke/requirements.txt delete mode 100755 script/smoke/smoke-lib.sh delete mode 100755 script/smoke/stablecoin-lifecycle.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bcfe0..bed8ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,7 +170,7 @@ jobs: id: check run: | set +e - output=$(python3 scripts/check-coverage.py 2>&1) + output=$(python3 script/check-coverage.py 2>&1) exit_code=$? echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" { diff --git a/.gitignore b/.gitignore index 213c6fa..3fd5a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,15 @@ broadcast/ .env.* !.env.example +# Python smoketest (script/smoke): venv + bytecode caches. The interface ABIs +# under script/smoke/abi/ ARE committed (refresh with `make smoke-bindings`). +script/smoke/.venv/ +__pycache__/ +*.pyc + # OS / editor .DS_Store *.swp *.swo .idea/ -.vscode/ +.vscode/ \ No newline at end of file diff --git a/Makefile b/Makefile index 25a9213..a5f0327 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: coverage smoke smoke-factory smoke-asset smoke-stablecoin smoke-policy +.PHONY: coverage smoke smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-setup smoke-bindings # Generate an lcov coverage report and open it in the browser. # Scoped to src/ and test/lib/mocks/ (excludes test runner files). @@ -7,19 +7,42 @@ coverage: genhtml lcov.info --branch-coverage -o coverage --dark-mode --ignore-errors inconsistent,corrupt open coverage/index.html -# b20 precompile bring-up smoketest. cast-driven; sends real txs to $RPC_URL. -# Requires env: RPC_URL, DEPLOYER_PK, USER2_PK (see script/smoke/smoke-lib.sh). -# `make smoke` runs every journey fail-fast; the per-journey targets run one. +# Source the gitignored .env (shell-style, not Make-native) for the smoke +# recipes. Existing env wins: snapshot exports, source, then re-apply. +LOAD_ENV = pre=$$(export -p); set -a; [ -f .env ] && . ./.env; set +a; eval "$$pre"; + +PYTHON ?= python3.13 +VENV = script/smoke/.venv +# `smoke` is the package at script/smoke/, so its parent (script) is on the path. +SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke + +# One-time setup: create the smoketest venv and install web3. +smoke-setup: + $(PYTHON) -m venv $(VENV) + $(VENV)/bin/python -m pip install --upgrade pip + $(VENV)/bin/python -m pip install -r script/smoke/requirements.txt + +# Refresh the committed interface ABIs from the compiled artifacts. Only needed +# when the interfaces change (the harness binds to these via plain web3). +smoke-bindings: + forge build + @for c in IB20Factory IB20Asset IB20Stablecoin IPolicyRegistry; do \ + jq '.abi' "out/$$c.sol/$$c.json" > "script/smoke/abi/$$c.json" && echo "refreshed $$c.json"; \ + done + +# b20 precompile bring-up smoketest (web3.py + committed interface ABIs). Sends +# real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, USER2_PK and a venv +# (`make smoke-setup`). `make smoke` runs every journey fail-fast. smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-factory: - ./script/smoke/factory.sh + @$(SMOKE_RUN) factory smoke-asset: - ./script/smoke/asset-lifecycle.sh + @$(SMOKE_RUN) asset smoke-stablecoin: - ./script/smoke/stablecoin-lifecycle.sh + @$(SMOKE_RUN) stablecoin smoke-policy: - ./script/smoke/policy-registry.sh + @$(SMOKE_RUN) policy diff --git a/script/check-coverage.py b/script/check-coverage.py index 214a5cc..fafa695 100644 --- a/script/check-coverage.py +++ b/script/check-coverage.py @@ -7,7 +7,7 @@ as the source of truth rather than parsing Solidity files. Usage: - python3 scripts/check-coverage.py + python3 script/check-coverage.py """ import subprocess diff --git a/script/smoke/__init__.py b/script/smoke/__init__.py new file mode 100644 index 0000000..cf10afc --- /dev/null +++ b/script/smoke/__init__.py @@ -0,0 +1 @@ +"""b20 precompile bring-up smoketest (web3.py + committed interface ABIs).""" diff --git a/script/smoke/__main__.py b/script/smoke/__main__.py new file mode 100644 index 0000000..5cca877 --- /dev/null +++ b/script/smoke/__main__.py @@ -0,0 +1,36 @@ +"""CLI: python -m smoke . + +Journeys: factory, asset, stablecoin, policy. Env (RPC_URL / DEPLOYER_PK / +USER2_PK) is sourced by the Makefile from .env; running directly requires it +exported. The target chain is assumed to already have the b20 features activated. +""" + +from __future__ import annotations + +import importlib +import sys + +from . import config +from .chain import Chain, die, log + +JOURNEYS = { + "factory": "smoke.journeys.factory", + "asset": "smoke.journeys.asset_lifecycle", + "stablecoin": "smoke.journeys.stablecoin_lifecycle", + "policy": "smoke.journeys.policy_registry", +} + + +def main(argv: list[str]) -> None: + if len(argv) != 1 or argv[0] not in JOURNEYS: + die(f"usage: python -m smoke <{'|'.join(JOURNEYS)}>") + cfg = config.Config.from_env() + chain = Chain(cfg) + log(f"preflight ok \u2014 chain={chain.chain_id} deployer={chain.DEPLOYER}") + log(f"run nonce: {cfg.run_nonce}" + (" (pinned via SMOKE_SALT)" if cfg.salt_pinned else "")) + module = importlib.import_module(JOURNEYS[argv[0]]) + module.run(chain) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/script/smoke/abi/IB20Asset.json b/script/smoke/abi/IB20Asset.json new file mode 100644 index 0000000..1869878 --- /dev/null +++ b/script/smoke/abi/IB20Asset.json @@ -0,0 +1,1805 @@ +[ + { + "type": "function", + "name": "BURN_BLOCKED_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "BURN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DOMAIN_SEPARATOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "METADATA_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MINT_RECEIVER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MINT_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "OPERATOR_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PAUSE_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_EXECUTOR_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_RECEIVER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_SENDER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNPAUSE_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "WAD_PRECISION", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "announce", + "inputs": [ + { + "name": "internalCalls", + "type": "bytes[]", + "internalType": "bytes[]" + }, + { + "name": "id", + "type": "string", + "internalType": "string" + }, + { + "name": "description", + "type": "string", + "internalType": "string" + }, + { + "name": "uri", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "batchMint", + "inputs": [ + { + "name": "recipients", + "type": "address[]", + "internalType": "address[]" + }, + { + "name": "amounts", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burn", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burnBlocked", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burnWithMemo", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "contractURI", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "eip712Domain", + "inputs": [], + "outputs": [ + { + "name": "fields", + "type": "bytes1", + "internalType": "bytes1" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "version", + "type": "string", + "internalType": "string" + }, + { + "name": "chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "verifyingContract", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "extensions", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "extraMetadata", + "inputs": [ + { + "name": "key", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isAnnouncementIdUsed", + "inputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isPaused", + "inputs": [ + { + "name": "feature", + "type": "uint8", + "internalType": "enum IB20.PausableFeature" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "mintWithMemo", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "multiplier", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "nonces", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [ + { + "name": "features", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "pausedFeatures", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "permit", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "policyId", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceLastAdmin", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "callerConfirmation", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "scaledBalanceOf", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supplyCap", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "toRawBalance", + "inputs": [ + { + "name": "scaledBalance", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "rawBalance", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "toScaledBalance", + "inputs": [ + { + "name": "rawBalance", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFrom", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFromWithMemo", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferWithMemo", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpause", + "inputs": [ + { + "name": "features", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateContractURI", + "inputs": [ + { + "name": "newURI", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateExtraMetadata", + "inputs": [ + { + "name": "key", + "type": "string", + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateMultiplier", + "inputs": [ + { + "name": "newMultiplier", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateName", + "inputs": [ + { + "name": "newName", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updatePolicy", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newPolicyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateSupplyCap", + "inputs": [ + { + "name": "newSupplyCap", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateSymbol", + "inputs": [ + { + "name": "newSymbol", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Announcement", + "inputs": [ + { + "name": "caller", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "id", + "type": "string", + "indexed": false, + "internalType": "string" + }, + { + "name": "description", + "type": "string", + "indexed": false, + "internalType": "string" + }, + { + "name": "uri", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Approval", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BurnedBlocked", + "inputs": [ + { + "name": "caller", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ContractURIUpdated", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "EIP712DomainChanged", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "EndAnnouncement", + "inputs": [ + { + "name": "id", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ExtraMetadataUpdated", + "inputs": [ + { + "name": "key", + "type": "string", + "indexed": false, + "internalType": "string" + }, + { + "name": "value", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "LastAdminRenounced", + "inputs": [ + { + "name": "previousAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Memo", + "inputs": [ + { + "name": "caller", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "memo", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "MultiplierUpdated", + "inputs": [ + { + "name": "multiplier", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NameUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newName", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "features", + "type": "uint8[]", + "indexed": false, + "internalType": "enum IB20.PausableFeature[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyUpdated", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "oldPolicyId", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + }, + { + "name": "newPolicyId", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SupplyCapUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "oldSupplyCap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "newSupplyCap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SymbolUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newSymbol", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "features", + "type": "uint8[]", + "indexed": false, + "internalType": "enum IB20.PausableFeature[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccessControlBadConfirmation", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "neededRole", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AccountNotBlocked", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "AnnouncementIdAlreadyUsed", + "inputs": [ + { + "name": "id", + "type": "string", + "internalType": "string" + } + ] + }, + { + "type": "error", + "name": "AnnouncementInProgress", + "inputs": [] + }, + { + "type": "error", + "name": "ContractPaused", + "inputs": [ + { + "name": "feature", + "type": "uint8", + "internalType": "enum IB20.PausableFeature" + } + ] + }, + { + "type": "error", + "name": "EmptyBatch", + "inputs": [] + }, + { + "type": "error", + "name": "EmptyFeatureSet", + "inputs": [] + }, + { + "type": "error", + "name": "ExpiredSignature", + "inputs": [ + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientAllowance", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "allowance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InternalCallFailed", + "inputs": [ + { + "name": "call", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "type": "error", + "name": "InternalCallMalformed", + "inputs": [ + { + "name": "call", + "type": "bytes", + "internalType": "bytes" + } + ] + }, + { + "type": "error", + "name": "InvalidAmount", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidApprover", + "inputs": [ + { + "name": "approver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidMetadataKey", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidReceiver", + "inputs": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSigner", + "inputs": [ + { + "name": "signer", + "type": "address", + "internalType": "address" + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSpender", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSupplyCap", + "inputs": [ + { + "name": "currentSupply", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "proposedCap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "LastAdminCannotRenounce", + "inputs": [] + }, + { + "type": "error", + "name": "LengthMismatch", + "inputs": [ + { + "name": "leftLen", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "rightLen", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "NotSoleAdmin", + "inputs": [] + }, + { + "type": "error", + "name": "PolicyForbids", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "PolicyNotFound", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "SupplyCapExceeded", + "inputs": [ + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "attempted", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedPolicyType", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] diff --git a/script/smoke/abi/IB20Factory.json b/script/smoke/abi/IB20Factory.json new file mode 100644 index 0000000..aadd691 --- /dev/null +++ b/script/smoke/abi/IB20Factory.json @@ -0,0 +1,222 @@ +[ + { + "type": "function", + "name": "createB20", + "inputs": [ + { + "name": "variant", + "type": "uint8", + "internalType": "enum IB20Factory.B20Variant" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "params", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "initCalls", + "type": "bytes[]", + "internalType": "bytes[]" + } + ], + "outputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getB20Address", + "inputs": [ + { + "name": "variant", + "type": "uint8", + "internalType": "enum IB20Factory.B20Variant" + }, + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isB20", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isB20Initialized", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "B20Created", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "variant", + "type": "uint8", + "indexed": true, + "internalType": "enum IB20Factory.B20Variant" + }, + { + "name": "name", + "type": "string", + "indexed": false, + "internalType": "string" + }, + { + "name": "symbol", + "type": "string", + "indexed": false, + "internalType": "string" + }, + { + "name": "decimals", + "type": "uint8", + "indexed": false, + "internalType": "uint8" + }, + { + "name": "variantEventParams", + "type": "bytes", + "indexed": false, + "internalType": "bytes" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InitCallFailed", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidCurrency", + "inputs": [ + { + "name": "code", + "type": "string", + "internalType": "string" + } + ] + }, + { + "type": "error", + "name": "InvalidDecimals", + "inputs": [ + { + "name": "decimals", + "type": "uint8", + "internalType": "uint8" + } + ] + }, + { + "type": "error", + "name": "InvalidVariant", + "inputs": [] + }, + { + "type": "error", + "name": "MissingRequiredField", + "inputs": [ + { + "name": "field", + "type": "string", + "internalType": "string" + } + ] + }, + { + "type": "error", + "name": "TokenAlreadyExists", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "UnsupportedVersion", + "inputs": [ + { + "name": "version", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "variant", + "type": "uint8", + "internalType": "enum IB20Factory.B20Variant" + } + ] + } +] diff --git a/script/smoke/abi/IB20Stablecoin.json b/script/smoke/abi/IB20Stablecoin.json new file mode 100644 index 0000000..28090ca --- /dev/null +++ b/script/smoke/abi/IB20Stablecoin.json @@ -0,0 +1,1467 @@ +[ + { + "type": "function", + "name": "BURN_BLOCKED_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "BURN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DEFAULT_ADMIN_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DOMAIN_SEPARATOR", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "METADATA_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MINT_RECEIVER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MINT_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "PAUSE_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_EXECUTOR_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_RECEIVER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "TRANSFER_SENDER_POLICY", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "UNPAUSE_ROLE", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "balanceOf", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "burn", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burnBlocked", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burnWithMemo", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "contractURI", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "currency", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "uint8" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "eip712Domain", + "inputs": [], + "outputs": [ + { + "name": "fields", + "type": "bytes1", + "internalType": "bytes1" + }, + { + "name": "name", + "type": "string", + "internalType": "string" + }, + { + "name": "version", + "type": "string", + "internalType": "string" + }, + { + "name": "chainId", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "verifyingContract", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "extensions", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "grantRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "hasRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "isPaused", + "inputs": [ + { + "name": "feature", + "type": "uint8", + "internalType": "enum IB20.PausableFeature" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "mintWithMemo", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "nonces", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pause", + "inputs": [ + { + "name": "features", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "pausedFeatures", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "permit", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "value", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "v", + "type": "uint8", + "internalType": "uint8" + }, + { + "name": "r", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "s", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "policyId", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceLastAdmin", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "callerConfirmation", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "revokeRole", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setRoleAdmin", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supplyCap", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFrom", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferFromWithMemo", + "inputs": [ + { + "name": "from", + "type": "address", + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferWithMemo", + "inputs": [ + { + "name": "to", + "type": "address", + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "memo", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "unpause", + "inputs": [ + { + "name": "features", + "type": "uint8[]", + "internalType": "enum IB20.PausableFeature[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateContractURI", + "inputs": [ + { + "name": "newURI", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateName", + "inputs": [ + { + "name": "newName", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updatePolicy", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "newPolicyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateSupplyCap", + "inputs": [ + { + "name": "newSupplyCap", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateSymbol", + "inputs": [ + { + "name": "newSymbol", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "Approval", + "inputs": [ + { + "name": "owner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "spender", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BurnedBlocked", + "inputs": [ + { + "name": "caller", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ContractURIUpdated", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "EIP712DomainChanged", + "inputs": [], + "anonymous": false + }, + { + "type": "event", + "name": "LastAdminRenounced", + "inputs": [ + { + "name": "previousAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Memo", + "inputs": [ + { + "name": "caller", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "memo", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "NameUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newName", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Paused", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "features", + "type": "uint8[]", + "indexed": false, + "internalType": "enum IB20.PausableFeature[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyUpdated", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "oldPolicyId", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + }, + { + "name": "newPolicyId", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleAdminChanged", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "previousAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newAdminRole", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleGranted", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RoleRevoked", + "inputs": [ + { + "name": "role", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "account", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "sender", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SupplyCapUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "oldSupplyCap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "newSupplyCap", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SymbolUpdated", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newSymbol", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { + "name": "from", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "to", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Unpaused", + "inputs": [ + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "features", + "type": "uint8[]", + "indexed": false, + "internalType": "enum IB20.PausableFeature[]" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AccessControlBadConfirmation", + "inputs": [] + }, + { + "type": "error", + "name": "AccessControlUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + }, + { + "name": "neededRole", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "AccountNotBlocked", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "ContractPaused", + "inputs": [ + { + "name": "feature", + "type": "uint8", + "internalType": "enum IB20.PausableFeature" + } + ] + }, + { + "type": "error", + "name": "EmptyFeatureSet", + "inputs": [] + }, + { + "type": "error", + "name": "ExpiredSignature", + "inputs": [ + { + "name": "deadline", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientAllowance", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + }, + { + "name": "allowance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InsufficientBalance", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "balance", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "needed", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "InvalidAmount", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidApprover", + "inputs": [ + { + "name": "approver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidReceiver", + "inputs": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSigner", + "inputs": [ + { + "name": "signer", + "type": "address", + "internalType": "address" + }, + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSpender", + "inputs": [ + { + "name": "spender", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidSupplyCap", + "inputs": [ + { + "name": "currentSupply", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "proposedCap", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "LastAdminCannotRenounce", + "inputs": [] + }, + { + "type": "error", + "name": "NotSoleAdmin", + "inputs": [] + }, + { + "type": "error", + "name": "PolicyForbids", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "PolicyNotFound", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "SupplyCapExceeded", + "inputs": [ + { + "name": "cap", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "attempted", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "UnsupportedPolicyType", + "inputs": [ + { + "name": "policyScope", + "type": "bytes32", + "internalType": "bytes32" + } + ] + } +] diff --git a/script/smoke/abi/IPolicyRegistry.json b/script/smoke/abi/IPolicyRegistry.json new file mode 100644 index 0000000..405985e --- /dev/null +++ b/script/smoke/abi/IPolicyRegistry.json @@ -0,0 +1,399 @@ +[ + { + "type": "function", + "name": "createPolicy", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + }, + { + "name": "policyType", + "type": "uint8", + "internalType": "enum IPolicyRegistry.PolicyType" + } + ], + "outputs": [ + { + "name": "newPolicyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createPolicyWithAccounts", + "inputs": [ + { + "name": "admin", + "type": "address", + "internalType": "address" + }, + { + "name": "policyType", + "type": "uint8", + "internalType": "enum IPolicyRegistry.PolicyType" + }, + { + "name": "accounts", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [ + { + "name": "newPolicyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "finalizeUpdateAdmin", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "isAuthorized", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "account", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "pendingPolicyAdmin", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "policyAdmin", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "policyExists", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceAdmin", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "stageUpdateAdmin", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "newAdmin", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateAllowlist", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "allowed", + "type": "bool", + "internalType": "bool" + }, + { + "name": "accounts", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateBlocklist", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "blocked", + "type": "bool", + "internalType": "bool" + }, + { + "name": "accounts", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "AllowlistUpdated", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "allowed", + "type": "bool", + "indexed": false, + "internalType": "bool" + }, + { + "name": "accounts", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "BlocklistUpdated", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "updater", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "blocked", + "type": "bool", + "indexed": false, + "internalType": "bool" + }, + { + "name": "accounts", + "type": "address[]", + "indexed": false, + "internalType": "address[]" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyAdminStaged", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "currentAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "pendingAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyAdminUpdated", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "previousAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newAdmin", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "PolicyCreated", + "inputs": [ + { + "name": "policyId", + "type": "uint64", + "indexed": true, + "internalType": "uint64" + }, + { + "name": "creator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "policyType", + "type": "uint8", + "indexed": false, + "internalType": "enum IPolicyRegistry.PolicyType" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "BatchSizeTooLarge", + "inputs": [ + { + "name": "maxBatchSize", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "IncompatiblePolicyType", + "inputs": [] + }, + { + "type": "error", + "name": "NoPendingAdmin", + "inputs": [] + }, + { + "type": "error", + "name": "PolicyNotFound", + "inputs": [] + }, + { + "type": "error", + "name": "Unauthorized", + "inputs": [] + }, + { + "type": "error", + "name": "ZeroAddress", + "inputs": [] + } +] diff --git a/script/smoke/abis.py b/script/smoke/abis.py new file mode 100644 index 0000000..c1a1720 --- /dev/null +++ b/script/smoke/abis.py @@ -0,0 +1,26 @@ +"""Committed interface ABIs (extracted from forge `out/`). + +These are the strict contract surface the harness binds to via plain web3 +(`w3.eth.contract(abi=...)`). They change only when the interfaces change; +regenerate with `make smoke-bindings`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +_DIR = Path(__file__).parent / "abi" + + +def _load(name: str) -> list[dict[str, Any]]: + return json.loads((_DIR / f"{name}.json").read_text()) + + +FACTORY_ABI = _load("IB20Factory") +ASSET_ABI = _load("IB20Asset") +STABLECOIN_ABI = _load("IB20Stablecoin") +POLICY_ABI = _load("IPolicyRegistry") + +ALL_ABIS = [FACTORY_ABI, ASSET_ABI, STABLECOIN_ABI, POLICY_ABI] diff --git a/script/smoke/asset-lifecycle.sh b/script/smoke/asset-lifecycle.sh deleted file mode 100755 index ef10082..0000000 --- a/script/smoke/asset-lifecycle.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# asset-lifecycle.sh — B20 Asset variant smoketest. -# -# STUB: step-level scaffold for scope review. No executable bodies yet. -# Flexes the full operator lifecycle of an Asset token (decimals 18): issuance, -# transfers + memo, delegated spend, announcements (batchMint + rebase), metadata, -# burn — then the gates that must reject (cap, pause, role, announce-id reuse). -# -# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./asset-lifecycle.sh -# (or `make smoke-asset`) - -set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=script/smoke/smoke-lib.sh -source "$HERE/smoke-lib.sh" - -# Golden path. -asset_journey() { - # Setup. createB20(ASSET, salt_for asset, params, initCalls) where initCalls - # bundles (via encode_init_calls): grantRole(MINT/BURN/BURN_BLOCKED/PAUSE/ - # UNPAUSE/METADATA/OPERATOR, deployer) + updateSupplyCap(CAP). admin=deployer, - # decimals=ASSET_DECIMALS. Policies stay ALWAYS_ALLOW (default). - # assert isB20Initialized(tok) == true; assert decimals() == 18. - # - # 1. mint: mint(alice, 1000e18) - # assert balanceOf(alice) == 1000e18; assert totalSupply() == 1000e18 - # 2. mint to deployer (so it can send): mint(deployer, 500e18) - # 3. transfer: transfer(bob, 200e18) from deployer - # assert balanceOf(bob) == 200e18; assert balanceOf(deployer) == 300e18 - # 4. transferWithMemo: transferWithMemo(bob, 1e18, memo) from deployer - # assert_log_order tx Transfer Memo (Memo immediately follows Transfer) - # 5. delegated spend (distinct executor): approve(user2, 50e18) from deployer; - # fund_user2; transferFrom(deployer, bob, 50e18) signed by USER2 - # assert allowance(deployer, user2) == 0; assert balances moved - # 6. announce + batchMint: announce(internalCalls=[call_batch_mint([alice,bob], - # [10e18,20e18])], id="smoke-batch-1", desc, uri) from deployer - # assert Announcement→EndAnnouncement bracket for the id; - # assert isAnnouncementIdUsed("smoke-batch-1") == true; assert balances - # 7. announce + rebase: announce([call_update_multiplier(2e18)], id="smoke-rebase-1", …) - # assert multiplier() == 2e18; assert scaledBalanceOf(alice) == 2 * balanceOf(alice) - # assert toRawBalance(toScaledBalance(x)) within 1 ULP of x - # 8. extra metadata: updateExtraMetadata("category","rwa") - # assert extraMetadata("category") == "rwa"; - # then updateExtraMetadata("category","") -> assert extraMetadata == "" - # 9. metadata: updateName("Asset Two"); assert name() == "Asset Two" - # updateSymbol("AST2"); assert symbol() == "AST2" - # 10. burn: burn(100e18) from deployer; assert totalSupply() down by 100e18 - : -} - -# Critical edges. -asset_edges() { - # 11. supply cap: mint(alice, CAP - totalSupply + 1) -> SupplyCapExceeded(cap,attempted) - # 12. pause: pause([TRANSFER]); assert isPaused(TRANSFER) == true - # transfer(bob, 1) -> ContractPaused(TRANSFER) - # unpause([TRANSFER]) to restore; assert transfer(bob,1) succeeds again - # 13. role gate: mint(alice, 1) signed by USER2 (no MINT_ROLE) - # -> AccessControlUnauthorizedAccount(user2, MINT_ROLE) - # 14. announce id reuse: announce([], id="smoke-batch-1", …) -> AnnouncementIdAlreadyUsed - : -} - -main() { - preflight - log "asset-lifecycle: starting" - asset_journey - asset_edges - log "asset-lifecycle: OK" -} - -main "$@" diff --git a/script/smoke/chain.py b/script/smoke/chain.py new file mode 100644 index 0000000..442e0d0 --- /dev/null +++ b/script/smoke/chain.py @@ -0,0 +1,191 @@ +"""Chain harness: provider, signers, send/read, revert + event assertions. + +Wraps web3 + the committed interface ABIs so journeys read like the contract +API. Every mutating call goes through `send`, which signs, broadcasts to the +live node, waits for the receipt, asserts success, and records it for the +flow-level `assert_events_emitted` check. Reads and expected-revert simulations +use `eth_call` against the node, so the real precompiles execute (no local EVM). +""" + +from __future__ import annotations + +import sys + +from eth_account import Account +from eth_account.signers.local import LocalAccount +from eth_typing import ChecksumAddress +from hexbytes import HexBytes +from web3 import Web3 +from web3.contract.contract import Contract +from web3.exceptions import ContractLogicError +from web3.logs import DISCARD +from web3.types import TxReceipt + +from . import config +from .abis import ASSET_ABI, FACTORY_ABI, POLICY_ABI, STABLECOIN_ABI +from .codec import topic0 +from .errors import ERROR_BY_SELECTOR + + +def log(msg: str) -> None: + print(f"[smoke] {msg}", file=sys.stderr) + + +def step(n: object, desc: str) -> None: + print(f" \u2192 [{n}] {desc}", file=sys.stderr) + + +def ok(desc: str) -> None: + print(f" \u2713 {desc}", file=sys.stderr) + + +def die(msg: str) -> None: + raise SystemExit(f"[smoke] ERROR: {msg}") + + +class Chain: + """Live-node harness bound to one run's config.""" + + def __init__(self, cfg: config.Config) -> None: + self.cfg = cfg + self.w3 = Web3(Web3.HTTPProvider(cfg.rpc_url)) + if not self.w3.is_connected(): + die(f"RPC_URL did not answer: {cfg.rpc_url}") + self.chain_id = self.w3.eth.chain_id + + self.deployer: LocalAccount = Account.from_key(cfg.deployer_pk) + self.user2: LocalAccount = Account.from_key(cfg.user2_pk) + self.DEPLOYER: ChecksumAddress = self.deployer.address + self.USER2: ChecksumAddress = self.user2.address + self.ALICE = cfg.new_addr("alice") + self.BOB = cfg.new_addr("bob") + + self.factory = self.w3.eth.contract(address=config.B20_FACTORY, abi=FACTORY_ABI) + self.policy = self.w3.eth.contract(address=config.POLICY_REGISTRY, abi=POLICY_ABI) + # Address-less handles for encoding bootstrap calldata (init-calls). + self.asset_abi = self.w3.eth.contract(abi=ASSET_ABI) + self.stablecoin_abi = self.w3.eth.contract(abi=STABLECOIN_ABI) + + self._receipts: list[TxReceipt] = [] + self._user2_funded = False + + # ── contracts at an address ───────────────────────────────────────────── + def asset_at(self, address: ChecksumAddress) -> Contract: + return self.w3.eth.contract(address=address, abi=ASSET_ABI) + + def stablecoin_at(self, address: ChecksumAddress) -> Contract: + return self.w3.eth.contract(address=address, abi=STABLECOIN_ABI) + + # ── send / read ───────────────────────────────────────────────────────── + def send(self, fn, account: LocalAccount) -> TxReceipt: + """Sign + broadcast a contract function, wait, assert success, record it.""" + tx = fn.build_transaction( + {"from": account.address, "nonce": self.w3.eth.get_transaction_count(account.address)} + ) + signed = account.sign_transaction(tx) + tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt["status"] != 1: + die(f"tx reverted: {fn.fn_name}") + self._receipts.append(receipt) + return receipt + + def fund_user2(self) -> None: + """Send user2 a one-time gas float from the deployer.""" + if self._user2_funded: + return + step("fund", f"deployer \u2192 user2 gas float ({self.cfg.gas_float_wei} wei)") + tx = { + "from": self.DEPLOYER, + "to": self.USER2, + "value": self.cfg.gas_float_wei, + "gas": 21000, + "gasPrice": self.w3.eth.gas_price, + "nonce": self.w3.eth.get_transaction_count(self.DEPLOYER), + "chainId": self.chain_id, + } + signed = self.deployer.sign_transaction(tx) + receipt = self.w3.eth.wait_for_transaction_receipt(self.w3.eth.send_raw_transaction(signed.raw_transaction)) + if receipt["status"] != 1: + die("failed to fund user2") + self._user2_funded = True + ok("user2 funded") + + # ── assertions ─────────────────────────────────────────────────────────── + def assert_eq(self, got: object, want: object, desc: str) -> None: + gn, wn = _norm(got), _norm(want) + if gn != wn: + die(f"assert_eq failed [{desc}]: got={gn} want={wn}") + ok(desc) + + def expect_revert(self, error_name: str, fn, frm: ChecksumAddress) -> None: + """Simulate `fn` via eth_call from `frm`; assert it reverts with error_name. + + Resolves the name from the 4-byte selector in the revert data. + """ + try: + fn.call({"from": frm}) + except ContractLogicError as exc: + data = getattr(exc, "data", None) + got = None + if isinstance(data, str) and data.startswith("0x") and len(data) >= 10: + got = ERROR_BY_SELECTOR.get(data[:10].lower()) + if got == error_name: + ok(f"reverts {error_name}") + return + die(f"revert mismatch: got={got!r} want={error_name} (raw: {data or exc})") + except Exception as exc: # noqa: BLE001 - surface any non-revert failure + die(f"expected revert {error_name} but call raised {type(exc).__name__}: {exc}") + die(f"expected revert {error_name} but call succeeded") + + def assert_log_order(self, receipt: TxReceipt, sig_a: str, sig_b: str, desc: str) -> None: + """Assert event A is logged immediately before event B in the receipt.""" + a, b = topic0(sig_a), topic0(sig_b) + tops = [HexBytes(lg["topics"][0]) for lg in receipt["logs"] if lg["topics"]] + if not any(tops[i] == a and tops[i + 1] == b for i in range(len(tops) - 1)): + die(f"log order [{desc}]: expected {sig_a} immediately before {sig_b}") + ok(desc) + + def assert_events_emitted(self, desc: str, *signatures: str) -> None: + """Flow-level check: each signature's topic0 appears across recorded txs.""" + if not self._receipts: + die(f"assert_events_emitted [{desc}]: no txs recorded this run") + seen = {HexBytes(lg["topics"][0]) for r in self._receipts for lg in r["logs"] if lg["topics"]} + missing = [s for s in signatures if topic0(s) not in seen] + if missing: + die(f"expected events not emitted [{desc}]: {', '.join(missing)}") + ok(f"{desc} ({len(signatures)} event type{'s' if len(signatures) != 1 else ''} confirmed emitted)") + + # ── factory / policy helpers ────────────────────────────────────────────── + def predict_b20(self, variant: int, salt: bytes, sender: ChecksumAddress | None = None) -> ChecksumAddress: + return self.factory.functions.getB20Address(variant, sender or self.DEPLOYER, salt).call() + + def create_b20(self, variant: int, salt: bytes, params: bytes, init_calls: list[bytes]) -> TxReceipt: + return self.send(self.factory.functions.createB20(variant, salt, params, init_calls), self.deployer) + + def create_b20_fn(self, variant: int, salt: bytes, params: bytes, init_calls: list[bytes]): + """A createB20 call object (not sent) for expect_revert on the edge cases.""" + return self.factory.functions.createB20(variant, salt, params, init_calls) + + def create_policy(self, admin: ChecksumAddress, ptype: int) -> int: + receipt = self.send(self.policy.functions.createPolicy(admin, ptype), self.deployer) + return self._policy_id_from(receipt) + + def create_policy_with_accounts(self, admin: ChecksumAddress, ptype: int, accounts: list[ChecksumAddress]) -> int: + receipt = self.send(self.policy.functions.createPolicyWithAccounts(admin, ptype, accounts), 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 _norm(v: object) -> object: + """Normalize for comparison: lowercase hex/addresses so checksums match.""" + if isinstance(v, (bytes, bytearray)): + return "0x" + bytes(v).hex() + if isinstance(v, str) and v.startswith(("0x", "0X")): + return v.lower() + return v diff --git a/script/smoke/codec.py b/script/smoke/codec.py new file mode 100644 index 0000000..ec9ed0b --- /dev/null +++ b/script/smoke/codec.py @@ -0,0 +1,70 @@ +"""ABI encoding for the one place the contract API is opaque bytes. + +`createB20` takes `bytes params` (an abi-encoded B20*CreateParams struct) and a +`bytes[]` of bootstrap calls. The structs never appear in any external +signature, so no ABI can describe them — this is the single encode that stays +hand-written. We localize it here behind typed dataclasses and eth_abi, and +build the bootstrap calldata from the token ABI so the init-calls reference real +function names rather than stringly selectors. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from eth_abi import encode as abi_encode +from eth_typing import ChecksumAddress +from hexbytes import HexBytes +from web3 import Web3 + +# Current B20*CreateParams encoding version (leading struct field). +PARAMS_VERSION = 1 + +_ASSET_PARAMS_TYPE = "(uint8,string,string,address,uint8)" +_STABLECOIN_PARAMS_TYPE = "(uint8,string,string,address,string)" + + +@dataclass(frozen=True) +class AssetCreateParams: + """IB20Factory.B20AssetCreateParams.""" + + name: str + symbol: str + initial_admin: ChecksumAddress + decimals: int + + def encode(self) -> bytes: + return abi_encode( + [_ASSET_PARAMS_TYPE], + [(PARAMS_VERSION, self.name, self.symbol, self.initial_admin, self.decimals)], + ) + + +@dataclass(frozen=True) +class StablecoinCreateParams: + """IB20Factory.B20StablecoinCreateParams.""" + + name: str + symbol: str + initial_admin: ChecksumAddress + currency: str + + def encode(self) -> bytes: + return abi_encode( + [_STABLECOIN_PARAMS_TYPE], + [(PARAMS_VERSION, self.name, self.symbol, self.initial_admin, self.currency)], + ) + + +def init_call(token_contract, fn_name: str, *args) -> bytes: + """Encode a single bootstrap call (selector + args) for createB20's initCalls. + + `token_contract` is any IB20Asset/IB20Stablecoin web3 contract — used only for + its ABI, so it need not be bound to a deployed address. + """ + return bytes(HexBytes(token_contract.encode_abi(fn_name, args=list(args)))) + + +def topic0(signature: str) -> HexBytes: + """topic[0] (event signature hash) for a canonical event signature string.""" + return HexBytes(Web3.keccak(text=signature)) diff --git a/script/smoke/config.py b/script/smoke/config.py new file mode 100644 index 0000000..a70f0f5 --- /dev/null +++ b/script/smoke/config.py @@ -0,0 +1,107 @@ +"""Run configuration for the b20 precompile smoketest. + +Addresses, enum/constant values, derived role + policy-scope hashes, and the +per-run salt namespace. Environment (RPC_URL / DEPLOYER_PK / USER2_PK, plus +optional GAS_FLOAT_ETHER / SMOKE_SALT) is read here; the Makefile sources .env. +""" + +from __future__ import annotations + +import os +import secrets +from dataclasses import dataclass + +from eth_typing import ChecksumAddress +from web3 import Web3 + +# Precompile addresses (from StdPrecompiles.sol — public, stable singletons). +B20_FACTORY: ChecksumAddress = Web3.to_checksum_address("0xB20f000000000000000000000000000000000000") +POLICY_REGISTRY: ChecksumAddress = Web3.to_checksum_address("0x8453000000000000000000000000000000000002") + +ZERO: ChecksumAddress = Web3.to_checksum_address("0x" + "00" * 20) + + +def amt(whole: int, decimals: int) -> int: + """whole * 10**decimals (token base units).""" + return whole * 10**decimals + +# B20Variant enum (IB20Factory). +VARIANT_ASSET = 0 +VARIANT_STABLECOIN = 1 + +# PolicyType enum (IPolicyRegistry). +POLICY_TYPE_BLOCKLIST = 0 +POLICY_TYPE_ALLOWLIST = 1 + +# Built-in policy IDs: ALWAYS_ALLOW = 0, ALWAYS_BLOCK = (uint64(ALLOWLIST) << 56) | 1. +ALWAYS_ALLOW_ID = 0 +ALWAYS_BLOCK_ID = (1 << 56) | 1 + +# PausableFeature enum (IB20). +FEATURE_TRANSFER = 0 +FEATURE_MINT = 1 +FEATURE_BURN = 2 + +# Token decimals per variant. +ASSET_DECIMALS = 18 +STABLECOIN_DECIMALS = 6 + + +def _role(name: str) -> bytes: + """keccak256(name) for a role / policy-scope constant (B20Constants).""" + return Web3.keccak(text=name) + + +DEFAULT_ADMIN_ROLE = b"\x00" * 32 +MINT_ROLE = _role("MINT_ROLE") +BURN_ROLE = _role("BURN_ROLE") +BURN_BLOCKED_ROLE = _role("BURN_BLOCKED_ROLE") +PAUSE_ROLE = _role("PAUSE_ROLE") +UNPAUSE_ROLE = _role("UNPAUSE_ROLE") +METADATA_ROLE = _role("METADATA_ROLE") +OPERATOR_ROLE = _role("OPERATOR_ROLE") + +TRANSFER_SENDER_POLICY = _role("TRANSFER_SENDER_POLICY") +TRANSFER_RECEIVER_POLICY = _role("TRANSFER_RECEIVER_POLICY") +TRANSFER_EXECUTOR_POLICY = _role("TRANSFER_EXECUTOR_POLICY") +MINT_RECEIVER_POLICY = _role("MINT_RECEIVER_POLICY") + + +@dataclass(frozen=True) +class Config: + """Resolved run configuration from the environment.""" + + rpc_url: str + deployer_pk: str + user2_pk: str + gas_float_wei: int + run_nonce: str + salt_pinned: bool + + @classmethod + def from_env(cls) -> "Config": + def need(key: str) -> str: + val = os.environ.get(key) + if not val: + raise SystemExit(f"[smoke] ERROR: set {key} (see script/smoke/smoke/config.py)") + return val + + pinned = os.environ.get("SMOKE_SALT") + gas_ether = os.environ.get("GAS_FLOAT_ETHER", "0.01") + return cls( + rpc_url=need("RPC_URL"), + deployer_pk=need("DEPLOYER_PK"), + user2_pk=need("USER2_PK"), + gas_float_wei=Web3.to_wei(gas_ether, "ether"), + run_nonce=pinned or secrets.token_hex(16), + salt_pinned=pinned is not None, + ) + + def salt_for(self, journey: str) -> bytes: + """createB20 salt for a journey, namespaced by run_nonce (unique per run).""" + return Web3.keccak(text=f"base-std.smoke.{journey}.{self.run_nonce}") + + def new_addr(self, label: str) -> ChecksumAddress: + """Keyless address (recipient / list member); fresh per run.""" + h = Web3.keccak(text=f"base-std.smoke.addr.{label}.{self.run_nonce}") + return Web3.to_checksum_address(h[-20:]) diff --git a/script/smoke/errors.py b/script/smoke/errors.py new file mode 100644 index 0000000..bbee6eb --- /dev/null +++ b/script/smoke/errors.py @@ -0,0 +1,31 @@ +"""Selector -> custom-error-name map, derived from the interface ABIs. + +`expect_revert` names a revert by matching the 4-byte selector in the revert +data (web3's ContractCustomError.data) against this map. +""" + +from __future__ import annotations + +from typing import Any + +from web3 import Web3 + +from .abis import ALL_ABIS + + +def _signature(error: dict[str, Any]) -> str: + types = ",".join(inp["type"] for inp in error.get("inputs", [])) + return f"{error['name']}({types})" + + +def _collect() -> dict[str, str]: + out: dict[str, str] = {} + for abi in ALL_ABIS: + for entry in abi: + if entry.get("type") == "error": + selector = "0x" + Web3.keccak(text=_signature(entry))[:4].hex() + out[selector.lower()] = entry["name"] + return out + + +ERROR_BY_SELECTOR: dict[str, str] = _collect() diff --git a/script/smoke/factory.sh b/script/smoke/factory.sh deleted file mode 100755 index 2c167b7..0000000 --- a/script/smoke/factory.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# factory.sh — B20Factory precompile smoketest. -# -# STUB: step-level scaffold for scope review. No executable bodies yet. -# Flexes deterministic creation + address prediction + the variant/identity -# query surface, then the factory's creation-time reverts. -# -# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./factory.sh (or `make smoke-factory`) - -set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=script/smoke/smoke-lib.sh -source "$HERE/smoke-lib.sh" - -# Golden path: deterministic creation + identity queries for both variants. -factory_journey() { - # 1. predict: addrA = getB20Address(ASSET, deployer, salt_for asset) - # assert isB20(addrA) == true (prefix recoverable, no storage read) - # assert isB20Initialized(addrA) == false (not created yet) - # 2. create: tok = createB20(ASSET, salt, encode_asset_params(...), []) - # assert tok == addrA (prediction matches actual) - # assert isB20Initialized(tok) == true (flips exactly once on completion) - # 3. predict + create STABLECOIN at salt_for stablecoin - # assert returned addr == getB20Address(STABLECOIN, deployer, salt) - # assert isB20(addrS) == true - # 4. assert isB20(some random non-b20 address) == false - : -} - -# Critical edges: creation-time reverts (match the exact selector). -factory_edges() { - # 5. dup salt: createB20(ASSET, same salt as step 2) -> TokenAlreadyExists(address) - # 6. decimals out of range: createB20(ASSET, decimals=5) -> InvalidDecimals(5) - # createB20(ASSET, decimals=19) -> InvalidDecimals(19) - # 7. bad currency: createB20(STABLECOIN, currency="usd") -> InvalidCurrency("usd") - # createB20(STABLECOIN, currency="") -> MissingRequiredField("currency") - # 8. bad variant: createB20(variant=2, ...) -> InvalidVariant() - : -} - -main() { - preflight - log "factory: starting" - factory_journey - factory_edges - log "factory: OK" -} - -main "$@" diff --git a/script/smoke/journeys/__init__.py b/script/smoke/journeys/__init__.py new file mode 100644 index 0000000..2eba79c --- /dev/null +++ b/script/smoke/journeys/__init__.py @@ -0,0 +1 @@ +"""Per-precompile smoketest journeys.""" diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py new file mode 100644 index 0000000..15892ab --- /dev/null +++ b/script/smoke/journeys/asset_lifecycle.py @@ -0,0 +1,162 @@ +"""B20 Asset variant smoketest. + +Full operator lifecycle of an Asset token (decimals 18): issuance, transfers + +memo, delegated spend, announcements (batchMint + rebase), metadata, burn — then +the gates that must reject (cap, pause, role, announce-id reuse) — then a +flow-level event check. +""" + +from __future__ import annotations + +from .. import config +from ..chain import Chain, die, log, ok, step +from ..codec import AssetCreateParams, init_call + +MEMO = b"smoke".ljust(32, b"\x00") + + +def _setup(c: Chain): + salt = c.cfg.salt_for("asset") + params = AssetCreateParams("Asset One", "AST", c.DEPLOYER, config.ASSET_DECIMALS).encode() + cap = config.amt(1_000_000_000, 18) + roles = [ + config.MINT_ROLE, + config.BURN_ROLE, + config.BURN_BLOCKED_ROLE, + config.PAUSE_ROLE, + config.UNPAUSE_ROLE, + config.METADATA_ROLE, + config.OPERATOR_ROLE, + ] + init_calls = [init_call(c.asset_abi, "grantRole", r, c.DEPLOYER) for r in roles] + init_calls.append(init_call(c.asset_abi, "updateSupplyCap", cap)) + + step("setup", f"create ASSET token (admin=deployer, decimals={config.ASSET_DECIMALS}, all roles -> deployer)") + tok_addr = c.predict_b20(config.VARIANT_ASSET, salt) + c.create_b20(config.VARIANT_ASSET, salt, params, init_calls) + tok = c.asset_at(tok_addr) + c.assert_eq(c.factory.functions.isB20Initialized(tok_addr).call(), True, "token initialized") + c.assert_eq(tok.functions.decimals().call(), config.ASSET_DECIMALS, f"decimals == {config.ASSET_DECIMALS}") + return tok + + +def _journey(c: Chain, tok) -> None: + step(1, "mint(alice, 1000)") + c.send(tok.functions.mint(c.ALICE, config.amt(1000, 18)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1000, 18), "alice balance") + c.assert_eq(tok.functions.totalSupply().call(), config.amt(1000, 18), "total supply") + + step(2, "mint(deployer, 500)") + c.send(tok.functions.mint(c.DEPLOYER, config.amt(500, 18)), c.deployer) + + step(3, "transfer(bob, 200) from deployer") + c.send(tok.functions.transfer(c.BOB, config.amt(200, 18)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(200, 18), "bob balance") + c.assert_eq(tok.functions.balanceOf(c.DEPLOYER).call(), config.amt(300, 18), "deployer balance") + + step(4, "transferWithMemo(bob, 1) from deployer; Memo follows Transfer") + receipt = c.send(tok.functions.transferWithMemo(c.BOB, config.amt(1, 18), MEMO), c.deployer) + c.assert_log_order( + receipt, "Transfer(address,address,uint256)", "Memo(address,bytes32)", "Memo immediately follows Transfer" + ) + + step(5, "delegated spend: approve(user2,50) then user2 transferFrom(deployer,bob,50)") + c.send(tok.functions.approve(c.USER2, config.amt(50, 18)), c.deployer) + c.fund_user2() + c.send(tok.functions.transferFrom(c.DEPLOYER, c.BOB, config.amt(50, 18)), c.user2) + c.assert_eq(tok.functions.allowance(c.DEPLOYER, c.USER2).call(), 0, "allowance consumed") + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(251, 18), "bob balance after delegated spend") + + step(6, "announce + batchMint([alice:10, bob:20])") + batch = init_call(c.asset_abi, "batchMint", [c.ALICE, c.BOB], [config.amt(10, 18), config.amt(20, 18)]) + c.send( + tok.functions.announce([batch], "smoke-batch-1", "batch issuance", "ipfs://smoke/batch-1"), + c.deployer, + ) + c.assert_eq(tok.functions.isAnnouncementIdUsed("smoke-batch-1").call(), True, "announcement id consumed") + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1010, 18), "alice balance after batch") + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(271, 18), "bob balance after batch") + + step(7, "announce + rebase: updateMultiplier(2e18); scaled view doubles") + rebase = init_call(c.asset_abi, "updateMultiplier", config.amt(2, 18)) + c.send(tok.functions.announce([rebase], "smoke-rebase-1", "2x rebase", "ipfs://smoke/rebase-1"), c.deployer) + c.assert_eq(tok.functions.multiplier().call(), config.amt(2, 18), "multiplier == 2e18") + raw = tok.functions.balanceOf(c.ALICE).call() + scaled = tok.functions.scaledBalanceOf(c.ALICE).call() + c.assert_eq(scaled, raw * 2, "scaledBalanceOf(alice) == 2 * balanceOf(alice)") + + step("7b", "round-trip toRawBalance(toScaledBalance(x)) within 1 ULP of x") + x = config.amt(12345, 18) + rt = tok.functions.toRawBalance(tok.functions.toScaledBalance(x).call()).call() + if abs(x - rt) > 1: + die(f"round-trip drift > 1 ULP: x={x} rt={rt}") + ok("round-trip within 1 ULP") + + step(8, "extra metadata set then remove") + c.send(tok.functions.updateExtraMetadata("category", "rwa"), c.deployer) + c.assert_eq(tok.functions.extraMetadata("category").call(), "rwa", "extraMetadata set") + c.send(tok.functions.updateExtraMetadata("category", ""), c.deployer) + c.assert_eq(tok.functions.extraMetadata("category").call(), "", "extraMetadata removed") + + step(9, "metadata: updateName / updateSymbol") + c.send(tok.functions.updateName("Asset Two"), c.deployer) + c.assert_eq(tok.functions.name().call(), "Asset Two", "name updated") + c.send(tok.functions.updateSymbol("AST2"), c.deployer) + c.assert_eq(tok.functions.symbol().call(), "AST2", "symbol updated") + + step(10, "burn(100) from deployer") + c.send(tok.functions.burn(config.amt(100, 18)), c.deployer) + c.assert_eq(tok.functions.totalSupply().call(), config.amt(1430, 18), "total supply after burn") + + +def _edges(c: Chain, tok) -> None: + step(11, "supply cap: lower cap to current supply, then mint 1 -> SupplyCapExceeded") + total = tok.functions.totalSupply().call() + c.send(tok.functions.updateSupplyCap(total), c.deployer) + c.expect_revert("SupplyCapExceeded", tok.functions.mint(c.ALICE, 1), c.DEPLOYER) + + step(12, "pause TRANSFER: transfer reverts ContractPaused; unpause restores") + c.send(tok.functions.pause([config.FEATURE_TRANSFER]), c.deployer) + c.assert_eq(tok.functions.isPaused(config.FEATURE_TRANSFER).call(), True, "TRANSFER paused") + c.expect_revert("ContractPaused", tok.functions.transfer(c.BOB, 1), c.DEPLOYER) + c.send(tok.functions.unpause([config.FEATURE_TRANSFER]), c.deployer) + c.assert_eq(tok.functions.isPaused(config.FEATURE_TRANSFER).call(), False, "TRANSFER unpaused") + c.send(tok.functions.transfer(c.BOB, config.amt(1, 18)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(272, 18), "transfer works again after unpause") + + step(13, "role gate: user2 mint -> AccessControlUnauthorizedAccount") + c.expect_revert("AccessControlUnauthorizedAccount", tok.functions.mint(c.ALICE, 1), c.USER2) + + step(14, "announce id reuse -> AnnouncementIdAlreadyUsed") + reuse = tok.functions.announce([], "smoke-batch-1", "dup", "ipfs://smoke/dup") + c.expect_revert("AnnouncementIdAlreadyUsed", reuse, c.DEPLOYER) + + +def _events(c: Chain) -> None: + step(15, "expected events emitted across the flow") + c.assert_events_emitted( + "asset events", + "B20Created(address,uint8,string,string,uint8,bytes)", + "RoleGranted(bytes32,address,address)", + "SupplyCapUpdated(address,uint256,uint256)", + "Transfer(address,address,uint256)", + "Memo(address,bytes32)", + "Approval(address,address,uint256)", + "Announcement(address,string,string,string)", + "EndAnnouncement(string)", + "MultiplierUpdated(uint256)", + "ExtraMetadataUpdated(string,string)", + "NameUpdated(address,string)", + "SymbolUpdated(address,string)", + "Paused(address,uint8[])", + "Unpaused(address,uint8[])", + ) + + +def run(c: Chain) -> None: + log("asset-lifecycle: starting") + tok = _setup(c) + _journey(c, tok) + _edges(c, tok) + _events(c) + log("asset-lifecycle: OK") diff --git a/script/smoke/journeys/factory.py b/script/smoke/journeys/factory.py new file mode 100644 index 0000000..3eccd2a --- /dev/null +++ b/script/smoke/journeys/factory.py @@ -0,0 +1,79 @@ +"""B20Factory precompile smoketest. + +Deterministic creation + address prediction + the variant/identity query +surface, then the factory's creation-time reverts, then a flow-level event +check. +""" + +from __future__ import annotations + +from .. import config +from ..chain import Chain, log, ok, step +from ..codec import AssetCreateParams, StablecoinCreateParams + +DEAD = "0x000000000000000000000000000000000000dEaD" + + +def _journey(c: Chain) -> None: + salt_a = c.cfg.salt_for("factory-asset") + params_a = AssetCreateParams("Factory Asset", "FAST", c.DEPLOYER, config.ASSET_DECIMALS).encode() + + step(1, "predict ASSET address; isB20 true, isB20Initialized false (pre-create)") + addr_a = c.predict_b20(config.VARIANT_ASSET, salt_a) + c.assert_eq(c.factory.functions.isB20(addr_a).call(), True, "isB20(predicted) == true") + c.assert_eq(c.factory.functions.isB20Initialized(addr_a).call(), False, "isB20Initialized == false pre-create") + + step(2, "create ASSET; prediction matches, isB20Initialized flips true") + c.create_b20(config.VARIANT_ASSET, salt_a, params_a, []) + c.assert_eq(c.predict_b20(config.VARIANT_ASSET, salt_a), addr_a, "address prediction is stable") + c.assert_eq(c.factory.functions.isB20Initialized(addr_a).call(), True, "isB20Initialized == true post-create") + + salt_s = c.cfg.salt_for("factory-stablecoin") + params_s = StablecoinCreateParams("Factory USD", "FUSD", c.DEPLOYER, "USD").encode() + + step(3, "predict + create STABLECOIN; isB20 true") + addr_s = c.predict_b20(config.VARIANT_STABLECOIN, salt_s) + c.create_b20(config.VARIANT_STABLECOIN, salt_s, params_s, []) + c.assert_eq(c.predict_b20(config.VARIANT_STABLECOIN, salt_s), addr_s, "stablecoin prediction is stable") + c.assert_eq(c.factory.functions.isB20(addr_s).call(), True, "isB20(stablecoin) == true") + + step(4, "non-b20 address reads false") + c.assert_eq(c.factory.functions.isB20(c.w3.to_checksum_address(DEAD)).call(), False, "isB20(non-b20) == false") + + +def _edges(c: Chain) -> None: + params_a = AssetCreateParams("Factory Asset", "FAST", c.DEPLOYER, config.ASSET_DECIMALS).encode() + params_bad5 = AssetCreateParams("Bad", "BAD", c.DEPLOYER, 5).encode() + params_bad19 = AssetCreateParams("Bad", "BAD", c.DEPLOYER, 19).encode() + params_lower_ccy = StablecoinCreateParams("Lower", "LOW", c.DEPLOYER, "usd").encode() + params_empty_ccy = StablecoinCreateParams("Empty", "EMP", c.DEPLOYER, "").encode() + + def create(variant, journey, params): + return c.create_b20_fn(variant, c.cfg.salt_for(journey), params, []) + + step(5, "duplicate salt -> TokenAlreadyExists") + c.expect_revert("TokenAlreadyExists", create(config.VARIANT_ASSET, "factory-asset", params_a), c.DEPLOYER) + + step(6, "decimals out of range -> InvalidDecimals") + c.expect_revert("InvalidDecimals", create(config.VARIANT_ASSET, "factory-d5", params_bad5), c.DEPLOYER) + c.expect_revert("InvalidDecimals", create(config.VARIANT_ASSET, "factory-d19", params_bad19), c.DEPLOYER) + + step(7, "bad currency -> InvalidCurrency / MissingRequiredField") + c.expect_revert("InvalidCurrency", create(config.VARIANT_STABLECOIN, "factory-lc", params_lower_ccy), c.DEPLOYER) + c.expect_revert("MissingRequiredField", create(config.VARIANT_STABLECOIN, "factory-ec", params_empty_ccy), c.DEPLOYER) + + step(8, "unknown variant -> InvalidVariant") + c.expect_revert("InvalidVariant", create(2, "factory-bv", params_a), c.DEPLOYER) + + +def _events(c: Chain) -> None: + step(9, "expected events emitted across the flow") + c.assert_events_emitted("factory events", "B20Created(address,uint8,string,string,uint8,bytes)") + + +def run(c: Chain) -> None: + log("factory: starting") + _journey(c) + _edges(c) + _events(c) + log("factory: OK") diff --git a/script/smoke/journeys/policy_registry.py b/script/smoke/journeys/policy_registry.py new file mode 100644 index 0000000..9b809c3 --- /dev/null +++ b/script/smoke/journeys/policy_registry.py @@ -0,0 +1,124 @@ +"""PolicyRegistry precompile smoketest. + +Policy creation (both types), membership, the built-in sentinels, the two-step +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. +""" + +from __future__ import annotations + +from .. import config +from ..chain import Chain, log, step +from ..codec import AssetCreateParams, init_call + + +def _journey(c: Chain) -> int: + step(1, "create ALLOWLIST policy (pidA); admin == deployer") + pid_a = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST) + c.assert_eq(c.policy.functions.policyExists(pid_a).call(), True, "pidA exists") + c.assert_eq(c.policy.functions.policyAdmin(pid_a).call(), c.DEPLOYER, "pidA admin == deployer") + + step(2, "create seeded BLOCKLIST policy (pidB) blocking bob") + pid_b = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_BLOCKLIST, [c.BOB]) + c.assert_eq(c.policy.functions.isAuthorized(pid_b, c.BOB).call(), False, "bob blocked in pidB") + c.assert_eq(c.policy.functions.isAuthorized(pid_b, c.ALICE).call(), True, "alice allowed (blocklist default)") + + step(3, "allowlist membership: add alice to pidA") + c.send(c.policy.functions.updateAllowlist(pid_a, True, [c.ALICE]), c.deployer) + c.assert_eq(c.policy.functions.isAuthorized(pid_a, c.ALICE).call(), True, "alice allowed in pidA") + c.assert_eq(c.policy.functions.isAuthorized(pid_a, c.BOB).call(), False, "bob not in pidA (allowlist default)") + + step(4, "built-in sentinels") + c.assert_eq(c.policy.functions.isAuthorized(config.ALWAYS_ALLOW_ID, c.BOB).call(), True, "ALWAYS_ALLOW authorizes anyone") + c.assert_eq(c.policy.functions.isAuthorized(config.ALWAYS_BLOCK_ID, c.BOB).call(), False, "ALWAYS_BLOCK blocks anyone") + + step(5, "two-step admin transfer pidA: deployer stages user2, user2 finalizes") + c.send(c.policy.functions.stageUpdateAdmin(pid_a, c.USER2), c.deployer) + c.assert_eq(c.policy.functions.pendingPolicyAdmin(pid_a).call(), c.USER2, "user2 staged as pending admin") + c.fund_user2() + c.send(c.policy.functions.finalizeUpdateAdmin(pid_a), c.user2) + c.assert_eq(c.policy.functions.policyAdmin(pid_a).call(), c.USER2, "pidA admin == user2") + c.assert_eq(c.policy.functions.pendingPolicyAdmin(pid_a).call(), config.ZERO, "pending admin cleared") + + step(6, "renounce pidA admin (user2); policy frozen but still queryable") + c.send(c.policy.functions.renounceAdmin(pid_a), c.user2) + c.assert_eq(c.policy.functions.policyAdmin(pid_a).call(), config.ZERO, "pidA admin renounced") + c.assert_eq(c.policy.functions.policyExists(pid_a).call(), True, "pidA still exists (frozen)") + + return pid_b + + +def _enforcement(c: Chain): + step(7, "create ALLOWLIST policy (pidR) seeded with alice") + pid_r = c.create_policy_with_accounts(c.DEPLOYER, config.POLICY_TYPE_ALLOWLIST, [c.ALICE]) + + step(8, "create ASSET token wired to pidR on TRANSFER_RECEIVER + MINT_RECEIVER") + salt = c.cfg.salt_for("policy-enforce") + params = AssetCreateParams("Gated Asset", "GATE", c.DEPLOYER, config.ASSET_DECIMALS).encode() + init_calls = [ + init_call(c.asset_abi, "updatePolicy", config.TRANSFER_RECEIVER_POLICY, pid_r), + init_call(c.asset_abi, "updatePolicy", config.MINT_RECEIVER_POLICY, pid_r), + 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) + tok = c.asset_at(tok_addr) + c.assert_eq(tok.functions.policyId(config.MINT_RECEIVER_POLICY).call(), pid_r, "MINT_RECEIVER_POLICY == pidR") + + step(9, "allowed paths: mint to allowlisted accounts, then transfer to one") + c.send(tok.functions.mint(c.ALICE, config.amt(100, 18)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(100, 18), "alice minted (in allowlist)") + c.send(c.policy.functions.updateAllowlist(pid_r, True, [c.DEPLOYER]), c.deployer) + c.send(tok.functions.mint(c.DEPLOYER, config.amt(100, 18)), c.deployer) + c.send(tok.functions.transfer(c.ALICE, config.amt(1, 18)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(101, 18), "transfer to allowlisted receiver") + + step(10, "denied receiver on transfer -> PolicyForbids") + c.expect_revert("PolicyForbids", tok.functions.transfer(c.BOB, config.amt(1, 18)), c.DEPLOYER) + + step(11, "denied receiver on mint -> PolicyForbids") + c.expect_revert("PolicyForbids", tok.functions.mint(c.BOB, config.amt(1, 18)), c.DEPLOYER) + + return tok, pid_r + + +def _edges(c: Chain, tok, pid_r: int, pid_b: int) -> None: + step(12, "wrong-type mutation: updateBlocklist on an ALLOWLIST -> IncompatiblePolicyType") + c.expect_revert("IncompatiblePolicyType", c.policy.functions.updateBlocklist(pid_r, True, [c.BOB]), c.DEPLOYER) + + step(13, "non-admin mutation: user2 updates pidR -> Unauthorized") + c.expect_revert("Unauthorized", c.policy.functions.updateAllowlist(pid_r, True, [c.BOB]), c.USER2) + + step(14, "zero admin: createPolicy(0) -> ZeroAddress") + c.expect_revert("ZeroAddress", c.policy.functions.createPolicy(config.ZERO, config.POLICY_TYPE_ALLOWLIST), c.DEPLOYER) + + step(15, "finalize with nothing staged -> NoPendingAdmin") + c.expect_revert("NoPendingAdmin", c.policy.functions.finalizeUpdateAdmin(pid_b), c.DEPLOYER) + + step(16, "token write-time validation: updatePolicy(unknown id) -> PolicyNotFound") + c.expect_revert("PolicyNotFound", tok.functions.updatePolicy(config.TRANSFER_SENDER_POLICY, 999999), c.DEPLOYER) + + +def _events(c: Chain) -> None: + step(17, "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)", + "B20Created(address,uint8,string,string,uint8,bytes)", + "PolicyUpdated(bytes32,uint64,uint64)", + "Transfer(address,address,uint256)", + "RoleGranted(bytes32,address,address)", + ) + + +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) + _events(c) + log("policy-registry: OK") diff --git a/script/smoke/journeys/stablecoin_lifecycle.py b/script/smoke/journeys/stablecoin_lifecycle.py new file mode 100644 index 0000000..e15f763 --- /dev/null +++ b/script/smoke/journeys/stablecoin_lifecycle.py @@ -0,0 +1,97 @@ +"""B20 Stablecoin variant smoketest. + +The Stablecoin deltas (fixed 6 decimals, immutable currency) plus the +regulated-issuer freeze-and-seize path (blocklist + burnBlocked), then a +flow-level event check. +""" + +from __future__ import annotations + +from .. import config +from ..chain import Chain, log, step +from ..codec import StablecoinCreateParams, init_call + + +def _setup(c: Chain): + salt = c.cfg.salt_for("stablecoin") + params = StablecoinCreateParams("USD Coin", "USDC", c.DEPLOYER, "USD").encode() + roles = [ + config.MINT_ROLE, + config.BURN_ROLE, + config.BURN_BLOCKED_ROLE, + config.PAUSE_ROLE, + config.UNPAUSE_ROLE, + config.METADATA_ROLE, + ] + init_calls = [init_call(c.stablecoin_abi, "grantRole", r, c.DEPLOYER) for r in roles] + + step("setup", "create STABLECOIN token (admin=deployer, currency=USD, roles -> deployer)") + tok_addr = c.predict_b20(config.VARIANT_STABLECOIN, salt) + c.create_b20(config.VARIANT_STABLECOIN, salt, params, init_calls) + tok = c.stablecoin_at(tok_addr) + c.assert_eq(c.factory.functions.isB20Initialized(tok_addr).call(), True, "token initialized") + return tok + + +def _journey(c: Chain, tok) -> None: + step(1, "variant identity: currency == USD, decimals == 6") + c.assert_eq(tok.functions.currency().call(), "USD", "currency() == USD") + c.assert_eq(tok.functions.decimals().call(), config.STABLECOIN_DECIMALS, f"decimals == {config.STABLECOIN_DECIMALS}") + + step(2, "mint(alice, 1000)") + c.send(tok.functions.mint(c.ALICE, config.amt(1000, 6)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1000, 6), "alice balance") + + step(3, "mint(deployer, 500); transfer(bob, 200)") + c.send(tok.functions.mint(c.DEPLOYER, config.amt(500, 6)), c.deployer) + c.send(tok.functions.transfer(c.BOB, config.amt(200, 6)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(200, 6), "bob balance") + c.assert_eq(tok.functions.balanceOf(c.DEPLOYER).call(), config.amt(300, 6), "deployer balance") + + step(4, "freeze setup: blocklist policy on TRANSFER_SENDER_POLICY, block alice") + pid = c.create_policy(c.DEPLOYER, config.POLICY_TYPE_BLOCKLIST) + c.send(tok.functions.updatePolicy(config.TRANSFER_SENDER_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 blocked") + + step(5, "seize: burnBlocked(alice, 400); Transfer then BurnedBlocked") + receipt = c.send(tok.functions.burnBlocked(c.ALICE, config.amt(400, 6)), c.deployer) + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(600, 6), "alice balance after seize") + c.assert_eq(tok.functions.totalSupply().call(), config.amt(1100, 6), "total supply after seize") + c.assert_log_order( + receipt, + "Transfer(address,address,uint256)", + "BurnedBlocked(address,address,uint256)", + "BurnedBlocked immediately follows Transfer", + ) + + +def _edges(c: Chain, tok) -> None: + step(6, "seize an unblocked account -> AccountNotBlocked") + c.expect_revert("AccountNotBlocked", tok.functions.burnBlocked(c.BOB, 1), c.DEPLOYER) + + step(7, "role gate: user2 mint -> AccessControlUnauthorizedAccount") + c.expect_revert("AccessControlUnauthorizedAccount", tok.functions.mint(c.ALICE, 1), c.USER2) + + +def _events(c: Chain) -> None: + step(8, "expected events emitted across the flow") + c.assert_events_emitted( + "stablecoin events", + "B20Created(address,uint8,string,string,uint8,bytes)", + "RoleGranted(bytes32,address,address)", + "Transfer(address,address,uint256)", + "BurnedBlocked(address,address,uint256)", + "PolicyCreated(uint64,address,uint8)", + "BlocklistUpdated(uint64,address,bool,address[])", + "PolicyUpdated(bytes32,uint64,uint64)", + ) + + +def run(c: Chain) -> None: + log("stablecoin-lifecycle: starting") + tok = _setup(c) + _journey(c, tok) + _edges(c, tok) + _events(c) + log("stablecoin-lifecycle: OK") diff --git a/script/smoke/policy-registry.sh b/script/smoke/policy-registry.sh deleted file mode 100755 index 9b0cb26..0000000 --- a/script/smoke/policy-registry.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# policy-registry.sh — PolicyRegistry precompile smoketest. -# -# STUB: step-level scaffold for scope review. No executable bodies yet. -# Flexes policy creation (both types), membership, the built-in sentinels, the -# two-step 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. -# -# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./policy-registry.sh -# (or `make smoke-policy`) - -set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=script/smoke/smoke-lib.sh -source "$HERE/smoke-lib.sh" - -# Golden path: registry mechanics. -policy_journey() { - # 1. create allowlist: pidA = createPolicy(deployer, ALLOWLIST) - # assert policyExists(pidA) == true; assert policyAdmin(pidA) == deployer - # 2. create seeded blocklist: pidB = createPolicyWithAccounts(deployer, BLOCKLIST, [bob]) - # assert isAuthorized(pidB, bob) == false (blocked) - # assert isAuthorized(pidB, alice) == true (blocklist default-allow) - # 3. membership: updateAllowlist(pidA, true, [alice]) - # assert isAuthorized(pidA, alice) == true; isAuthorized(pidA, bob) == false - # 4. built-ins: assert isAuthorized(ALWAYS_ALLOW_ID, anyone) == true - # assert isAuthorized(ALWAYS_BLOCK_ID, anyone) == false - # 5. two-step admin transfer: stageUpdateAdmin(pidA, user2) from deployer - # assert pendingPolicyAdmin(pidA) == user2; fund_user2; - # finalizeUpdateAdmin(pidA) signed by USER2 - # assert policyAdmin(pidA) == user2; assert pendingPolicyAdmin(pidA) == 0 - # 6. renounce: renounceAdmin(pidA) signed by USER2 - # assert policyAdmin(pidA) == 0; assert policyExists(pidA) == true (frozen, still queryable) - : -} - -# Golden path: a token enforcing a policy end-to-end. -policy_enforcement() { - # 7. fresh allowlist with alice as the only member: - # pidR = createPolicyWithAccounts(deployer, ALLOWLIST, [alice]) - # 8. create an ASSET token wired to it via initCalls: - # updatePolicy(TRANSFER_RECEIVER_POLICY, pidR) + updatePolicy(MINT_RECEIVER_POLICY, pidR) - # + grant MINT_ROLE to deployer. admin=deployer. - # 9. allowed paths: mint(alice, 100e18) succeeds (alice ∈ allowlist) - # mint(deployer, 100e18) requires deployer ∈ allowlist — add deployer to pidR first, - # then transfer(alice, 1e18) from deployer succeeds. - # 10. denied receiver on transfer: transfer(bob, 1e18) from deployer - # -> PolicyForbids(TRANSFER_RECEIVER_POLICY, pidR) (bob ∉ allowlist) - # 11. denied receiver on mint: mint(bob, 1e18) - # -> PolicyForbids(MINT_RECEIVER_POLICY, pidR) - : -} - -# Critical edges. -policy_edges() { - # 12. wrong-type mutation: updateBlocklist(pidA /* an ALLOWLIST */, …) - # -> IncompatiblePolicyType() - # 13. non-admin mutation: updateAllowlist(pidR, …) signed by USER2 (not admin) - # -> Unauthorized() - # 14. zero admin: createPolicy(address(0), ALLOWLIST) -> ZeroAddress() - # 15. no pending: finalizeUpdateAdmin(pidB) with nothing staged -> NoPendingAdmin() - # 16. token write-time validation: updatePolicy(TRANSFER_SENDER_POLICY, ) - # on the token -> PolicyNotFound(id) (consumer must validate at write time) - : -} - -main() { - preflight - log "policy-registry: starting" - policy_journey - policy_enforcement - policy_edges - log "policy-registry: OK" -} - -main "$@" diff --git a/script/smoke/requirements.txt b/script/smoke/requirements.txt new file mode 100644 index 0000000..929232e --- /dev/null +++ b/script/smoke/requirements.txt @@ -0,0 +1 @@ +web3>=7.6,<8 diff --git a/script/smoke/smoke-lib.sh b/script/smoke/smoke-lib.sh deleted file mode 100755 index bace178..0000000 --- a/script/smoke/smoke-lib.sh +++ /dev/null @@ -1,178 +0,0 @@ -# shellcheck shell=bash -# smoke-lib.sh — shared helpers for the b20 precompile bring-up smoketest. -# -# STUB: this is a scaffold for scope review. Function bodies are intentionally -# unimplemented (marked TODO). Sourced by each journey script; not run directly. -# -# The smoketest flexes the b20 precompiles (B20Factory, PolicyRegistry, -# ActivationRegistry, and the per-token precompiles) on a freshly-cut chain by -# sending real transactions with `cast` and asserting read-backs. It assumes the -# b20 features are already activated on the target chain and that $DEPLOYER_PK is -# funded in genesis. Everything is driven through env vars so no chain identity -# lands in this OSS repo. -# -# Required env: -# RPC_URL RPC endpoint of the target chain (e.g. a freshly-cut net). -# DEPLOYER_PK Funded private key. Privileged actor: token admin + every role, -# primary holder, and policy admin #1. -# USER2_PK Second signer (need not be pre-funded; the deployer sends it a -# gas float at runtime). The distinct `transferFrom` executor and -# policy admin #2 (finalizes the two-step admin transfer). -# Optional env: -# SMOKE_SALT Suffix mixed into every createB20 salt so the suite can be -# re-run on a chain that already holds the default-salt tokens. -# GAS_FLOAT Wei sent to USER2 before it signs (default: a small fixed float). - -# ────────────────────────────────────────────────────────────────────────────── -# Precompile addresses (from StdPrecompiles.sol — public, stable singletons). -# ────────────────────────────────────────────────────────────────────────────── -readonly B20_FACTORY=0xB20f000000000000000000000000000000000000 -readonly POLICY_REGISTRY=0x8453000000000000000000000000000000000002 -readonly ACTIVATION_REGISTRY=0x8453000000000000000000000000000000000001 - -# B20Variant enum (IB20Factory). -readonly VARIANT_ASSET=0 -readonly VARIANT_STABLECOIN=1 - -# PolicyType enum (IPolicyRegistry). -readonly POLICY_TYPE_BLOCKLIST=0 -readonly POLICY_TYPE_ALLOWLIST=1 - -# Built-in policy IDs (PolicyRegistry README): ALWAYS_ALLOW = 0, -# ALWAYS_BLOCK = (ALLOWLIST << 56) | 1. -readonly ALWAYS_ALLOW_ID=0 -# TODO: ALWAYS_BLOCK_ID — compute (uint64(1) << 56 | 1) as a decimal/hex literal. - -# Asset decimals used by the asset journey (in [6,18]). -readonly ASSET_DECIMALS=18 -readonly STABLECOIN_DECIMALS=6 - -# ────────────────────────────────────────────────────────────────────────────── -# Derived constants (filled at runtime via cast; declared here for reference). -# Role hashes are keccak256("MINT_ROLE") etc. (B20Constants); DEFAULT_ADMIN_ROLE -# is bytes32(0). Policy scopes are keccak256("TRANSFER_SENDER_POLICY") etc. -# ────────────────────────────────────────────────────────────────────────────── -# TODO: populate via role_hash / scope_hash helpers (cast keccak), e.g. -# MINT_ROLE=$(role_hash MINT_ROLE) -# TRANSFER_SENDER_POLICY=$(scope_hash TRANSFER_SENDER_POLICY) - -# ────────────────────────────────────────────────────────────────────────────── -# Logging / control -# ────────────────────────────────────────────────────────────────────────────── - -# log MSG — narrate a phase to stderr. -log() { echo "[smoke] $*" >&2; } - -# die MSG — abort the whole run with a nonzero exit (CI signal). -die() { echo "[smoke] ERROR: $*" >&2; exit 1; } - -# step N DESC — narrate a numbered step within a journey. -step() { :; } # TODO: echo " → [$1] $2" >&2 - -# ok DESC — mark the most recent step as passed (✓). -ok() { :; } # TODO: echo " ✓ $*" >&2 - -# ────────────────────────────────────────────────────────────────────────────── -# Preflight -# ────────────────────────────────────────────────────────────────────────────── - -# preflight — validate required bins (cast) and env (RPC_URL/DEPLOYER_PK/USER2_PK) -# are present, that RPC_URL answers eth_chainId, and that the b20 features are -# activated (isActivated on ACTIVATION_REGISTRY). die() on any failure. -preflight() { :; } # TODO - -# ────────────────────────────────────────────────────────────────────────────── -# Actors / addresses -# ────────────────────────────────────────────────────────────────────────────── - -# deployer_addr — echo the address for DEPLOYER_PK (cast wallet address). -deployer_addr() { :; } # TODO - -# user2_addr — echo the address for USER2_PK. -user2_addr() { :; } # TODO - -# fund_user2 — send USER2 a gas float from the deployer (idempotent enough: only -# tops up if USER2 balance < GAS_FLOAT). Call before any USER2-signed step. -fund_user2() { :; } # TODO - -# new_addr LABEL — echo a fresh keyless address (deterministic from LABEL) used as -# a token recipient / policy-list member / seize target. Never signs. -new_addr() { :; } # TODO: cast wallet address for keccak(LABEL+SMOKE_SALT), or a fixed table. - -# ────────────────────────────────────────────────────────────────────────────── -# Salt / hashing helpers -# ────────────────────────────────────────────────────────────────────────────── - -# salt_for JOURNEY — echo the deterministic createB20 salt for a journey, -# keccak("base-std.smoke." + SMOKE_SALT). Stable per fresh chain; -# override SMOKE_SALT to re-run on a chain that already has the tokens. -salt_for() { :; } # TODO: cast keccak "base-std.smoke.$1${SMOKE_SALT:-}" - -# role_hash NAME — echo keccak256(NAME) for a role constant (e.g. MINT_ROLE). -role_hash() { :; } # TODO: cast keccak "$1" (DEFAULT_ADMIN_ROLE is bytes32(0)) - -# scope_hash NAME — echo keccak256(NAME) for a policy scope (e.g. TRANSFER_SENDER_POLICY). -scope_hash() { :; } # TODO: cast keccak "$1" - -# ────────────────────────────────────────────────────────────────────────────── -# ABI encoding (cast-only) — the gnarly createB20 inputs and friends. -# ────────────────────────────────────────────────────────────────────────────── - -# encode_asset_params NAME SYMBOL ADMIN DECIMALS — echo the ABI-encoded -# B20AssetCreateParams blob (version byte = 1) for createB20's `params` arg. -encode_asset_params() { :; } -# TODO: cast abi-encode 'x((uint8,string,string,address,uint8))' \ -# "(1,\"$1\",\"$2\",$3,$4)" - -# encode_stablecoin_params NAME SYMBOL ADMIN CURRENCY — echo the ABI-encoded -# B20StablecoinCreateParams blob (version byte = 1). -encode_stablecoin_params() { :; } -# TODO: cast abi-encode 'x((uint8,string,string,address,string))' \ -# "(1,\"$1\",\"$2\",$3,\"$4\")" - -# encode_init_calls CALLDATA... — echo the ABI-encoded bytes[] of bootstrap -# initCalls from a list of pre-encoded calldata blobs. -encode_init_calls() { :; } # TODO: cast abi-encode 'x(bytes[])' "[$(join , "$@")]" - -# call_grant_role ROLE ACCOUNT — echo `cast calldata grantRole(bytes32,address)`. -# call_update_supply_cap CAP — echo calldata updateSupplyCap(uint256). -# call_update_policy SCOPE POLICYID — echo calldata updatePolicy(bytes32,uint64). -# call_batch_mint RECIPS AMTS — echo calldata batchMint(address[],uint256[]). -# call_update_multiplier M — echo calldata updateMultiplier(uint256). -# (These wrap `cast calldata` for use inside encode_init_calls / announce().) -call_grant_role() { :; } # TODO -call_update_supply_cap() { :; } # TODO -call_update_policy() { :; } # TODO -call_batch_mint() { :; } # TODO -call_update_multiplier() { :; } # TODO - -# ────────────────────────────────────────────────────────────────────────────── -# Send / read / assert -# ────────────────────────────────────────────────────────────────────────────── - -# send PK TO SIG ARGS... — cast send from PK; die() if status != 1. Echoes the -# tx hash so callers can inspect logs. -send() { :; } # TODO: cast send --rpc-url "$RPC_URL" --private-key "$PK" "$TO" "$SIG" "$@" - -# call TO SIG ARGS... — cast call (read); echo the decoded return value. -call() { :; } # TODO: cast call --rpc-url "$RPC_URL" "$TO" "$SIG" "$@" - -# assert_eq GOT WANT DESC — die() unless GOT == WANT. -assert_eq() { :; } # TODO - -# assert_call TO SIG ARGS... -- WANT DESC — call() then assert_eq the result. -assert_call() { :; } # TODO - -# expect_revert SELECTOR -- PK TO SIG ARGS... — send the (expected-to-fail) call -# and assert it reverts with the EXACT custom-error SELECTOR (e.g. the 4-byte -# sig of PolicyForbids / SupplyCapExceeded). Parse cast's revert output; die() -# if it succeeds or reverts with a different selector. -expect_revert() { :; } # TODO - -# selector SIG — echo the 4-byte selector for an error/function signature, e.g. -# selector 'PolicyForbids(bytes32,uint64)' (cast sig). -selector() { :; } # TODO: cast sig "$1" - -# assert_log_order TXHASH SIG_A SIG_B DESC — assert event SIG_A is logged -# immediately before SIG_B in TXHASH's receipt (e.g. Transfer then Memo). -assert_log_order() { :; } # TODO: cast receipt --json | parse topics[0] diff --git a/script/smoke/stablecoin-lifecycle.sh b/script/smoke/stablecoin-lifecycle.sh deleted file mode 100755 index df73215..0000000 --- a/script/smoke/stablecoin-lifecycle.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# stablecoin-lifecycle.sh — B20 Stablecoin variant smoketest. -# -# STUB: step-level scaffold for scope review. No executable bodies yet. -# Flexes the Stablecoin deltas (fixed 6 decimals, immutable currency) plus the -# regulated-issuer freeze-and-seize path (blocklist + burnBlocked). -# -# Run: RPC_URL=... DEPLOYER_PK=... USER2_PK=... ./stablecoin-lifecycle.sh -# (or `make smoke-stablecoin`) - -set -euo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=script/smoke/smoke-lib.sh -source "$HERE/smoke-lib.sh" - -# Golden path. -stablecoin_journey() { - # Setup. createB20(STABLECOIN, salt_for stablecoin, encode_stablecoin_params( - # "USD Coin","USDC",deployer,"USD"), initCalls) where initCalls grants - # MINT/BURN/BURN_BLOCKED/PAUSE/UNPAUSE/METADATA to deployer. admin=deployer. - # assert isB20Initialized(tok) == true. - # - # 1. variant identity: assert currency() == "USD"; assert decimals() == 6 - # 2. mint: mint(alice, 1000e6); assert balanceOf(alice) == 1000e6 - # 3. mint to deployer (500e6) then transfer(bob, 200e6); assert balances - # 4. freeze setup: pid = createPolicy(deployer, BLOCKLIST); - # updatePolicy(TRANSFER_SENDER_POLICY, pid); - # updateBlocklist(pid, true, [alice]); assert isAuthorized(pid, alice) == false - # 5. seize: burnBlocked(alice, 400e6) from deployer (holds BURN_BLOCKED_ROLE) - # assert balanceOf(alice) == 600e6; assert totalSupply down by 400e6 - # assert_log_order tx Transfer BurnedBlocked (Transfer(alice,0) then BurnedBlocked) - : -} - -# Critical edges. -stablecoin_edges() { - # 6. seize an unblocked account: burnBlocked(bob, 1) where bob ∉ blocklist - # -> AccountNotBlocked(bob) - # 7. role gate: mint(alice, 1) signed by USER2 (no MINT_ROLE) - # -> AccessControlUnauthorizedAccount(user2, MINT_ROLE) - : -} - -main() { - preflight - log "stablecoin-lifecycle: starting" - stablecoin_journey - stablecoin_edges - log "stablecoin-lifecycle: OK" -} - -main "$@" From 88bff554f4b2ed7bc6d743093fb826ada97bc33e Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 9 Jun 2026 12:49:46 -0700 Subject: [PATCH 3/8] add evm invariant smoke tests --- Makefile | 24 ++- script/smoke/__main__.py | 80 +++++-- script/smoke/abi/PrecompileProbe.json | 172 +++++++++++++++ script/smoke/abis.py | 13 ++ script/smoke/chain.py | 199 +++++++++++++++++- script/smoke/config.py | 5 + .../smoke/journeys/precompile_invariants.py | 195 +++++++++++++++++ test/lib/PrecompileProbe.sol | 85 ++++++++ 8 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 script/smoke/abi/PrecompileProbe.json create mode 100644 script/smoke/journeys/precompile_invariants.py create mode 100644 test/lib/PrecompileProbe.sol diff --git a/Makefile b/Makefile index a5f0327..eee0f9d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: coverage smoke smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-setup smoke-bindings +.PHONY: coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup smoke-bindings # Generate an lcov coverage report and open it in the browser. -# Scoped to src/ and test/lib/mocks/ (excludes test runner files). +# Scoped to src/ and test/lib/mocks/ (excludes test runner files and the smoke probe helper). coverage: - forge coverage --no-match-coverage "(\.t\.sol|Test\.sol)$$" --report lcov + forge coverage --no-match-coverage "(\.t\.sol|Test\.sol|Probe\.sol)$$" --report lcov genhtml lcov.info --branch-coverage -o coverage --dark-mode --ignore-errors inconsistent,corrupt open coverage/index.html @@ -23,17 +23,28 @@ smoke-setup: $(VENV)/bin/python -m pip install -r script/smoke/requirements.txt # Refresh the committed interface ABIs from the compiled artifacts. Only needed -# when the interfaces change (the harness binds to these via plain web3). +# when the interfaces change (the harness binds to these via plain web3). Also +# emits the PrecompileProbe artifact (abi + bytecode) the invariants journey deploys. smoke-bindings: forge build @for c in IB20Factory IB20Asset IB20Stablecoin IPolicyRegistry; do \ jq '.abi' "out/$$c.sol/$$c.json" > "script/smoke/abi/$$c.json" && echo "refreshed $$c.json"; \ done + @jq '{abi: .abi, bytecode: .bytecode.object}' out/PrecompileProbe.sol/PrecompileProbe.json \ + > script/smoke/abi/PrecompileProbe.json && echo "refreshed PrecompileProbe.json" # b20 precompile bring-up smoketest (web3.py + committed interface ABIs). Sends # real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, USER2_PK and a venv # (`make smoke-setup`). `make smoke` runs every journey fail-fast. -smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy +smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants + +# Run every journey in a single process. KEEP_GOING=1 runs them all and reports a +# summary without erroring on failure (audit/triage mode); default fails fast and +# exits non-zero on the first failure (CI gating). +# make smoke-all # fail-fast +# make smoke-all KEEP_GOING=1 # run all, report, exit 0 +smoke-all: + @$(SMOKE_RUN) all $(if $(KEEP_GOING),--keep-going,) smoke-factory: @$(SMOKE_RUN) factory @@ -46,3 +57,6 @@ smoke-stablecoin: smoke-policy: @$(SMOKE_RUN) policy + +smoke-invariants: + @$(SMOKE_RUN) invariants diff --git a/script/smoke/__main__.py b/script/smoke/__main__.py index 5cca877..860fc48 100644 --- a/script/smoke/__main__.py +++ b/script/smoke/__main__.py @@ -1,8 +1,15 @@ -"""CLI: python -m smoke . +"""CLI: python -m smoke [ ...] [-k] -Journeys: factory, asset, stablecoin, policy. Env (RPC_URL / DEPLOYER_PK / -USER2_PK) is sourced by the Makefile from .env; running directly requires it -exported. The target chain is assumed to already have the b20 features activated. +Journeys: factory, asset, stablecoin, policy, invariants — or `all` to run every +journey in sequence. Env (RPC_URL / DEPLOYER_PK / USER2_PK) is sourced by the +Makefile from .env; running directly requires it exported. The target chain is +assumed to already have the b20 features activated. + +Flags: + -k, --keep-going Run every selected journey even if one fails, print a summary, + and exit 0 regardless of failures. Without it the suite fails + fast and exits non-zero on the first failure (the default, + suitable for CI gating). """ from __future__ import annotations @@ -11,25 +18,74 @@ import sys from . import config -from .chain import Chain, die, log +from .chain import Chain, log JOURNEYS = { "factory": "smoke.journeys.factory", "asset": "smoke.journeys.asset_lifecycle", "stablecoin": "smoke.journeys.stablecoin_lifecycle", "policy": "smoke.journeys.policy_registry", + "invariants": "smoke.journeys.precompile_invariants", } +# Canonical run order; also the expansion of `all`. +ORDER = ["factory", "asset", "stablecoin", "policy", "invariants"] + + +def _usage() -> str: + return f"usage: python -m smoke <{'|'.join(ORDER)}|all> [more journeys ...] [-k|--keep-going]" + + +def _plan(argv: list[str]) -> tuple[list[str], bool]: + """Parse argv into (ordered journey names, keep_going). Raises SystemExit on bad usage.""" + keep_going = False + names: list[str] = [] + for arg in argv: + if arg in ("-k", "--keep-going"): + keep_going = True + else: + names.append(arg) + + if not names: + raise SystemExit(f"[smoke] ERROR: {_usage()}") + if "all" in names: + return ORDER, keep_going + unknown = [n for n in names if n not in JOURNEYS] + if unknown: + raise SystemExit(f"[smoke] ERROR: unknown journey(s): {', '.join(unknown)}\n{_usage()}") + return [n for n in ORDER if n in names], keep_going + def main(argv: list[str]) -> None: - if len(argv) != 1 or argv[0] not in JOURNEYS: - die(f"usage: python -m smoke <{'|'.join(JOURNEYS)}>") + selected, keep_going = _plan(argv) cfg = config.Config.from_env() - chain = Chain(cfg) - log(f"preflight ok \u2014 chain={chain.chain_id} deployer={chain.DEPLOYER}") - log(f"run nonce: {cfg.run_nonce}" + (" (pinned via SMOKE_SALT)" if cfg.salt_pinned else "")) - module = importlib.import_module(JOURNEYS[argv[0]]) - module.run(chain) + + results: list[tuple[str, bool, str]] = [] + for name in selected: + chain = Chain(cfg) + log(f"preflight ok \u2014 chain={chain.chain_id} deployer={chain.DEPLOYER}") + log(f"run nonce: {cfg.run_nonce}" + (" (pinned via SMOKE_SALT)" if cfg.salt_pinned else "")) + module = importlib.import_module(JOURNEYS[name]) + try: + module.run(chain) + results.append((name, True, "")) + except SystemExit as exc: + if not keep_going: + raise + results.append((name, False, str(exc.code or "").replace("[smoke] ERROR: ", ""))) + except Exception as exc: # noqa: BLE001 - keep-going turns any journey crash into a recorded failure + if not keep_going: + raise + results.append((name, False, f"{type(exc).__name__}: {exc}")) + + if not keep_going: + return + + failed = [r for r in results if not r[1]] + log(f"smoke summary: {len(results) - len(failed)}/{len(results)} journeys passed") + for name, _, detail in failed: + log(f" \u2717 {name} \u2014 {detail}") + # --keep-going asks the suite NOT to error on failure: report and exit 0. if __name__ == "__main__": diff --git a/script/smoke/abi/PrecompileProbe.json b/script/smoke/abi/PrecompileProbe.json new file mode 100644 index 0000000..a279f4c --- /dev/null +++ b/script/smoke/abi/PrecompileProbe.json @@ -0,0 +1,172 @@ +{ + "abi": [ + { + "type": "receive", + "stateMutability": "payable" + }, + { + "type": "function", + "name": "callThenRevert", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "probeCall", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "r", + "type": "tuple", + "internalType": "struct PrecompileProbe.Result", + "components": [ + { + "name": "ok", + "type": "bool", + "internalType": "bool" + }, + { + "name": "ret", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "gasUsed", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "probeCallWithGas", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "gasAmount", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "r", + "type": "tuple", + "internalType": "struct PrecompileProbe.Result", + "components": [ + { + "name": "ok", + "type": "bool", + "internalType": "bool" + }, + { + "name": "ret", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "gasUsed", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "probeReturndata", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "ok", + "type": "bool", + "internalType": "bool" + }, + { + "name": "raw", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "probeStaticcall", + "inputs": [ + { + "name": "target", + "type": "address", + "internalType": "address" + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "ok", + "type": "bool", + "internalType": "bool" + }, + { + "name": "ret", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b506106768061001c5f395ff3fe60806040526004361061004c575f3560e01c806329633d26146100575780634cf9256e1461008c5780638ee52744146100b957806399776c77146100da578063c5a29eb8146100f9575f5ffd5b3661005357005b5f5ffd5b348015610062575f5ffd5b506100766100713660046104da565b61010c565b604051610083919061055e565b60405180910390f35b348015610097575f5ffd5b506100ab6100a636600461059b565b6101c7565b6040516100839291906105ea565b3480156100c4575f5ffd5b506100d86100d336600461059b565b61022e565b005b3480156100e5575f5ffd5b506100ab6100f436600461059b565b61033e565b61007661010736600461059b565b6103c0565b61013060405180606001604052805f15158152602001606081526020015f81525090565b5f5a90505f5f876001600160a01b031685888860405161015192919061060c565b5f604051808303815f8787f1925050503d805f811461018b576040519150601f19603f3d011682016040523d82523d5f602084013e610190565b606091505b5091509150604051806060016040528083151581526020018281526020015a6101b9908661061b565b905298975050505050505050565b5f6060846001600160a01b031684846040516101e492919061060c565b5f60405180830381855afa9150503d805f811461021c576040519150601f19603f3d011682016040523d82523d5f602084013e610221565b606091505b5090969095509350505050565b5f836001600160a01b0316838360405161024992919061060c565b5f604051808303815f865af19150503d805f8114610282576040519150601f19603f3d011682016040523d82523d5f602084013e610287565b606091505b50509050806102e85760405162461bcd60e51b815260206004820152602260248201527f507265636f6d70696c6550726f62653a20696e6e65722063616c6c206661696c604482015261195960f21b60648201526084015b60405180910390fd5b60405162461bcd60e51b815260206004820152602560248201527f507265636f6d70696c6550726f62653a20696e74656e74696f6e616c20726f6c6044820152646c6261636b60d81b60648201526084016102df565b5f6060846001600160a01b0316848460405161035b92919061060c565b5f604051808303815f865af19150503d805f8114610394576040519150601f19603f3d011682016040523d82523d5f602084013e610399565b606091505b50506040513d8082529193509150805f602084013e80602083010160405250935093915050565b6103e460405180606001604052805f15158152602001606081526020015f81525090565b5f5a90505f5f866001600160a01b031634878760405161040592919061060c565b5f6040518083038185875af1925050503d805f811461043f576040519150601f19603f3d011682016040523d82523d5f602084013e610444565b606091505b5091509150604051806060016040528083151581526020018281526020015a61046d908661061b565b9052979650505050505050565b80356001600160a01b0381168114610490575f5ffd5b919050565b5f5f83601f8401126104a5575f5ffd5b50813567ffffffffffffffff8111156104bc575f5ffd5b6020830191508360208285010111156104d3575f5ffd5b9250929050565b5f5f5f5f606085870312156104ed575f5ffd5b6104f68561047a565b9350602085013567ffffffffffffffff811115610511575f5ffd5b61051d87828801610495565b9598909750949560400135949350505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b602081528151151560208201525f6020830151606060408401526105856080840182610530565b9050604084015160608401528091505092915050565b5f5f5f604084860312156105ad575f5ffd5b6105b68461047a565b9250602084013567ffffffffffffffff8111156105d1575f5ffd5b6105dd86828701610495565b9497909650939450505050565b8215158152604060208201525f6106046040830184610530565b949350505050565b818382375f9101908152919050565b8181038181111561063a57634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220aafa0217efc08c07f56ba187b94fefbcf1a45b8ace25faad6c1a586caecfba4264736f6c634300081e0033" +} diff --git a/script/smoke/abis.py b/script/smoke/abis.py index c1a1720..ca10237 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -24,3 +24,16 @@ def _load(name: str) -> list[dict[str, Any]]: POLICY_ABI = _load("IPolicyRegistry") ALL_ABIS = [FACTORY_ABI, ASSET_ABI, STABLECOIN_ABI, POLICY_ABI] + + +def probe_artifact() -> tuple[list[dict[str, Any]], str]: + """abi + creation bytecode for PrecompileProbe (emitted by `make smoke-bindings`). + + The probe is the one helper the harness deploys, so unlike the interface ABIs it needs bytecode + too. The artifact is regenerated from forge `out/` rather than committed by hand. + """ + path = _DIR / "PrecompileProbe.json" + if not path.exists(): + raise SystemExit("[smoke] ERROR: script/smoke/abi/PrecompileProbe.json missing; run `make smoke-bindings`") + art = json.loads(path.read_text()) + return art["abi"], art["bytecode"] diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 442e0d0..8226a18 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import sys from eth_account import Account @@ -68,6 +69,7 @@ def __init__(self, cfg: config.Config) -> None: self._receipts: list[TxReceipt] = [] self._user2_funded = False + self.trace = cfg.trace # ── contracts at an address ───────────────────────────────────────────── def asset_at(self, address: ChecksumAddress) -> Contract: @@ -86,6 +88,7 @@ def send(self, fn, account: LocalAccount) -> TxReceipt: tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) if receipt["status"] != 1: + self.trace_tx(tx_hash, label=f"{fn.fn_name} reverted") die(f"tx reverted: {fn.fn_name}") self._receipts.append(receipt) return receipt @@ -112,9 +115,25 @@ def fund_user2(self) -> None: ok("user2 funded") # ── assertions ─────────────────────────────────────────────────────────── - def assert_eq(self, got: object, want: object, desc: str) -> None: + def assert_eq( + self, + got: object, + want: object, + desc: str, + *, + repro_fn=None, + repro_overrides: dict | None = None, + repro_call: dict | None = None, + repro_tx: object | None = None, + ) -> None: + """Assert equality. On failure, dump the full RPC trace of the reproducing call/tx if provided. + + Pass a `repro_*` so the diagnostic can replay the offending call: `repro_fn` (+`repro_overrides`) + for a bound contract function, `repro_call` for a hand-built tx dict, or `repro_tx` for a tx hash. + """ gn, wn = _norm(got), _norm(want) if gn != wn: + self._diagnose(f"assert failed: {desc}", repro_fn, repro_overrides, repro_call, repro_tx) die(f"assert_eq failed [{desc}]: got={gn} want={wn}") ok(desc) @@ -133,9 +152,11 @@ def expect_revert(self, error_name: str, fn, frm: ChecksumAddress) -> None: if got == error_name: ok(f"reverts {error_name}") return + self._diagnose(f"revert mismatch: want {error_name}", repro_fn=fn, repro_overrides={"from": frm}) die(f"revert mismatch: got={got!r} want={error_name} (raw: {data or exc})") except Exception as exc: # noqa: BLE001 - surface any non-revert failure die(f"expected revert {error_name} but call raised {type(exc).__name__}: {exc}") + self._diagnose(f"expected revert {error_name} but call succeeded", repro_fn=fn, repro_overrides={"from": frm}) die(f"expected revert {error_name} but call succeeded") def assert_log_order(self, receipt: TxReceipt, sig_a: str, sig_b: str, desc: str) -> None: @@ -156,6 +177,182 @@ def assert_events_emitted(self, desc: str, *signatures: str) -> None: die(f"expected events not emitted [{desc}]: {', '.join(missing)}") ok(f"{desc} ({len(signatures)} event type{'s' if len(signatures) != 1 else ''} confirmed emitted)") + # ── deploy / raw low-level calls ────────────────────────────────────────── + def deploy(self, abi: list, bytecode: str, *args, account: LocalAccount | None = None) -> Contract: + """Deploy a contract from abi+bytecode and return a bound handle (used for the probe).""" + account = account or self.deployer + factory = self.w3.eth.contract(abi=abi, bytecode=bytecode) + tx = factory.constructor(*args).build_transaction( + {"from": account.address, "nonce": self.w3.eth.get_transaction_count(account.address)} + ) + signed = account.sign_transaction(tx) + receipt = self.w3.eth.wait_for_transaction_receipt(self.w3.eth.send_raw_transaction(signed.raw_transaction)) + if receipt["status"] != 1 or not receipt.get("contractAddress"): + die("contract deploy reverted") + return self.w3.eth.contract(address=receipt["contractAddress"], abi=abi) + + def raw_call(self, to: ChecksumAddress, data: bytes, *, value: int = 0, frm: ChecksumAddress | None = None) -> bytes: + """eth_call with hand-built calldata; returns raw return bytes (traces + raises on revert).""" + tx = {"to": to, "from": frm or self.DEPLOYER, "data": HexBytes(data), "value": value} + try: + return bytes(self.w3.eth.call(tx)) + except Exception: + self.trace_call(tx, label="raw_call reverted") + raise + + def expect_raw_revert( + self, + desc: str, + to: ChecksumAddress, + data: bytes, + *, + value: int = 0, + frm: ChecksumAddress | None = None, + error_name: str | None = None, + ) -> None: + """Simulate a hand-built call; assert it reverts. Optionally match the custom-error selector.""" + tx = {"to": to, "from": frm or self.DEPLOYER, "data": HexBytes(data), "value": value} + try: + self.w3.eth.call(tx) + except ContractLogicError as exc: + raw = getattr(exc, "data", None) + got = None + if isinstance(raw, str) and raw.startswith("0x") and len(raw) >= 10: + got = ERROR_BY_SELECTOR.get(raw[:10].lower()) + if error_name is not None and got != error_name: + self._diagnose(f"{desc}: revert mismatch", repro_call=tx) + die(f"{desc}: revert mismatch got={got!r} want={error_name} (raw: {raw or exc})") + ok(f"{desc} (reverts{f' {got}' if got else ''})") + return + except Exception as exc: # noqa: BLE001 - any node-level rejection still counts as "not accepted" + ok(f"{desc} (rejected: {type(exc).__name__})") + return + self._diagnose(f"{desc}: expected revert but call succeeded", repro_call=tx) + die(f"{desc}: expected revert but call succeeded") + + def send_expecting_revert(self, fn, account: LocalAccount, *, gas: int = 2_000_000) -> TxReceipt: + """Broadcast a real tx with explicit gas (skips estimation) and assert the receipt reverted.""" + tx = fn.build_transaction( + { + "from": account.address, + "nonce": self.w3.eth.get_transaction_count(account.address), + "gas": gas, + } + ) + signed = account.sign_transaction(tx) + tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction) + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt["status"] != 0: + self.trace_tx(tx_hash, label=f"{fn.fn_name} unexpectedly succeeded") + die(f"expected on-chain revert but tx succeeded: {fn.fn_name}") + return receipt + + # ── rpc tracing (failure diagnostics) ───────────────────────────────────── + def _diagnose( + self, + label: str, + repro_fn=None, + repro_overrides: dict | None = None, + repro_call: dict | None = None, + repro_tx: object | None = None, + ) -> None: + """Best-effort: dump the full RPC trace of the offending call/tx. Never masks the real failure.""" + try: + if repro_fn is not None: + self.trace_call(self._fn_call_tx(repro_fn, repro_overrides), label=label) + elif repro_call is not None: + self.trace_call(repro_call, label=label) + elif repro_tx is not None: + self.trace_tx(repro_tx, label=label) + except Exception as exc: # noqa: BLE001 - diagnostics must not raise over the assertion + log(f"(diagnostics failed: {type(exc).__name__}: {exc})") + + def _fn_call_tx(self, fn, overrides: dict | None = None) -> dict: + """Reconstruct the eth_call tx dict for a bound contract function (for replay/trace).""" + handle = self.w3.eth.contract(abi=fn.contract_abi) + data = HexBytes(handle.encode_abi(fn.fn_name, args=list(fn.args))) + tx = {"to": fn.address, "from": (overrides or {}).get("from", self.DEPLOYER), "data": data} + if overrides and overrides.get("value"): + tx["value"] = overrides["value"] + return tx + + def _rpc_tx(self, tx: dict) -> dict: + """Hex-encode a tx dict for the JSON-RPC debug_* params object.""" + out: dict = {} + for key in ("from", "to"): + if tx.get(key) is not None: + out[key] = tx[key] + data = tx.get("data") + if data is not None: + out["data"] = data if isinstance(data, str) else "0x" + bytes(data).hex() + if tx.get("value"): + out["value"] = hex(int(tx["value"])) + if tx.get("gas"): + out["gas"] = hex(int(tx["gas"])) + return out + + def trace_call(self, tx: dict, *, block: str = "latest", label: str = "failed eth_call") -> None: + """Print the exact eth_call request and a debug_traceCall (callTracer) call tree.""" + data = tx.get("data") + dhex = data if isinstance(data, str) else "0x" + bytes(data or b"").hex() + log(f"\u2500\u2500 rpc trace: {label} \u2500\u2500") + log(f" eth_call to={tx.get('to')} from={tx.get('from')} value={tx.get('value', 0)}") + log(f" selector={dhex[:10]} data={dhex}") + if not self.trace: + log(" (debug trace disabled; set SMOKE_TRACE=1 for the full call tree)") + self._print_revert_data(tx, block) + return + resp = self.w3.provider.make_request( + "debug_traceCall", [self._rpc_tx(tx), block, {"tracer": "callTracer", "tracerConfig": {"withLog": True}}] + ) + self._print_trace_response(resp, fallback_tx=tx, block=block) + + def trace_tx(self, tx_hash: object, *, label: str = "failed tx") -> None: + """Print receipt summary and a debug_traceTransaction (callTracer) call tree.""" + h = tx_hash.hex() if isinstance(tx_hash, (bytes, bytearray)) else str(tx_hash) + if not h.startswith("0x"): + h = "0x" + h + log(f"\u2500\u2500 rpc trace: {label} \u2500\u2500") + log(f" tx={h}") + try: + rcpt = self.w3.eth.get_transaction_receipt(tx_hash) + log(f" status={rcpt['status']} gasUsed={rcpt['gasUsed']} block={rcpt['blockNumber']}") + except Exception: # noqa: BLE001 - receipt is best-effort context + pass + if not self.trace: + log(" (debug trace disabled; set SMOKE_TRACE=1 for the full call tree)") + return + resp = self.w3.provider.make_request( + "debug_traceTransaction", [h, {"tracer": "callTracer", "tracerConfig": {"withLog": True}}] + ) + self._print_trace_response(resp) + + def _print_trace_response(self, resp: dict, *, fallback_tx: dict | None = None, block: str = "latest") -> None: + err = resp.get("error") + if err: + log(f" debug trace unavailable: {err.get('message', err) if isinstance(err, dict) else err}") + if fallback_tx is not None: + self._print_revert_data(fallback_tx, block) + return + log(" callTracer:") + print(json.dumps(resp.get("result"), indent=2, default=str), file=sys.stderr) + + def _print_revert_data(self, tx: dict, block: str) -> None: + """Fallback when debug_* is unsupported: replay the eth_call and decode any revert bytes.""" + try: + self.w3.eth.call( + {"to": tx.get("to"), "from": tx.get("from"), "data": HexBytes(tx.get("data") or b""), + "value": tx.get("value", 0)}, + block, + ) + log(" replay: eth_call succeeded (no revert data)") + except ContractLogicError as exc: + raw = getattr(exc, "data", None) + name = ERROR_BY_SELECTOR.get(raw[:10].lower()) if isinstance(raw, str) and len(raw) >= 10 else None + log(f" replay revert: data={raw} ({name or 'unknown/empty selector'})") + except Exception as exc: # noqa: BLE001 - surface whatever the node said + log(f" replay error: {type(exc).__name__}: {exc}") + # ── factory / policy helpers ────────────────────────────────────────────── def predict_b20(self, variant: int, salt: bytes, sender: ChecksumAddress | None = None) -> ChecksumAddress: return self.factory.functions.getB20Address(variant, sender or self.DEPLOYER, salt).call() diff --git a/script/smoke/config.py b/script/smoke/config.py index a70f0f5..c5bf95d 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -77,6 +77,7 @@ class Config: gas_float_wei: int run_nonce: str salt_pinned: bool + trace: bool @classmethod def from_env(cls) -> "Config": @@ -88,6 +89,9 @@ def need(key: str) -> str: pinned = os.environ.get("SMOKE_SALT") gas_ether = os.environ.get("GAS_FLOAT_ETHER", "0.01") + # Failure diagnostics emit a debug_traceCall/Transaction call tree. On by default (only fires on + # failures); set SMOKE_TRACE=0 to print just the request + replayed revert data instead. + trace = os.environ.get("SMOKE_TRACE", "1").strip().lower() not in ("0", "false", "off", "no", "") return cls( rpc_url=need("RPC_URL"), deployer_pk=need("DEPLOYER_PK"), @@ -95,6 +99,7 @@ def need(key: str) -> str: gas_float_wei=Web3.to_wei(gas_ether, "ether"), run_nonce=pinned or secrets.token_hex(16), salt_pinned=pinned is not None, + trace=trace, ) def salt_for(self, journey: str) -> bytes: diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py new file mode 100644 index 0000000..ce787c1 --- /dev/null +++ b/script/smoke/journeys/precompile_invariants.py @@ -0,0 +1,195 @@ +"""Precompile EVM-context invariant smoketest. + +Audits the behaviors Solidity grants for free that a precompile has no notion of and must implement +explicitly: payable rejection, selector dispatch, calldata canonicalization, STATICCALL read-only +enforcement, returndata fidelity, gas containment, and revert atomicity. Two layers: + + * raw `eth_call` with hand-built (often deliberately malformed) calldata, straight from web3 — for + inputs the Solidity compiler would never emit (dirty high bits, unknown selectors, truncated args, + value attached to a non-payable method); + * a deployed `PrecompileProbe` contract for the cases that need a real caller frame (STATICCALL, + DELEGATECALL, value forwarding, gas forwarding, revert atomicity). + +Unlike the lifecycle journeys, the assertions here encode the *desired* invariant — a failure is a +precompile finding to triage, not a flaky test. The runner therefore does NOT fail fast: it runs every +check, prints a summary, and exits non-zero only at the end if any required invariant did not hold. To +accept a known divergence, add its check name to `INFORMATIONAL` — it will still be reported but won't +fail the run. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable + +from hexbytes import HexBytes +from web3 import Web3 + +from .. import config +from ..abis import probe_artifact +from ..chain import Chain, log, ok, step + +FACTORY = config.B20_FACTORY +POLICY = config.POLICY_REGISTRY +ALLOWLIST = config.POLICY_TYPE_ALLOWLIST + +# Check names that are known/accepted divergences: reported, but do not fail the run. +INFORMATIONAL: set[str] = set() + + +def _clean(contract, fn_name: str, *args) -> bytes: + """Canonical ABI calldata (selector + args) for a function on a bound contract handle.""" + return bytes(HexBytes(contract.encode_abi(fn_name, args=list(args)))) + + +def _selector(sig: str) -> bytes: + return bytes(Web3.keccak(text=sig)[:4]) + + +def _addr_word(address: str) -> bytes: + return bytes(12) + bytes(HexBytes(address)) + + +# ── raw calldata edges (no helper contract; web3 emits bytes Solidity wouldn't) ──────────────────── +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) + + +def _unknown_selector_reverts(c: Chain, _probe) -> None: + c.expect_raw_revert("unknown selector", FACTORY, b"\xde\xad\xbe\xef") + + +def _empty_calldata_reverts(c: Chain, _probe) -> None: + c.expect_raw_revert("empty calldata", FACTORY, b"") + + +def _truncated_args_revert(c: Chain, _probe) -> None: + truncated = _selector("createPolicy(address,uint8)") + _addr_word(c.DEPLOYER) + c.expect_raw_revert("truncated calldata", POLICY, truncated) + + +def _enum_out_of_range_reverts(c: Chain, _probe) -> None: + bad_enum = _selector("createPolicy(address,uint8)") + _addr_word(c.DEPLOYER) + (5).to_bytes(32, "big") + c.expect_raw_revert("enum out of range", POLICY, bad_enum) + + +def _dirty_high_bits_masked(c: Chain, _probe) -> None: + pid, acct = config.ALWAYS_BLOCK_ID, c.BOB + sel = _selector("isAuthorized(uint64,address)") + dirty = sel + (b"\xff" * 24 + pid.to_bytes(8, "big")) + (b"\xff" * 12 + bytes(HexBytes(acct))) + clean_ret = c.raw_call(POLICY, _clean(c.policy, "isAuthorized", pid, acct)) + dirty_ret = c.raw_call(POLICY, dirty) + c.assert_eq( + "0x" + dirty_ret.hex(), + "0x" + clean_ret.hex(), + "dirty-bit and clean isAuthorized agree", + repro_call={"to": POLICY, "from": c.DEPLOYER, "data": dirty}, + ) + + +# ── caller-frame context (requires the deployed PrecompileProbe) ─────────────────────────────────── +def _staticcall_read_only(c: Chain, probe) -> None: + create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) + fn = probe.functions.probeStaticcall(POLICY, create_data) + okflag, _ = fn.call() + c.assert_eq(okflag, False, "mutating call fails under STATICCALL", repro_fn=fn) + + +def _value_forwarding_rejected(c: Chain, probe) -> None: + create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) + overrides = {"from": c.DEPLOYER, "value": 1} + 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) + + +def _returndata_fidelity(c: Chain, probe) -> None: + zero_create = _clean(c.policy, "createPolicy", config.ZERO, ALLOWLIST) + fn = probe.functions.probeReturndata(POLICY, zero_create) + okflag, raw = fn.call() + c.assert_eq(okflag, False, "createPolicy(0) reverts", repro_fn=fn) + c.assert_eq( + "0x" + bytes(raw)[:4].hex(), + "0x" + _selector("ZeroAddress()").hex(), + "returndata carries ZeroAddress", + repro_fn=fn, + ) + + +def _oog_contained(c: Chain, probe) -> None: + zero_create = _clean(c.policy, "createPolicy", config.ZERO, ALLOWLIST) + fn = probe.functions.probeCallWithGas(POLICY, zero_create, 100) + res = fn.call() + c.assert_eq(res[0], False, "sub-call with 100 gas fails", repro_fn=fn) + ok("outer frame returned (OOG did not kill the whole call)") + + +def _atomicity(c: Chain, probe) -> None: + # Single-writer assumption: the smoke chain is driven by one deployer, so policy ids are sequential + # within this run. A reverted creation in between must NOT consume an id. + create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) + id1 = c.create_policy(c.DEPLOYER, ALLOWLIST) + c.send_expecting_revert(probe.functions.callThenRevert(POLICY, create_data), c.deployer) + id2 = c.create_policy(c.DEPLOYER, ALLOWLIST) + c.assert_eq(id2 - id1, 1, "reverted createPolicy consumed no policy id") + + +# 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), + ("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), + ("out-of-range enum reverts", _enum_out_of_range_reverts), + ("dirty high bits masked (uint64 + address canonicalization)", _dirty_high_bits_masked), + ("STATICCALL read-only enforced", _staticcall_read_only), + ("value forwarding rejected through a contract", _value_forwarding_rejected), + ("returndata fidelity (RETURNDATACOPY of revert payload)", _returndata_fidelity), + ("OOG contained to sub-call", _oog_contained), + ("revert atomicity (no committed state on rollback)", _atomicity), +] + + +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() + probe = c.deploy(abi, bytecode) + ok(f"probe deployed at {probe.address}") + return probe + + +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") + log("precompile-invariants: OK") diff --git a/test/lib/PrecompileProbe.sol b/test/lib/PrecompileProbe.sol new file mode 100644 index 0000000..6980238 --- /dev/null +++ b/test/lib/PrecompileProbe.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title PrecompileProbe +/// +/// @notice Smoke-test helper that issues low-level CALL / STATICCALL / DELEGATECALL into the b20 +/// precompiles from a real contract frame, capturing success, returndata, and gas consumed. +/// It lets the `precompile_invariants` Python journey assert EVM-context invariants that an +/// EOA + `eth_call` cannot synthesize: STATICCALL read-only enforcement, value forwarding, +/// returndata fidelity (RETURNDATASIZE/RETURNDATACOPY), gas forwarding, and revert atomicity. +/// +/// @dev Test-only. Deployed fresh per smoke run; never part of any production deployment. Every probe +/// that expects a failing sub-call captures the outcome instead of bubbling it, so the journey can +/// assert on `ok` rather than relying on the harness's revert plumbing. +contract PrecompileProbe { + /// @notice Outcome of a probed sub-call. + /// + /// @param ok Whether the sub-call returned successfully (false on revert / OOG). + /// @param ret Returndata (return value on success, revert payload on failure). + /// @param gasUsed Gas consumed by the sub-call frame as measured by the probe. + struct Result { + bool ok; + bytes ret; + uint256 gasUsed; + } + + /// @notice CALL `target` with `data`, forwarding any attached value. Never reverts. + /// + /// @dev Used for the value-forwarding invariant: send value to a non-payable precompile method and + /// assert `ok == false`. + function probeCall(address target, bytes calldata data) external payable returns (Result memory r) { + uint256 g0 = gasleft(); + (bool ok, bytes memory ret) = target.call{value: msg.value}(data); + r = Result(ok, ret, g0 - gasleft()); + } + + /// @notice STATICCALL `target` with `data`. `ok == false` proves the callee attempted a state write. + /// + /// @dev Marked `view` so the journey reaches it with a plain `eth_call`. STATICCALL itself cannot write, + /// so a mutating precompile method must revert here. + function probeStaticcall(address target, bytes calldata data) external view returns (bool ok, bytes memory ret) { + (ok, ret) = target.staticcall(data); + } + + /// @notice CALL `target` forwarding at most `gasAmount`; capture whether it fit and gas consumed. + /// + /// @dev Used to assert OOG is contained to the sub-frame (outer frame survives, `ok == false`) rather + /// than killing the whole transaction. + function probeCallWithGas(address target, bytes calldata data, uint256 gasAmount) + external + returns (Result memory r) + { + uint256 g0 = gasleft(); + (bool ok, bytes memory ret) = target.call{gas: gasAmount}(data); + r = Result(ok, ret, g0 - gasleft()); + } + + /// @notice CALL `target` (expected to revert), then surface the raw returndata via + /// RETURNDATASIZE / RETURNDATACOPY. + /// + /// @dev Validates that a precompile's revert payload is faithfully exposed in the returndata buffer. + function probeReturndata(address target, bytes calldata data) external returns (bool ok, bytes memory raw) { + (ok,) = target.call(data); + assembly { + let size := returndatasize() + raw := mload(0x40) + mstore(raw, size) + returndatacopy(add(raw, 0x20), 0, size) + mstore(0x40, add(add(raw, 0x20), size)) + } + } + + /// @notice Perform `data` on `target` (which must succeed), then revert the whole frame. + /// + /// @dev Used for the atomicity invariant: a committed-then-rolled-back precompile mutation must leave no + /// persisted state. The outer journey sends this as a real tx, asserts it reverts, then checks the + /// precompile state is unchanged. + function callThenRevert(address target, bytes calldata data) external { + (bool ok,) = target.call(data); + require(ok, "PrecompileProbe: inner call failed"); + revert("PrecompileProbe: intentional rollback"); + } + + receive() external payable {} +} From ef247cf71fa25859226f4bab29b6b51ccf16d137 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 9 Jun 2026 14:22:11 -0700 Subject: [PATCH 4/8] Add missing tests, check for feature activation --- .gitignore | 5 +- Makefile | 36 +- foundry.toml | 10 + script/smoke/__init__.py | 2 +- script/smoke/__main__.py | 33 +- script/smoke/abi/IB20Asset.json | 1805 ----------------- script/smoke/abi/IB20Factory.json | 222 -- script/smoke/abi/IB20Stablecoin.json | 1467 -------------- script/smoke/abi/IPolicyRegistry.json | 399 ---- script/smoke/abi/PrecompileProbe.json | 172 -- script/smoke/abis.py | 35 +- script/smoke/chain.py | 81 +- script/smoke/config.py | 18 + script/smoke/journeys/asset_lifecycle.py | 11 +- .../smoke/journeys/precompile_invariants.py | 31 +- 15 files changed, 207 insertions(+), 4120 deletions(-) delete mode 100644 script/smoke/abi/IB20Asset.json delete mode 100644 script/smoke/abi/IB20Factory.json delete mode 100644 script/smoke/abi/IB20Stablecoin.json delete mode 100644 script/smoke/abi/IPolicyRegistry.json delete mode 100644 script/smoke/abi/PrecompileProbe.json diff --git a/.gitignore b/.gitignore index 3fd5a2d..fdfdf16 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ broadcast/ .env.* !.env.example -# Python smoketest (script/smoke): venv + bytecode caches. The interface ABIs -# under script/smoke/abi/ ARE committed (refresh with `make smoke-bindings`). +# Python smoketest (script/smoke): venv + bytecode caches. Interface ABIs are +# read from the (gitignored) forge `out/` dir at runtime, so nothing under +# script/smoke/ needs committing beyond the harness sources. script/smoke/.venv/ __pycache__/ *.pyc diff --git a/Makefile b/Makefile index eee0f9d..2a9ce0a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup smoke-bindings +.PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup # Generate an lcov coverage report and open it in the browser. # Scoped to src/ and test/lib/mocks/ (excludes test runner files and the smoke probe helper). @@ -22,20 +22,16 @@ smoke-setup: $(VENV)/bin/python -m pip install --upgrade pip $(VENV)/bin/python -m pip install -r script/smoke/requirements.txt -# Refresh the committed interface ABIs from the compiled artifacts. Only needed -# when the interfaces change (the harness binds to these via plain web3). Also -# emits the PrecompileProbe artifact (abi + bytecode) the invariants journey deploys. -smoke-bindings: +# Compile the contracts. The smoke harness binds to the interface ABIs and the +# PrecompileProbe artifact straight from `out/` (gitignored), so every smoke +# target depends on this — the ABIs always match the current source. +build: forge build - @for c in IB20Factory IB20Asset IB20Stablecoin IPolicyRegistry; do \ - jq '.abi' "out/$$c.sol/$$c.json" > "script/smoke/abi/$$c.json" && echo "refreshed $$c.json"; \ - done - @jq '{abi: .abi, bytecode: .bytecode.object}' out/PrecompileProbe.sol/PrecompileProbe.json \ - > script/smoke/abi/PrecompileProbe.json && echo "refreshed PrecompileProbe.json" - -# b20 precompile bring-up smoketest (web3.py + committed interface ABIs). Sends -# real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, USER2_PK and a venv -# (`make smoke-setup`). `make smoke` runs every journey fail-fast. + +# b20 precompile bring-up smoketest (web3.py + the interface ABIs read from +# `out/`). Sends real txs to $RPC_URL; requires env RPC_URL, DEPLOYER_PK, +# USER2_PK and a venv (`make smoke-setup`). `make smoke` runs every journey +# fail-fast. smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants # Run every journey in a single process. KEEP_GOING=1 runs them all and reports a @@ -43,20 +39,20 @@ smoke: smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants # exits non-zero on the first failure (CI gating). # make smoke-all # fail-fast # make smoke-all KEEP_GOING=1 # run all, report, exit 0 -smoke-all: +smoke-all: build @$(SMOKE_RUN) all $(if $(KEEP_GOING),--keep-going,) -smoke-factory: +smoke-factory: build @$(SMOKE_RUN) factory -smoke-asset: +smoke-asset: build @$(SMOKE_RUN) asset -smoke-stablecoin: +smoke-stablecoin: build @$(SMOKE_RUN) stablecoin -smoke-policy: +smoke-policy: build @$(SMOKE_RUN) policy -smoke-invariants: +smoke-invariants: build @$(SMOKE_RUN) invariants diff --git a/foundry.toml b/foundry.toml index e608858..cb0e5e7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -53,3 +53,13 @@ tab_width = 4 quote_style = "double" bracket_spacing = false int_types = "long" + +# forge lint runs on every `forge build`. The test runners intentionally +# exercise raw behavior (e.g. ignoring ERC20 transfer return values), so linting +# them is pure noise — but the mocks under test/lib/mocks/ are quasi-production +# code where findings (unsafe casts, block.timestamp, etc.) matter. So ignore +# the runners (test/unit, test/regression) and the test/lib base contracts, but +# NOT test/lib/mocks/ — leaving src/ and the mocks fully linted. (forge lint's +# `ignore` does not honor `!` re-includes, so the mocks are kept in by omission.) +[lint] +ignore = ["test/unit/**/*.sol", "test/regression/**/*.sol", "test/lib/*.sol"] diff --git a/script/smoke/__init__.py b/script/smoke/__init__.py index cf10afc..c7b0516 100644 --- a/script/smoke/__init__.py +++ b/script/smoke/__init__.py @@ -1 +1 @@ -"""b20 precompile bring-up smoketest (web3.py + committed interface ABIs).""" +"""b20 precompile bring-up smoketest (web3.py + interface ABIs read from `out/`).""" diff --git a/script/smoke/__main__.py b/script/smoke/__main__.py index 860fc48..9ed8c69 100644 --- a/script/smoke/__main__.py +++ b/script/smoke/__main__.py @@ -2,8 +2,10 @@ Journeys: factory, asset, stablecoin, policy, invariants — or `all` to run every journey in sequence. Env (RPC_URL / DEPLOYER_PK / USER2_PK) is sourced by the -Makefile from .env; running directly requires it exported. The target chain is -assumed to already have the b20 features activated. +Makefile from .env; running directly requires it exported. A preflight liveness +probe checks the b20 precompiles are actually active on the target chain (fork +>= Beryl); if not, the journey is skipped rather than reporting environment state +(inactive feature → account-state fall-through) as contract defects. Flags: -k, --keep-going Run every selected journey even if one fails, print a summary, @@ -60,31 +62,42 @@ def main(argv: list[str]) -> None: selected, keep_going = _plan(argv) cfg = config.Config.from_env() - results: list[tuple[str, bool, str]] = [] + results: list[tuple[str, str, str]] = [] # (name, status: pass|fail|skip, detail) for name in selected: chain = Chain(cfg) - log(f"preflight ok \u2014 chain={chain.chain_id} deployer={chain.DEPLOYER}") + log(f"preflight ok \u2014 chain={chain.chain_id} block={chain.w3.eth.block_number} deployer={chain.DEPLOYER}") log(f"run nonce: {cfg.run_nonce}" + (" (pinned via SMOKE_SALT)" if cfg.salt_pinned else "")) + chain.ensure_deployer_funded() + active, why = chain.features_activated() + if not active: + log(f"b20 features NOT ACTIVE on chain {chain.chain_id}: {why}") + log("Chain/fork-activation state, NOT a contract defect \u2014 skipping (use the ActivationRegistry to enable).") + results.append((name, "skip", why)) + continue module = importlib.import_module(JOURNEYS[name]) try: module.run(chain) - results.append((name, True, "")) + results.append((name, "pass", "")) except SystemExit as exc: if not keep_going: raise - results.append((name, False, str(exc.code or "").replace("[smoke] ERROR: ", ""))) + results.append((name, "fail", str(exc.code or "").replace("[smoke] ERROR: ", ""))) except Exception as exc: # noqa: BLE001 - keep-going turns any journey crash into a recorded failure if not keep_going: raise - results.append((name, False, f"{type(exc).__name__}: {exc}")) + results.append((name, "fail", f"{type(exc).__name__}: {exc}")) if not keep_going: return - failed = [r for r in results if not r[1]] - log(f"smoke summary: {len(results) - len(failed)}/{len(results)} journeys passed") - for name, _, detail in failed: + passed = sum(1 for _, status, _ in results if status == "pass") + failed = [(n, d) for n, status, d in results if status == "fail"] + skipped = [(n, d) for n, status, d in results if status == "skip"] + log(f"smoke summary: {passed} passed, {len(failed)} failed, {len(skipped)} skipped (of {len(results)})") + for name, detail in failed: log(f" \u2717 {name} \u2014 {detail}") + for name, detail in skipped: + log(f" \u2298 {name} \u2014 {detail}") # --keep-going asks the suite NOT to error on failure: report and exit 0. diff --git a/script/smoke/abi/IB20Asset.json b/script/smoke/abi/IB20Asset.json deleted file mode 100644 index 1869878..0000000 --- a/script/smoke/abi/IB20Asset.json +++ /dev/null @@ -1,1805 +0,0 @@ -[ - { - "type": "function", - "name": "BURN_BLOCKED_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "BURN_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "DEFAULT_ADMIN_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "DOMAIN_SEPARATOR", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "METADATA_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "MINT_RECEIVER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "MINT_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "OPERATOR_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "PAUSE_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_EXECUTOR_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_RECEIVER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_SENDER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "UNPAUSE_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "WAD_PRECISION", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "allowance", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "announce", - "inputs": [ - { - "name": "internalCalls", - "type": "bytes[]", - "internalType": "bytes[]" - }, - { - "name": "id", - "type": "string", - "internalType": "string" - }, - { - "name": "description", - "type": "string", - "internalType": "string" - }, - { - "name": "uri", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "approve", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "balanceOf", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "batchMint", - "inputs": [ - { - "name": "recipients", - "type": "address[]", - "internalType": "address[]" - }, - { - "name": "amounts", - "type": "uint256[]", - "internalType": "uint256[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "burn", - "inputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "burnBlocked", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "burnWithMemo", - "inputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "contractURI", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "decimals", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint8", - "internalType": "uint8" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "eip712Domain", - "inputs": [], - "outputs": [ - { - "name": "fields", - "type": "bytes1", - "internalType": "bytes1" - }, - { - "name": "name", - "type": "string", - "internalType": "string" - }, - { - "name": "version", - "type": "string", - "internalType": "string" - }, - { - "name": "chainId", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "verifyingContract", - "type": "address", - "internalType": "address" - }, - { - "name": "salt", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "extensions", - "type": "uint256[]", - "internalType": "uint256[]" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "extraMetadata", - "inputs": [ - { - "name": "key", - "type": "string", - "internalType": "string" - } - ], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "getRoleAdmin", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "grantRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "hasRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "isAnnouncementIdUsed", - "inputs": [ - { - "name": "id", - "type": "string", - "internalType": "string" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "isPaused", - "inputs": [ - { - "name": "feature", - "type": "uint8", - "internalType": "enum IB20.PausableFeature" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "mint", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "mintWithMemo", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "multiplier", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "name", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "nonces", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "pause", - "inputs": [ - { - "name": "features", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "pausedFeatures", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "permit", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "value", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "deadline", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "v", - "type": "uint8", - "internalType": "uint8" - }, - { - "name": "r", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "s", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "policyId", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "uint64", - "internalType": "uint64" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "renounceLastAdmin", - "inputs": [], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "renounceRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "callerConfirmation", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "revokeRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "scaledBalanceOf", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "setRoleAdmin", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "newAdminRole", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "supplyCap", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "symbol", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "toRawBalance", - "inputs": [ - { - "name": "scaledBalance", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "rawBalance", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "toScaledBalance", - "inputs": [ - { - "name": "rawBalance", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "totalSupply", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "transfer", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferFrom", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferFromWithMemo", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferWithMemo", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "unpause", - "inputs": [ - { - "name": "features", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateContractURI", - "inputs": [ - { - "name": "newURI", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateExtraMetadata", - "inputs": [ - { - "name": "key", - "type": "string", - "internalType": "string" - }, - { - "name": "value", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateMultiplier", - "inputs": [ - { - "name": "newMultiplier", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateName", - "inputs": [ - { - "name": "newName", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updatePolicy", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "newPolicyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateSupplyCap", - "inputs": [ - { - "name": "newSupplyCap", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateSymbol", - "inputs": [ - { - "name": "newSymbol", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "event", - "name": "Announcement", - "inputs": [ - { - "name": "caller", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "id", - "type": "string", - "indexed": false, - "internalType": "string" - }, - { - "name": "description", - "type": "string", - "indexed": false, - "internalType": "string" - }, - { - "name": "uri", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Approval", - "inputs": [ - { - "name": "owner", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "BurnedBlocked", - "inputs": [ - { - "name": "caller", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "from", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "ContractURIUpdated", - "inputs": [], - "anonymous": false - }, - { - "type": "event", - "name": "EIP712DomainChanged", - "inputs": [], - "anonymous": false - }, - { - "type": "event", - "name": "EndAnnouncement", - "inputs": [ - { - "name": "id", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "ExtraMetadataUpdated", - "inputs": [ - { - "name": "key", - "type": "string", - "indexed": false, - "internalType": "string" - }, - { - "name": "value", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "LastAdminRenounced", - "inputs": [ - { - "name": "previousAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Memo", - "inputs": [ - { - "name": "caller", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "memo", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "MultiplierUpdated", - "inputs": [ - { - "name": "multiplier", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "NameUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newName", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Paused", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "features", - "type": "uint8[]", - "indexed": false, - "internalType": "enum IB20.PausableFeature[]" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "PolicyUpdated", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "oldPolicyId", - "type": "uint64", - "indexed": false, - "internalType": "uint64" - }, - { - "name": "newPolicyId", - "type": "uint64", - "indexed": false, - "internalType": "uint64" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleAdminChanged", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "previousAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "newAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleGranted", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleRevoked", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "SupplyCapUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "oldSupplyCap", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "newSupplyCap", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "SymbolUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newSymbol", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Transfer", - "inputs": [ - { - "name": "from", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Unpaused", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "features", - "type": "uint8[]", - "indexed": false, - "internalType": "enum IB20.PausableFeature[]" - } - ], - "anonymous": false - }, - { - "type": "error", - "name": "AccessControlBadConfirmation", - "inputs": [] - }, - { - "type": "error", - "name": "AccessControlUnauthorizedAccount", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - }, - { - "name": "neededRole", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "AccountNotBlocked", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "AnnouncementIdAlreadyUsed", - "inputs": [ - { - "name": "id", - "type": "string", - "internalType": "string" - } - ] - }, - { - "type": "error", - "name": "AnnouncementInProgress", - "inputs": [] - }, - { - "type": "error", - "name": "ContractPaused", - "inputs": [ - { - "name": "feature", - "type": "uint8", - "internalType": "enum IB20.PausableFeature" - } - ] - }, - { - "type": "error", - "name": "EmptyBatch", - "inputs": [] - }, - { - "type": "error", - "name": "EmptyFeatureSet", - "inputs": [] - }, - { - "type": "error", - "name": "ExpiredSignature", - "inputs": [ - { - "name": "deadline", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InsufficientAllowance", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "allowance", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "needed", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InsufficientBalance", - "inputs": [ - { - "name": "sender", - "type": "address", - "internalType": "address" - }, - { - "name": "balance", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "needed", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InternalCallFailed", - "inputs": [ - { - "name": "call", - "type": "bytes", - "internalType": "bytes" - } - ] - }, - { - "type": "error", - "name": "InternalCallMalformed", - "inputs": [ - { - "name": "call", - "type": "bytes", - "internalType": "bytes" - } - ] - }, - { - "type": "error", - "name": "InvalidAmount", - "inputs": [] - }, - { - "type": "error", - "name": "InvalidApprover", - "inputs": [ - { - "name": "approver", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidMetadataKey", - "inputs": [] - }, - { - "type": "error", - "name": "InvalidReceiver", - "inputs": [ - { - "name": "receiver", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSender", - "inputs": [ - { - "name": "sender", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSigner", - "inputs": [ - { - "name": "signer", - "type": "address", - "internalType": "address" - }, - { - "name": "owner", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSpender", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSupplyCap", - "inputs": [ - { - "name": "currentSupply", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "proposedCap", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "LastAdminCannotRenounce", - "inputs": [] - }, - { - "type": "error", - "name": "LengthMismatch", - "inputs": [ - { - "name": "leftLen", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "rightLen", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "NotSoleAdmin", - "inputs": [] - }, - { - "type": "error", - "name": "PolicyForbids", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ] - }, - { - "type": "error", - "name": "PolicyNotFound", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ] - }, - { - "type": "error", - "name": "SupplyCapExceeded", - "inputs": [ - { - "name": "cap", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "attempted", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "Unauthorized", - "inputs": [] - }, - { - "type": "error", - "name": "UnsupportedPolicyType", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - } - ] - } -] diff --git a/script/smoke/abi/IB20Factory.json b/script/smoke/abi/IB20Factory.json deleted file mode 100644 index aadd691..0000000 --- a/script/smoke/abi/IB20Factory.json +++ /dev/null @@ -1,222 +0,0 @@ -[ - { - "type": "function", - "name": "createB20", - "inputs": [ - { - "name": "variant", - "type": "uint8", - "internalType": "enum IB20Factory.B20Variant" - }, - { - "name": "salt", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "params", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "initCalls", - "type": "bytes[]", - "internalType": "bytes[]" - } - ], - "outputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "getB20Address", - "inputs": [ - { - "name": "variant", - "type": "uint8", - "internalType": "enum IB20Factory.B20Variant" - }, - { - "name": "sender", - "type": "address", - "internalType": "address" - }, - { - "name": "salt", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "isB20", - "inputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "isB20Initialized", - "inputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "event", - "name": "B20Created", - "inputs": [ - { - "name": "token", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "variant", - "type": "uint8", - "indexed": true, - "internalType": "enum IB20Factory.B20Variant" - }, - { - "name": "name", - "type": "string", - "indexed": false, - "internalType": "string" - }, - { - "name": "symbol", - "type": "string", - "indexed": false, - "internalType": "string" - }, - { - "name": "decimals", - "type": "uint8", - "indexed": false, - "internalType": "uint8" - }, - { - "name": "variantEventParams", - "type": "bytes", - "indexed": false, - "internalType": "bytes" - } - ], - "anonymous": false - }, - { - "type": "error", - "name": "InitCallFailed", - "inputs": [ - { - "name": "index", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InvalidCurrency", - "inputs": [ - { - "name": "code", - "type": "string", - "internalType": "string" - } - ] - }, - { - "type": "error", - "name": "InvalidDecimals", - "inputs": [ - { - "name": "decimals", - "type": "uint8", - "internalType": "uint8" - } - ] - }, - { - "type": "error", - "name": "InvalidVariant", - "inputs": [] - }, - { - "type": "error", - "name": "MissingRequiredField", - "inputs": [ - { - "name": "field", - "type": "string", - "internalType": "string" - } - ] - }, - { - "type": "error", - "name": "TokenAlreadyExists", - "inputs": [ - { - "name": "token", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "UnsupportedVersion", - "inputs": [ - { - "name": "version", - "type": "uint8", - "internalType": "uint8" - }, - { - "name": "variant", - "type": "uint8", - "internalType": "enum IB20Factory.B20Variant" - } - ] - } -] diff --git a/script/smoke/abi/IB20Stablecoin.json b/script/smoke/abi/IB20Stablecoin.json deleted file mode 100644 index 28090ca..0000000 --- a/script/smoke/abi/IB20Stablecoin.json +++ /dev/null @@ -1,1467 +0,0 @@ -[ - { - "type": "function", - "name": "BURN_BLOCKED_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "BURN_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "DEFAULT_ADMIN_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "DOMAIN_SEPARATOR", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "METADATA_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "MINT_RECEIVER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "MINT_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "PAUSE_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_EXECUTOR_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_RECEIVER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "TRANSFER_SENDER_POLICY", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "UNPAUSE_ROLE", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "allowance", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "approve", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "balanceOf", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "burn", - "inputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "burnBlocked", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "burnWithMemo", - "inputs": [ - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "contractURI", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "currency", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "decimals", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint8", - "internalType": "uint8" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "eip712Domain", - "inputs": [], - "outputs": [ - { - "name": "fields", - "type": "bytes1", - "internalType": "bytes1" - }, - { - "name": "name", - "type": "string", - "internalType": "string" - }, - { - "name": "version", - "type": "string", - "internalType": "string" - }, - { - "name": "chainId", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "verifyingContract", - "type": "address", - "internalType": "address" - }, - { - "name": "salt", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "extensions", - "type": "uint256[]", - "internalType": "uint256[]" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "getRoleAdmin", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "grantRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "hasRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "isPaused", - "inputs": [ - { - "name": "feature", - "type": "uint8", - "internalType": "enum IB20.PausableFeature" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "mint", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "mintWithMemo", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "name", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "nonces", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "pause", - "inputs": [ - { - "name": "features", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "pausedFeatures", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "permit", - "inputs": [ - { - "name": "owner", - "type": "address", - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "value", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "deadline", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "v", - "type": "uint8", - "internalType": "uint8" - }, - { - "name": "r", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "s", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "policyId", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "uint64", - "internalType": "uint64" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "renounceLastAdmin", - "inputs": [], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "renounceRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "callerConfirmation", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "revokeRole", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "setRoleAdmin", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "newAdminRole", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "supplyCap", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "symbol", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "string", - "internalType": "string" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "totalSupply", - "inputs": [], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "transfer", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferFrom", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferFromWithMemo", - "inputs": [ - { - "name": "from", - "type": "address", - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "transferWithMemo", - "inputs": [ - { - "name": "to", - "type": "address", - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "memo", - "type": "bytes32", - "internalType": "bytes32" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "unpause", - "inputs": [ - { - "name": "features", - "type": "uint8[]", - "internalType": "enum IB20.PausableFeature[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateContractURI", - "inputs": [ - { - "name": "newURI", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateName", - "inputs": [ - { - "name": "newName", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updatePolicy", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "newPolicyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateSupplyCap", - "inputs": [ - { - "name": "newSupplyCap", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateSymbol", - "inputs": [ - { - "name": "newSymbol", - "type": "string", - "internalType": "string" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "event", - "name": "Approval", - "inputs": [ - { - "name": "owner", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "spender", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "BurnedBlocked", - "inputs": [ - { - "name": "caller", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "from", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "ContractURIUpdated", - "inputs": [], - "anonymous": false - }, - { - "type": "event", - "name": "EIP712DomainChanged", - "inputs": [], - "anonymous": false - }, - { - "type": "event", - "name": "LastAdminRenounced", - "inputs": [ - { - "name": "previousAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Memo", - "inputs": [ - { - "name": "caller", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "memo", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "NameUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newName", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Paused", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "features", - "type": "uint8[]", - "indexed": false, - "internalType": "enum IB20.PausableFeature[]" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "PolicyUpdated", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "oldPolicyId", - "type": "uint64", - "indexed": false, - "internalType": "uint64" - }, - { - "name": "newPolicyId", - "type": "uint64", - "indexed": false, - "internalType": "uint64" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleAdminChanged", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "previousAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "newAdminRole", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleGranted", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "RoleRevoked", - "inputs": [ - { - "name": "role", - "type": "bytes32", - "indexed": true, - "internalType": "bytes32" - }, - { - "name": "account", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "sender", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "SupplyCapUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "oldSupplyCap", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - }, - { - "name": "newSupplyCap", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "SymbolUpdated", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newSymbol", - "type": "string", - "indexed": false, - "internalType": "string" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Transfer", - "inputs": [ - { - "name": "from", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "to", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "amount", - "type": "uint256", - "indexed": false, - "internalType": "uint256" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "Unpaused", - "inputs": [ - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "features", - "type": "uint8[]", - "indexed": false, - "internalType": "enum IB20.PausableFeature[]" - } - ], - "anonymous": false - }, - { - "type": "error", - "name": "AccessControlBadConfirmation", - "inputs": [] - }, - { - "type": "error", - "name": "AccessControlUnauthorizedAccount", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - }, - { - "name": "neededRole", - "type": "bytes32", - "internalType": "bytes32" - } - ] - }, - { - "type": "error", - "name": "AccountNotBlocked", - "inputs": [ - { - "name": "account", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "ContractPaused", - "inputs": [ - { - "name": "feature", - "type": "uint8", - "internalType": "enum IB20.PausableFeature" - } - ] - }, - { - "type": "error", - "name": "EmptyFeatureSet", - "inputs": [] - }, - { - "type": "error", - "name": "ExpiredSignature", - "inputs": [ - { - "name": "deadline", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InsufficientAllowance", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - }, - { - "name": "allowance", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "needed", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InsufficientBalance", - "inputs": [ - { - "name": "sender", - "type": "address", - "internalType": "address" - }, - { - "name": "balance", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "needed", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "InvalidAmount", - "inputs": [] - }, - { - "type": "error", - "name": "InvalidApprover", - "inputs": [ - { - "name": "approver", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidReceiver", - "inputs": [ - { - "name": "receiver", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSender", - "inputs": [ - { - "name": "sender", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSigner", - "inputs": [ - { - "name": "signer", - "type": "address", - "internalType": "address" - }, - { - "name": "owner", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSpender", - "inputs": [ - { - "name": "spender", - "type": "address", - "internalType": "address" - } - ] - }, - { - "type": "error", - "name": "InvalidSupplyCap", - "inputs": [ - { - "name": "currentSupply", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "proposedCap", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "LastAdminCannotRenounce", - "inputs": [] - }, - { - "type": "error", - "name": "NotSoleAdmin", - "inputs": [] - }, - { - "type": "error", - "name": "PolicyForbids", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - }, - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ] - }, - { - "type": "error", - "name": "PolicyNotFound", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ] - }, - { - "type": "error", - "name": "SupplyCapExceeded", - "inputs": [ - { - "name": "cap", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "attempted", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "Unauthorized", - "inputs": [] - }, - { - "type": "error", - "name": "UnsupportedPolicyType", - "inputs": [ - { - "name": "policyScope", - "type": "bytes32", - "internalType": "bytes32" - } - ] - } -] diff --git a/script/smoke/abi/IPolicyRegistry.json b/script/smoke/abi/IPolicyRegistry.json deleted file mode 100644 index 405985e..0000000 --- a/script/smoke/abi/IPolicyRegistry.json +++ /dev/null @@ -1,399 +0,0 @@ -[ - { - "type": "function", - "name": "createPolicy", - "inputs": [ - { - "name": "admin", - "type": "address", - "internalType": "address" - }, - { - "name": "policyType", - "type": "uint8", - "internalType": "enum IPolicyRegistry.PolicyType" - } - ], - "outputs": [ - { - "name": "newPolicyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "createPolicyWithAccounts", - "inputs": [ - { - "name": "admin", - "type": "address", - "internalType": "address" - }, - { - "name": "policyType", - "type": "uint8", - "internalType": "enum IPolicyRegistry.PolicyType" - }, - { - "name": "accounts", - "type": "address[]", - "internalType": "address[]" - } - ], - "outputs": [ - { - "name": "newPolicyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "finalizeUpdateAdmin", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "isAuthorized", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "account", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "pendingPolicyAdmin", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "policyAdmin", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "policyExists", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, - { - "type": "function", - "name": "renounceAdmin", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "stageUpdateAdmin", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "newAdmin", - "type": "address", - "internalType": "address" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateAllowlist", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "allowed", - "type": "bool", - "internalType": "bool" - }, - { - "name": "accounts", - "type": "address[]", - "internalType": "address[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "updateBlocklist", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "internalType": "uint64" - }, - { - "name": "blocked", - "type": "bool", - "internalType": "bool" - }, - { - "name": "accounts", - "type": "address[]", - "internalType": "address[]" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "event", - "name": "AllowlistUpdated", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "indexed": true, - "internalType": "uint64" - }, - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "allowed", - "type": "bool", - "indexed": false, - "internalType": "bool" - }, - { - "name": "accounts", - "type": "address[]", - "indexed": false, - "internalType": "address[]" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "BlocklistUpdated", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "indexed": true, - "internalType": "uint64" - }, - { - "name": "updater", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "blocked", - "type": "bool", - "indexed": false, - "internalType": "bool" - }, - { - "name": "accounts", - "type": "address[]", - "indexed": false, - "internalType": "address[]" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "PolicyAdminStaged", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "indexed": true, - "internalType": "uint64" - }, - { - "name": "currentAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "pendingAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "PolicyAdminUpdated", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "indexed": true, - "internalType": "uint64" - }, - { - "name": "previousAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "newAdmin", - "type": "address", - "indexed": true, - "internalType": "address" - } - ], - "anonymous": false - }, - { - "type": "event", - "name": "PolicyCreated", - "inputs": [ - { - "name": "policyId", - "type": "uint64", - "indexed": true, - "internalType": "uint64" - }, - { - "name": "creator", - "type": "address", - "indexed": true, - "internalType": "address" - }, - { - "name": "policyType", - "type": "uint8", - "indexed": false, - "internalType": "enum IPolicyRegistry.PolicyType" - } - ], - "anonymous": false - }, - { - "type": "error", - "name": "BatchSizeTooLarge", - "inputs": [ - { - "name": "maxBatchSize", - "type": "uint256", - "internalType": "uint256" - } - ] - }, - { - "type": "error", - "name": "IncompatiblePolicyType", - "inputs": [] - }, - { - "type": "error", - "name": "NoPendingAdmin", - "inputs": [] - }, - { - "type": "error", - "name": "PolicyNotFound", - "inputs": [] - }, - { - "type": "error", - "name": "Unauthorized", - "inputs": [] - }, - { - "type": "error", - "name": "ZeroAddress", - "inputs": [] - } -] diff --git a/script/smoke/abi/PrecompileProbe.json b/script/smoke/abi/PrecompileProbe.json deleted file mode 100644 index a279f4c..0000000 --- a/script/smoke/abi/PrecompileProbe.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "abi": [ - { - "type": "receive", - "stateMutability": "payable" - }, - { - "type": "function", - "name": "callThenRevert", - "inputs": [ - { - "name": "target", - "type": "address", - "internalType": "address" - }, - { - "name": "data", - "type": "bytes", - "internalType": "bytes" - } - ], - "outputs": [], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "probeCall", - "inputs": [ - { - "name": "target", - "type": "address", - "internalType": "address" - }, - { - "name": "data", - "type": "bytes", - "internalType": "bytes" - } - ], - "outputs": [ - { - "name": "r", - "type": "tuple", - "internalType": "struct PrecompileProbe.Result", - "components": [ - { - "name": "ok", - "type": "bool", - "internalType": "bool" - }, - { - "name": "ret", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "gasUsed", - "type": "uint256", - "internalType": "uint256" - } - ] - } - ], - "stateMutability": "payable" - }, - { - "type": "function", - "name": "probeCallWithGas", - "inputs": [ - { - "name": "target", - "type": "address", - "internalType": "address" - }, - { - "name": "data", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "gasAmount", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "r", - "type": "tuple", - "internalType": "struct PrecompileProbe.Result", - "components": [ - { - "name": "ok", - "type": "bool", - "internalType": "bool" - }, - { - "name": "ret", - "type": "bytes", - "internalType": "bytes" - }, - { - "name": "gasUsed", - "type": "uint256", - "internalType": "uint256" - } - ] - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "probeReturndata", - "inputs": [ - { - "name": "target", - "type": "address", - "internalType": "address" - }, - { - "name": "data", - "type": "bytes", - "internalType": "bytes" - } - ], - "outputs": [ - { - "name": "ok", - "type": "bool", - "internalType": "bool" - }, - { - "name": "raw", - "type": "bytes", - "internalType": "bytes" - } - ], - "stateMutability": "nonpayable" - }, - { - "type": "function", - "name": "probeStaticcall", - "inputs": [ - { - "name": "target", - "type": "address", - "internalType": "address" - }, - { - "name": "data", - "type": "bytes", - "internalType": "bytes" - } - ], - "outputs": [ - { - "name": "ok", - "type": "bool", - "internalType": "bool" - }, - { - "name": "ret", - "type": "bytes", - "internalType": "bytes" - } - ], - "stateMutability": "view" - } - ], - "bytecode": "0x6080604052348015600e575f5ffd5b506106768061001c5f395ff3fe60806040526004361061004c575f3560e01c806329633d26146100575780634cf9256e1461008c5780638ee52744146100b957806399776c77146100da578063c5a29eb8146100f9575f5ffd5b3661005357005b5f5ffd5b348015610062575f5ffd5b506100766100713660046104da565b61010c565b604051610083919061055e565b60405180910390f35b348015610097575f5ffd5b506100ab6100a636600461059b565b6101c7565b6040516100839291906105ea565b3480156100c4575f5ffd5b506100d86100d336600461059b565b61022e565b005b3480156100e5575f5ffd5b506100ab6100f436600461059b565b61033e565b61007661010736600461059b565b6103c0565b61013060405180606001604052805f15158152602001606081526020015f81525090565b5f5a90505f5f876001600160a01b031685888860405161015192919061060c565b5f604051808303815f8787f1925050503d805f811461018b576040519150601f19603f3d011682016040523d82523d5f602084013e610190565b606091505b5091509150604051806060016040528083151581526020018281526020015a6101b9908661061b565b905298975050505050505050565b5f6060846001600160a01b031684846040516101e492919061060c565b5f60405180830381855afa9150503d805f811461021c576040519150601f19603f3d011682016040523d82523d5f602084013e610221565b606091505b5090969095509350505050565b5f836001600160a01b0316838360405161024992919061060c565b5f604051808303815f865af19150503d805f8114610282576040519150601f19603f3d011682016040523d82523d5f602084013e610287565b606091505b50509050806102e85760405162461bcd60e51b815260206004820152602260248201527f507265636f6d70696c6550726f62653a20696e6e65722063616c6c206661696c604482015261195960f21b60648201526084015b60405180910390fd5b60405162461bcd60e51b815260206004820152602560248201527f507265636f6d70696c6550726f62653a20696e74656e74696f6e616c20726f6c6044820152646c6261636b60d81b60648201526084016102df565b5f6060846001600160a01b0316848460405161035b92919061060c565b5f604051808303815f865af19150503d805f8114610394576040519150601f19603f3d011682016040523d82523d5f602084013e610399565b606091505b50506040513d8082529193509150805f602084013e80602083010160405250935093915050565b6103e460405180606001604052805f15158152602001606081526020015f81525090565b5f5a90505f5f866001600160a01b031634878760405161040592919061060c565b5f6040518083038185875af1925050503d805f811461043f576040519150601f19603f3d011682016040523d82523d5f602084013e610444565b606091505b5091509150604051806060016040528083151581526020018281526020015a61046d908661061b565b9052979650505050505050565b80356001600160a01b0381168114610490575f5ffd5b919050565b5f5f83601f8401126104a5575f5ffd5b50813567ffffffffffffffff8111156104bc575f5ffd5b6020830191508360208285010111156104d3575f5ffd5b9250929050565b5f5f5f5f606085870312156104ed575f5ffd5b6104f68561047a565b9350602085013567ffffffffffffffff811115610511575f5ffd5b61051d87828801610495565b9598909750949560400135949350505050565b5f81518084528060208401602086015e5f602082860101526020601f19601f83011685010191505092915050565b602081528151151560208201525f6020830151606060408401526105856080840182610530565b9050604084015160608401528091505092915050565b5f5f5f604084860312156105ad575f5ffd5b6105b68461047a565b9250602084013567ffffffffffffffff8111156105d1575f5ffd5b6105dd86828701610495565b9497909650939450505050565b8215158152604060208201525f6106046040830184610530565b949350505050565b818382375f9101908152919050565b8181038181111561063a57634e487b7160e01b5f52601160045260245ffd5b9291505056fea2646970667358221220aafa0217efc08c07f56ba187b94fefbcf1a45b8ace25faad6c1a586caecfba4264736f6c634300081e0033" -} diff --git a/script/smoke/abis.py b/script/smoke/abis.py index ca10237..f65fe32 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -1,8 +1,10 @@ -"""Committed interface ABIs (extracted from forge `out/`). +"""Interface ABIs loaded straight from the Foundry build output (`out/`). These are the strict contract surface the harness binds to via plain web3 -(`w3.eth.contract(abi=...)`). They change only when the interfaces change; -regenerate with `make smoke-bindings`. +(`w3.eth.contract(abi=...)`). They are read from the compiled artifacts under +`out/`, so they always match the current source — run `forge build` first (the +smoke Make targets do this for you). The `out/` tree is gitignored; nothing +here is committed or copied by hand. """ from __future__ import annotations @@ -11,11 +13,23 @@ from pathlib import Path from typing import Any -_DIR = Path(__file__).parent / "abi" +# abis.py -> smoke -> script -> project root, where forge writes `out/`. +_OUT = Path(__file__).resolve().parents[2] / "out" + + +def _artifact(name: str) -> dict[str, Any]: + """Load the forge artifact for `.sol/.json` (errors if unbuilt).""" + path = _OUT / f"{name}.sol" / f"{name}.json" + if not path.exists(): + raise SystemExit( + f"[smoke] ERROR: {path} missing; run `forge build` first " + "(the smoke Make targets build automatically)" + ) + return json.loads(path.read_text()) def _load(name: str) -> list[dict[str, Any]]: - return json.loads((_DIR / f"{name}.json").read_text()) + return _artifact(name)["abi"] FACTORY_ABI = _load("IB20Factory") @@ -27,13 +41,10 @@ def _load(name: str) -> list[dict[str, Any]]: def probe_artifact() -> tuple[list[dict[str, Any]], str]: - """abi + creation bytecode for PrecompileProbe (emitted by `make smoke-bindings`). + """abi + creation bytecode for PrecompileProbe (compiled into `out/`). The probe is the one helper the harness deploys, so unlike the interface ABIs it needs bytecode - too. The artifact is regenerated from forge `out/` rather than committed by hand. + too. Read straight from the forge artifact rather than a committed copy. """ - path = _DIR / "PrecompileProbe.json" - if not path.exists(): - raise SystemExit("[smoke] ERROR: script/smoke/abi/PrecompileProbe.json missing; run `make smoke-bindings`") - art = json.loads(path.read_text()) - return art["abi"], art["bytecode"] + art = _artifact("PrecompileProbe") + return art["abi"], art["bytecode"]["object"] diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 8226a18..439cff0 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -1,6 +1,6 @@ """Chain harness: provider, signers, send/read, revert + event assertions. -Wraps web3 + the committed interface ABIs so journeys read like the contract +Wraps web3 + the interface ABIs (read from forge `out/`) so journeys read like the contract API. Every mutating call goes through `send`, which signs, broadcasts to the live node, waits for the receipt, asserts success, and records it for the flow-level `assert_events_emitted` check. Reads and expected-revert simulations @@ -11,6 +11,9 @@ import json import sys +import time +import urllib.error +import urllib.request from eth_account import Account from eth_account.signers.local import LocalAccount @@ -114,6 +117,82 @@ def fund_user2(self) -> None: self._user2_funded = True ok("user2 funded") + # ── activation preflight ────────────────────────────────────────────────── + def features_activated(self) -> tuple[bool, str]: + """Check the b20 features are switched on via the ActivationRegistry (the authoritative gate). + + The b20/policy precompiles are installed at fork >= Beryl and each feature is individually gated by + the ActivationRegistry. `isActivated(bytes32)` is a never-revert view returning a bool. If the + registry itself isn't installed (fork < Beryl) the call falls through to account state — an empty + account returns `0x`, stub bytecode hits an invalid opcode — which we report as "registry not + installed". A clean `false` means the feature exists but isn't activated. In any of these cases the + invariant/lifecycle checks would be testing environment state, not contract logic, so the caller + skips the journey instead of reporting findings. Returns (active, reason-if-not). + """ + selector = bytes(Web3.keccak(text="isActivated(bytes32)")[:4]) + for label, feature in ( + ("base.b20_asset", config.FEATURE_B20_ASSET), + ("base.b20_stablecoin", config.FEATURE_B20_STABLECOIN), + ("base.policy_registry", config.FEATURE_POLICY_REGISTRY), + ): + try: + ret = bytes( + self.w3.eth.call( + {"to": config.ACTIVATION_REGISTRY, "from": self.DEPLOYER, "data": HexBytes(selector + bytes(feature))} + ) + ) + except Exception as exc: # noqa: BLE001 - reverting view => registry not intercepting + return False, f"ActivationRegistry not installed (isActivated errored: {type(exc).__name__}) — fork < Beryl?" + if len(ret) != 32: + return False, f"ActivationRegistry not installed (isActivated returned {len(ret)} bytes) — fork < Beryl?" + if int.from_bytes(ret, "big") == 0: + return False, f"feature '{label}' is not activated on this chain" + return True, "" + + # ── faucet preflight ────────────────────────────────────────────────────── + def ensure_deployer_funded(self) -> None: + """Top up the deployer via the configured faucet if its balance is below the floor. + + Internal dev chains (e.g. base-zeronet) are periodically nuked, which wipes the deployer's + balance. This is opt-in and idempotent: it checks the balance first and only calls the faucet + when underfunded, so it is a no-op on a persistently funded chain. URL + network come from + `.env` (`FAUCET_URL`, `FAUCET_NETWORK`); amount and floor default but are overridable. + """ + bal = self.w3.eth.get_balance(self.DEPLOYER) + if bal >= self.cfg.faucet_min_wei: + return + if not (self.cfg.faucet_url and self.cfg.faucet_network): + die( + f"deployer {self.DEPLOYER} underfunded ({bal} wei < {self.cfg.faucet_min_wei}) and no " + "faucet configured (set FAUCET_URL + FAUCET_NETWORK in .env)" + ) + step("faucet", f"deployer balance {bal} wei < floor; requesting {self.cfg.faucet_amount} ETH") + self._request_faucet(self.DEPLOYER, self.cfg.faucet_amount) + deadline = time.time() + 60 + while time.time() < deadline: + bal = self.w3.eth.get_balance(self.DEPLOYER) + if bal >= self.cfg.faucet_min_wei: + ok(f"deployer funded: {bal} wei") + return + time.sleep(2) + die(f"deployer still underfunded after faucet request ({bal} wei < {self.cfg.faucet_min_wei})") + + def _request_faucet(self, address: ChecksumAddress, amount: str) -> None: + """POST the faucet a top-up request for `address`. Blocks until the HTTP call returns.""" + payload = json.dumps( + {"network": self.cfg.faucet_network, "token": "eth", "amount": amount, "address": address} + ).encode() + req = urllib.request.Request( + self.cfg.faucet_url, data=payload, headers={"content-type": "application/json"}, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + log(f"faucet HTTP {resp.status}: {resp.read(500).decode('utf-8', 'replace')}") + except urllib.error.HTTPError as exc: + die(f"faucet request failed: HTTP {exc.code} {exc.read(500).decode('utf-8', 'replace')}") + except Exception as exc: # noqa: BLE001 - network/timeout; surface clearly + die(f"faucet request error: {type(exc).__name__}: {exc}") + # ── assertions ─────────────────────────────────────────────────────────── def assert_eq( self, diff --git a/script/smoke/config.py b/script/smoke/config.py index c5bf95d..33a7b33 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -17,6 +17,13 @@ # Precompile addresses (from StdPrecompiles.sol — public, stable singletons). B20_FACTORY: ChecksumAddress = Web3.to_checksum_address("0xB20f000000000000000000000000000000000000") POLICY_REGISTRY: ChecksumAddress = Web3.to_checksum_address("0x8453000000000000000000000000000000000002") +ACTIVATION_REGISTRY: ChecksumAddress = Web3.to_checksum_address("0x8453000000000000000000000000000000000001") + +# Feature ids gating the b20 precompiles, queried via ActivationRegistry.isActivated (the authoritative +# activation gate). Names mirror test/lib/mocks/ActivationRegistryFeatureList.sol. +FEATURE_B20_ASSET = Web3.keccak(text="base.b20_asset") +FEATURE_B20_STABLECOIN = Web3.keccak(text="base.b20_stablecoin") +FEATURE_POLICY_REGISTRY = Web3.keccak(text="base.policy_registry") ZERO: ChecksumAddress = Web3.to_checksum_address("0x" + "00" * 20) @@ -78,6 +85,10 @@ class Config: run_nonce: str salt_pinned: bool trace: bool + faucet_url: str + faucet_network: str + faucet_amount: str + faucet_min_wei: int @classmethod def from_env(cls) -> "Config": @@ -92,6 +103,9 @@ def need(key: str) -> str: # Failure diagnostics emit a debug_traceCall/Transaction call tree. On by default (only fires on # failures); set SMOKE_TRACE=0 to print just the request + replayed revert data instead. trace = os.environ.get("SMOKE_TRACE", "1").strip().lower() not in ("0", "false", "off", "no", "") + # Optional faucet top-up for the deployer (internal dev chains get nuked, wiping its balance). + # Host/network stay in .env (gitignored) so no internal reference lands in committed code. Funding + # only fires when the balance is below FAUCET_MIN_ETHER and both URL + network are set. return cls( rpc_url=need("RPC_URL"), deployer_pk=need("DEPLOYER_PK"), @@ -100,6 +114,10 @@ def need(key: str) -> str: run_nonce=pinned or secrets.token_hex(16), salt_pinned=pinned is not None, trace=trace, + faucet_url=os.environ.get("FAUCET_URL", "").strip(), + faucet_network=os.environ.get("FAUCET_NETWORK", "").strip(), + faucet_amount=os.environ.get("FAUCET_AMOUNT", "0.05").strip(), + faucet_min_wei=Web3.to_wei(os.environ.get("FAUCET_MIN_ETHER", "0.02"), "ether"), ) def salt_for(self, journey: str) -> bytes: diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 15892ab..a6f3dca 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -115,10 +115,19 @@ def _edges(c: Chain, tok) -> None: c.send(tok.functions.updateSupplyCap(total), c.deployer) c.expect_revert("SupplyCapExceeded", tok.functions.mint(c.ALICE, 1), c.DEPLOYER) - step(12, "pause TRANSFER: transfer reverts ContractPaused; unpause restores") + step("11b", "transfer insufficient balance -> InsufficientBalance (user2 holds 0 tokens)") + c.expect_revert("InsufficientBalance", tok.functions.transfer(c.BOB, config.amt(1, 18)), c.USER2) + + step("11c", "transferFrom insufficient allowance -> InsufficientAllowance (allowance consumed in step 5)") + c.expect_revert("InsufficientAllowance", tok.functions.transferFrom(c.DEPLOYER, c.BOB, config.amt(1, 18)), c.USER2) + + step(12, "pause TRANSFER: transfer AND transferFrom revert ContractPaused; unpause restores") + # Approve user2 first so transferFrom clears the allowance check and the pause gate is the binding revert. + c.send(tok.functions.approve(c.USER2, config.amt(5, 18)), c.deployer) c.send(tok.functions.pause([config.FEATURE_TRANSFER]), c.deployer) c.assert_eq(tok.functions.isPaused(config.FEATURE_TRANSFER).call(), True, "TRANSFER paused") c.expect_revert("ContractPaused", tok.functions.transfer(c.BOB, 1), c.DEPLOYER) + c.expect_revert("ContractPaused", tok.functions.transferFrom(c.DEPLOYER, c.BOB, config.amt(1, 18)), c.USER2) c.send(tok.functions.unpause([config.FEATURE_TRANSFER]), c.deployer) c.assert_eq(tok.functions.isPaused(config.FEATURE_TRANSFER).call(), False, "TRANSFER unpaused") c.send(tok.functions.transfer(c.BOB, config.amt(1, 18)), c.deployer) diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index ce787c1..f692260 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -28,6 +28,7 @@ from .. import config from ..abis import probe_artifact from ..chain import Chain, log, ok, step +from ..codec import AssetCreateParams, init_call FACTORY = config.B20_FACTORY POLICY = config.POLICY_REGISTRY @@ -126,13 +127,27 @@ def _oog_contained(c: Chain, probe) -> None: def _atomicity(c: Chain, probe) -> None: - # Single-writer assumption: the smoke chain is driven by one deployer, so policy ids are sequential - # within this run. A reverted creation in between must NOT consume an id. - create_data = _clean(c.policy, "createPolicy", c.DEPLOYER, ALLOWLIST) - id1 = c.create_policy(c.DEPLOYER, ALLOWLIST) - c.send_expecting_revert(probe.functions.callThenRevert(POLICY, create_data), c.deployer) - id2 = c.create_policy(c.DEPLOYER, ALLOWLIST) - c.assert_eq(id2 - id1, 1, "reverted createPolicy consumed no policy id") + # A reverted mint must leave totalSupply/balances untouched AND commit no Transfer log. Deploy a + # token, grant the probe MINT_ROLE (bootstrap window bypasses the role gate), then have the probe + # mint-then-revert in a single tx and assert nothing persisted. + salt = c.cfg.salt_for("invariants-atomicity") + params = AssetCreateParams("Atomic", "ATOM", c.DEPLOYER, config.ASSET_DECIMALS).encode() + tok_addr = c.predict_b20(config.VARIANT_ASSET, salt) + c.create_b20( + config.VARIANT_ASSET, salt, params, [init_call(c.asset_abi, "grantRole", config.MINT_ROLE, probe.address)] + ) + tok = c.asset_at(tok_addr) + + supply_before = tok.functions.totalSupply().call() + alice_before = tok.functions.balanceOf(c.ALICE).call() + mint_data = init_call(c.asset_abi, "mint", c.ALICE, config.amt(1000, 18)) + receipt = c.send_expecting_revert(probe.functions.callThenRevert(tok_addr, mint_data), c.deployer) + + transfer_topic = HexBytes(Web3.keccak(text="Transfer(address,address,uint256)")) + committed = any(lg["topics"] and HexBytes(lg["topics"][0]) == transfer_topic for lg in receipt["logs"]) + c.assert_eq(committed, False, "reverted mint committed no Transfer log") + c.assert_eq(tok.functions.totalSupply().call(), supply_before, "totalSupply unchanged after reverted mint") + c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), alice_before, "alice balance unchanged after reverted mint") # Ordered audit checklist: (name, fn). `name` doubles as the INFORMATIONAL downgrade key. @@ -147,7 +162,7 @@ def _atomicity(c: Chain, probe) -> None: ("value forwarding rejected through a contract", _value_forwarding_rejected), ("returndata fidelity (RETURNDATACOPY of revert payload)", _returndata_fidelity), ("OOG contained to sub-call", _oog_contained), - ("revert atomicity (no committed state on rollback)", _atomicity), + ("revert atomicity (reverted mint: no Transfer log, supply/balance unchanged)", _atomicity), ] From 2df28b95305934d96af2fa04788f3b2f3bcf19e0 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 9 Jun 2026 15:21:50 -0700 Subject: [PATCH 5/8] add selfdestruct smoke test --- .env.template | 16 +++++++++ .gitignore | 1 + script/smoke/abis.py | 10 ++++++ script/smoke/chain.py | 18 +++++++--- .../smoke/journeys/precompile_invariants.py | 35 ++++++++++++++++++- test/lib/ForceFeeder.sol | 23 ++++++++++++ 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 .env.template create mode 100644 test/lib/ForceFeeder.sol diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..3165da5 --- /dev/null +++ b/.env.template @@ -0,0 +1,16 @@ +# Copy to .env (gitignored) and fill in. The Makefile sources .env for the +# smoke recipes; script/smoke/config.py reads these values. + +# --- Required --- +# JSON-RPC endpoint the smoketest sends real txs to. +RPC_URL="" +# Funded deployer key (signs all setup/admin txs). +DEPLOYER_PK="" +# Second actor key (recipient / non-admin paths). +USER2_PK="" + +# --- Optional: deployer faucet top-up --- +# When both are set, the deployer is funded if its balance is below +# FAUCET_MIN_ETHER (default 0.02). Leave blank to disable. +FAUCET_URL="" +FAUCET_NETWORK="" diff --git a/.gitignore b/.gitignore index fdfdf16..0cfbae5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ broadcast/ .env .env.* !.env.example +!.env.template # Python smoketest (script/smoke): venv + bytecode caches. Interface ABIs are # read from the (gitignored) forge `out/` dir at runtime, so nothing under diff --git a/script/smoke/abis.py b/script/smoke/abis.py index f65fe32..220c551 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -48,3 +48,13 @@ def probe_artifact() -> tuple[list[dict[str, Any]], str]: """ art = _artifact("PrecompileProbe") return art["abi"], art["bytecode"]["object"] + + +def forcefeeder_artifact() -> tuple[list[dict[str, Any]], str]: + """abi + creation bytecode for ForceFeeder (compiled into `out/`). + + Deployed with a non-zero `value` to SELFDESTRUCT its balance into a target address — the + unblockable ether push used by the force-fed-ether invariant check. + """ + art = _artifact("ForceFeeder") + return art["abi"], art["bytecode"]["object"] diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 439cff0..e71cb6d 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -257,13 +257,21 @@ def assert_events_emitted(self, desc: str, *signatures: str) -> None: ok(f"{desc} ({len(signatures)} event type{'s' if len(signatures) != 1 else ''} confirmed emitted)") # ── deploy / raw low-level calls ────────────────────────────────────────── - def deploy(self, abi: list, bytecode: str, *args, account: LocalAccount | None = None) -> Contract: - """Deploy a contract from abi+bytecode and return a bound handle (used for the probe).""" + def deploy( + self, abi: list, bytecode: str, *args, account: LocalAccount | None = None, value: int = 0 + ) -> Contract: + """Deploy a contract from abi+bytecode and return a bound handle (used for the probe). + + `value` attaches wei to the constructor (e.g. for a payable force-feeder that self-destructs + its balance into a target). The returned handle points at the deployed address even if the + constructor leaves no code there. + """ account = account or self.deployer factory = self.w3.eth.contract(abi=abi, bytecode=bytecode) - tx = factory.constructor(*args).build_transaction( - {"from": account.address, "nonce": self.w3.eth.get_transaction_count(account.address)} - ) + overrides = {"from": account.address, "nonce": self.w3.eth.get_transaction_count(account.address)} + if value: + overrides["value"] = value + tx = factory.constructor(*args).build_transaction(overrides) signed = account.sign_transaction(tx) receipt = self.w3.eth.wait_for_transaction_receipt(self.w3.eth.send_raw_transaction(signed.raw_transaction)) if receipt["status"] != 1 or not receipt.get("contractAddress"): diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index f692260..890d5d8 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -26,7 +26,7 @@ from web3 import Web3 from .. import config -from ..abis import probe_artifact +from ..abis import forcefeeder_artifact, probe_artifact from ..chain import Chain, log, ok, step from ..codec import AssetCreateParams, init_call @@ -150,6 +150,38 @@ def _atomicity(c: Chain, probe) -> None: c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), alice_before, "alice balance unchanged after reverted mint") +def _create_gas_independent_of_prefunded_balance(c: Chain, _probe) -> None: + # Finding (b20-precompile-selfdestruct-audit.md): the factory decides "already deployed?" on + # code-hash only, but set_code decides whether to charge the CREATE + EIP-8037 state-expansion + # gas via AccountInfo::is_empty(), which is false the moment an address holds any balance. So a + # token address that was force-fed ether (SELFDESTRUCT / coinbase / genesis — paths a callvalue + # guard cannot block) still creates, but skips the state gas the network is owed. Desired + # invariant: createB20 gas is a function of its calldata, NOT of the target's pre-existing balance. + # + # Two identical creations (same calldata, fresh disjoint addresses) so the only variable is whether + # the target was pre-funded. If pre-funding makes creation cheaper, that's the divergence. + params = AssetCreateParams("GasProbe", "GASP", c.DEPLOYER, config.ASSET_DECIMALS).encode() + + salt_ctrl = c.cfg.salt_for("invariants-gas-control") + gas_unfunded = c.create_b20(config.VARIANT_ASSET, salt_ctrl, params, [])["gasUsed"] + + salt_fed = c.cfg.salt_for("invariants-gas-prefunded") + target = c.predict_b20(config.VARIANT_ASSET, salt_fed) + abi, bytecode = forcefeeder_artifact() + c.deploy(abi, bytecode, target, value=1) # SELFDESTRUCT 1 wei into the predicted token address + fed = c.w3.eth.get_balance(target) + c.assert_eq(fed >= 1, True, f"force-fed ether landed at predicted token address ({fed} wei @ {target})") + + gas_prefunded = c.create_b20(config.VARIANT_ASSET, salt_fed, params, [])["gasUsed"] + + c.assert_eq( + gas_prefunded < gas_unfunded, + False, + f"createB20 gas independent of target's prefunded balance " + f"(unfunded={gas_unfunded}, prefunded={gas_prefunded}, discount={gas_unfunded - gas_prefunded})", + ) + + # 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), @@ -163,6 +195,7 @@ def _atomicity(c: Chain, probe) -> None: ("returndata fidelity (RETURNDATACOPY of revert payload)", _returndata_fidelity), ("OOG contained to sub-call", _oog_contained), ("revert atomicity (reverted mint: no Transfer log, supply/balance unchanged)", _atomicity), + ("createB20 gas independent of force-fed target balance (SELFDESTRUCT)", _create_gas_independent_of_prefunded_balance), ] diff --git a/test/lib/ForceFeeder.sol b/test/lib/ForceFeeder.sol new file mode 100644 index 0000000..5b3f67c --- /dev/null +++ b/test/lib/ForceFeeder.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ForceFeeder +/// +/// @notice Smoke-test helper that force-credits ether to an arbitrary address via SELFDESTRUCT — the +/// one ether transfer the receiver cannot observe or refuse. Used by the `precompile_invariants` +/// journey to push wei into a b20 precompile / token address that exposes no payable entrypoint, +/// reproducing the force-fed-ether conditions audited in `b20-precompile-selfdestruct-audit.md`. +/// +/// @dev Test-only; never part of any production deployment. The contract is created and self-destructed +/// within the same transaction, so under EIP-6780 the account is both emptied and removed while its +/// balance is still forwarded to `target`. Deploy with a non-zero `value` equal to the wei to feed; +/// no code remains at the deployed address afterward. +contract ForceFeeder { + /// @param target Recipient force-fed this contract's entire balance. + constructor(address target) payable { + assembly { + // SELFDESTRUCT: forward the full balance to `target`, unconditionally and uninterceptably. + selfdestruct(target) + } + } +} From 8d9979e96bfa92a80b49e63e2babf6b5712c1793 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 9 Jun 2026 15:23:09 -0700 Subject: [PATCH 6/8] fix comment --- script/smoke/journeys/precompile_invariants.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/script/smoke/journeys/precompile_invariants.py b/script/smoke/journeys/precompile_invariants.py index 890d5d8..f0aff34 100644 --- a/script/smoke/journeys/precompile_invariants.py +++ b/script/smoke/journeys/precompile_invariants.py @@ -151,15 +151,6 @@ def _atomicity(c: Chain, probe) -> None: def _create_gas_independent_of_prefunded_balance(c: Chain, _probe) -> None: - # Finding (b20-precompile-selfdestruct-audit.md): the factory decides "already deployed?" on - # code-hash only, but set_code decides whether to charge the CREATE + EIP-8037 state-expansion - # gas via AccountInfo::is_empty(), which is false the moment an address holds any balance. So a - # token address that was force-fed ether (SELFDESTRUCT / coinbase / genesis — paths a callvalue - # guard cannot block) still creates, but skips the state gas the network is owed. Desired - # invariant: createB20 gas is a function of its calldata, NOT of the target's pre-existing balance. - # - # Two identical creations (same calldata, fresh disjoint addresses) so the only variable is whether - # the target was pre-funded. If pre-funding makes creation cheaper, that's the divergence. params = AssetCreateParams("GasProbe", "GASP", c.DEPLOYER, config.ASSET_DECIMALS).encode() salt_ctrl = c.cfg.salt_for("invariants-gas-control") From 898b7382d9c3a3cf385e6472c6294a4c726769f2 Mon Sep 17 00:00:00 2001 From: Amie Date: Wed, 10 Jun 2026 11:25:30 -0700 Subject: [PATCH 7/8] docs(smoke): README for the b20 precompile smoketest (#155) * docs(smoke): add quickstart runner + README for local b20 smoketests * docs(smoke): drop local anvil quickstart; document RPC-against-a-real-node flow --- script/smoke/README.md | 139 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 script/smoke/README.md diff --git a/script/smoke/README.md b/script/smoke/README.md new file mode 100644 index 0000000..f0138ba --- /dev/null +++ b/script/smoke/README.md @@ -0,0 +1,139 @@ +# b20 precompile smoketest + +A lightweight, dependency-thin smoketest that drives the b20 precompiles +(`B20Factory`, `B20Asset`, `B20Stablecoin`, `PolicyRegistry`) by sending **real +transactions to a live JSON-RPC endpoint**. It is the runbook check for +precompile bring-up: point it at a node where the b20 features are activated and +it walks the full operator lifecycle of each precompile, asserting balances, +events, and revert reasons against the real Rust implementation. + +It is deliberately *not* a Foundry test. The harness is plain +[`web3.py`](https://web3py.readthedocs.io/) talking directly to RPC, so it has no +dependency on `forge`'s in-process EVM. The only thing it borrows from the build +is the **interface ABIs**, which it reads straight from `out/` after a +`forge build`, so the surface it binds to always matches the current source. + +## What you need + +The suite talks to a **real node over JSON-RPC** that has the b20 features +activated. It is not coupled to any particular node: a remote Base fork +(>= Beryl), or a node you run yourself (for example a local build of +[`base/base`](https://github.com/base/base)) both work, as long as the +precompiles are deployed and the features are switched on in the +ActivationRegistry. The suite does not stand a node up for you and does not fund +anyone for you: you supply the endpoint and two funded keys. + +## Running + +```bash +make smoke-setup # one-time: create the venv + install web3 +cp .env.template .env # then set RPC_URL, DEPLOYER_PK, USER2_PK +make smoke-all KEEP_GOING=1 # all journeys, audit summary; or one: make smoke-factory +``` + +`DEPLOYER_PK` must hold enough ether to sign the setup and admin txs (it also +sends `USER2_PK` a small one-time gas float). You are responsible for funding it: +on a real network you fund it yourself, or, if the chain has a faucet, set +`FAUCET_URL` + `FAUCET_NETWORK` in `.env` and the preflight tops the deployer up +when it falls below the floor. `foundry.toml` also defines fork RPC endpoints +(e.g. `vibenet`) you can point `RPC_URL` at, provided the features are activated +there. + +`.env` is gitignored; the Makefile sources it for every smoke recipe and existing +shell env wins over `.env` values. + +### Make targets + +```bash +make smoke # run every journey, fail-fast (CI gating default) +make smoke-all # all journeys, single process, fail-fast +make smoke-all KEEP_GOING=1 # all journeys, summarize, exit 0 regardless +make smoke-factory # one journey at a time: factory|asset|stablecoin|policy|invariants +make smoke-setup # create the venv + install web3 (one-time) +``` + +> The `smoke-*` targets set `PYTHONPATH=script` for you. Running `python -m smoke` +> by hand needs that too (and the env exported), else you get `No module named +> smoke` — prefer the Make targets. The raw CLI takes an arbitrary subset and a +> fail-fast/keep-going flag, e.g. `python -m smoke asset policy -k`. + +### Environment / config knobs + +| Var | Required | Default | Meaning | +|---|---|---|---| +| `RPC_URL` | yes | — | JSON-RPC endpoint to send txs to. | +| `DEPLOYER_PK` | yes | — | Funded key that signs setup/admin txs. | +| `USER2_PK` | yes | — | Second actor (recipient / non-admin paths). | +| `GAS_FLOAT_ETHER` | no | `0.01` | One-time gas float the deployer sends user2. | +| `SMOKE_SALT` | no | random | Pin the per-run salt namespace (reproducible addresses). | +| `SMOKE_TRACE` | no | `1` | On failure, dump a `debug_traceCall/Transaction` call tree. Set `0` for just the request + replayed revert data. | +| `FAUCET_URL` / `FAUCET_NETWORK` | no | — | Optional deployer top-up when underfunded. | +| `FAUCET_AMOUNT` / `FAUCET_MIN_ETHER` | no | `0.05` / `0.02` | Faucet amount and balance floor. | + +## What it checks + +Five "journeys", each runnable on its own or all together: + +| Journey | What it exercises | +|---|---| +| `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | +| `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). | +| `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `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/`. | + +Each lifecycle journey ends with a flow-level check that every expected event +type was emitted. The `invariants` journey is a *collect-all audit*: it runs +every check, reports findings at the end, and fails only if a required invariant +did not hold (see [Interpreting output](#interpreting-output)). + +## Interpreting output + +Per-step lines are prefixed `→` (step), `✓` (assertion passed), `✗` (failed). +Each journey logs `: OK` on success. A run ends in one of three states per +journey: + +- **pass** — all assertions held. +- **fail** — an assertion or expected revert did not match. For lifecycle + journeys this is fail-fast; the harness dumps the offending call (and a trace + when `SMOKE_TRACE=1`). +- **skip** — the preflight found the b20 features are **not activated** on the + target chain. Reported as chain/fork state, *not* a contract defect: + + ``` + [smoke] b20 features NOT ACTIVE on chain : ActivationRegistry not installed ... fork < Beryl? + [smoke] ... skipping (use the ActivationRegistry to enable). + ``` + + If everything skips, your RPC simply doesn't have the precompiles active. + Activate the b20 features in the ActivationRegistry, or point `RPC_URL` at a + node that already has them. + +The `invariants` journey is special: it collects all findings and prints +`N/12 invariants held`. A finding is a precompile behavior to triage, not a flaky +test. To accept a known divergence, add its check name to the `INFORMATIONAL` set +in `journeys/precompile_invariants.py` — it stays reported but no longer fails the +run. + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `No module named smoke` | Running outside `make`. Use the Make targets or export `PYTHONPATH=script`. | +| `RPC_URL did not answer` | Endpoint unreachable. Check the node is up and the URL/port. | +| Everything **skipped** | Target node doesn't have the b20 features active. Activate them in the ActivationRegistry, or point `RPC_URL` at a node that has them. | +| `deployer ... underfunded ... no faucet configured` | Fund `DEPLOYER_PK`, or set `FAUCET_URL` + `FAUCET_NETWORK`. | + +## Package layout + +``` +script/smoke/ + __main__.py # CLI: python -m smoke [-k]; preflight + dispatch + config.py # addresses, enum/role/feature constants, env -> Config + chain.py # web3 harness: send/read, revert + event assertions, RPC tracing + abis.py # interface ABIs + probe/feeder artifacts, read from out/ + codec.py # the one hand-written encode: createB20 params + initCalls + errors.py # selector -> custom-error-name map (from the ABIs) + journeys/ # factory, asset_lifecycle, stablecoin_lifecycle, policy_registry, precompile_invariants + requirements.txt +``` From 394642d785c356485320f2250a949c31c0a1fc1a Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 10 Jun 2026 11:32:22 -0700 Subject: [PATCH 8/8] doc fixes --- .gitignore | 3 --- Makefile | 20 +++++++++----------- foundry.toml | 7 ------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 0cfbae5..9d730f9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,6 @@ broadcast/ !.env.example !.env.template -# Python smoketest (script/smoke): venv + bytecode caches. Interface ABIs are -# read from the (gitignored) forge `out/` dir at runtime, so nothing under -# script/smoke/ needs committing beyond the harness sources. script/smoke/.venv/ __pycache__/ *.pyc diff --git a/Makefile b/Makefile index 2a9ce0a..77d513a 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,11 @@ +# Source the gitignored .env for the smoke recipes. +LOAD_ENV = pre=$$(export -p); set -a; [ -f .env ] && . ./.env; set +a; eval "$$pre"; + +PYTHON ?= python3.13 +VENV = script/smoke/.venv +# `smoke` is the package at script/smoke/, so its parent (script) is on the path. +SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke + .PHONY: build coverage smoke smoke-all smoke-factory smoke-asset smoke-stablecoin smoke-policy smoke-invariants smoke-setup # Generate an lcov coverage report and open it in the browser. @@ -7,14 +15,6 @@ coverage: genhtml lcov.info --branch-coverage -o coverage --dark-mode --ignore-errors inconsistent,corrupt open coverage/index.html -# Source the gitignored .env (shell-style, not Make-native) for the smoke -# recipes. Existing env wins: snapshot exports, source, then re-apply. -LOAD_ENV = pre=$$(export -p); set -a; [ -f .env ] && . ./.env; set +a; eval "$$pre"; - -PYTHON ?= python3.13 -VENV = script/smoke/.venv -# `smoke` is the package at script/smoke/, so its parent (script) is on the path. -SMOKE_RUN = $(LOAD_ENV) PYTHONPATH=script $(VENV)/bin/python -m smoke # One-time setup: create the smoketest venv and install web3. smoke-setup: @@ -22,9 +22,7 @@ smoke-setup: $(VENV)/bin/python -m pip install --upgrade pip $(VENV)/bin/python -m pip install -r script/smoke/requirements.txt -# Compile the contracts. The smoke harness binds to the interface ABIs and the -# PrecompileProbe artifact straight from `out/` (gitignored), so every smoke -# target depends on this — the ABIs always match the current source. +# Compile the contracts. build: forge build diff --git a/foundry.toml b/foundry.toml index cb0e5e7..4c84502 100644 --- a/foundry.toml +++ b/foundry.toml @@ -54,12 +54,5 @@ quote_style = "double" bracket_spacing = false int_types = "long" -# forge lint runs on every `forge build`. The test runners intentionally -# exercise raw behavior (e.g. ignoring ERC20 transfer return values), so linting -# them is pure noise — but the mocks under test/lib/mocks/ are quasi-production -# code where findings (unsafe casts, block.timestamp, etc.) matter. So ignore -# the runners (test/unit, test/regression) and the test/lib base contracts, but -# NOT test/lib/mocks/ — leaving src/ and the mocks fully linted. (forge lint's -# `ignore` does not honor `!` re-includes, so the mocks are kept in by omission.) [lint] ignore = ["test/unit/**/*.sol", "test/regression/**/*.sol", "test/lib/*.sol"]