From f21f958b4894d572b35155b25c4bfcc3bd3f56a5 Mon Sep 17 00:00:00 2001 From: Joe Pegler Date: Fri, 27 Mar 2026 21:00:06 +0000 Subject: [PATCH 1/3] chore: withMultichain --- .changeset/tender-wombats-drop.md | 5 + AGENTS.md | 8 +- README.md | 24 ++-- .../fixtures/contracts/withContracts.mdx | 23 ++-- .../fixtures/contracts/withDeployments.mdx | 31 +++-- .../pages/fixtures/isolation/withSnapshot.mdx | 26 ++-- docs/pages/fixtures/runtime/index.mdx | 23 ++-- docs/pages/fixtures/runtime/withBundler.mdx | 14 +- docs/pages/fixtures/runtime/withChain.mdx | 7 +- .../fixtures/runtime/withExternalRuntime.mdx | 30 +++-- docs/pages/fixtures/runtime/withFork.mdx | 10 +- .../pages/fixtures/runtime/withMultiChain.mdx | 54 ++++++++ .../fixtures/tokens/withErc20Balance.mdx | 19 +-- .../fixtures/wallets/withFundedWallet.mdx | 18 +-- .../fixtures/wallets/withImpersonation.mdx | 15 ++- docs/pages/index.mdx | 7 +- docs/pages/overview.mdx | 9 +- docs/pages/quickstart.mdx | 9 +- .../core/src/scenarios/fixtures/PRIMITIVES.md | 1 - .../scenarios/fixtures/withBundler.test.ts | 68 +++++----- .../src/scenarios/fixtures/withBundler.ts | 38 +++--- .../src/scenarios/fixtures/withChain.test.ts | 13 +- .../core/src/scenarios/fixtures/withChain.ts | 28 ++-- .../scenarios/fixtures/withContracts.test.ts | 62 +++++---- .../src/scenarios/fixtures/withContracts.ts | 48 ++++--- .../fixtures/withDeployments.test.ts | 78 +++++++---- .../src/scenarios/fixtures/withDeployments.ts | 44 +++--- .../fixtures/withErc20Balance.test.ts | 12 +- .../scenarios/fixtures/withErc20Balance.ts | 31 +++-- .../fixtures/withExternalRuntime.test.ts | 13 +- .../scenarios/fixtures/withExternalRuntime.ts | 28 ++-- .../src/scenarios/fixtures/withFork.test.ts | 13 +- .../core/src/scenarios/fixtures/withFork.ts | 28 ++-- .../fixtures/withFundedWallet.test.ts | 30 +++-- .../scenarios/fixtures/withFundedWallet.ts | 34 +++-- .../fixtures/withImpersonation.test.ts | 60 +++++---- .../scenarios/fixtures/withImpersonation.ts | 34 +++-- .../scenarios/fixtures/withMultiChain.test.ts | 94 +++++++++++++ .../src/scenarios/fixtures/withMultiChain.ts | 125 ++++++++++++++++++ .../scenarios/fixtures/withSnapshot.test.ts | 26 ++-- .../src/scenarios/fixtures/withSnapshot.ts | 22 ++- packages/core/src/scenarios/index.ts | 5 +- .../core/src/scenarios/requireContext.test.ts | 20 ++- .../scenario-invalid-chain.typespec.ts | 22 --- .../src/scenarios/scenario-typing.test.ts | 5 + packages/core/src/scenarios/types.ts | 94 ++++++------- packages/core/src/scenarios/utils.ts | 22 +-- packages/examples/examples/scenarios.test.ts | 118 ++++++++++++----- vocs.config.ts | 1 + 49 files changed, 1042 insertions(+), 507 deletions(-) create mode 100644 .changeset/tender-wombats-drop.md create mode 100644 docs/pages/fixtures/runtime/withMultiChain.mdx create mode 100644 packages/core/src/scenarios/fixtures/withMultiChain.test.ts create mode 100644 packages/core/src/scenarios/fixtures/withMultiChain.ts delete mode 100644 packages/core/src/scenarios/scenario-invalid-chain.typespec.ts diff --git a/.changeset/tender-wombats-drop.md b/.changeset/tender-wombats-drop.md new file mode 100644 index 0000000..ff05fd8 --- /dev/null +++ b/.changeset/tender-wombats-drop.md @@ -0,0 +1,5 @@ +--- +"@st8craft/core": patch +--- + +withMultiChain diff --git a/AGENTS.md b/AGENTS.md index 7096d93..2b5d85d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,11 @@ Build small, composable pieces: - `withSmartAccount` - `withPaymaster` -## Multi-chain (future) +## Multi-chain -Represent as namespaced contexts: -`ctx.chains.ethereum`, `ctx.chains.base`. -Do not simulate cross-chain logic in v1. +Represent chain state as namespaced contexts: `ctx.chains.` (for example `ctx.chains.ethereum`, `ctx.chains.base`). +Use `withMultiChain` for multiple runtimes in one scenario, or repeat single-chain fixtures with different `chainKey` values. +Do not simulate cross-chain messaging or bridges in v1 unless you add explicit test doubles for that scope. ## Development Priorities diff --git a/README.md b/README.md index 61eb722..158e089 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ test( withFundedWallet({ balance: 1_000_000_000_000_000_000n, // 1 ETH in wei }), - async ({ wallet, publicClient }) => { - const balance = await publicClient.getBalance({ address: wallet }); + async ({ chains }) => { + const ch = chains!.default; + const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(balance).toBe(1_000_000_000_000_000_000n); }, ), @@ -100,12 +101,13 @@ test( token: USDC_MAINNET, amount: 1_000_000n, // 1 USDC (6 decimals) }), - async ({ walletClient, publicClient }) => { - const usdc = await publicClient.readContract({ + async ({ chains }) => { + const ch = chains!.default; + const usdc = await ch.publicClient.readContract({ address: USDC_MAINNET, abi: erc20Abi, functionName: "balanceOf", - args: [walletClient.account.address], + args: [ch.wallet!], }); expect(usdc).toBe(1_000_000n); @@ -117,13 +119,14 @@ test( ## Core Primitives - `scenario(...steps, testFn)`: composes setup steps into one async test function (examples wrap it with Vitest `test`). -- `withChain()`: starts a fresh local Anvil runtime. +- `withChain()`: starts a fresh local Anvil runtime (under `ctx.chains.default` unless `chainKey` is set). - `withFork({ rpcUrl, blockNumber })`: starts a pinned local fork for deterministic mainnet state. -- `withFundedWallet({ balance, erc20? })`: creates and funds a test wallet. +- `withMultiChain({ ... })`: starts or attaches multiple named chains on `ctx.chains`. +- `withFundedWallet({ balance, erc20?, chain? })`: creates and funds a test wallet on a chain entry. - `withErc20Balance({ token, amount })`: seeds ERC-20 balance on compatible local or forked nodes. - `withSnapshot()`: snapshots before inner steps and reverts in `finally`. -- `withContracts(...)`: injects runtime bytecode at known addresses. -- `withDeployments(...)`: performs real deployments with constructor semantics. +- `withContracts({ contracts, chain? })`: injects runtime bytecode at known addresses on a chain entry. +- `withDeployments({ deployments, chain? })`: performs real deployments with constructor semantics on a chain entry. ## Use It When / Skip It When @@ -156,7 +159,8 @@ bun run test Included examples in `packages/examples/examples/scenarios.test.ts`: -- fresh local chain plus funded wallet +- fresh local chain plus funded wallet (`ctx.chains.default`) +- two local chains via `withMultiChain` plus per-chain funded wallets - forked mainnet plus funded wallet plus real contract call - forked mainnet plus funded wallet plus USDC via `withFundedWallet.erc20` or `withErc20Balance` - runtime bytecode injection with `withContracts` diff --git a/docs/pages/fixtures/contracts/withContracts.mdx b/docs/pages/fixtures/contracts/withContracts.mdx index d4c22ee..b7d4cd3 100644 --- a/docs/pages/fixtures/contracts/withContracts.mdx +++ b/docs/pages/fixtures/contracts/withContracts.mdx @@ -3,7 +3,7 @@ title: withContracts description: Inject runtime bytecode at fixed addresses (test-only) with optional typed contract handles. --- -## `withContracts({ ... })` +## `withContracts({ contracts, chain? })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -24,13 +24,15 @@ test( scenario( withChain(), withContracts({ - answer: { - artifact: answerArtifact, - address: ANSWER_ADDRESS, + contracts: { + answer: { + artifact: answerArtifact, + address: ANSWER_ADDRESS, + }, }, }), - async ({ contracts }) => { - expect(contracts?.answer).toBeTruthy(); + async ({ chains }) => { + expect(chains?.default.contracts?.answer).toBeTruthy(); }, ), ); @@ -57,20 +59,21 @@ export const answerArtifact = { ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| -| `name` (record key) | `string` | Yes | Contract name you choose as the key in your config object. | Becomes `ctx.contracts[name]` for later steps. | +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives bytecode and contract handles. | +| `contracts` | `Record` | Yes | Named injection specs. | Keys become `ctx.chains[chain].contracts[name]`. | +| `name` (record key) | `string` | Yes | Contract name you choose as the key under `contracts`. | Becomes the handle key for later steps. | | `artifact` | `ContractArtifact` | Yes | Supplies runtime bytecode via `deployedBytecode` (and optionally `abi` for typed handles). | Determines what `testClient.setCode` installs, and whether a typed contract handle is produced. | | `address` | `Hex` | Yes | Address where the runtime bytecode is installed. | Controls the fixed address used for subsequent calls/reads. | | `afterSetCode` | `(ctx) => Promise` | No | Optional async hook called after `setCode`. | Lets you seed extra storage or perform node-only setup for this contract. | ### Adds to context -- `contracts` (merged across entries and steps) +- `ctx.chains[chain].contracts` (merged across entries and steps) ### Context requirements -- Requires runtime + clients from `withChain`, `withFork`, or `withExternalRuntime` (for `ctx.testClient`). +- Requires runtime + clients on the target chain from `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain` (for `ctx.chains[chain].testClient`). ### Lifecycle Managed middleware that installs bytecode, then forwards to `next`. ### Notes and caveats Use `withContracts()` for injecting known runtime bytecode at known addresses. It is optimized for setup speed and deterministic addressing, not for constructor semantics. - diff --git a/docs/pages/fixtures/contracts/withDeployments.mdx b/docs/pages/fixtures/contracts/withDeployments.mdx index fcfc8ad..1bb214f 100644 --- a/docs/pages/fixtures/contracts/withDeployments.mdx +++ b/docs/pages/fixtures/contracts/withDeployments.mdx @@ -1,9 +1,9 @@ --- title: withDeployments -description: Deploy contracts using constructor semantics, and merge deployment results into ctx.deployments. +description: Deploy contracts using constructor semantics, and merge deployment results into ctx.chains[chain].deployments. --- -## `withDeployments({ ... })` +## `withDeployments({ deployments, chain? })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -19,18 +19,20 @@ import { isAddress } from "viem"; import { answerArtifact } from "./config.js"; test( - "deployment record is present in ctx.deployments", + "deployment record is present on the chain context", scenario( withChain(), withFundedWallet({ balance: 1n }), withDeployments({ - answer: { - artifact: answerArtifact, - args: [], + deployments: { + answer: { + artifact: answerArtifact, + args: [], + }, }, }), - async ({ deployments }) => { - expect(isAddress(deployments?.answer?.address)).toBe(true); + async ({ chains }) => { + expect(isAddress(chains?.default.deployments?.answer?.address)).toBe(true); }, ), ); @@ -56,22 +58,23 @@ export const answerArtifact = { ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| -| `name` (record key) | `string` | Yes | Deployment name you choose as the key in your config object. | Becomes `ctx.deployments[name]`. | +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives deployments. | +| `deployments` | `Record` | Yes | Named deployment specs. | Keys become `ctx.chains[chain].deployments[name]`. | +| `name` (record key) | `string` | Yes | Deployment name you choose as the key under `deployments`. | Becomes the deployment record key. | | `artifact` | `ContractArtifact` | Yes | Contract artifact including required `abi` plus creation `bytecode`. | Drives `walletClient.deployContract`, and (when ABI is present) produces a typed contract handle. | | `args` | `readonly unknown[] | DeploymentArgsResolver` | No | Constructor arguments, or a resolver `(ctx) => args` that can depend on earlier deployments. | Controls constructor calldata for that deployment. | | `afterDeploy` | `(ctx) => Promise` | No | Optional async hook called after the receipt is mined and merged into `deployments`. | Allows extra node-only setup after deployment. | ### Adds to context -- `deployments` (map of deployment name → deployment record) +- `ctx.chains[chain].deployments` (map of deployment name to deployment record) ### Context requirements -- Requires runtime + clients from `withChain`, `withFork`, or `withExternalRuntime` (for `ctx.publicClient` and `ctx.testClient`). -- Requires a funded wallet on `ctx.walletClient` (typically provided by `withFundedWallet`) so `walletClient.deployContract` can send the transaction. +- Requires runtime + clients on the target chain from `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain` (for `ctx.chains[chain].publicClient` and `ctx.chains[chain].testClient`). +- Requires a funded wallet on `ctx.chains[chain].walletClient` (typically provided by `withFundedWallet`) so `walletClient.deployContract` can send the transaction. ### Lifecycle Managed middleware that deploys contracts in key order, then forwards to `next`. ### Notes and caveats -- Deployment ordering is the key order in your config object. +- Deployment ordering is the key order in your `deployments` object. - `artifact.abi` is required at runtime (deployment fails if missing). - diff --git a/docs/pages/fixtures/isolation/withSnapshot.mdx b/docs/pages/fixtures/isolation/withSnapshot.mdx index 9cd6625..d0537fb 100644 --- a/docs/pages/fixtures/isolation/withSnapshot.mdx +++ b/docs/pages/fixtures/isolation/withSnapshot.mdx @@ -3,7 +3,7 @@ title: withSnapshot description: Snapshot the runtime before inner steps run, then revert in a finally block. --- -## withSnapshot() +## withSnapshot([{ chain }]) **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -23,8 +23,9 @@ test( scenario( withChain(), withSnapshot(), - async ({ publicClient }) => { - expect(await publicClient.getBlockNumber()).toBeGreaterThan(0n); + async ({ chains }) => { + const ch = chains!.default; + expect(await ch.publicClient.getBlockNumber()).toBeGreaterThan(0n); }, ), ); @@ -33,13 +34,13 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| -| `()` | `none` | No | This fixture does not accept any configuration options. | Always uses `ctx.testClient.snapshot()` and reverts in `finally`. | +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain entry is snapshotted and reverted. | ### Adds to context None; it forwards the context unchanged. ### Context requirements -- Requires runtime clients on context, so it must come after a runtime source fixture like `withChain`, `withFork`, or `withExternalRuntime`. +- Requires runtime clients on the target chain, so it must come after a runtime source fixture like `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain`. ### Example (suite hooks with `describe`) @@ -79,18 +80,19 @@ describe("shared runtime with snapshot isolation", () => { withExternalRuntime({ runtime: handle }), withSnapshot(), withFundedWallet({ balance: parseEther("1") }), - async ({ publicClient, walletClient }) => { - const before = await publicClient.getBalance({ address: recipient }); + async ({ chains }) => { + const ch = chains!.default; + const before = await ch.publicClient.getBalance({ address: recipient }); expect(before).toBe(0n); // Send ETH to `recipient` from the scenario wallet. // Any chain mutation here is rolled back by `withSnapshot()`. - await walletClient.sendTransaction({ + await ch.walletClient.sendTransaction({ to: recipient, value: txValue, }); - const after = await publicClient.getBalance({ address: recipient }); + const after = await ch.publicClient.getBalance({ address: recipient }); expect(after).toBe(txValue); }, ), @@ -101,8 +103,9 @@ describe("shared runtime with snapshot isolation", () => { scenario( withExternalRuntime({ runtime: handle }), withSnapshot(), - async ({ publicClient }) => { - const balance = await publicClient.getBalance({ address: recipient }); + async ({ chains }) => { + const ch = chains!.default; + const balance = await ch.publicClient.getBalance({ address: recipient }); expect(balance).toBe(0n); }, ), @@ -116,4 +119,3 @@ Managed lifecycle, it snapshots before `next` and reverts afterward via a `final ### Notes and caveats - Nesting multiple `withSnapshot()` layers creates nested snapshot scopes. - When reusing one `runtime` handle via `withExternalRuntime()`, avoid concurrent tests that mutate chain state, since `withSnapshot()` relies on snapshot and revert ordering. - diff --git a/docs/pages/fixtures/runtime/index.mdx b/docs/pages/fixtures/runtime/index.mdx index a2aa21b..21c7e96 100644 --- a/docs/pages/fixtures/runtime/index.mdx +++ b/docs/pages/fixtures/runtime/index.mdx @@ -1,23 +1,28 @@ --- title: Runtime Source Fixtures -description: Start or attach an Anvil runtime and wire viem clients with withChain, withFork, and withExternalRuntime. +description: Start or attach Anvil runtimes and wire viem clients under ctx.chains with withChain, withFork, withExternalRuntime, or withMultiChain. --- # Runtime Source Fixtures **Test runners:** Examples in this section use Vitest. `scenario(...)` returns an async function compatible with other `test(...)`-style runners (for example Jest or Node `node:test`). -Statecraft scenarios require a runtime and viem clients. These fixtures provide the base runtime context and are generally mutually exclusive in normal scenarios. +Statecraft scenarios require at least one runtime and viem clients. Runtime fixtures place chain state under **`ctx.chains.`** (the default key is **`default`** when you use `withChain`, `withFork`, or `withExternalRuntime` without `chainKey`). -Use exactly one of `withChain`, `withFork`, or `withExternalRuntime` as your base runtime step. Everything else (wallet funding, ERC-20 seeding, contracts, and isolation) assumes this base step exists. +## Choosing a base runtime strategy + +- **Single chain:** use exactly one of `withChain`, `withFork`, or `withExternalRuntime` as your first runtime step (or combine multiple single-chain steps with different `chainKey` values). +- **Multiple chains in one step:** use [`withMultiChain`](/fixtures/runtime/withMultiChain) with a map of named chain specs. + +Downstream fixtures (wallets, ERC-20 seeding, contracts, isolation, bundler) target a chain with an optional `chain` argument (default `default`). ## Fixtures -- [`withChain()`](/fixtures/runtime/withChain) -- [`withFork({ rpcUrl, blockNumber })`](/fixtures/runtime/withFork) -- [`withExternalRuntime({ runtime, runtimeMode?, clients? })`](/fixtures/runtime/withExternalRuntime) +- [`withChain([config])`](/fixtures/runtime/withChain) +- [`withFork({ rpcUrl, blockNumber, ... })`](/fixtures/runtime/withFork) +- [`withExternalRuntime({ runtime, ... })`](/fixtures/runtime/withExternalRuntime) +- [`withMultiChain({ ... })`](/fixtures/runtime/withMultiChain) ## Additional wiring -- [`withBundler({ entryPoint, mode? })`](/fixtures/runtime/withBundler) -- `withBundler` requires fork runtime mode (`withFork(...)`, or external runtime declared with `runtimeMode: "fork"`). - +- [`withBundler({ chain?, entryPoint, mode? })`](/fixtures/runtime/withBundler) +- `withBundler` requires fork runtime mode on that chain (`withFork(...)`, a `withMultiChain` fork entry, or external runtime with `runtimeMode: "fork"`). diff --git a/docs/pages/fixtures/runtime/withBundler.mdx b/docs/pages/fixtures/runtime/withBundler.mdx index 7927bc4..3d5b60a 100644 --- a/docs/pages/fixtures/runtime/withBundler.mdx +++ b/docs/pages/fixtures/runtime/withBundler.mdx @@ -3,7 +3,7 @@ title: withBundler description: Start a local Alto bundler connected to the current Anvil runtime. --- -## `withBundler({ entryPoint, mode? })` +## `withBundler({ entryPoint, mode?[, chain] })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -18,15 +18,16 @@ import { scenario, withFork, withBundler } from "@st8craft/core"; const ENTRY_POINT = "0x0576a174D229E3cFA37253523E645A78A0C91B57"; test( - "bundler wiring is exposed in scenario context", + "bundler wiring is exposed on the chain context", scenario( withFork({ rpcUrl: process.env.VITE_RPC_URL!, blockNumber: 22_000_000n, }), withBundler({ entryPoint: ENTRY_POINT, mode: "alto" }), - async ({ bundlerClient }) => { - expect(await bundlerClient.getSupportedEntryPoints()).toContain(ENTRY_POINT); + async ({ chains }) => { + const ch = chains!.default; + expect(await ch.bundlerClient!.getSupportedEntryPoints()).toContain(ENTRY_POINT); }, ), ); @@ -35,16 +36,18 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which forked chain receives bundler fields. | | `entryPoint` | `Address` | Yes | ERC-4337 entry point address (typically EntryPoint v0.7 / v0.6). | Configures the bundler instance and how user operations are encoded. | | `mode` | `"alto"` | No | Bundler runtime mode. | Selects the local Alto bundler implementation. | ### Adds to context +On `ctx.chains[chain]`: - `bundlerUrl` - `bundlerClient` - `entryPoint` ### Context requirements -- Requires runtime + `ctx.testClient` from `withFork` / `withExternalRuntime`. +- Requires fork runtime mode on that chain entry (`withFork`, a `withMultiChain` fork entry, or external runtime with `runtimeMode: "fork"`). - Expects the host project to have the peer dependency used by Alto (`@pimlico/alto`). Docs examples are type-checked, not executed here. ### Lifecycle @@ -52,4 +55,3 @@ Managed lifecycle, the fixture starts a local bundler for the scenario and stops ### Notes and caveats Additional examples for user operations are planned. - diff --git a/docs/pages/fixtures/runtime/withChain.mdx b/docs/pages/fixtures/runtime/withChain.mdx index f11d6d9..8a0e379 100644 --- a/docs/pages/fixtures/runtime/withChain.mdx +++ b/docs/pages/fixtures/runtime/withChain.mdx @@ -21,9 +21,10 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ publicClient, walletClient }) => { + async ({ chains }) => { + const ch = chains!.default; expect( - await publicClient.getBalance({ address: walletClient.account!.address }), + await ch.publicClient.getBalance({ address: ch.wallet! }), ).toBe( parseEther("1"), ); @@ -35,10 +36,12 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chainKey` | `string` | No | Key on `ctx.chains` for this runtime (default `default`). | Namespaces clients when composing multiple chains. | | `chainId` | `number` | No | Anvil `--chain-id` override. | Client chain identity (affects viem `chain.id`) and runtime chain id. | | `key` | `string` | No | Stable correlation id forwarded to the runtime layer. | Correlates runtime config across restarts (advanced). | ### Adds to context +Under `ctx.chains[chainKey]` (default key `default`): - `runtime` - `runtimeMode` (`"chain"`) - `chain` diff --git a/docs/pages/fixtures/runtime/withExternalRuntime.mdx b/docs/pages/fixtures/runtime/withExternalRuntime.mdx index b6b9a79..dc9ae0d 100644 --- a/docs/pages/fixtures/runtime/withExternalRuntime.mdx +++ b/docs/pages/fixtures/runtime/withExternalRuntime.mdx @@ -3,7 +3,7 @@ title: withExternalRuntime description: Attach a caller-owned runtime handle and wire viem clients, without starting or stopping Anvil. --- -## `withExternalRuntime({ runtime, runtimeMode?, clients? })` +## `withExternalRuntime({ runtime, runtimeMode?, clients?, chainKey? })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -46,10 +46,11 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { "builds shared state for later tests", scenario( withExternalRuntime({ runtime: handle }), - async ({ publicClient, testClient }) => { - const start = await publicClient.getBlockNumber(); - await testClient.mine({ blocks: 2 }); - sharedBlock = await publicClient.getBlockNumber(); + async ({ chains }) => { + const ch = chains!.default; + const start = await ch.publicClient.getBlockNumber(); + await ch.testClient.mine({ blocks: 2 }); + sharedBlock = await ch.publicClient.getBlockNumber(); expect(sharedBlock).toBe(start + 2n); }, ), @@ -59,8 +60,8 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { "depends on the previous test's state", scenario( withExternalRuntime({ runtime: handle }), - async ({ publicClient }) => { - expect(await publicClient.getBlockNumber()).toBe(sharedBlock); + async ({ chains }) => { + expect(await chains!.default.publicClient.getBlockNumber()).toBe(sharedBlock); }, ), ); @@ -70,10 +71,11 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { scenario( withExternalRuntime({ runtime: handle }), withSnapshot(), - async ({ publicClient, testClient }) => { - const before = await publicClient.getBlockNumber(); - await testClient.mine({ blocks: 5 }); - expect(await publicClient.getBlockNumber()).toBe(before + 5n); + async ({ chains }) => { + const ch = chains!.default; + const before = await ch.publicClient.getBlockNumber(); + await ch.testClient.mine({ blocks: 5 }); + expect(await ch.publicClient.getBlockNumber()).toBe(before + 5n); }, ), ); @@ -82,8 +84,8 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { "proves isolated test did not leak shared state", scenario( withExternalRuntime({ runtime: handle }), - async ({ publicClient }) => { - expect(await publicClient.getBlockNumber()).toBe(sharedBlock); + async ({ chains }) => { + expect(await chains!.default.publicClient.getBlockNumber()).toBe(sharedBlock); }, ), ); @@ -93,11 +95,13 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chainKey` | `string` | No | Key on `ctx.chains` (default `default`). | Namespaces clients when composing multiple chains. | | `runtime` | `RuntimeHandle` | Yes | Live runtime handle created by `startRuntime(...)`. | Provides the RPC endpoint and runtime identity used by the scenario clients. | | `runtimeMode` | `"chain" \| "fork"` | No | Declares whether the attached runtime handle is chain or fork mode. Defaults to `"chain"`. | Enables mode-sensitive fixtures (for example `withBundler`, which requires fork mode). | | `clients` | `CreateClientsOptions` | No | Optional client wiring overrides for chain identity and signer key. | Selects viem chain id (`chainId`) and signer private key (`privateKey`). | ### Adds to context +Under `ctx.chains[chainKey]` (default key `default`): - `runtime` - `runtimeMode` (defaults to `"chain"` when omitted) - `chain` diff --git a/docs/pages/fixtures/runtime/withFork.mdx b/docs/pages/fixtures/runtime/withFork.mdx index 76718ca..a00cf65 100644 --- a/docs/pages/fixtures/runtime/withFork.mdx +++ b/docs/pages/fixtures/runtime/withFork.mdx @@ -3,7 +3,7 @@ title: withFork description: Start a local Anvil fork from a remote JSON-RPC endpoint at a pinned block. --- -## `withFork({ rpcUrl, blockNumber[, chainId, key] })` +## `withFork({ rpcUrl, blockNumber[, chainId, key, chainKey] })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -23,9 +23,10 @@ test( blockNumber: 22_000_000n, }), withFundedWallet({ balance: 1n }), - async ({ publicClient, walletClient }) => { + async ({ chains }) => { + const ch = chains!.default; expect( - await publicClient.getBalance({ address: walletClient.account!.address }), + await ch.publicClient.getBalance({ address: ch.wallet! }), ).toBe(1n); }, ), @@ -35,12 +36,14 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chainKey` | `string` | No | Key on `ctx.chains` (default `default`). | Namespaces clients when composing multiple chains. | | `rpcUrl` | `string` | Yes | HTTP(S) RPC URL to fork. | Source for remote chain state. | | `blockNumber` | `bigint` | Yes | Pinned fork block number. | Determinism: pins the fork so reads and state are stable. | | `chainId` | `number` | No | Anvil `--chain-id` override. | Client chain identity (advanced). | | `key` | `string` | No | Stable correlation id forwarded to the runtime layer. | Correlates runtime config across restarts (advanced). | ### Adds to context +Under `ctx.chains[chainKey]` (default key `default`): - `runtime` - `runtimeMode` (`"fork"`) - `chain` @@ -57,4 +60,3 @@ Managed lifecycle, the fixture starts and stops Anvil for the scenario. ### Notes and caveats - Prefer pinned `blockNumber` values for determinism. - This fixture is intended for compatible local/forked runtimes (Anvil-style). - diff --git a/docs/pages/fixtures/runtime/withMultiChain.mdx b/docs/pages/fixtures/runtime/withMultiChain.mdx new file mode 100644 index 0000000..4a5b21b --- /dev/null +++ b/docs/pages/fixtures/runtime/withMultiChain.mdx @@ -0,0 +1,54 @@ +--- +title: withMultiChain +description: Start or attach multiple Anvil runtimes and expose them as named entries on ctx.chains. +--- + +## withMultiChain(config) + +**Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). + +### Why it is useful +Use `withMultiChain` when a single test needs more than one isolated EVM context (for example Ethereum plus an L2, or two pinned forks). Each key in `config` becomes `ctx.chains.` with its own `runtime`, `publicClient`, `walletClient`, and `testClient`. + +### Example +```ts twoslash [index.ts] +import { test, expect } from "vitest"; +import { parseEther } from "viem"; +import { scenario, withMultiChain, withFundedWallet } from "@st8craft/core"; + +test( + "two local chains", + scenario( + withMultiChain({ + a: { type: "chain", chainId: 31_337 }, + b: { type: "chain", chainId: 31_338 }, + }), + withFundedWallet({ chain: "a", balance: parseEther("1") }), + withFundedWallet({ chain: "b", balance: parseEther("2") }), + async ({ chains }) => { + const ba = await chains!.a.publicClient.getBalance({ address: chains!.a.wallet! }); + const bb = await chains!.b.publicClient.getBalance({ address: chains!.b.wallet! }); + expect(ba).toBe(parseEther("1")); + expect(bb).toBe(parseEther("2")); + }, + ), +); +``` + +### Config shape +Each entry is a tagged union: + +| `type` | Meaning | +| --- | --- | +| `"chain"` | Fresh Anvil chain (optional `chainId`, `key`). | +| `"fork"` | Pinned fork (`rpcUrl`, `blockNumber`, optional `chainId`, `key`). | +| `"external"` | Attach an existing `runtime` (optional `runtimeMode`, `clients`). | + +### Lifecycle +Runtimes started by this fixture are stopped in reverse startup order in `finally`. External runtimes are never stopped here. + +### Adds to context +- `chains`: a map of chain keys to full chain contexts (same fields as [`withChain`](/fixtures/runtime/withChain) per entry). + +### Context requirements +None; this fixture can be the first runtime step, or compose after other steps that already set `ctx.chains` (duplicate keys throw). diff --git a/docs/pages/fixtures/tokens/withErc20Balance.mdx b/docs/pages/fixtures/tokens/withErc20Balance.mdx index 684bcdf..b98eb0e 100644 --- a/docs/pages/fixtures/tokens/withErc20Balance.mdx +++ b/docs/pages/fixtures/tokens/withErc20Balance.mdx @@ -3,7 +3,7 @@ title: withErc20Balance description: Seed an ERC-20 balance for a recipient in local/forked test state. --- -## `withErc20Balance({ token, amount[, to] })` +## `withErc20Balance({ token, amount[, chain, to] })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -27,13 +27,14 @@ test( token: USDC, amount: 1_000_000n, }), - async ({ publicClient, walletClient }) => { + async ({ chains }) => { + const ch = chains!.default; expect( - await publicClient.readContract({ + await ch.publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "balanceOf", - args: [walletClient.account!.address], + args: [ch.wallet!], }), ).toBe(1_000_000n); }, @@ -44,20 +45,20 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives the balance write. | | `token` | `Address` | Yes | ERC-20 token contract address. | Determines which token storage is rewritten. | | `amount` | `bigint` | Yes | Token raw units balance (for example, from `parseUnits`). | Sets the recipient's balance in token raw units. | -| `to` | `Address` | No | Recipient address to set. When omitted, uses `ctx.wallet`. | Chooses the account whose balance state is rewritten. | +| `to` | `Address` | No | Recipient address to set. When omitted, uses `ctx.chains[chain].wallet`. | Chooses the account whose balance state is rewritten. | ### Adds to context -None; this fixture mainly mutates node state before forwarding to the callback. +None; this fixture mainly mutates node state before forwarding to the callback (it may refresh `wallet` on the chain entry when `to` is omitted). ### Context requirements -- Requires runtime + clients from `withChain`, `withFork`, or `withExternalRuntime` so `ctx.testClient` exists. -- If `to` is omitted, `ctx.wallet` must already be set (typically by `withFundedWallet`). +- Requires runtime + clients on the target chain from `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain` so `ctx.chains[chain].testClient` exists. +- If `to` is omitted, `ctx.chains[chain].wallet` must already be set (typically by `withFundedWallet`). ### Lifecycle Managed middleware that writes test-only token state on entry, then forwards to `next`. ### Notes and caveats This is test-only state manipulation: it rewrites token balance state on the node and is not a production mint path. It may fail for non-standard token implementations (for example rebasing tokens or unusual storage layouts). - diff --git a/docs/pages/fixtures/wallets/withFundedWallet.mdx b/docs/pages/fixtures/wallets/withFundedWallet.mdx index 348b342..80d67d8 100644 --- a/docs/pages/fixtures/wallets/withFundedWallet.mdx +++ b/docs/pages/fixtures/wallets/withFundedWallet.mdx @@ -3,7 +3,7 @@ title: withFundedWallet description: Create a funded scenario account, optionally seeding ERC-20 balances for that account. --- -## `withFundedWallet({ balance[, privateKey, erc20] })` +## `withFundedWallet({ balance[, chain, privateKey, erc20] })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -21,8 +21,9 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ publicClient, walletClient }) => { - expect(await publicClient.getBalance({ address: walletClient.account!.address })).toBe( + async ({ chains }) => { + const ch = chains!.default; + expect(await ch.publicClient.getBalance({ address: ch.wallet! })).toBe( parseEther("1"), ); }, @@ -33,21 +34,22 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives the funded account. | | `balance` | `bigint` | Yes | ETH balance to set in wei. | Sets the funded address ETH balance via anvil test-client state mutation. | -| `privateKey` | `Hex` | No | Stable signer key to use instead of generating a new one. | Determines `walletClient.account` and `ctx.wallet`. | +| `privateKey` | `Hex` | No | Stable signer key to use instead of generating a new one. | Determines `walletClient.account` and `ctx.chains[chain].wallet`. | | `erc20` | `readonly WithFundedWalletErc20Balance[]` | No | Optional ERC-20 seeds for the funded address. Each entry is `{ token: Address; amount: bigint }` where `amount` is raw token units. | Writes token balance state for the funded address after ETH funding. | ### Adds to context +On `ctx.chains[chain]`: - `wallet` -- `walletClient` +- `walletClient` (replaced with the funded account) ### Context requirements -Requires runtime + clients from `withChain`, `withFork`, or `withExternalRuntime` so `ctx.testClient` exists. +Requires runtime + clients on the target chain from `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain` so `ctx.chains[chain].testClient` exists. ### Lifecycle Managed with respect to the wallet setup, this fixture does not start or stop Anvil. ### Notes and caveats - This is test-only state manipulation: it sets balance storage on compatible local/forked runtimes. -- If you call `withFundedWallet` multiple times inside the same scenario, the last one wins for `ctx.wallet` and `ctx.walletClient`. - +- If you call `withFundedWallet` multiple times for the same `chain` inside the same scenario, the last one wins for `wallet` and `walletClient` on that chain entry. diff --git a/docs/pages/fixtures/wallets/withImpersonation.mdx b/docs/pages/fixtures/wallets/withImpersonation.mdx index f2a2ceb..8a6b8f5 100644 --- a/docs/pages/fixtures/wallets/withImpersonation.mdx +++ b/docs/pages/fixtures/wallets/withImpersonation.mdx @@ -3,7 +3,7 @@ title: withImpersonation description: Impersonate an existing account for scenario signing, with optional ETH top-up and automatic teardown. --- -## `withImpersonation({ address[, balance, stopOnExit] })` +## `withImpersonation({ address[, chain, balance, stopOnExit] })` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). @@ -31,9 +31,10 @@ test( address: "0x000000000000000000000000000000000000dead", balance: parseEther("1"), }), - async ({ wallet, walletClient, publicClient }) => { - expect(walletClient.account!.address).toBe(wallet); - const eth = await publicClient.getBalance({ address: wallet }); + async ({ chains }) => { + const ch = chains!.default; + expect(ch.walletClient.account!.address).toBe(ch.wallet); + const eth = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(eth).toBe(parseEther("1")); }, ), @@ -43,16 +44,18 @@ test( ### Options | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| -| `address` | `Address` | Yes | Existing account to impersonate. | Calls Anvil impersonation and becomes `walletClient.account` + `ctx.wallet`. | +| `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives impersonation. | +| `address` | `Address` | Yes | Existing account to impersonate. | Calls Anvil impersonation and becomes `walletClient.account` + `ctx.chains[chain].wallet`. | | `balance` | `bigint` | No | ETH balance to set in wei before test body runs. | Calls `testClient.setBalance` for `address`. | | `stopOnExit` | `boolean` | No | Whether to stop impersonation in fixture teardown. Defaults to `true`. | Controls `stopImpersonatingAccount` in `finally`. | ### Adds to context +On `ctx.chains[chain]`: - `wallet` - `walletClient` ### Context requirements -Requires runtime + clients from `withChain`, `withFork`, or `withExternalRuntime` so `ctx.testClient` exists. +Requires runtime + clients on the target chain from `withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain` so `ctx.chains[chain].testClient` exists. ### Lifecycle This fixture does not start or stop Anvil. It manages impersonation lifecycle for the configured address and, by default, stops impersonation when downstream steps finish. diff --git a/docs/pages/index.mdx b/docs/pages/index.mdx index c74ff4a..d4c6d17 100644 --- a/docs/pages/index.mdx +++ b/docs/pages/index.mdx @@ -66,9 +66,10 @@ import { HomePage, Button } from "vocs/components"; token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // USDC amount: parseUnits("1_000_000", 6), }), - async ({ publicClient, walletClient }) => { - // walletClient now has both 1 ETH and seeded USDC - const balance = await publicClient.getBalance({ address: walletClient.account.address }); + async ({ chains }) => { + const ch = chains!.default; + // ch.walletClient now has both 1 ETH and seeded USDC + const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(balance).toBe(parseEther("1")); }, ), diff --git a/docs/pages/overview.mdx b/docs/pages/overview.mdx index 5651127..d0f8cc7 100644 --- a/docs/pages/overview.mdx +++ b/docs/pages/overview.mdx @@ -38,9 +38,10 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ publicClient, walletClient }) => { - const balance = await publicClient.getBalance({ - address: walletClient.account!.address, + async ({ chains }) => { + const ch = chains!.default; + const balance = await ch.publicClient.getBalance({ + address: ch.wallet!, }); expect(balance).toBe(parseEther("1")); }, @@ -68,7 +69,7 @@ As a rule of thumb, you should be able to read a test and answer "what state doe ## Type safety in practice -Because TypeScript understands the fixture order, it can infer which fields are available in your `async ({ ... })` callback. In the example above, `publicClient` and `wallet` exist because you included `withChain()` and `withFundedWallet()`. +Because TypeScript understands the fixture order, it can infer which fields are available in your `async ({ ... })` callback. In the example above, `chains.default` includes viem clients after `withChain()`, and `chains.default.wallet` after `withFundedWallet()`. If you write custom fixtures, use `requireContext` to fail fast when a step expects keys that are not present. diff --git a/docs/pages/quickstart.mdx b/docs/pages/quickstart.mdx index e7ceac9..d32a283 100644 --- a/docs/pages/quickstart.mdx +++ b/docs/pages/quickstart.mdx @@ -80,9 +80,10 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ publicClient, walletClient }) => { - const balance = await publicClient.getBalance({ - address: walletClient.account!.address, + async ({ chains }) => { + const ch = chains!.default; + const balance = await ch.publicClient.getBalance({ + address: ch.wallet!, }); expect(balance).toBe(parseEther("1")); @@ -101,7 +102,7 @@ bunx vitest run tests/quickstart.test.ts ## What happened (and what success looks like) -This test starts a fresh local Anvil chain, funds a scenario wallet with `withFundedWallet`, reads the wallet balance with `publicClient.getBalance`, and asserts it. +This test starts a fresh local Anvil chain, funds a scenario wallet with `withFundedWallet` on `ctx.chains.default`, reads the wallet balance with `chains.default.publicClient.getBalance`, and asserts it. Success looks like a passing Vitest run for `funded wallet on local chain`. diff --git a/packages/core/src/scenarios/fixtures/PRIMITIVES.md b/packages/core/src/scenarios/fixtures/PRIMITIVES.md index 1d10901..ea804e1 100644 --- a/packages/core/src/scenarios/fixtures/PRIMITIVES.md +++ b/packages/core/src/scenarios/fixtures/PRIMITIVES.md @@ -68,7 +68,6 @@ withNextBlockTimestamp: 3 advanceTime: 3 ## Multi-chain withMultiChain: 4 -chain: 4 ## Cross-chain (low priority) withCrossChainMessage: 2 withBridgeMock: 2 diff --git a/packages/core/src/scenarios/fixtures/withBundler.test.ts b/packages/core/src/scenarios/fixtures/withBundler.test.ts index 8d641b8..5b85652 100644 --- a/packages/core/src/scenarios/fixtures/withBundler.test.ts +++ b/packages/core/src/scenarios/fixtures/withBundler.test.ts @@ -9,6 +9,20 @@ const createBundlerClient = vi.fn(); vi.mock("../internal/startBundler.js", () => ({ startBundler })); vi.mock("../../clients/index.js", () => ({ createBundlerClient })); +function forkCtx(overrides: Record = {}) { + return { + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + runtimeMode: "fork", + publicClient: { chain: {} }, + walletClient: {}, + testClient: { setBalance: vi.fn(async () => {}), ...overrides }, + }, + }, + } as any; +} + describe("withBundler", () => { test("throws when runtime clients are missing", async () => { const { withBundler } = await import("./withBundler.js"); @@ -22,7 +36,7 @@ describe("withBundler", () => { throw new Error("next should not run"); }, ), - ).rejects.toThrow(/missing runtime clients/i); + ).rejects.toThrow(/missing runtime clients for chain "default"/i); }); test("starts bundler, injects bundler fields, and stops on success", async () => { @@ -40,18 +54,13 @@ describe("withBundler", () => { const { withBundler } = await import("./withBundler.js"); - const ctx = { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - runtimeMode: "fork", - publicClient: { chain: {} }, - walletClient: {}, - testClient: { setBalance: vi.fn(async () => {}) }, - } as any; + const ctx = forkCtx(); + ctx.chains.default.chain = {}; const next = vi.fn(async (nextCtx: any) => { - expect(nextCtx.bundlerUrl).toBe("http://127.0.0.1:9999"); - expect(nextCtx.entryPoint).toBe(ENTRYPOINT); - expect(nextCtx.bundlerClient).toBeDefined(); + expect(nextCtx.chains.default.bundlerUrl).toBe("http://127.0.0.1:9999"); + expect(nextCtx.chains.default.entryPoint).toBe(ENTRYPOINT); + expect(nextCtx.chains.default.bundlerClient).toBeDefined(); }); const step = withBundler({ entryPoint: ENTRYPOINT }); @@ -76,13 +85,8 @@ describe("withBundler", () => { const { withBundler } = await import("./withBundler.js"); - const ctx = { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - runtimeMode: "fork", - publicClient: { chain: {} }, - walletClient: {}, - testClient: { setBalance: vi.fn(async () => {}) }, - } as any; + const ctx = forkCtx(); + ctx.chains.default.chain = {}; const step = withBundler({ entryPoint: ENTRYPOINT }); await expect( @@ -101,15 +105,11 @@ describe("withBundler", () => { expect(step).toBeDefined(); const badStep = withBundler({ entryPoint: ENTRYPOINT, mode: "nope" as any }); + const ctx = forkCtx(); + ctx.chains.default.chain = {}; await expect( badStep( - { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - runtimeMode: "fork", - publicClient: { chain: {} }, - walletClient: {}, - testClient: { setBalance: vi.fn(async () => {}) }, - } as any, + ctx, async () => {}, ), ).rejects.toThrow(/only supports mode='alto'/i); @@ -119,17 +119,21 @@ describe("withBundler", () => { const { withBundler } = await import("./withBundler.js"); const step = withBundler({ entryPoint: ENTRYPOINT }); - await expect( - step( - { + const ctx = { + chains: { + default: { runtime: { rpcUrl: "http://127.0.0.1:8545" }, runtimeMode: "chain", + chain: {}, publicClient: { chain: {} }, walletClient: {}, testClient: { setBalance: vi.fn(async () => {}) }, - } as any, - async () => {}, - ), - ).rejects.toThrow(/requires withFork\(\.\.\.\) to run first/i); + }, + }, + } as any; + + await expect( + step(ctx, async () => {}), + ).rejects.toThrow(/requires withFork\(\.\.\.\)/i); }); }); diff --git a/packages/core/src/scenarios/fixtures/withBundler.ts b/packages/core/src/scenarios/fixtures/withBundler.ts index 6ae0cc3..61e5070 100644 --- a/packages/core/src/scenarios/fixtures/withBundler.ts +++ b/packages/core/src/scenarios/fixtures/withBundler.ts @@ -1,12 +1,14 @@ import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import type { Address } from "viem"; -import { requireRuntimeClients } from "../utils.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; import type { BundlerClient } from "../../clients/index.js"; import { createBundlerClient } from "../../clients/index.js"; import type { ScenarioBundlerContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; import { startBundler } from "../internal/startBundler.js"; export type WithBundlerConfig = { + /** Key on `ctx.chains` (default `default`). */ + chain?: string; /** ERC-4337 entry point address (typically EntryPoint v0.7 / v0.6). */ entryPoint: Address; /** Bundler runtime mode. Currently only `alto` is supported. */ @@ -17,16 +19,19 @@ const DEFAULT_EXECUTOR_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; /** - * Middleware: starts a local Alto bundler connected to the current Anvil runtime, then wires - * a typed viem-compatible JSON-RPC client into scenario context. + * Middleware: starts a local Alto bundler connected to the Anvil runtime for `ctx.chains[chain]`, then wires + * a typed viem-compatible JSON-RPC client into that chain entry. * * Requires `@pimlico/alto` to be installed in the host project (declared as a peer dependency). */ export function withBundler(config: WithBundlerConfig): ScenarioStep { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); - if (ctx.runtimeMode !== "fork") { - throw new Error("withBundler(...) requires withFork(...) to run first."); + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; + + if (ch.runtimeMode !== "fork") { + throw new Error("withBundler(...) requires withFork(...) (or a fork entry in withMultiChain) for that chain first."); } if (!config?.entryPoint) { @@ -37,36 +42,39 @@ export function withBundler(config: WithBundlerConfig): ScenarioStep { const step = withChain({ chainId: 31337, key: "suite" }); const next = vi.fn(async (nextCtx: any) => { - expect(nextCtx.runtime).toBe(runtime); - expect(nextCtx.runtimeMode).toBe("chain"); - expect(nextCtx.chain).toBe(clients.publicClient.chain); - expect(nextCtx.publicClient).toBe(clients.publicClient); - expect(nextCtx.walletClient).toBe(clients.walletClient); - expect(nextCtx.testClient).toBe(clients.testClient); + const ch = nextCtx.chains.default; + expect(ch.runtime).toBe(runtime); + expect(ch.runtimeMode).toBe("chain"); + expect(ch.chain).toBe(clients.publicClient.chain); + expect(ch.publicClient).toBe(clients.publicClient); + expect(ch.walletClient).toBe(clients.walletClient); + expect(ch.testClient).toBe(clients.testClient); expect(nextCtx.keep).toBe("me"); }); diff --git a/packages/core/src/scenarios/fixtures/withChain.ts b/packages/core/src/scenarios/fixtures/withChain.ts index c4087ce..485acb0 100644 --- a/packages/core/src/scenarios/fixtures/withChain.ts +++ b/packages/core/src/scenarios/fixtures/withChain.ts @@ -1,9 +1,13 @@ import { startRuntime, stopRuntime } from "../../runtime/index.js"; import { createClients } from "../../clients/index.js"; -import type { EmptyScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; /** Options for starting a fresh chain (non-fork) anvil instance. */ export type WithChainConfig = { + /** + * Key on `ctx.chains` for this runtime (default `default`). + */ + chainKey?: string; /** Anvil `--chain-id` when set; defaults match runtime/clients package defaults. */ chainId?: number; /** Stable id forwarded to `RuntimeConfig.key` on the runtime package for correlation across restarts. */ @@ -11,9 +15,10 @@ export type WithChainConfig = { }; /** - * Middleware: starts an empty-chain anvil, wires viem clients, runs `next`, then stops the runtime. + * Middleware: starts an empty-chain anvil, wires viem clients under `ctx.chains[chainKey]`, runs `next`, then stops the runtime. */ -export function withChain(config: WithChainConfig = {}): ScenarioStep { +export function withChain(config: WithChainConfig = {}): ScenarioStep { + const chainKey = config.chainKey ?? "default"; return async (ctx, next) => { const runtime = await startRuntime({ mode: "chain", @@ -25,12 +30,17 @@ export function withChain(config: WithChainConfig = {}): ScenarioStep { describe("withContracts", () => { test("throws when runtime clients are missing", async () => { const step = withContracts({ - token: { - address: "0x00000000000000000000000000000000000000aa", - artifact: { deployedBytecode: "0x60016000f3" }, + contracts: { + token: { + address: "0x00000000000000000000000000000000000000aa", + artifact: { deployedBytecode: "0x60016000f3" }, + }, }, }); @@ -27,7 +29,7 @@ describe("withContracts", () => { step({} as any, async () => { throw new Error("next should not run"); }), - ).rejects.toThrow(/missing runtime clients/i); + ).rejects.toThrow(/missing runtime clients for chain "default"/i); }); test("sets code, calls afterSetCode, and merges named contracts", async () => { @@ -36,37 +38,44 @@ describe("withContracts", () => { getContract.mockImplementation(({ address }) => ({ address, kind: "contract" })); const ctx = { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: {}, - testClient: { setCode }, - contracts: { existing: { address: "0x0000000000000000000000000000000000000001" } }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: {}, + testClient: { setCode }, + contracts: { existing: { address: "0x0000000000000000000000000000000000000001" } }, + }, + }, } as any; const step = withContracts({ - token: { - address: "0x00000000000000000000000000000000000000aa", - artifact: { - abi: [], - deployedBytecode: { object: "0x60016000f3" as Hex }, + contracts: { + token: { + address: "0x00000000000000000000000000000000000000aa", + artifact: { + abi: [], + deployedBytecode: { object: "0x60016000f3" as Hex }, + }, + afterSetCode, }, - afterSetCode, - }, - proxy: { - address: "0x00000000000000000000000000000000000000bb", - artifact: { - deployedBytecode: "0x60026000f3", + proxy: { + address: "0x00000000000000000000000000000000000000bb", + artifact: { + deployedBytecode: "0x60026000f3", + }, }, }, }); const next = vi.fn(async (nextCtx: any) => { - expect(nextCtx.contracts.existing).toEqual(ctx.contracts.existing); - expect(nextCtx.contracts.token).toEqual({ + const c = nextCtx.chains.default.contracts; + expect(c.existing).toEqual(ctx.chains.default.contracts.existing); + expect(c.token).toEqual({ address: "0x00000000000000000000000000000000000000aa", kind: "contract", }); - expect(nextCtx.contracts.proxy).toEqual({ + expect(c.proxy).toEqual({ address: "0x00000000000000000000000000000000000000bb", }); }); @@ -82,11 +91,12 @@ describe("withContracts", () => { bytecode: "0x60026000f3", }); expect(afterSetCode).toHaveBeenCalledWith({ + chain: "default", name: "token", address: "0x00000000000000000000000000000000000000aa", - testClient: ctx.testClient, - publicClient: ctx.publicClient, - walletClient: ctx.walletClient, + testClient: ctx.chains.default.testClient, + publicClient: ctx.chains.default.publicClient, + walletClient: ctx.chains.default.walletClient, }); expect(getContract).toHaveBeenCalledTimes(1); expect(next).toHaveBeenCalledTimes(1); diff --git a/packages/core/src/scenarios/fixtures/withContracts.ts b/packages/core/src/scenarios/fixtures/withContracts.ts index d05f7cf..2e5339c 100644 --- a/packages/core/src/scenarios/fixtures/withContracts.ts +++ b/packages/core/src/scenarios/fixtures/withContracts.ts @@ -6,7 +6,7 @@ import type { ScenarioRuntimeClientsContext, ScenarioStep, } from "../types.js"; -import { extractBytecode, requireRuntimeClients } from "../utils.js"; +import { extractBytecode, requireChainScopedRuntimeClients } from "../utils.js"; /** * Injects bytecode at a fixed address and optionally exposes a viem contract client on context. @@ -23,34 +23,35 @@ export type ContractInjection = { }; /** - * Map of contract name → injection spec; names become keys on `ctx.contracts`. + * Contract injections for {@link withContracts}. Use `chain` to select `ctx.chains[chain]` (default `default`). */ -export type WithContractsConfig = Record; - -type WithContractsIn = ScenarioRuntimeClientsContext & { - contracts?: ScenarioContracts; -}; -type WithContractsOut = ScenarioRuntimeClientsContext & { - contracts: ScenarioContracts; +export type WithContractsConfig = { + chain?: string; + contracts: Record; }; +type WithContractsIn = ScenarioRuntimeClientsContext; +type WithContractsOut = ScenarioRuntimeClientsContext; + /** - * Middleware: for each entry, `setCode` at `address`, then merge contract handles into `ctx.contracts`. - * Requires a prior `withChain` / `withFork` (runtime + clients). + * Middleware: for each entry, `setCode` at `address`, then merge contract handles into `ctx.chains[chain].contracts`. + * Requires a prior runtime fixture for that chain. */ export function withContracts( config: WithContractsConfig, ): ScenarioStep { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); - const contracts: ScenarioContracts = { ...(ctx.contracts ?? {}) }; + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; + const contracts: ScenarioContracts = { ...(ch.contracts ?? {}) }; - for (const [name, entry] of Object.entries(config)) { + for (const [name, entry] of Object.entries(config.contracts)) { const bytecode = extractBytecode( entry.artifact.deployedBytecode, `${name}.deployedBytecode`, ); - await ctx.testClient.setCode({ + await ch.testClient.setCode({ address: entry.address, bytecode, }); @@ -59,24 +60,31 @@ export function withContracts( ? getContract({ address: entry.address, abi: entry.artifact.abi as never, - client: { public: ctx.publicClient, wallet: ctx.walletClient }, + client: { public: ch.publicClient, wallet: ch.walletClient }, }) : { address: entry.address }; if (entry.afterSetCode) { await entry.afterSetCode({ + chain: chainKey, name, address: entry.address, - testClient: ctx.testClient, - publicClient: ctx.publicClient, - walletClient: ctx.walletClient, + testClient: ch.testClient, + publicClient: ch.publicClient, + walletClient: ch.walletClient, }); } } await next({ ...ctx, - contracts, + chains: { + ...ctx.chains, + [chainKey]: { + ...ch, + contracts, + }, + }, }); }; } diff --git a/packages/core/src/scenarios/fixtures/withDeployments.test.ts b/packages/core/src/scenarios/fixtures/withDeployments.test.ts index f85eb22..6d6d8f6 100644 --- a/packages/core/src/scenarios/fixtures/withDeployments.test.ts +++ b/packages/core/src/scenarios/fixtures/withDeployments.test.ts @@ -20,18 +20,24 @@ describe("withDeployments", () => { test("throws when artifact ABI is missing", async () => { const step = withDeployments({ - token: { - artifact: { bytecode: "0x60016000f3" }, + deployments: { + token: { + artifact: { bytecode: "0x60016000f3" }, + }, }, }); await expect( step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: { account: {} }, - testClient: {}, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: { account: {} }, + testClient: {}, + }, + }, } as any, async () => { throw new Error("next should not run"); @@ -42,18 +48,24 @@ describe("withDeployments", () => { test("throws when wallet account is missing", async () => { const step = withDeployments({ - token: { - artifact: { abi: [], bytecode: "0x60016000f3" }, + deployments: { + token: { + artifact: { abi: [], bytecode: "0x60016000f3" }, + }, }, }); await expect( step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: {}, - testClient: {}, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: {}, + testClient: {}, + }, + }, } as any, async () => { throw new Error("next should not run"); @@ -81,31 +93,38 @@ describe("withDeployments", () => { getContract.mockImplementation(({ address }) => ({ address, kind: "contract" })); const step = withDeployments({ - first: { - artifact: { abi: [], bytecode: "0x60016000f3" }, - args: [123n], - }, - second: { - artifact: { abi: [], bytecode: { object: "0x60026000f3" } }, - args: ({ deployments }) => [deployments.first!.address], - afterDeploy, + deployments: { + first: { + artifact: { abi: [], bytecode: "0x60016000f3" }, + args: [123n], + }, + second: { + artifact: { abi: [], bytecode: { object: "0x60026000f3" } }, + args: ({ deployments }) => [deployments.first!.address], + afterDeploy, + }, }, }); const next = vi.fn(async (nextCtx: any) => { - expect(nextCtx.deployments.existing).toEqual({ address: "0x0000000000000000000000000000000000000001" }); - expect(nextCtx.deployments.first.address).toBe("0x00000000000000000000000000000000000000aa"); - expect(nextCtx.deployments.second.address).toBe("0x00000000000000000000000000000000000000bb"); + const dep = nextCtx.chains.default.deployments!; + expect(dep.existing).toEqual({ address: "0x0000000000000000000000000000000000000001" }); + expect(dep.first.address).toBe("0x00000000000000000000000000000000000000aa"); + expect(dep.second.address).toBe("0x00000000000000000000000000000000000000bb"); }); await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { waitForTransactionReceipt }, - walletClient: { account, deployContract }, - testClient: {}, - wallet: account.address, - deployments: { existing: { address: "0x0000000000000000000000000000000000000001" } }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { waitForTransactionReceipt }, + walletClient: { account, deployContract }, + testClient: {}, + wallet: account.address, + deployments: { existing: { address: "0x0000000000000000000000000000000000000001" } }, + }, + }, } as any, next, ); @@ -125,6 +144,7 @@ describe("withDeployments", () => { expect(afterDeploy).toHaveBeenCalledWith( expect.objectContaining({ name: "second", + chain: "default", wallet: account.address, }), ); diff --git a/packages/core/src/scenarios/fixtures/withDeployments.ts b/packages/core/src/scenarios/fixtures/withDeployments.ts index 599ca47..46c679a 100644 --- a/packages/core/src/scenarios/fixtures/withDeployments.ts +++ b/packages/core/src/scenarios/fixtures/withDeployments.ts @@ -7,7 +7,7 @@ import type { ScenarioRuntimeClientsContext, ScenarioStep, } from "../types.js"; -import { extractBytecode, requireRuntimeClients } from "../utils.js"; +import { extractBytecode, requireChainScopedRuntimeClients } from "../utils.js"; /** * Declares one contract to deploy via `walletClient.deployContract` in declaration order. @@ -22,53 +22,58 @@ export type DeploymentSpec = { }; /** - * Map of deployment name → spec; names become keys on `ctx.deployments`. + * Deployments for {@link withDeployments}. Use `chain` to select `ctx.chains[chain]` (default `default`). */ -export type WithDeploymentsConfig = Record; +export type WithDeploymentsConfig = { + chain?: string; + deployments: Record; +}; type DeploymentsMap = Record; /** - * Middleware: deploys each spec in key order, then merges `DeploymentRecord`s into `ctx.deployments`. - * Requires a prior `withChain` / `withFork` and a wallet account on `walletClient`. + * Middleware: deploys each spec in key order on `ctx.chains[chain]`, then merges `DeploymentRecord`s into that chain entry. + * Requires a prior runtime fixture and a wallet account on `walletClient` for that chain. */ export function withDeployments( config: WithDeploymentsConfig, ): ScenarioStep< - C & { deployments?: DeploymentsMap }, - C & { deployments: DeploymentsMap } + C & { chains: C["chains"] }, + C & { chains: C["chains"] } > { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); - const deployments: DeploymentsMap = { ...(ctx.deployments ?? {}) }; + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; + const deployments: DeploymentsMap = { ...(ch.deployments ?? {}) }; - for (const [name, spec] of Object.entries(config)) { + for (const [name, spec] of Object.entries(config.deployments)) { if (!spec.artifact.abi) { throw new Error(`${name}.artifact.abi is required for deployment.`); } const bytecode = extractBytecode(spec.artifact.bytecode, `${name}.bytecode`); const args = typeof spec.args === "function" ? await spec.args({ deployments }) : spec.args ?? []; - const account = ctx.walletClient.account; + const account = ch.walletClient.account; if (!account) { throw new Error("withDeployments(...) requires a walletClient account."); } - const hash = await ctx.walletClient.deployContract({ + const hash = await ch.walletClient.deployContract({ abi: spec.artifact.abi as never, bytecode, args: args as readonly unknown[], account, }); - const receipt = await ctx.publicClient.waitForTransactionReceipt({ hash }); + const receipt = await ch.publicClient.waitForTransactionReceipt({ hash }); const deployment: DeploymentRecord = { address: receipt.contractAddress!, receipt, contract: getContract({ address: receipt.contractAddress!, abi: spec.artifact.abi as never, - client: { public: ctx.publicClient, wallet: ctx.walletClient }, + client: { public: ch.publicClient, wallet: ch.walletClient }, }), }; @@ -76,17 +81,24 @@ export function withDeployments( if (spec.afterDeploy) { await spec.afterDeploy({ + chain: chainKey, name, deployment, deployments, - wallet: ctx.wallet, + wallet: ch.wallet, }); } } await next({ ...ctx, - deployments, + chains: { + ...ctx.chains, + [chainKey]: { + ...ch, + deployments, + }, + }, }); }; } diff --git a/packages/core/src/scenarios/fixtures/withErc20Balance.test.ts b/packages/core/src/scenarios/fixtures/withErc20Balance.test.ts index b24455d..6beb0a4 100644 --- a/packages/core/src/scenarios/fixtures/withErc20Balance.test.ts +++ b/packages/core/src/scenarios/fixtures/withErc20Balance.test.ts @@ -11,10 +11,14 @@ describe("withErc20Balance", () => { }); const ctx = { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: {}, - testClient: { mode: "anvil" }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: {}, + testClient: { mode: "anvil" }, + }, + }, } as any; await expect( diff --git a/packages/core/src/scenarios/fixtures/withErc20Balance.ts b/packages/core/src/scenarios/fixtures/withErc20Balance.ts index 5ebc6ce..d409c18 100644 --- a/packages/core/src/scenarios/fixtures/withErc20Balance.ts +++ b/packages/core/src/scenarios/fixtures/withErc20Balance.ts @@ -1,7 +1,7 @@ import type { Address } from "viem"; import type { ScenarioContext, ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; import { dealErc20Balance } from "../internal/dealErc20Balance.js"; -import { requireRuntimeClients } from "../utils.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; /** * Options for {@link withErc20Balance}: seeds an ERC-20 balance in local/forked test state. @@ -10,12 +10,14 @@ import { requireRuntimeClients } from "../utils.js"; * tokens on-chain in production and must not be used outside compatible local test runtimes. */ export type WithErc20BalanceConfig = { + /** Key on `ctx.chains` (default `default`). */ + chain?: string; /** ERC-20 token contract address. */ token: Address; /** Balance to set (token raw units, e.g. from `parseUnits`). */ amount: bigint; /** - * Recipient address. When omitted, uses `ctx.wallet` from {@link withFundedWallet} (or any step that sets it). + * Recipient address. When omitted, uses `ctx.chains[chain].wallet` from {@link withFundedWallet} (or any step that sets it). */ to?: Address; }; @@ -28,24 +30,26 @@ export type WithErc20Balance = { /** * Middleware: sets an ERC-20 balance for a recipient on Anvil-compatible runtimes. * - * Requires a prior {@link withChain} or {@link withFork} (for `testClient`). If `to` is omitted, - * requires {@link withFundedWallet} or another step that sets `ctx.wallet`. + * Requires a prior runtime fixture for `chain`. If `to` is omitted, + * requires {@link withFundedWallet} or another step that sets `ctx.chains[chain].wallet`. * * May fail for non-standard tokens (e.g. rebasing or unusual storage layouts); it is not a generic mint path. */ export const withErc20Balance: WithErc20Balance = ((config: WithErc20BalanceConfig) => { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; - const recipient = config.to ?? ctx.wallet; + const recipient = config.to ?? ch.wallet; if (!recipient) { throw new Error( - "withErc20Balance(...) requires a recipient: pass `to`, or compose withFundedWallet(...) so ctx.wallet is set.", + `withErc20Balance(...) requires a recipient: pass \`to\`, or compose withFundedWallet(...) so ctx.chains["${chainKey}"].wallet is set.`, ); } await dealErc20Balance({ - testClient: ctx.testClient, + testClient: ch.testClient, token: config.token, recipient, amount: config.amount, @@ -55,7 +59,16 @@ export const withErc20Balance: WithErc20Balance = ((config: WithErc20BalanceConf if (config.to !== undefined) { await forward({ ...ctx }); } else { - await forward({ ...ctx, wallet: recipient }); + await forward({ + ...ctx, + chains: { + ...ctx.chains, + [chainKey]: { + ...ch, + wallet: recipient, + }, + }, + }); } }; }) as WithErc20Balance; diff --git a/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts b/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts index c44f91d..ea136f4 100644 --- a/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts +++ b/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts @@ -18,12 +18,13 @@ describe("withExternalRuntime", () => { expect(next).toHaveBeenCalledTimes(1); const [ctx] = next.mock.calls[0]!; - expect(ctx.runtime.key).toBe("suite"); - expect(ctx.runtimeMode).toBe("chain"); - expect(ctx.chain).toBeDefined(); - expect(ctx.publicClient).toBeDefined(); - expect(ctx.walletClient).toBeDefined(); - expect(ctx.testClient).toBeDefined(); + const ch = ctx.chains!.default; + expect(ch.runtime.key).toBe("suite"); + expect(ch.runtimeMode).toBe("chain"); + expect(ch.chain).toBeDefined(); + expect(ch.publicClient).toBeDefined(); + expect(ch.walletClient).toBeDefined(); + expect(ch.testClient).toBeDefined(); }); test("does not stop runtime lifecycle", async () => { diff --git a/packages/core/src/scenarios/fixtures/withExternalRuntime.ts b/packages/core/src/scenarios/fixtures/withExternalRuntime.ts index 9fca3a1..8cca488 100644 --- a/packages/core/src/scenarios/fixtures/withExternalRuntime.ts +++ b/packages/core/src/scenarios/fixtures/withExternalRuntime.ts @@ -1,12 +1,16 @@ import { createClients, type CreateClientsOptions } from "../../clients/index.js"; import type { RuntimeHandle, RuntimeMode } from "../../runtime/index.js"; -import type { EmptyScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; /** * Options for attaching an existing runtime handle to scenario context. * Lifecycle is external: this fixture never starts or stops anvil. */ export type WithExternalRuntimeConfig = { + /** + * Key on `ctx.chains` for this runtime (default `default`). + */ + chainKey?: string; /** Live runtime handle created by `startRuntime(...)`. */ runtime: RuntimeHandle; /** @@ -19,23 +23,29 @@ export type WithExternalRuntimeConfig = { }; /** - * Middleware: attaches a caller-owned runtime and viem clients, then runs `next`. + * Middleware: attaches a caller-owned runtime and viem clients under `ctx.chains[chainKey]`, then runs `next`. * * Use this when test hooks own lifecycle (`beforeAll`/`afterAll`) and scenarios should * reuse a suite-scoped anvil process. */ -export function withExternalRuntime(config: WithExternalRuntimeConfig): ScenarioStep { +export function withExternalRuntime(config: WithExternalRuntimeConfig): ScenarioStep { + const chainKey = config.chainKey ?? "default"; return async (ctx, next) => { const clients = createClients(config.runtime, config.clients); await next({ ...ctx, - runtime: config.runtime, - runtimeMode: config.runtimeMode ?? "chain", - chain: clients.publicClient.chain, - publicClient: clients.publicClient, - walletClient: clients.walletClient, - testClient: clients.testClient, + chains: { + ...(ctx.chains ?? {}), + [chainKey]: { + runtime: config.runtime, + runtimeMode: config.runtimeMode ?? "chain", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }, + }, }); }; } diff --git a/packages/core/src/scenarios/fixtures/withFork.test.ts b/packages/core/src/scenarios/fixtures/withFork.test.ts index 1d9ba02..d0bca5c 100644 --- a/packages/core/src/scenarios/fixtures/withFork.test.ts +++ b/packages/core/src/scenarios/fixtures/withFork.test.ts @@ -62,12 +62,13 @@ describe("withFork", () => { }); const next = vi.fn(async (nextCtx: any) => { - expect(nextCtx.runtime).toBe(runtime); - expect(nextCtx.runtimeMode).toBe("fork"); - expect(nextCtx.chain).toBe(clients.publicClient.chain); - expect(nextCtx.publicClient).toBe(clients.publicClient); - expect(nextCtx.walletClient).toBe(clients.walletClient); - expect(nextCtx.testClient).toBe(clients.testClient); + const ch = nextCtx.chains.default; + expect(ch.runtime).toBe(runtime); + expect(ch.runtimeMode).toBe("fork"); + expect(ch.chain).toBe(clients.publicClient.chain); + expect(ch.publicClient).toBe(clients.publicClient); + expect(ch.walletClient).toBe(clients.walletClient); + expect(ch.testClient).toBe(clients.testClient); }); await step({ seed: true } as any, next); diff --git a/packages/core/src/scenarios/fixtures/withFork.ts b/packages/core/src/scenarios/fixtures/withFork.ts index 6b58c1e..bb37d0c 100644 --- a/packages/core/src/scenarios/fixtures/withFork.ts +++ b/packages/core/src/scenarios/fixtures/withFork.ts @@ -1,9 +1,13 @@ import { startRuntime, stopRuntime } from "../../runtime/index.js"; import { createClients } from "../../clients/index.js"; -import type { EmptyScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; /** Options for an anvil instance forked from a remote JSON-RPC endpoint at a pinned block. */ export type WithForkConfig = { + /** + * Key on `ctx.chains` for this runtime (default `default`). + */ + chainKey?: string; /** HTTP(S) RPC URL of the chain to fork (passed to anvil `--fork-url`). */ rpcUrl: string; /** @@ -17,9 +21,10 @@ export type WithForkConfig = { }; /** - * Middleware: starts a forked anvil, wires viem clients, runs `next`, then stops the runtime. + * Middleware: starts a forked anvil, wires viem clients under `ctx.chains[chainKey]`, runs `next`, then stops the runtime. */ -export function withFork(config: WithForkConfig): ScenarioStep { +export function withFork(config: WithForkConfig): ScenarioStep { + const chainKey = config.chainKey ?? "default"; return async (ctx, next) => { if (!config.rpcUrl) { throw new Error("withFork(...) requires rpcUrl."); @@ -41,12 +46,17 @@ export function withFork(config: WithForkConfig): ScenarioStep { step({} as any, async () => { throw new Error("next should not run"); }), - ).rejects.toThrow(/missing runtime clients/i); + ).rejects.toThrow(/missing runtime clients for chain "default"/i); }); test("uses provided private key, funds wallet, and forwards updated context", async () => { @@ -61,10 +61,14 @@ describe("withFundedWallet", () => { const step = withFundedWallet({ balance: 2n, privateKey }); await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { chain: { id: 31337 } }, - walletClient: { account: undefined }, - testClient: { setBalance }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { chain: { id: 31337 } }, + walletClient: { account: undefined }, + testClient: { setBalance }, + }, + }, } as any, next, ); @@ -83,8 +87,8 @@ describe("withFundedWallet", () => { }); const [forwarded] = next.mock.calls[0]!; - expect(forwarded.wallet).toBe(account.address); - expect(forwarded.walletClient).toBe(walletClient); + expect(forwarded.chains!.default.wallet).toBe(account.address); + expect(forwarded.chains!.default.walletClient).toBe(walletClient); }); test("generates key when omitted and seeds configured ERC-20 balances", async () => { @@ -107,10 +111,14 @@ describe("withFundedWallet", () => { await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { chain: { id: 31337 } }, - walletClient: {}, - testClient: { setBalance }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { chain: { id: 31337 } }, + walletClient: {}, + testClient: { setBalance }, + }, + }, } as any, async () => {}, ); diff --git a/packages/core/src/scenarios/fixtures/withFundedWallet.ts b/packages/core/src/scenarios/fixtures/withFundedWallet.ts index 98f5046..f53d73a 100644 --- a/packages/core/src/scenarios/fixtures/withFundedWallet.ts +++ b/packages/core/src/scenarios/fixtures/withFundedWallet.ts @@ -6,7 +6,7 @@ import type { ScenarioStep, } from "../types.js"; import { dealErc20Balance } from "../internal/dealErc20Balance.js"; -import { requireRuntimeClients } from "../utils.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; /** One ERC-20 balance to seed for the funded wallet (after ETH funding). */ export type WithFundedWalletErc20Balance = { @@ -18,6 +18,8 @@ export type WithFundedWalletErc20Balance = { /** Options for creating (or reusing) a test account and funding it on anvil. */ export type WithFundedWalletConfig = { + /** Key on `ctx.chains` to fund (default `default`). */ + chain?: string; /** Balance set via `testClient.setBalance` (wei). */ balance: bigint; /** When set, uses this key; otherwise generates a new private key. */ @@ -30,39 +32,46 @@ export type WithFundedWalletConfig = { }; /** - * Middleware: ensures a funded account, sets `ctx.wallet`, and replaces `walletClient` with that account. - * Requires a prior `withChain` / `withFork` so `testClient` and chain RPC are available. + * Middleware: ensures a funded account on `ctx.chains[chain]`, sets `wallet` on that chain entry, and replaces `walletClient`. + * Requires a prior runtime fixture for that chain. */ export function withFundedWallet( config: WithFundedWalletConfig, ): ScenarioStep { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; const privateKey = config.privateKey ?? generatePrivateKey(); const account = privateKeyToAccount(privateKey); - await ctx.testClient.setBalance({ + await ch.testClient.setBalance({ address: account.address, value: config.balance, }); const walletClient = createWalletClient({ account, - chain: ctx.publicClient.chain, - transport: http(ctx.runtime.rpcUrl), + chain: ch.publicClient.chain, + transport: http(ch.runtime.rpcUrl), }); - const nextCtx: ScenarioFundedWalletContext = { - ...ctx, + const updatedChain = { + ...ch, wallet: account.address, walletClient, }; + const nextChains = { + ...ctx.chains, + [chainKey]: updatedChain, + }; + if (config.erc20?.length) { for (const entry of config.erc20) { await dealErc20Balance({ - testClient: nextCtx.testClient, + testClient: updatedChain.testClient, token: entry.token, recipient: account.address, amount: entry.amount, @@ -70,6 +79,9 @@ export function withFundedWallet( } } - await next(nextCtx); + await next({ + ...ctx, + chains: nextChains, + }); }; } diff --git a/packages/core/src/scenarios/fixtures/withImpersonation.test.ts b/packages/core/src/scenarios/fixtures/withImpersonation.test.ts index c65b097..b5cdcc6 100644 --- a/packages/core/src/scenarios/fixtures/withImpersonation.test.ts +++ b/packages/core/src/scenarios/fixtures/withImpersonation.test.ts @@ -30,7 +30,7 @@ describe("withImpersonation", () => { step({} as any, async () => { throw new Error("next should not run"); }), - ).rejects.toThrow(/missing runtime clients/i); + ).rejects.toThrow(/missing runtime clients for chain "default"/i); }); test("impersonates address, optionally sets balance, forwards context, and stops on success", async () => { @@ -51,13 +51,17 @@ describe("withImpersonation", () => { await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { chain: { id: 31337 } }, - walletClient: {}, - testClient: { - impersonateAccount, - stopImpersonatingAccount, - setBalance, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { chain: { id: 31337 } }, + walletClient: {}, + testClient: { + impersonateAccount, + stopImpersonatingAccount, + setBalance, + }, + }, }, } as any, next, @@ -73,8 +77,8 @@ describe("withImpersonation", () => { }); const [forwarded] = next.mock.calls[0]!; - expect(forwarded.wallet).toBe(address); - expect(forwarded.walletClient).toBe(walletClient); + expect(forwarded.chains!.default.wallet).toBe(address); + expect(forwarded.chains!.default.walletClient).toBe(walletClient); expect(stopImpersonatingAccount).toHaveBeenCalledWith({ address }); }); @@ -91,13 +95,17 @@ describe("withImpersonation", () => { await expect( step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { chain: { id: 31337 } }, - walletClient: {}, - testClient: { - impersonateAccount, - stopImpersonatingAccount, - setBalance: vi.fn(), + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { chain: { id: 31337 } }, + walletClient: {}, + testClient: { + impersonateAccount, + stopImpersonatingAccount, + setBalance: vi.fn(), + }, + }, }, } as any, async () => { @@ -125,13 +133,17 @@ describe("withImpersonation", () => { await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: { chain: { id: 31337 } }, - walletClient: {}, - testClient: { - impersonateAccount, - stopImpersonatingAccount, - setBalance: vi.fn(), + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: { chain: { id: 31337 } }, + walletClient: {}, + testClient: { + impersonateAccount, + stopImpersonatingAccount, + setBalance: vi.fn(), + }, + }, }, } as any, async () => {}, diff --git a/packages/core/src/scenarios/fixtures/withImpersonation.ts b/packages/core/src/scenarios/fixtures/withImpersonation.ts index 6d53065..5824fcc 100644 --- a/packages/core/src/scenarios/fixtures/withImpersonation.ts +++ b/packages/core/src/scenarios/fixtures/withImpersonation.ts @@ -4,10 +4,12 @@ import type { ScenarioRuntimeClientsContext, ScenarioStep, } from "../types.js"; -import { requireRuntimeClients } from "../utils.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; /** Options for impersonating an existing on-chain account via Anvil test client controls. */ export type withImpersonationConfig = { + /** Key on `ctx.chains` (default `default`). */ + chain?: string; /** Address to impersonate for transaction signing in this scenario step. */ address: Address; /** Optional ETH balance to set in wei before forwarding context. */ @@ -20,20 +22,22 @@ export type withImpersonationConfig = { }; /** - * Middleware: impersonates `config.address`, swaps in a wallet client for that account, + * Middleware: impersonates `config.address` on `ctx.chains[chain]`, swaps in a wallet client for that account, * runs `next`, then stops impersonation by default. - * Requires prior runtime fixtures (`withChain`, `withFork`, or `withExternalRuntime`). + * Requires prior runtime fixtures for that chain (`withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain`). */ export function withImpersonation( config: withImpersonationConfig, ): ScenarioStep { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; - await ctx.testClient.impersonateAccount({ address: config.address }); + await ch.testClient.impersonateAccount({ address: config.address }); if (config.balance !== undefined) { - await ctx.testClient.setBalance({ + await ch.testClient.setBalance({ address: config.address, value: config.balance, }); @@ -41,19 +45,27 @@ export function withImpersonation( const walletClient = createWalletClient({ account: config.address, - chain: ctx.publicClient.chain, - transport: http(ctx.runtime.rpcUrl), + chain: ch.publicClient.chain, + transport: http(ch.runtime.rpcUrl), }); + const nextChains = { + ...ctx.chains, + [chainKey]: { + ...ch, + wallet: config.address, + walletClient, + }, + }; + try { await next({ ...ctx, - wallet: config.address, - walletClient, + chains: nextChains, }); } finally { if (config.stopOnExit !== false) { - await ctx.testClient.stopImpersonatingAccount({ + await ch.testClient.stopImpersonatingAccount({ address: config.address, }); } diff --git a/packages/core/src/scenarios/fixtures/withMultiChain.test.ts b/packages/core/src/scenarios/fixtures/withMultiChain.test.ts new file mode 100644 index 0000000..9a626ee --- /dev/null +++ b/packages/core/src/scenarios/fixtures/withMultiChain.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const startRuntime = vi.fn(); +const stopRuntime = vi.fn(); +const createClients = vi.fn(); + +vi.mock("../../runtime/index.js", () => ({ + startRuntime, + stopRuntime, +})); + +vi.mock("../../clients/index.js", () => ({ + createClients, +})); + +describe("withMultiChain", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("throws when config is empty", async () => { + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain({}); + + await expect( + step({} as any, async () => { + throw new Error("next should not run"); + }), + ).rejects.toThrow(/at least one chain entry/i); + }); + + test("starts two chain runtimes, forwards ctx.chains, and stops both in reverse order", async () => { + const runtimeA = { rpcUrl: "http://127.0.0.1:8545" }; + const runtimeB = { rpcUrl: "http://127.0.0.1:8546" }; + const clientsA = { + publicClient: { chain: { id: 31337 } }, + walletClient: { a: true }, + testClient: { a: true }, + }; + const clientsB = { + publicClient: { chain: { id: 31338 } }, + walletClient: { b: true }, + testClient: { b: true }, + }; + + startRuntime.mockResolvedValueOnce(runtimeA).mockResolvedValueOnce(runtimeB); + createClients.mockReturnValueOnce(clientsA).mockReturnValueOnce(clientsB); + + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain({ + a: { type: "chain", chainId: 31337 }, + b: { type: "chain", chainId: 31338 }, + }); + + const next = vi.fn(async (nextCtx: any) => { + expect(nextCtx.chains.a.runtime).toBe(runtimeA); + expect(nextCtx.chains.a.runtimeMode).toBe("chain"); + expect(nextCtx.chains.b.runtime).toBe(runtimeB); + expect(nextCtx.chains.b.runtimeMode).toBe("chain"); + }); + + await step({} as any, next); + + expect(startRuntime).toHaveBeenCalledTimes(2); + expect(stopRuntime).toHaveBeenCalledTimes(2); + expect(stopRuntime).toHaveBeenNthCalledWith(1, runtimeB); + expect(stopRuntime).toHaveBeenNthCalledWith(2, runtimeA); + }); + + test("throws on duplicate keys", async () => { + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain({ + a: { type: "chain" }, + }); + + await expect( + step( + { + chains: { + a: { + runtime: { rpcUrl: "x" }, + runtimeMode: "chain" as const, + chain: {}, + publicClient: {}, + walletClient: {}, + testClient: {}, + }, + }, + } as any, + async () => {}, + ), + ).rejects.toThrow(/duplicate chain key "a"/i); + }); +}); diff --git a/packages/core/src/scenarios/fixtures/withMultiChain.ts b/packages/core/src/scenarios/fixtures/withMultiChain.ts new file mode 100644 index 0000000..e83a918 --- /dev/null +++ b/packages/core/src/scenarios/fixtures/withMultiChain.ts @@ -0,0 +1,125 @@ +import { createClients, type CreateClientsOptions } from "../../clients/index.js"; +import { startRuntime, stopRuntime, type RuntimeHandle, type RuntimeMode } from "../../runtime/index.js"; +import type { ScenarioChainContext, ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; + +/** + * One chain entry for {@link withMultiChain}: either a fresh chain, a pinned fork, or an external runtime. + */ +export type WithMultiChainEntry = + | { + type: "chain"; + /** Anvil `--chain-id` when set. */ + chainId?: number; + /** Stable id forwarded to `RuntimeConfig.key`. */ + key?: string; + } + | { + type: "fork"; + /** HTTP(S) RPC URL of the chain to fork. */ + rpcUrl: string; + /** Pinned fork block (required). */ + blockNumber: bigint; + chainId?: number; + key?: string; + } + | { + type: "external"; + runtime: RuntimeHandle; + runtimeMode?: RuntimeMode; + clients?: CreateClientsOptions; + }; + +/** + * Map of chain key → chain spec. Keys become `ctx.chains.`. + */ +export type WithMultiChainConfig = Record; + +/** + * Middleware: starts or attaches multiple chain runtimes, wires viem clients under `ctx.chains`, runs `next`, + * then stops only runtimes this fixture started (external entries are not stopped). + */ +export function withMultiChain(config: WithMultiChainConfig): ScenarioStep { + return async (ctx, next) => { + const keys = Object.keys(config); + if (keys.length === 0) { + throw new Error("withMultiChain(...) requires at least one chain entry."); + } + + const sortedKeys = [...keys].sort(); + const owned: RuntimeHandle[] = []; + const chains: Record = { ...(ctx.chains ?? {}) }; + + try { + for (const key of sortedKeys) { + const entry = config[key]; + if (!entry) { + continue; + } + if (chains[key]) { + throw new Error(`withMultiChain(...) duplicate chain key "${key}".`); + } + + if (entry.type === "chain") { + const runtime = await startRuntime({ + mode: "chain", + ...(entry.chainId !== undefined ? { chainId: entry.chainId } : {}), + ...(entry.key !== undefined ? { key: entry.key } : {}), + }); + owned.push(runtime); + const clients = createClients(runtime, entry.chainId !== undefined ? { chainId: entry.chainId } : {}); + chains[key] = { + runtime, + runtimeMode: "chain", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }; + } else if (entry.type === "fork") { + if (!entry.rpcUrl) { + throw new Error(`withMultiChain(...) chain "${key}" (fork) requires rpcUrl.`); + } + if (entry.blockNumber === undefined) { + throw new Error(`withMultiChain(...) chain "${key}" (fork) requires a pinned blockNumber.`); + } + const runtime = await startRuntime({ + mode: "fork", + rpcUrl: entry.rpcUrl, + blockNumber: entry.blockNumber, + ...(entry.chainId !== undefined ? { chainId: entry.chainId } : {}), + ...(entry.key !== undefined ? { key: entry.key } : {}), + }); + owned.push(runtime); + const clients = createClients(runtime, entry.chainId !== undefined ? { chainId: entry.chainId } : {}); + chains[key] = { + runtime, + runtimeMode: "fork", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }; + } else { + const clients = createClients(entry.runtime, entry.clients); + chains[key] = { + runtime: entry.runtime, + runtimeMode: entry.runtimeMode ?? "chain", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }; + } + } + + await next({ + ...ctx, + chains, + }); + } finally { + for (const handle of owned.reverse()) { + await stopRuntime(handle); + } + } + }; +} diff --git a/packages/core/src/scenarios/fixtures/withSnapshot.test.ts b/packages/core/src/scenarios/fixtures/withSnapshot.test.ts index 10e67e1..a5b82b1 100644 --- a/packages/core/src/scenarios/fixtures/withSnapshot.test.ts +++ b/packages/core/src/scenarios/fixtures/withSnapshot.test.ts @@ -9,7 +9,7 @@ describe("withSnapshot", () => { step({} as any, async () => { throw new Error("next should not run"); }), - ).rejects.toThrow(/missing runtime clients/i); + ).rejects.toThrow(/missing runtime clients for chain "default"/i); }); test("creates snapshot, runs next, and reverts on success", async () => { @@ -20,10 +20,14 @@ describe("withSnapshot", () => { const step = withSnapshot(); await step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: {}, - testClient: { snapshot, revert }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: {}, + testClient: { snapshot, revert }, + }, + }, } as any, next, ); @@ -41,10 +45,14 @@ describe("withSnapshot", () => { await expect( step( { - runtime: { rpcUrl: "http://127.0.0.1:8545" }, - publicClient: {}, - walletClient: {}, - testClient: { snapshot, revert }, + chains: { + default: { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: {}, + testClient: { snapshot, revert }, + }, + }, } as any, async () => { throw new Error("boom"); diff --git a/packages/core/src/scenarios/fixtures/withSnapshot.ts b/packages/core/src/scenarios/fixtures/withSnapshot.ts index ccc045a..4586a6c 100644 --- a/packages/core/src/scenarios/fixtures/withSnapshot.ts +++ b/packages/core/src/scenarios/fixtures/withSnapshot.ts @@ -1,19 +1,27 @@ import type { ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; -import { requireRuntimeClients } from "../utils.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; + +/** Options for {@link withSnapshot}. */ +export type WithSnapshotConfig = { + /** Key on `ctx.chains` to snapshot (default `default`). */ + chain?: string; +}; /** - * Middleware: takes an anvil snapshot before `next`, then reverts to it in `finally` - * (isolates side effects of inner steps). Requires `withChain` / `withFork`. + * Middleware: takes an anvil snapshot on `ctx.chains[chain]` before `next`, then reverts to it in `finally` + * (isolates side effects of inner steps). Requires a prior runtime fixture for that chain. */ -export function withSnapshot(): ScenarioStep { +export function withSnapshot(config: WithSnapshotConfig = {}): ScenarioStep { + const chainKey = config.chain ?? "default"; return async (ctx, next) => { - requireRuntimeClients(ctx); + requireChainScopedRuntimeClients(ctx, chainKey); + const ch = ctx.chains[chainKey]!; - const snapshotId = await ctx.testClient.snapshot(); + const snapshotId = await ch.testClient.snapshot(); try { await next(ctx); } finally { - await ctx.testClient.revert({ id: snapshotId }); + await ch.testClient.revert({ id: snapshotId }); } }; } diff --git a/packages/core/src/scenarios/index.ts b/packages/core/src/scenarios/index.ts index d71bc45..c0be911 100644 --- a/packages/core/src/scenarios/index.ts +++ b/packages/core/src/scenarios/index.ts @@ -10,13 +10,14 @@ export { withErc20Balance } from "./fixtures/withErc20Balance.js"; export { withContracts } from "./fixtures/withContracts.js"; export { withDeployments } from "./fixtures/withDeployments.js"; export { withBundler } from "./fixtures/withBundler.js"; +export { withMultiChain } from "./fixtures/withMultiChain.js"; export type { AfterDeployContext, AfterSetCodeContext, ContractArtifact, - BundlerContext, ScenarioBundlerContext, + ScenarioChainContext, DeploymentArgsResolver, DeploymentRecord, EmptyScenarioContext, @@ -43,3 +44,5 @@ export type { DeploymentSpec, } from "./fixtures/withDeployments.js"; export type { WithBundlerConfig } from "./fixtures/withBundler.js"; +export type { WithMultiChainConfig, WithMultiChainEntry } from "./fixtures/withMultiChain.js"; +export type { WithSnapshotConfig } from "./fixtures/withSnapshot.js"; diff --git a/packages/core/src/scenarios/requireContext.test.ts b/packages/core/src/scenarios/requireContext.test.ts index 0445269..0d07ead 100644 --- a/packages/core/src/scenarios/requireContext.test.ts +++ b/packages/core/src/scenarios/requireContext.test.ts @@ -1,19 +1,31 @@ import { describe, expect, test } from "vitest"; import { requireContext } from "./utils.js"; import type { ScenarioContext } from "./types.js"; +import type { Chain } from "viem"; describe("requireContext", () => { test("returns the same object when keys are present", () => { + const chain = { id: 31337 } as Chain; const ctx: ScenarioContext = { - wallet: "0x0000000000000000000000000000000000000001", + chains: { + default: { + runtime: { key: "k", rpcUrl: "http://127.0.0.1:8545", stop: async () => {} }, + runtimeMode: "chain", + chain, + publicClient: {} as any, + walletClient: {} as any, + testClient: {} as any, + wallet: "0x0000000000000000000000000000000000000001", + }, + }, }; - const narrowed = requireContext(ctx, "wallet"); + const narrowed = requireContext(ctx, "chains"); expect(narrowed).toBe(ctx); - expect(narrowed.wallet).toBe(ctx.wallet); + expect(narrowed.chains).toBe(ctx.chains); }); test("throws when a key is missing", () => { const ctx: ScenarioContext = {}; - expect(() => requireContext(ctx, "wallet")).toThrow(/missing required key:\s*wallet/i); + expect(() => requireContext(ctx, "chains")).toThrow(/missing required key:\s*chains/i); }); }); diff --git a/packages/core/src/scenarios/scenario-invalid-chain.typespec.ts b/packages/core/src/scenarios/scenario-invalid-chain.typespec.ts deleted file mode 100644 index 28ea586..0000000 --- a/packages/core/src/scenarios/scenario-invalid-chain.typespec.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Compile-only: invalid fixture order must be a type error (see @ts-expect-error below). - * This file is typechecked with the package; it is not executed as a test module. - */ -import { scenario } from "./scenario.js"; -import { withChain } from "./fixtures/withChain.js"; -import { withErc20Balance } from "./fixtures/withErc20Balance.js"; -import type { ScenarioFundedWalletContext, ScenarioTest } from "./types.js"; - -const USDC_MAINNET = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48" as const; - -const needsFundedWallet: ScenarioTest = async () => {}; - -scenario( - // @ts-expect-error withChain does not set ctx.wallet; withErc20Balance without `to` needs withFundedWallet first - withChain(), - withErc20Balance({ - token: USDC_MAINNET, - amount: 1n, - }), - needsFundedWallet, -); diff --git a/packages/core/src/scenarios/scenario-typing.test.ts b/packages/core/src/scenarios/scenario-typing.test.ts index 9ab2968..9248905 100644 --- a/packages/core/src/scenarios/scenario-typing.test.ts +++ b/packages/core/src/scenarios/scenario-typing.test.ts @@ -7,6 +7,7 @@ import { withExternalRuntime } from "./fixtures/withExternalRuntime.js"; import { withFundedWallet } from "./fixtures/withFundedWallet.js"; import { withErc20Balance } from "./fixtures/withErc20Balance.js"; import { withBundler } from "./fixtures/withBundler.js"; +import { withMultiChain } from "./fixtures/withMultiChain.js"; import type { ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep, ScenarioTest } from "./types.js"; import type { ScenarioBundlerContext } from "./types.js"; @@ -18,6 +19,10 @@ test("withChain output type is runtime clients context", () => { expectTypeOf>>().toEqualTypeOf(); }); +test("withMultiChain output type is runtime clients context", () => { + expectTypeOf>>().toEqualTypeOf(); +}); + test("withExternalRuntime output type is runtime clients context", () => { const runtime = { key: "t", diff --git a/packages/core/src/scenarios/types.ts b/packages/core/src/scenarios/types.ts index 7b4388b..9bb67e7 100644 --- a/packages/core/src/scenarios/types.ts +++ b/packages/core/src/scenarios/types.ts @@ -64,47 +64,27 @@ export type DeploymentArgsResolver = (ctx: { export type EmptyScenarioContext = {}; /** - * Context after {@link withChain} or {@link withFork}: runtime handle and viem clients are always set. + * Per-chain EVM context: one Anvil runtime, viem clients, and chain-scoped state (wallet, contracts, bundler). */ -export type ScenarioRuntimeClientsContext = ScenarioContext & { - runtime: NonNullable; - runtimeMode: NonNullable; - chain: NonNullable; - publicClient: NonNullable; - walletClient: NonNullable; - testClient: NonNullable; -}; - -/** - * Context after {@link withFundedWallet}: funded account address is always set (in addition to runtime clients). - */ -export type ScenarioFundedWalletContext = ScenarioRuntimeClientsContext & { - wallet: Hex; -}; - -/** - * Accumulated context passed through `withX` middleware. Fields are added by fixtures (e.g. {@link withChain}). - */ -export type ScenarioContext = { - /** Live anvil handle; set by `withChain` or `withFork`. */ - runtime?: RuntimeHandle; - /** Runtime mode (`chain` or `fork`); set by runtime fixtures. */ - runtimeMode?: RuntimeMode; - /** Chain identity used by all viem clients in this scenario runtime. */ - chain?: Chain; - /** Viem public client; set with runtime fixtures. */ - publicClient?: PublicClient; - /** Viem wallet client; may be replaced by {@link withFundedWallet}. */ - walletClient?: WalletClient; +export type ScenarioChainContext = { + /** Live anvil handle for this chain entry. */ + runtime: RuntimeHandle; + /** Runtime mode (`chain` or `fork`). */ + runtimeMode: RuntimeMode; + /** Chain identity used by viem clients for this entry. */ + chain: Chain; + publicClient: PublicClient; + /** Viem wallet client; may be replaced by {@link withFundedWallet} or {@link withImpersonation}. */ + walletClient: WalletClient; /** Viem anvil test client (snapshots, `setCode`, etc.). */ - testClient?: TestClient<"anvil", Transport, Chain>; - /** Address of the funded test wallet when `withFundedWallet` ran. */ + testClient: TestClient<"anvil", Transport, Chain>; + /** Address of the funded or impersonated wallet when a wallet fixture ran on this chain. */ wallet?: Hex; - /** Named contract handles from `withContracts` and merged across steps. */ + /** Named contract handles from `withContracts` on this chain. */ contracts?: ScenarioContracts; - /** Named deployment records from `withDeployments` and merged across steps. */ + /** Named deployment records from `withDeployments` on this chain. */ deployments?: Record; - /** HTTP RPC endpoint for a local ERC-4337 bundler. Set by {@link withBundler}. */ + /** HTTP RPC endpoint for a local ERC-4337 bundler. Set by {@link withBundler} on this chain. */ bundlerUrl?: string; /** Viem-compatible bundler client for RPC methods like `eth_sendUserOperation`. Set by {@link withBundler}. */ bundlerClient?: BundlerClient; @@ -113,42 +93,54 @@ export type ScenarioContext = { }; /** - * Bundler-only context fields added by {@link withBundler}. + * Accumulated context passed through `withX` middleware. Runtime state lives under {@link ScenarioChainContext} + * keyed by chain name (for example `ctx.chains.ethereum`). */ -export type BundlerContext = { - /** HTTP RPC endpoint for the local bundler. */ - bundlerUrl: string; - /** Viem-compatible bundler client for JSON-RPC methods like `eth_sendUserOperation`. */ - bundlerClient: BundlerClient; - /** ERC-4337 entry point address configured for this bundler instance. */ - entryPoint: Address; +export type ScenarioContext = { + chains?: Record; }; /** - * Scenario context after {@link withBundler}: runtime clients plus bundler RPC and a typed client. + * Context after a runtime fixture (`withChain`, `withFork`, `withExternalRuntime`, or `withMultiChain`): at least one chain entry exists with clients. + */ +export type ScenarioRuntimeClientsContext = ScenarioContext & { + chains: Record; +}; + +/** + * Context after {@link withFundedWallet}: the targeted chain entry includes `wallet`. Prefer reading `ctx.chains[chain].wallet`. + */ +export type ScenarioFundedWalletContext = ScenarioRuntimeClientsContext; + +/** + * Context after {@link withBundler}: the targeted chain entry includes bundler fields. */ -export type ScenarioBundlerContext = ScenarioContext & BundlerContext; +export type ScenarioBundlerContext = ScenarioRuntimeClientsContext; /** Context passed to `ContractInjection.afterSetCode` from `withContracts`. */ export type AfterSetCodeContext = { + /** Chain key this injection ran on. */ + chain: string; /** Contract key from the `withContracts` config map. */ name: string; /** Address where runtime bytecode was installed. */ address: Hex; - testClient: NonNullable; - publicClient: NonNullable; - walletClient: NonNullable; + testClient: NonNullable; + publicClient: NonNullable; + walletClient: NonNullable; }; /** Context passed to `DeploymentSpec.afterDeploy` from `withDeployments`. */ export type AfterDeployContext = { + /** Chain key this deployment ran on. */ + chain: string; /** Deployment key from the `withDeployments` config map. */ name: string; /** Record for the deployment just completed. */ deployment: DeploymentRecord; - /** All deployments so far, including this one. */ + /** All deployments so far on this chain, including this one. */ deployments: Record; - /** Funded wallet address when `withFundedWallet` ran before this step; otherwise `undefined`. */ + /** Funded wallet address when `withFundedWallet` ran before this step on this chain; otherwise `undefined`. */ wallet: Hex | undefined; }; diff --git a/packages/core/src/scenarios/utils.ts b/packages/core/src/scenarios/utils.ts index 9e8fe5d..7dca892 100644 --- a/packages/core/src/scenarios/utils.ts +++ b/packages/core/src/scenarios/utils.ts @@ -1,4 +1,4 @@ -import type { ContractArtifact, ScenarioContext } from "./types.js"; +import type { ContractArtifact, ScenarioChainContext, ScenarioContext } from "./types.js"; import type { Hex } from "viem"; /** @@ -30,14 +30,20 @@ export function requireContext< return ctx as RequireScenarioKeys; } -export function requireRuntimeClients(ctx: ScenarioContext): asserts ctx is ScenarioContext & { - runtime: NonNullable; - publicClient: NonNullable; - walletClient: NonNullable; - testClient: NonNullable; +/** + * Asserts `ctx.chains[chainKey]` exists and has runtime + viem clients. + */ +export function requireChainScopedRuntimeClients( + ctx: ScenarioContext, + chainKey: string, +): asserts ctx is ScenarioContext & { + chains: Record & Record; } { - if (!ctx.runtime || !ctx.publicClient || !ctx.walletClient || !ctx.testClient) { - throw new Error("Scenario context is missing runtime clients. Compose with withChain(...) or withFork(...) first."); + const ch = ctx.chains?.[chainKey]; + if (!ch?.runtime || !ch.publicClient || !ch.walletClient || !ch.testClient) { + throw new Error( + `Scenario context is missing runtime clients for chain "${chainKey}". Compose withChain(...), withFork(...), withExternalRuntime(...), or withMultiChain(...) first.`, + ); } } diff --git a/packages/examples/examples/scenarios.test.ts b/packages/examples/examples/scenarios.test.ts index 0fb2861..f5cd1bf 100644 --- a/packages/examples/examples/scenarios.test.ts +++ b/packages/examples/examples/scenarios.test.ts @@ -12,6 +12,7 @@ import { withErc20Balance, withFork, withFundedWallet, + withMultiChain, withSnapshot, type ContractArtifact, type RuntimeHandle, @@ -41,8 +42,9 @@ describe("suite-scoped runtime (external lifecycle)", () => { withExternalRuntime({ runtime, clients: { chainId: 31_337 } }), withSnapshot(), withFundedWallet({ balance: parseEther("2") }), - async ({ wallet, publicClient }) => { - const balance = await publicClient.getBalance({ address: wallet }); + async ({ chains }) => { + const ch = chains!.default!; + const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(balance).toBe(parseEther("2")); }, )(); @@ -53,13 +55,14 @@ describe("suite-scoped runtime (external lifecycle)", () => { withExternalRuntime({ runtime, clients: { chainId: 31_337 } }), withSnapshot(), withFundedWallet({ balance: parseEther("1") }), - async ({ wallet, publicClient, testClient }) => { - const original = await publicClient.getBalance({ address: wallet }); - await testClient.setBalance({ - address: wallet, + async ({ chains }) => { + const ch = chains!.default!; + const original = await ch.publicClient.getBalance({ address: ch.wallet! }); + await ch.testClient.setBalance({ + address: ch.wallet!, value: parseEther("9"), }); - const changed = await publicClient.getBalance({ address: wallet }); + const changed = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(original).toBe(parseEther("1")); expect(changed).toBe(parseEther("9")); }, @@ -75,11 +78,12 @@ test( blockNumber: 22_000_000n, }), withBundler({ entryPoint: entryPoint4337, mode: "alto" }), - async ({ entryPoint, bundlerUrl, bundlerClient }) => { - expect(entryPoint).toBe(entryPoint4337); - expect(bundlerUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - const supported = await bundlerClient.getSupportedEntryPoints(); - const normalized = supported.map((a) => getAddress(a)); + async ({ chains }) => { + const ch = chains!.default!; + expect(ch.entryPoint).toBe(entryPoint4337); + expect(ch.bundlerUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const supported = await ch.bundlerClient!.getSupportedEntryPoints(); + const normalized = supported.map((a: `0x${string}`) => getAddress(a)); expect(normalized).toContain(entryPoint4337); }, ), @@ -93,8 +97,9 @@ test( withFundedWallet({ balance: parseEther("1"), }), - async ({ wallet, publicClient }) => { - const balance = await publicClient.getBalance({ address: wallet }); + async ({ chains }) => { + const ch = chains!.default!; + const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); expect(balance).toBe(parseEther("1")); }, ), @@ -110,12 +115,13 @@ test( withFundedWallet({ balance: parseEther("1"), }), - async ({ wallet, publicClient }) => { - const tokenBalance = await publicClient.readContract({ + async ({ chains }) => { + const ch = chains!.default!; + const tokenBalance = await ch.publicClient.readContract({ address: wethAddress, abi: erc20Abi, functionName: "balanceOf", - args: [wallet], + args: [ch.wallet!], }); expect(tokenBalance).toBe(0n); @@ -139,12 +145,13 @@ test( }, ], }), - async ({ wallet, publicClient }) => { - const tokenBalance = await publicClient.readContract({ + async ({ chains }) => { + const ch = chains!.default!; + const tokenBalance = await ch.publicClient.readContract({ address: usdcAddress, abi: erc20Abi, functionName: "balanceOf", - args: [wallet], + args: [ch.wallet!], }); expect(tokenBalance).toBe(1_000_000n); }, @@ -165,12 +172,13 @@ test( token: usdcAddress, amount: 1_000_000n, }), - async ({ wallet, publicClient }) => { - const tokenBalance = await publicClient.readContract({ + async ({ chains }) => { + const ch = chains!.default!; + const tokenBalance = await ch.publicClient.readContract({ address: usdcAddress, abi: erc20Abi, functionName: "balanceOf", - args: [wallet], + args: [ch.wallet!], }); expect(tokenBalance).toBe(1_000_000n); }, @@ -182,13 +190,15 @@ test( scenario( withChain(), withDeployments({ - answer: { - artifact: answerArtifact as ContractArtifact, - args: [], + deployments: { + answer: { + artifact: answerArtifact as ContractArtifact, + args: [], + }, }, }), - async ({ deployments }) => { - const deployment = deployments?.answer; + async ({ chains }) => { + const deployment = chains!.default!.deployments?.answer; if (!deployment) { throw new Error("Expected deployment record for answer."); } @@ -204,13 +214,15 @@ test( scenario( withChain(), withContracts({ - answer: { - artifact: answerArtifact as ContractArtifact, - address: "0x1000000000000000000000000000000000000001", + contracts: { + answer: { + artifact: answerArtifact as ContractArtifact, + address: "0x1000000000000000000000000000000000000001", + }, }, }), - async ({ contracts }) => { - const contract = contracts?.answer; + async ({ chains }) => { + const contract = chains!.default!.contracts?.answer; if (!contract) { throw new Error("Expected contract handle for answer."); } @@ -219,3 +231,43 @@ test( }, ), ); + +test( + "withMultiChain: two local chains with independent funded wallets", + scenario( + withMultiChain({ + a: { type: "chain", chainId: 31_337 }, + b: { type: "chain", chainId: 31_338 }, + }), + withFundedWallet({ chain: "a", balance: parseEther("1") }), + withFundedWallet({ chain: "b", balance: parseEther("2") }), + async ({ chains }) => { + const a = chains!.a!; + const b = chains!.b!; + const ba = await a.publicClient.getBalance({ address: a.wallet! }); + const bb = await b.publicClient.getBalance({ address: b.wallet! }); + expect(ba).toBe(parseEther("1")); + expect(bb).toBe(parseEther("2")); + }, + ), +); + +test( + "withMultiChain: snapshot on one chain does not revert the other", + scenario( + withMultiChain({ + left: { type: "chain", chainId: 31_337 }, + right: { type: "chain", chainId: 31_338 }, + }), + withFundedWallet({ chain: "left", balance: parseEther("1") }), + withFundedWallet({ chain: "right", balance: parseEther("1") }), + withSnapshot({ chain: "left" }), + async ({ chains }) => { + const left = chains!.left!; + const right = chains!.right!; + await left.testClient.setBalance({ address: left.wallet!, value: parseEther("9") }); + expect(await left.publicClient.getBalance({ address: left.wallet! })).toBe(parseEther("9")); + expect(await right.publicClient.getBalance({ address: right.wallet! })).toBe(parseEther("1")); + }, + ), +); diff --git a/vocs.config.ts b/vocs.config.ts index b1b2008..51c95c0 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -160,6 +160,7 @@ export default defineConfig({ text: "withExternalRuntime", link: "/fixtures/runtime/withExternalRuntime", }, + { text: "withMultiChain", link: "/fixtures/runtime/withMultiChain" }, { text: "withBundler", link: "/fixtures/runtime/withBundler" }, ], }, From 8b42ade9ada35b11ba7adee994fe43795eeede7f Mon Sep 17 00:00:00 2001 From: Joe Pegler Date: Wed, 8 Apr 2026 20:49:22 +0100 Subject: [PATCH 2/3] chore: withBridge Add a bridge simulation fixture for multi-chain scenarios. It exposes an execute callback that allows tests to manually trigger deterministic native or ERC-20 balance transfers between defined source and destination chains. --- docs/pages/fixtures/runtime/index.mdx | 1 + docs/pages/fixtures/runtime/withBridge.mdx | 66 ++++++ .../src/scenarios/fixtures/withBridge.test.ts | 206 ++++++++++++++++++ .../core/src/scenarios/fixtures/withBridge.ts | 179 +++++++++++++++ packages/core/src/scenarios/index.ts | 7 + .../src/scenarios/scenario-typing.test.ts | 32 ++- packages/core/src/scenarios/types.ts | 72 ++++++ packages/examples/examples/scenarios.test.ts | 60 ++++- 8 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 docs/pages/fixtures/runtime/withBridge.mdx create mode 100644 packages/core/src/scenarios/fixtures/withBridge.test.ts create mode 100644 packages/core/src/scenarios/fixtures/withBridge.ts diff --git a/docs/pages/fixtures/runtime/index.mdx b/docs/pages/fixtures/runtime/index.mdx index 21c7e96..6381eb5 100644 --- a/docs/pages/fixtures/runtime/index.mdx +++ b/docs/pages/fixtures/runtime/index.mdx @@ -22,6 +22,7 @@ Downstream fixtures (wallets, ERC-20 seeding, contracts, isolation, bundler) tar - [`withFork({ rpcUrl, blockNumber, ... })`](/fixtures/runtime/withFork) - [`withExternalRuntime({ runtime, ... })`](/fixtures/runtime/withExternalRuntime) - [`withMultiChain({ ... })`](/fixtures/runtime/withMultiChain) +- [`withBridge({ srcChain, destChain, ... })`](/fixtures/runtime/withBridge) ## Additional wiring - [`withBundler({ chain?, entryPoint, mode? })`](/fixtures/runtime/withBundler) diff --git a/docs/pages/fixtures/runtime/withBridge.mdx b/docs/pages/fixtures/runtime/withBridge.mdx new file mode 100644 index 0000000..7b08c29 --- /dev/null +++ b/docs/pages/fixtures/runtime/withBridge.mdx @@ -0,0 +1,66 @@ +--- +title: withBridge +description: Expose a deterministic bridge simulator callback and let the test choose when to execute it. +--- + +## withBridge(config) + +`withBridge` is a test-double helper for multi-chain scenarios. It does not auto-run. Instead, it exposes `ctx.bridge.execute(...)` so the test decides exactly when to simulate a bridge transfer. You can wrap it with `vi.fn(...)` to assert integration points while still mutating balances across chains. + +Use this alongside [`withMultiChain`](/fixtures/runtime/withMultiChain) and chain-targeted wallet setup. + +### Example +```ts twoslash [index.ts] +import { test, expect, vi } from "vitest"; +import { parseEther } from "viem"; +import { scenario, withBridge, withFundedWallet, withMultiChain, NATIVE_TOKEN_ADDRESS } from "@st8craft/core"; + +test( + "simulate bridge at a chosen test step", + scenario( + withMultiChain({ + src: { type: "chain", chainId: 31_337 }, + dest: { type: "chain", chainId: 31_338 }, + }), + withFundedWallet({ chain: "src", balance: parseEther("2") }), + withFundedWallet({ chain: "dest", balance: parseEther("1") }), + withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }), + async ({ chains, bridge }) => { + const src = chains!.src; + const dest = chains!.dest; + const executeBridge = vi.fn(bridge!.execute); + + expect(await src.publicClient.getBalance({ address: src.wallet! })).toBe(parseEther("2")); + + const bridgePromise = executeBridge({ amountIn: parseEther("1"), price: 1n }); + + await vi.waitFor(async () => { + expect(await dest.publicClient.getBalance({ address: dest.wallet! })).toBe(parseEther("2")); + }); + + await bridgePromise; + expect(executeBridge).toHaveBeenCalledTimes(1); + }, + ), +); +``` + +### Config +- `srcChain`, `destChain`: chain keys from `ctx.chains`. +- `fromToken`, `toToken`: token addresses. Use `NATIVE_TOKEN_ADDRESS` for native balance. +- `from`, `to` (optional): default accounts, otherwise the chain wallet for each side. +- `priceScale` (optional): destination math divisor. `amountOut = amountIn * price / priceScale`. + +### Execute args +- `amountIn`: source debit amount. +- `price`: conversion price used for destination amount. +- `from`, `to` (optional): per-call address overrides. + +### Notes +- This is test-only behavior that mutates local or forked test state. +- The fixture does not attempt to model real bridge messaging or relayer behavior. diff --git a/packages/core/src/scenarios/fixtures/withBridge.test.ts b/packages/core/src/scenarios/fixtures/withBridge.test.ts new file mode 100644 index 0000000..08a4767 --- /dev/null +++ b/packages/core/src/scenarios/fixtures/withBridge.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { withBridge } from "./withBridge.js"; +import { NATIVE_TOKEN_ADDRESS } from "../types.js"; + +const { dealErc20Balance } = vi.hoisted(() => ({ + dealErc20Balance: vi.fn(), +})); + +vi.mock("../internal/dealErc20Balance.js", () => ({ + dealErc20Balance, +})); + +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48" as const; +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" as const; +const ALICE = "0x00000000000000000000000000000000000000a1" as const; +const BOB = "0x00000000000000000000000000000000000000b2" as const; + +describe("withBridge", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("exposes ctx.bridge and does not execute until called by test", async () => { + const srcBalances = new Map([[ALICE, 10n]]); + const destBalances = new Map([[BOB, 1n]]); + + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }); + + const ctx = { + chains: { + src: makeNativeChain({ wallet: ALICE, balances: srcBalances }), + dest: makeNativeChain({ wallet: BOB, balances: destBalances }), + }, + } as any; + + const next = vi.fn(async (nextCtx: any) => { + expect(nextCtx.bridge).toBeDefined(); + expect(srcBalances.get(ALICE)).toBe(10n); + expect(destBalances.get(BOB)).toBe(1n); + }); + + await step(ctx, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test("debits native on src and credits native on dest", async () => { + const srcBalances = new Map([[ALICE, 10n]]); + const destBalances = new Map([[BOB, 1n]]); + + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + priceScale: 10n, + }); + + const ctx = { + chains: { + src: makeNativeChain({ wallet: ALICE, balances: srcBalances }), + dest: makeNativeChain({ wallet: BOB, balances: destBalances }), + }, + } as any; + + await step(ctx, async (nextCtx: any) => { + const receipt = await nextCtx.bridge.execute({ amountIn: 5n, price: 4n }); + expect(receipt.amountOut).toBe(2n); + }); + + expect(srcBalances.get(ALICE)).toBe(5n); + expect(destBalances.get(BOB)).toBe(3n); + }); + + test("debits ERC-20 on src and credits ERC-20 on dest", async () => { + const srcTokenBalances = new Map([[`${USDC}:${ALICE}`.toLowerCase(), 1000n]]); + const destTokenBalances = new Map([[`${USDT}:${BOB}`.toLowerCase(), 50n]]); + + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: USDC, + toToken: USDT, + from: ALICE, + to: BOB, + priceScale: 100n, + }); + + const ctx = { + chains: { + src: makeErc20Chain(srcTokenBalances), + dest: makeErc20Chain(destTokenBalances), + }, + } as any; + + await step(ctx, async (nextCtx: any) => { + await nextCtx.bridge.execute({ amountIn: 500n, price: 20n }); + }); + + expect(dealErc20Balance).toHaveBeenCalledTimes(2); + expect(dealErc20Balance).toHaveBeenNthCalledWith(1, expect.objectContaining({ token: USDC, recipient: ALICE, amount: 500n })); + expect(dealErc20Balance).toHaveBeenNthCalledWith(2, expect.objectContaining({ token: USDT, recipient: BOB, amount: 150n })); + }); + + test("supports per-call recipient overrides", async () => { + const srcBalances = new Map([ + [ALICE, 10n], + [BOB, 20n], + ]); + const destBalances = new Map([ + [ALICE, 1n], + [BOB, 2n], + ]); + + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + from: ALICE, + to: ALICE, + }); + + const ctx = { + chains: { + src: makeNativeChain({ wallet: ALICE, balances: srcBalances }), + dest: makeNativeChain({ wallet: ALICE, balances: destBalances }), + }, + } as any; + + await step(ctx, async (nextCtx: any) => { + await nextCtx.bridge.execute({ + amountIn: 7n, + price: 2n, + from: BOB, + to: BOB, + }); + }); + + expect(srcBalances.get(ALICE)).toBe(10n); + expect(srcBalances.get(BOB)).toBe(13n); + expect(destBalances.get(ALICE)).toBe(1n); + expect(destBalances.get(BOB)).toBe(16n); + }); + + test("throws when required chains are missing", async () => { + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }); + + await expect(step({ chains: {} } as any, async () => {})).rejects.toThrow(/missing runtime clients for chain "src"/i); + }); +}); + +function makeNativeChain({ + wallet, + balances, +}: { + wallet?: `0x${string}`; + balances: Map; +}) { + return { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + runtimeMode: "chain" as const, + chain: { id: 31337 }, + publicClient: { + getBalance: vi.fn(async ({ address }: { address: `0x${string}` }) => balances.get(address) ?? 0n), + readContract: vi.fn(), + }, + walletClient: {}, + testClient: { + mode: "anvil", + setBalance: vi.fn(async ({ address, value }: { address: `0x${string}`; value: bigint }) => { + balances.set(address, value); + }), + }, + wallet, + }; +} + +function makeErc20Chain(tokenBalances: Map) { + return { + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + runtimeMode: "chain" as const, + chain: { id: 31337 }, + publicClient: { + getBalance: vi.fn(), + readContract: vi.fn(async ({ address, args }: { address: `0x${string}`; args: readonly unknown[] }) => { + const recipient = String(args[0]).toLowerCase(); + return tokenBalances.get(`${address}:${recipient}`.toLowerCase()) ?? 0n; + }), + }, + walletClient: {}, + testClient: { + mode: "anvil", + setBalance: vi.fn(), + }, + }; +} diff --git a/packages/core/src/scenarios/fixtures/withBridge.ts b/packages/core/src/scenarios/fixtures/withBridge.ts new file mode 100644 index 0000000..ee78c10 --- /dev/null +++ b/packages/core/src/scenarios/fixtures/withBridge.ts @@ -0,0 +1,179 @@ +import { erc20Abi, isAddressEqual, type Address } from "viem"; +import { dealErc20Balance } from "../internal/dealErc20Balance.js"; +import { + NATIVE_TOKEN_ADDRESS, + type BridgeExecuteArgs, + type BridgeExecution, + type ScenarioBridge, + type ScenarioBridgeContext, + type ScenarioRuntimeClientsContext, + type ScenarioStep, + type WithBridgeConfig, +} from "../types.js"; +import { requireChainScopedRuntimeClients } from "../utils.js"; + +const NATIVE_TOKEN = NATIVE_TOKEN_ADDRESS as Address; + +function isNativeToken(token: Address): boolean { + return isAddressEqual(token, NATIVE_TOKEN); +} + +/** + * Middleware: exposes `ctx.bridge.execute(...)` so tests can simulate a deterministic bridge transfer + * at a chosen moment. + */ +export function withBridge( + config: WithBridgeConfig, +): ScenarioStep< + C & { chains: C["chains"] }, + C & { chains: C["chains"]; bridge: ScenarioBridgeContext["bridge"] } +> { + const priceScale = config.priceScale ?? 1n; + if (priceScale <= 0n) { + throw new Error("withBridge(...) requires priceScale to be greater than zero."); + } + + return async (ctx, next) => { + requireChainScopedRuntimeClients(ctx, config.srcChain); + requireChainScopedRuntimeClients(ctx, config.destChain); + + const src = ctx.chains[config.srcChain]!; + const dest = ctx.chains[config.destChain]!; + + if (ctx.bridge) { + throw new Error("withBridge(...) ctx.bridge is already defined. Compose at most one withBridge(...) per scenario."); + } + + const bridge: ScenarioBridge = { + execute: async ({ amountIn, price, from, to }: BridgeExecuteArgs): Promise => { + if (amountIn < 0n) { + throw new Error("withBridge(...).execute(...) requires amountIn to be non-negative."); + } + if (price < 0n) { + throw new Error("withBridge(...).execute(...) requires price to be non-negative."); + } + + const fromAddress = from ?? config.from ?? src.wallet; + if (!fromAddress) { + throw new Error( + `withBridge(...).execute(...) requires a source recipient: pass \`from\`, configure \`config.from\`, or compose withFundedWallet(...) on source chain "${config.srcChain}".`, + ); + } + + const toAddress = to ?? config.to ?? dest.wallet; + if (!toAddress) { + throw new Error( + `withBridge(...).execute(...) requires a destination recipient: pass \`to\`, configure \`config.to\`, or compose withFundedWallet(...) on destination chain "${config.destChain}".`, + ); + } + + const amountOut = (amountIn * price) / priceScale; + + await debitAsset({ + chain: src, + token: config.fromToken, + owner: fromAddress, + amount: amountIn, + label: "source", + }); + + await creditAsset({ + chain: dest, + token: config.toToken, + owner: toAddress, + amount: amountOut, + }); + + return { + srcChain: config.srcChain, + destChain: config.destChain, + fromToken: config.fromToken, + toToken: config.toToken, + from: fromAddress, + to: toAddress, + amountIn, + amountOut, + price, + }; + }, + }; + + await next({ + ...ctx, + bridge, + }); + }; +} + +async function debitAsset({ + chain, + token, + owner, + amount, + label, +}: { + chain: ScenarioRuntimeClientsContext["chains"][string]; + token: Address; + owner: Address; + amount: bigint; + label: "source" | "destination"; +}): Promise { + if (isNativeToken(token)) { + const current = await chain.publicClient.getBalance({ address: owner }); + if (current < amount) { + throw new Error(`withBridge(...).execute(...) insufficient native ${label} balance: wanted ${amount}, got ${current}.`); + } + await chain.testClient.setBalance({ address: owner, value: current - amount }); + return; + } + + const current = await chain.publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }); + + if (current < amount) { + throw new Error(`withBridge(...).execute(...) insufficient ERC-20 ${label} balance: wanted ${amount}, got ${current}.`); + } + + await dealErc20Balance({ + testClient: chain.testClient, + token, + recipient: owner, + amount: current - amount, + }); +} + +async function creditAsset({ + chain, + token, + owner, + amount, +}: { + chain: ScenarioRuntimeClientsContext["chains"][string]; + token: Address; + owner: Address; + amount: bigint; +}): Promise { + if (isNativeToken(token)) { + const current = await chain.publicClient.getBalance({ address: owner }); + await chain.testClient.setBalance({ address: owner, value: current + amount }); + return; + } + + const current = await chain.publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: "balanceOf", + args: [owner], + }); + + await dealErc20Balance({ + testClient: chain.testClient, + token, + recipient: owner, + amount: current + amount, + }); +} diff --git a/packages/core/src/scenarios/index.ts b/packages/core/src/scenarios/index.ts index c0be911..2ae6a53 100644 --- a/packages/core/src/scenarios/index.ts +++ b/packages/core/src/scenarios/index.ts @@ -11,13 +11,19 @@ export { withContracts } from "./fixtures/withContracts.js"; export { withDeployments } from "./fixtures/withDeployments.js"; export { withBundler } from "./fixtures/withBundler.js"; export { withMultiChain } from "./fixtures/withMultiChain.js"; +export { withBridge } from "./fixtures/withBridge.js"; +export { NATIVE_TOKEN_ADDRESS } from "./types.js"; export type { AfterDeployContext, AfterSetCodeContext, ContractArtifact, ScenarioBundlerContext, + ScenarioBridge, + ScenarioBridgeContext, ScenarioChainContext, + BridgeExecution, + BridgeExecuteArgs, DeploymentArgsResolver, DeploymentRecord, EmptyScenarioContext, @@ -45,4 +51,5 @@ export type { } from "./fixtures/withDeployments.js"; export type { WithBundlerConfig } from "./fixtures/withBundler.js"; export type { WithMultiChainConfig, WithMultiChainEntry } from "./fixtures/withMultiChain.js"; +export type { WithBridgeConfig } from "./types.js"; export type { WithSnapshotConfig } from "./fixtures/withSnapshot.js"; diff --git a/packages/core/src/scenarios/scenario-typing.test.ts b/packages/core/src/scenarios/scenario-typing.test.ts index 9248905..cffed83 100644 --- a/packages/core/src/scenarios/scenario-typing.test.ts +++ b/packages/core/src/scenarios/scenario-typing.test.ts @@ -8,8 +8,9 @@ import { withFundedWallet } from "./fixtures/withFundedWallet.js"; import { withErc20Balance } from "./fixtures/withErc20Balance.js"; import { withBundler } from "./fixtures/withBundler.js"; import { withMultiChain } from "./fixtures/withMultiChain.js"; +import { withBridge } from "./fixtures/withBridge.js"; import type { ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep, ScenarioTest } from "./types.js"; -import type { ScenarioBundlerContext } from "./types.js"; +import { NATIVE_TOKEN_ADDRESS, type ScenarioBridgeContext, type ScenarioBundlerContext } from "./types.js"; const USDC_MAINNET = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48" as const; @@ -80,6 +81,16 @@ test("withBundler output type is scenario bundler context", () => { expectTypeOf>>().toEqualTypeOf(); }); +test("withBridge output type is scenario bridge context", () => { + expectTypeOf< + StepOut< + ReturnType< + typeof withBridge + > + > + >().toEqualTypeOf(); +}); + test("scenario(fork, bundler, test) accepts ScenarioTest", () => { const t: ScenarioTest = async (_ctx) => {}; expectTypeOf( @@ -92,3 +103,22 @@ test("scenario(fork, bundler, test) accepts ScenarioTest ), ).toEqualTypeOf<() => Promise>(); }); + +test("scenario(multichain, bridge, test) accepts ScenarioTest", () => { + const t: ScenarioTest = async (_ctx) => {}; + expectTypeOf( + scenario( + withMultiChain({ + src: { type: "chain", chainId: 31337 }, + dest: { type: "chain", chainId: 31338 }, + }), + withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }), + t, + ), + ).toEqualTypeOf<() => Promise>(); +}); diff --git a/packages/core/src/scenarios/types.ts b/packages/core/src/scenarios/types.ts index 9bb67e7..36cbe19 100644 --- a/packages/core/src/scenarios/types.ts +++ b/packages/core/src/scenarios/types.ts @@ -98,6 +98,7 @@ export type ScenarioChainContext = { */ export type ScenarioContext = { chains?: Record; + bridge?: ScenarioBridge; }; /** @@ -117,6 +118,77 @@ export type ScenarioFundedWalletContext = ScenarioRuntimeClientsContext; */ export type ScenarioBundlerContext = ScenarioRuntimeClientsContext; +/** + * Native token sentinel address used by bridge test-doubles to represent chain native balance mutations. + */ +export const NATIVE_TOKEN_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** + * Static route configuration for bridge simulation between two named chains. + */ +export type WithBridgeConfig = { + /** Source chain key from `ctx.chains`. */ + srcChain: string; + /** Destination chain key from `ctx.chains`. */ + destChain: string; + /** Source asset address, or {@link NATIVE_TOKEN_ADDRESS} for native balance. */ + fromToken: Address; + /** Destination asset address, or {@link NATIVE_TOKEN_ADDRESS} for native balance. */ + toToken: Address; + /** Optional default source account; falls back to `ctx.chains[srcChain].wallet` at call time. */ + from?: Address; + /** Optional default destination account; falls back to `ctx.chains[destChain].wallet` at call time. */ + to?: Address; + /** + * Divisor for bridge price math. Destination amount is `amountIn * price / priceScale`. + * Defaults to `1n`. + */ + priceScale?: bigint; +}; + +/** + * Per-call bridge execution input. + */ +export type BridgeExecuteArgs = { + /** Source amount debited from source chain/account. */ + amountIn: bigint; + /** Bridge conversion price used for destination amount math. */ + price: bigint; + /** Optional source account override for this execution. */ + from?: Address; + /** Optional destination account override for this execution. */ + to?: Address; +}; + +/** + * Result returned by one bridge simulation execution. + */ +export type BridgeExecution = { + srcChain: string; + destChain: string; + fromToken: Address; + toToken: Address; + from: Address; + to: Address; + amountIn: bigint; + amountOut: bigint; + price: bigint; +}; + +/** + * Callable bridge test-double exposed to tests through scenario context. + */ +export type ScenarioBridge = { + execute(args: BridgeExecuteArgs): Promise; +}; + +/** + * Context after {@link withBridge}: includes a bridge executor chosen by the fixture config. + */ +export type ScenarioBridgeContext = ScenarioRuntimeClientsContext & { + bridge: ScenarioBridge; +}; + /** Context passed to `ContractInjection.afterSetCode` from `withContracts`. */ export type AfterSetCodeContext = { /** Chain key this injection ran on. */ diff --git a/packages/examples/examples/scenarios.test.ts b/packages/examples/examples/scenarios.test.ts index f5cd1bf..bbb9e21 100644 --- a/packages/examples/examples/scenarios.test.ts +++ b/packages/examples/examples/scenarios.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { getAddress, parseEther } from "viem"; import { scenario, @@ -10,10 +10,12 @@ import { withContracts, withDeployments, withErc20Balance, + withBridge, withFork, withFundedWallet, withMultiChain, withSnapshot, + NATIVE_TOKEN_ADDRESS, type ContractArtifact, type RuntimeHandle, } from "@st8craft/core"; @@ -271,3 +273,59 @@ test( }, ), ); + +test( + "withBridge: test controls bridge timing with Vitest helpers", + scenario( + withMultiChain({ + src: { type: "chain", chainId: 31_337 }, + dest: { type: "chain", chainId: 31_338 }, + }), + withFundedWallet({ chain: "src", balance: parseEther("5") }), + withFundedWallet({ chain: "dest", balance: parseEther("1") }), + withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + priceScale: 2n, + }), + async ({ chains, bridge }) => { + const src = chains!.src!; + const dest = chains!.dest!; + const bridgeExecute = vi.fn(bridge!.execute); + + expect(await src.publicClient.getBalance({ address: src.wallet! })).toBe(parseEther("5")); + expect(await dest.publicClient.getBalance({ address: dest.wallet! })).toBe(parseEther("1")); + + vi.useFakeTimers(); + let bridgeReceipt: Awaited["execute"]>> | undefined; + let bridgeTask: Promise | undefined; + try { + setTimeout(() => { + bridgeTask = bridgeExecute({ + amountIn: parseEther("2"), + price: 2n, + }) + .then((receipt) => { + bridgeReceipt = receipt; + }); + }, 250); + + await vi.advanceTimersByTimeAsync(250); + await vi.waitFor(() => { + expect(bridgeTask).toBeDefined(); + }); + + await bridgeTask!; + } finally { + vi.useRealTimers(); + } + + expect(bridgeReceipt?.amountOut).toBe(parseEther("2")); + expect(bridgeExecute).toHaveBeenCalledTimes(1); + expect(await src.publicClient.getBalance({ address: src.wallet! })).toBe(parseEther("3")); + expect(await dest.publicClient.getBalance({ address: dest.wallet! })).toBe(parseEther("3")); + }, + ), +); From ded47b99e0b67bcb03cc0a2e348e1e9df7f03274 Mon Sep 17 00:00:00 2001 From: Joe Pegler Date: Sun, 12 Apr 2026 00:09:19 +0100 Subject: [PATCH 3/3] chore: refresh fixture docs with real-world scenarios Clarify each fixture example with production-adjacent testing scenarios and explicit notes on what setup Statecraft replaces, then align index/landing docs to route readers from minimal examples to business-logic-focused workflows. Made-with: Cursor --- .cursor/skills/impl-doc-consistency/SKILL.md | 85 ++++++ .../skills/impl-doc-consistency/reference.md | 49 ++++ README.md | 13 +- docs/pages/core-api-agent.mdx | 121 ++++++++ docs/pages/fixtures/contracts/index.mdx | 2 + .../fixtures/contracts/withContracts.mdx | 8 +- .../fixtures/contracts/withDeployments.mdx | 15 +- docs/pages/fixtures/isolation/index.mdx | 2 + .../pages/fixtures/isolation/withSnapshot.mdx | 32 ++- docs/pages/fixtures/runtime/index.mdx | 6 + docs/pages/fixtures/runtime/withBridge.mdx | 70 ++++- docs/pages/fixtures/runtime/withBundler.mdx | 13 +- docs/pages/fixtures/runtime/withChain.mdx | 30 +- .../fixtures/runtime/withExternalRuntime.mdx | 41 +-- docs/pages/fixtures/runtime/withFork.mdx | 35 ++- .../pages/fixtures/runtime/withMultiChain.mdx | 55 +++- docs/pages/fixtures/tokens/index.mdx | 2 + .../fixtures/tokens/withErc20Balance.mdx | 26 +- docs/pages/fixtures/wallets/index.mdx | 4 + .../fixtures/wallets/withFundedWallet.mdx | 20 +- .../fixtures/wallets/withImpersonation.mdx | 18 +- docs/pages/index.mdx | 20 +- docs/pages/overview.mdx | 26 +- docs/pages/quickstart.mdx | 16 +- packages/core/src/runtime/index.ts | 135 +++++++-- packages/core/src/scenarios/actions.test.ts | 75 +++++ packages/core/src/scenarios/actions.ts | 263 ++++++++++++++++++ packages/core/src/scenarios/errors.ts | 52 ++++ .../src/scenarios/fixtures/withBridge.test.ts | 55 ++++ .../core/src/scenarios/fixtures/withBridge.ts | 212 ++++++++++++-- .../src/scenarios/fixtures/withBundler.ts | 42 ++- .../src/scenarios/fixtures/withChain.test.ts | 2 + .../core/src/scenarios/fixtures/withChain.ts | 38 ++- .../src/scenarios/fixtures/withContracts.ts | 23 +- .../fixtures/withDeployments.test.ts | 35 +++ .../src/scenarios/fixtures/withDeployments.ts | 83 +++++- .../scenarios/fixtures/withErc20Balance.ts | 21 +- .../fixtures/withExternalRuntime.test.ts | 2 + .../scenarios/fixtures/withExternalRuntime.ts | 38 ++- .../src/scenarios/fixtures/withFork.test.ts | 36 +++ .../core/src/scenarios/fixtures/withFork.ts | 51 ++-- .../scenarios/fixtures/withFundedWallet.ts | 21 +- .../scenarios/fixtures/withImpersonation.ts | 21 +- .../scenarios/fixtures/withMultiChain.test.ts | 82 ++++++ .../src/scenarios/fixtures/withMultiChain.ts | 55 +++- packages/core/src/scenarios/index.ts | 32 ++- .../scenarios/internal/startBundler.test.ts | 31 ++- .../src/scenarios/internal/startBundler.ts | 81 +++++- packages/core/src/scenarios/preflight.ts | 34 +++ .../core/src/scenarios/requireContext.test.ts | 12 +- .../src/scenarios/scenario-typing.test.ts | 51 +++- packages/core/src/scenarios/scenario.test.ts | 40 ++- packages/core/src/scenarios/scenario.ts | 138 ++++++++- packages/core/src/scenarios/stepMeta.ts | 12 + packages/core/src/scenarios/types.ts | 64 ++++- packages/core/src/scenarios/utils.test.ts | 21 ++ packages/core/src/scenarios/utils.ts | 108 ++++++- packages/examples/examples/scenarios.test.ts | 49 ++-- scripts/generate-docs-seo.mjs | 1 + vocs.config.ts | 6 + 60 files changed, 2407 insertions(+), 324 deletions(-) create mode 100644 .cursor/skills/impl-doc-consistency/SKILL.md create mode 100644 .cursor/skills/impl-doc-consistency/reference.md create mode 100644 docs/pages/core-api-agent.mdx create mode 100644 packages/core/src/scenarios/actions.test.ts create mode 100644 packages/core/src/scenarios/actions.ts create mode 100644 packages/core/src/scenarios/errors.ts create mode 100644 packages/core/src/scenarios/preflight.ts create mode 100644 packages/core/src/scenarios/stepMeta.ts create mode 100644 packages/core/src/scenarios/utils.test.ts diff --git a/.cursor/skills/impl-doc-consistency/SKILL.md b/.cursor/skills/impl-doc-consistency/SKILL.md new file mode 100644 index 0000000..8296982 --- /dev/null +++ b/.cursor/skills/impl-doc-consistency/SKILL.md @@ -0,0 +1,85 @@ +--- +name: impl-doc-consistency +description: Enforce consistency between fixture implementation in packages/core/src and documentation in docs/pages by checking names, options, context fields, and lifecycle semantics. Use when adding or editing withX fixtures, updating fixture docs, or reviewing terminology drift across implementation and docs. +--- + +# Implementation-Documentation Consistency (Statecraft) + +Use this skill to keep `packages/core/src` and `docs/pages` aligned for fixture APIs, terminology, and behavior. + +## Scope + +Focus on fixture parity across: + +- `packages/core/src/scenarios/fixtures/with*.ts` +- `docs/pages/fixtures/**/with*.mdx` + +## Canonical Rule + +Treat implementation as source of truth for executable behavior. + +Allowed exceptions: + +- Docs can be clearer than code comments. +- Docs can add examples and caveats not present in code. +- If docs intentionally diverge from implementation, explicitly state why in the docs. + +## Core Checks + +For each `withX` fixture: + +1. **Existence parity** + - Every `withX.ts` has a matching `withX.mdx`. + - Every `withX.mdx` maps to a real fixture. + +2. **API signature parity** + - Function name and call shape match. + - Config option names match. + - Required vs optional options match. + +3. **Context parity** + - Docs "Adds to context" section matches properties actually written to context. + - Docs "Context requirements" match runtime prerequisites in code. + +4. **Lifecycle parity** + - Docs "Lifecycle" and caveats match startup/teardown behavior in code (`start`, `stop`, `finally`, ownership rules). + +5. **Terminology parity** + - Keep terms stable across code and docs: + - `chainKey` for runtime keying + - `chain` for chain selection in chain-scoped wallet/token fixtures + - `runtimeMode`, `publicClient`, `altPublicClient`, `walletClient`, `testClient` + - Avoid introducing synonyms for the same concept. + +## Workflow + +1. Build a quick fixture inventory from implementation and docs. +2. Compare each `withX` pair for API signature, options, context, lifecycle, and terms. +3. Fix docs first when code is correct. +4. Fix implementation if docs reveal an actual API bug. +5. Re-check the full fixture set once after edits. +6. Summarize any intentional deviations. + +## Output Format + +When applying this skill, produce: + +```markdown +## Consistency Report +- Fixture coverage: X/Y implemented fixtures documented +- Missing docs: ... +- Orphan docs: ... +- Option mismatches: ... +- Terminology mismatches: ... +- Lifecycle/context mismatches: ... + +## Changes Made +- ... + +## Remaining Risks +- ... +``` + +## References + +- [Fixture consistency reference](reference.md) diff --git a/.cursor/skills/impl-doc-consistency/reference.md b/.cursor/skills/impl-doc-consistency/reference.md new file mode 100644 index 0000000..0c36e5e --- /dev/null +++ b/.cursor/skills/impl-doc-consistency/reference.md @@ -0,0 +1,49 @@ +# Fixture Consistency Reference + +## Expected doc file shape + +Each fixture page should keep these sections in order: + +1. Title and one-line description (frontmatter) +2. Signature heading (`##` with `withX(...)`) +3. `### Why it is useful` +4. `### Example` +5. `### Options` table +6. `### Adds to context` +7. `### Context requirements` +8. `### Lifecycle` +9. `### Notes and caveats` + +## Current fixture surface (core) + +- `withChain` +- `withFork` +- `withExternalRuntime` +- `withMultiChain` +- `withSnapshot` +- `withFundedWallet` +- `withImpersonation` +- `withErc20Balance` +- `withContracts` +- `withDeployments` +- `withBundler` +- `withBridge` + +## Terminology conventions + +- **Runtime keying:** use `chainKey` +- **Chain selection in chain-scoped fixtures:** use `chain` +- **Context map:** `ctx.chains[]` +- **Client aliases:** `publicClient`, `altPublicClient` +- **Chain clients:** `publicClient`, `walletClient`, `testClient` +- **Modes:** `runtimeMode` with `"chain"` or `"fork"` + +## Naming note + +Config types are typically `WithXConfig`. + +Current exception in implementation: + +- `withImpersonationConfig` (lowercase leading `w`) + +If this remains intentional, docs and exports should keep it consistent and avoid introducing a conflicting type name. diff --git a/README.md b/README.md index 158e089..41f2b36 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,8 @@ test( withFundedWallet({ balance: 1_000_000_000_000_000_000n, // 1 ETH in wei }), - async ({ chains }) => { - const ch = chains!.default; - const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); + async ({ chains, publicClient }) => { + const balance = await publicClient.getBalance({ address: chains!.default.wallet! }); expect(balance).toBe(1_000_000_000_000_000_000n); }, ), @@ -101,13 +100,12 @@ test( token: USDC_MAINNET, amount: 1_000_000n, // 1 USDC (6 decimals) }), - async ({ chains }) => { - const ch = chains!.default; - const usdc = await ch.publicClient.readContract({ + async ({ chains, publicClient }) => { + const usdc = await publicClient.readContract({ address: USDC_MAINNET, abi: erc20Abi, functionName: "balanceOf", - args: [ch.wallet!], + args: [chains!.default.wallet!], }); expect(usdc).toBe(1_000_000n); @@ -122,6 +120,7 @@ test( - `withChain()`: starts a fresh local Anvil runtime (under `ctx.chains.default` unless `chainKey` is set). - `withFork({ rpcUrl, blockNumber })`: starts a pinned local fork for deterministic mainnet state. - `withMultiChain({ ... })`: starts or attaches multiple named chains on `ctx.chains`. +- `publicClient` / `altPublicClient`: top-level convenience aliases for the primary chain and optional secondary chain. - `withFundedWallet({ balance, erc20?, chain? })`: creates and funds a test wallet on a chain entry. - `withErc20Balance({ token, amount })`: seeds ERC-20 balance on compatible local or forked nodes. - `withSnapshot()`: snapshots before inner steps and reverts in `finally`. diff --git a/docs/pages/core-api-agent.mdx b/docs/pages/core-api-agent.mdx new file mode 100644 index 0000000..9f20c76 --- /dev/null +++ b/docs/pages/core-api-agent.mdx @@ -0,0 +1,121 @@ +--- +title: Core API (Agent) +description: Machine-readable contracts for agent planning, preflight simulation, execution, constraints, and error handling. +--- + +# Core API (Agent) + +This page documents the most agent-relevant contracts in Statecraft: explicit constraints, structured errors, preflight APIs, and execution hooks. + +## Global constraints + +```ts +import { describeScenarioConstraints } from "@st8craft/core"; + +const constraints = describeScenarioConstraints(); +// { +// maxChains: 2, +// publicClientAliasPolicies: ["prefer-default-then-lexical", "lexical"], +// defaultAliasPolicy: "prefer-default-then-lexical", +// supportedBundlerModes: ["alto"], +// } +``` + +## Structured errors + +Statecraft throws `StatecraftError` for machine-branchable failures. + +```ts +type StatecraftError = { + name: "StatecraftError"; + code: string; + reason: string; + context: Record; + suggestedAction?: string; + cause?: unknown; +}; +``` + +### Common error codes + +| Code | Meaning | Typical next action | +| --- | --- | --- | +| `SC_CONTEXT_MISSING` | Required context key/chain client not present | Reorder fixtures so prerequisites run first | +| `SC_PRECONDITION_FAILED` | Invalid argument or unmet runtime precondition | Fix config/inputs and retry | +| `SC_CONSTRAINT_VIOLATION` | Violates SDK constraints (for example >2 chains) | Split workflow or use valid configuration | +| `SC_RUNTIME_START_TIMEOUT` | Runtime did not become JSON-RPC ready | Retry and inspect startup diagnostics | +| `SC_RUNTIME_START_FAILED` | Runtime process failed during startup | Validate process/env and config | +| `SC_BUNDLER_START_FAILED` | Bundler startup or readiness probe failed | Inspect Alto install/config and retry | +| `SC_BUNDLER_UNSUPPORTED_MODE` | Bundler mode is unsupported | Use `mode: "alto"` | + +## Action planning + preflight + +Use action primitives to separate plan/simulate/execute. + +```ts +import { + planCall, + simulateCall, + planDeployment, + simulateDeployment, + executePlan, + summarizePreflight, + assertPreflight, +} from "@st8craft/core"; +``` + +### Preflight result contract + +```ts +type ActionPreflight = { + canExecute: boolean; + reasons: Array<{ + code: string; + reason: string; + context?: Record; + }>; + assumptions: string[]; + estimatedEffects: Record; +}; +``` + +Recommended flow: + +1. Build plan (`planCall` / `planDeployment`) +2. Simulate (`simulateCall` / `simulateDeployment`) +3. Gate mutation with `assertPreflight(result)` or your own policy +4. Execute (`executePlan`) + +## Scenario tracing hooks + +`scenario(...)` accepts optional run options: + +```ts +scenario( + { + options: { + onStepStart: (event) => {}, + onStepSuccess: (event) => {}, + onStepFailure: (event) => {}, + onCleanup: (event) => {}, + }, + }, + // ...withX steps, + async (ctx) => {}, +); +``` + +Event payloads expose: + +- step index + label +- duration +- context delta keys +- normalized `StatecraftError` on failures + +## Retry and idempotency + +- `bridge.execute(...)` accepts optional `idempotencyKey`. +- Reusing the same key with the same payload returns cached execution output. +- Reusing the same key with a different payload throws `SC_CONSTRAINT_VIOLATION`. + +For nonce-sensitive operations (raw tx/deployments), use external nonce coordination if retries are automated. diff --git a/docs/pages/fixtures/contracts/index.mdx b/docs/pages/fixtures/contracts/index.mdx index 2d87a75..c9e8333 100644 --- a/docs/pages/fixtures/contracts/index.mdx +++ b/docs/pages/fixtures/contracts/index.mdx @@ -9,6 +9,8 @@ description: Inject runtime bytecode at fixed addresses with withContracts, or d These fixtures set up contract code at deterministic addresses on your scenario runtime. +Pick `withContracts` when fixed addresses and setup speed matter most, or `withDeployments` when constructor args, deployment order, and deployment-time behavior are part of the test intent. + ## Fixtures - [`withContracts({ ... })` (bytecode injection)](/fixtures/contracts/withContracts) diff --git a/docs/pages/fixtures/contracts/withContracts.mdx b/docs/pages/fixtures/contracts/withContracts.mdx index b7d4cd3..3a4b0d1 100644 --- a/docs/pages/fixtures/contracts/withContracts.mdx +++ b/docs/pages/fixtures/contracts/withContracts.mdx @@ -8,7 +8,7 @@ description: Inject runtime bytecode at fixed addresses (test-only) with optiona **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withContracts()` when you want fast, deterministic contract setup at known addresses, without running constructors or worrying about deployment ordering. +Use `withContracts()` when address determinism matters more than constructor semantics, such as patching a dependency contract at a known address or stubbing integration points in a larger workflow. ### Example :::code-group @@ -20,7 +20,7 @@ import { answerArtifact } from "./config.js"; const ANSWER_ADDRESS = "0x1000000000000000000000000000000000000001"; test( - "runtime bytecode is installed and contract handle exists", + "inject dependency bytecode at a fixed address for integration tests", scenario( withChain(), withContracts({ @@ -32,6 +32,9 @@ test( }, }), async ({ chains }) => { + // In production, this dependency would come from a deployed contract. + // In this test, fixed-address injection keeps setup deterministic and + // lets your business logic tests skip full deploy pipelines. expect(chains?.default.contracts?.answer).toBeTruthy(); }, ), @@ -77,3 +80,4 @@ Managed middleware that installs bytecode, then forwards to `next`. ### Notes and caveats Use `withContracts()` for injecting known runtime bytecode at known addresses. It is optimized for setup speed and deterministic addressing, not for constructor semantics. +- If constructor args, init routines, or deployment ordering are the thing under test, use `withDeployments()` instead. diff --git a/docs/pages/fixtures/contracts/withDeployments.mdx b/docs/pages/fixtures/contracts/withDeployments.mdx index 1bb214f..783469a 100644 --- a/docs/pages/fixtures/contracts/withDeployments.mdx +++ b/docs/pages/fixtures/contracts/withDeployments.mdx @@ -8,7 +8,7 @@ description: Deploy contracts using constructor semantics, and merge deployment **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withDeployments()` when constructor arguments and deployment-time behavior matter, and you want deployment results (addresses and optional typed contract handles) available by name. +Use `withDeployments()` when constructor args, deployment ordering, or deployment-time checks are part of the behavior you need to validate. It keeps deployment results available by name so follow-up assertions can target business logic instead of setup plumbing. ### Example :::code-group @@ -19,7 +19,7 @@ import { isAddress } from "viem"; import { answerArtifact } from "./config.js"; test( - "deployment record is present on the chain context", + "deploy constructor-based dependencies before business logic assertions", scenario( withChain(), withFundedWallet({ balance: 1n }), @@ -32,7 +32,12 @@ test( }, }), async ({ chains }) => { - expect(isAddress(chains?.default.deployments?.answer?.address)).toBe(true); + const deploymentAddress = chains?.default.deployments?.answer?.address; + + // In production, this address would come from deploy scripts and env vars. + // In this test, deployment records are already on context so assertions + // can focus on the app behavior that depends on those contracts. + expect(isAddress(deploymentAddress)).toBe(true); }, ), ); @@ -59,6 +64,8 @@ export const answerArtifact = { | Option | Type | Required? | Meaning (units/semantics) | Used for / affects | |---|---|---|---|---| | `chain` | `string` | No | Key on `ctx.chains` (default `default`). | Selects which chain receives deployments. | +| `preflightMode` | `"none" \| "warn" \| "strict"` | No | Controls deployment simulation gating before send (`strict` blocks execution when simulation fails). | Improves deterministic preflight behavior for agents. | +| `onPreflight` | `(ctx) => void` | No | Callback receiving per-deployment preflight results (`canExecute`, `reasons`, `assumptions`, `estimatedEffects`). | Lets agents inspect and log simulation outcomes before mutation. | | `deployments` | `Record` | Yes | Named deployment specs. | Keys become `ctx.chains[chain].deployments[name]`. | | `name` (record key) | `string` | Yes | Deployment name you choose as the key under `deployments`. | Becomes the deployment record key. | | `artifact` | `ContractArtifact` | Yes | Contract artifact including required `abi` plus creation `bytecode`. | Drives `walletClient.deployContract`, and (when ABI is present) produces a typed contract handle. | @@ -78,3 +85,5 @@ Managed middleware that deploys contracts in key order, then forwards to `next`. ### Notes and caveats - Deployment ordering is the key order in your `deployments` object. - `artifact.abi` is required at runtime (deployment fails if missing). +- Structured error codes include `SC_PRECONDITION_FAILED` for missing ABI/account and failed strict preflight. +- Use `preflightMode: "strict"` when you want deployment simulation failures to block the scenario before any state mutation. diff --git a/docs/pages/fixtures/isolation/index.mdx b/docs/pages/fixtures/isolation/index.mdx index 9dcd138..535230b 100644 --- a/docs/pages/fixtures/isolation/index.mdx +++ b/docs/pages/fixtures/isolation/index.mdx @@ -9,6 +9,8 @@ description: Isolate side effects inside a scenario by snapshotting and revertin Isolation fixtures help you keep tests independent even when you reuse a runtime across multiple scenarios. +Use `withSnapshot()` when a scenario mutates state but your suite reuses a runtime handle and should not leak side effects into later tests. + ## Fixtures - [`withSnapshot()`](/fixtures/isolation/withSnapshot) diff --git a/docs/pages/fixtures/isolation/withSnapshot.mdx b/docs/pages/fixtures/isolation/withSnapshot.mdx index d0537fb..edc95ed 100644 --- a/docs/pages/fixtures/isolation/withSnapshot.mdx +++ b/docs/pages/fixtures/isolation/withSnapshot.mdx @@ -3,12 +3,12 @@ title: withSnapshot description: Snapshot the runtime before inner steps run, then revert in a finally block. --- -## withSnapshot([{ chain }]) +## `withSnapshot([{ chain }])` **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withSnapshot()` to isolate side effects inside a scenario, so changes made by inner steps are rolled back reliably. +Use `withSnapshot()` when you need deterministic rollback around mutating steps, especially in suites that reuse one external runtime for speed. It lets you test write-heavy flows while keeping later tests clean. ### Suite scoped runtimes and `describe` If you reuse one running Anvil runtime across multiple tests in a file or `describe` block (for example, by creating the runtime handle in `beforeAll` and attaching it via `withExternalRuntime()`), call `withSnapshot()` inside every `scenario(...)` that should not leak state between tests. This pattern assumes the tests are not running concurrently against the same `runtime` handle. @@ -19,13 +19,18 @@ import { test, expect } from "vitest"; import { scenario, withChain, withSnapshot } from "@st8craft/core"; test( - "snapshot isolates chain mutations", + "rollback local mutation after asserting business logic branch", scenario( withChain(), withSnapshot(), - async ({ chains }) => { - const ch = chains!.default; - expect(await ch.publicClient.getBlockNumber()).toBeGreaterThan(0n); + async ({ chains, publicClient }) => { + const before = await publicClient.getBlockNumber(); + await chains!.default.testClient.mine({ blocks: 3 }); + const after = await publicClient.getBlockNumber(); + + // In production, this could be a stateful branch you need to evaluate. + // In this test, snapshot isolation ensures temporary mutations are rolled back. + expect(after).toBe(before + 3n); }, ), ); @@ -80,19 +85,18 @@ describe("shared runtime with snapshot isolation", () => { withExternalRuntime({ runtime: handle }), withSnapshot(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default; - const before = await ch.publicClient.getBalance({ address: recipient }); + async ({ chains, publicClient }) => { + const before = await publicClient.getBalance({ address: recipient }); expect(before).toBe(0n); // Send ETH to `recipient` from the scenario wallet. // Any chain mutation here is rolled back by `withSnapshot()`. - await ch.walletClient.sendTransaction({ + await chains!.default.walletClient.sendTransaction({ to: recipient, value: txValue, }); - const after = await ch.publicClient.getBalance({ address: recipient }); + const after = await publicClient.getBalance({ address: recipient }); expect(after).toBe(txValue); }, ), @@ -103,9 +107,8 @@ describe("shared runtime with snapshot isolation", () => { scenario( withExternalRuntime({ runtime: handle }), withSnapshot(), - async ({ chains }) => { - const ch = chains!.default; - const balance = await ch.publicClient.getBalance({ address: recipient }); + async ({ publicClient }) => { + const balance = await publicClient.getBalance({ address: recipient }); expect(balance).toBe(0n); }, ), @@ -119,3 +122,4 @@ Managed lifecycle, it snapshots before `next` and reverts afterward via a `final ### Notes and caveats - Nesting multiple `withSnapshot()` layers creates nested snapshot scopes. - When reusing one `runtime` handle via `withExternalRuntime()`, avoid concurrent tests that mutate chain state, since `withSnapshot()` relies on snapshot and revert ordering. +- A common pattern is to keep a suite-scoped runtime warm for speed, then wrap only mutating scenarios with `withSnapshot()` to protect unrelated tests. diff --git a/docs/pages/fixtures/runtime/index.mdx b/docs/pages/fixtures/runtime/index.mdx index 6381eb5..eb989d3 100644 --- a/docs/pages/fixtures/runtime/index.mdx +++ b/docs/pages/fixtures/runtime/index.mdx @@ -9,6 +9,12 @@ description: Start or attach Anvil runtimes and wire viem clients under ctx.chai Statecraft scenarios require at least one runtime and viem clients. Runtime fixtures place chain state under **`ctx.chains.`** (the default key is **`default`** when you use `withChain`, `withFork`, or `withExternalRuntime` without `chainKey`). +Use this section to choose the runtime shape that matches your real testing job: +- local isolated logic (`withChain`) +- pinned historical integration checks (`withFork`) +- suite-owned shared runtime workflows (`withExternalRuntime`) +- coordinated multi-environment paths such as source/destination or L1/L2 (`withMultiChain`) + ## Choosing a base runtime strategy - **Single chain:** use exactly one of `withChain`, `withFork`, or `withExternalRuntime` as your first runtime step (or combine multiple single-chain steps with different `chainKey` values). diff --git a/docs/pages/fixtures/runtime/withBridge.mdx b/docs/pages/fixtures/runtime/withBridge.mdx index 7b08c29..d3e8cca 100644 --- a/docs/pages/fixtures/runtime/withBridge.mdx +++ b/docs/pages/fixtures/runtime/withBridge.mdx @@ -5,7 +5,8 @@ description: Expose a deterministic bridge simulator callback and let the test c ## withBridge(config) -`withBridge` is a test-double helper for multi-chain scenarios. It does not auto-run. Instead, it exposes `ctx.bridge.execute(...)` so the test decides exactly when to simulate a bridge transfer. You can wrap it with `vi.fn(...)` to assert integration points while still mutating balances across chains. +### Why it is useful +Use `withBridge()` to simulate deterministic bridge transfers inside tests without needing a real bridge integration, relayer queue, or asynchronous settlement service. The fixture injects a callable bridge test-double (`ctx.bridge`) and lets your test control the exact execution moment, which is useful when the business logic depends on a bridge quote or fulfilled transfer but the infra itself is not what you want to exercise. Use this alongside [`withMultiChain`](/fixtures/runtime/withMultiChain) and chain-targeted wallet setup. @@ -16,7 +17,7 @@ import { parseEther } from "viem"; import { scenario, withBridge, withFundedWallet, withMultiChain, NATIVE_TOKEN_ADDRESS } from "@st8craft/core"; test( - "simulate bridge at a chosen test step", + "test a relay-backed transfer without waiting on real bridge infra", scenario( withMultiChain({ src: { type: "chain", chainId: 31_337 }, @@ -34,33 +35,84 @@ test( const src = chains!.src; const dest = chains!.dest; const executeBridge = vi.fn(bridge!.execute); + const relay = { + submit: vi.fn(async () => ({ relayTransferId: "relay-1", quotedPrice: 1n })), + }; expect(await src.publicClient.getBalance({ address: src.wallet! })).toBe(parseEther("2")); - const bridgePromise = executeBridge({ amountIn: parseEther("1"), price: 1n }); + // In production, your app would submit to a bridge or relayer and wait + // for settlement on the destination chain. + // In this test, Statecraft lets you jump straight to deterministic + // fulfillment with `bridge.execute(...)` so you can assert your app logic. + const quote = await relay.submit(); + const bridgePromise = executeBridge({ + amountIn: parseEther("1"), + price: quote.quotedPrice, + idempotencyKey: quote.relayTransferId, + }); await vi.waitFor(async () => { expect(await dest.publicClient.getBalance({ address: dest.wallet! })).toBe(parseEther("2")); }); await bridgePromise; + expect(relay.submit).toHaveBeenCalledTimes(1); expect(executeBridge).toHaveBeenCalledTimes(1); }, ), ); ``` -### Config -- `srcChain`, `destChain`: chain keys from `ctx.chains`. -- `fromToken`, `toToken`: token addresses. Use `NATIVE_TOKEN_ADDRESS` for native balance. -- `from`, `to` (optional): default accounts, otherwise the chain wallet for each side. -- `priceScale` (optional): destination math divisor. `amountOut = amountIn * price / priceScale`. +### Options +| Option | Type | Required? | Meaning (units/semantics) | Used for / affects | +|---|---|---|---|---| +| `srcChain` | `string` | Yes | Source chain key from `ctx.chains`. | Selects source runtime clients and balance debits. | +| `destChain` | `string` | Yes | Destination chain key from `ctx.chains`. | Selects destination runtime clients and balance credits. | +| `fromToken` | `Address` | Yes | Source asset address, or `NATIVE_TOKEN_ADDRESS` for native token semantics. | Asset debited on source chain. | +| `toToken` | `Address` | Yes | Destination asset address, or `NATIVE_TOKEN_ADDRESS` for native token semantics. | Asset credited on destination chain. | +| `from` | `Address` | No | Default source account; falls back to `ctx.chains[srcChain].wallet`. | Default sender for `bridge.execute(...)`. | +| `to` | `Address` | No | Default destination account; falls back to `ctx.chains[destChain].wallet`. | Default receiver for `bridge.execute(...)`. | +| `priceScale` | `bigint` | No | Divisor in bridge pricing formula; defaults to `1n`. `amountOut = amountIn * price / priceScale`. | Controls integer conversion math in preflight + execute. | ### Execute args - `amountIn`: source debit amount. - `price`: conversion price used for destination amount. - `from`, `to` (optional): per-call address overrides. +- `idempotencyKey` (optional): replay key for safe retries with identical payload. + +### Preflight + +`ctx.bridge.preflight(args)` returns: + +- `canExecute` +- `reasons[]` +- `assumptions[]` +- `estimatedEffects` + +Use preflight to decide whether to execute or branch to remediation. + +### Adds to context +- `bridge` with: +- `bridge.preflight(args)` +- `bridge.execute(args)` + +### Context requirements +- Requires both `srcChain` and `destChain` to already exist on `ctx.chains` (typically via `withMultiChain`, or combined runtime fixtures that create those keys). +- Requires runtime clients on both chains (`publicClient` and `testClient`) for balance mutation and reads. +- Only one `withBridge(...)` may be composed per scenario (`ctx.bridge` must be undefined before this step). + +### Lifecycle +This fixture does not start or stop runtimes. It creates a deterministic in-memory bridge executor for the scenario context and delegates all state mutation to already-available chain clients. + +### Retry safety + +- If `idempotencyKey` is provided, identical replays return the cached execution result. +- Reusing the same key with a different payload throws `SC_CONSTRAINT_VIOLATION`. +- Without `idempotencyKey`, retries are not replay-safe. -### Notes +### Notes and caveats - This is test-only behavior that mutates local or forked test state. - The fixture does not attempt to model real bridge messaging or relayer behavior. +- Use it to test the logic around quoting, pending settlement, retries, and final balances, not to validate a real third-party bridge's production guarantees. +- Failure codes are structured (`SC_PRECONDITION_FAILED`, `SC_CONTEXT_MISSING`, `SC_CONSTRAINT_VIOLATION`). diff --git a/docs/pages/fixtures/runtime/withBundler.mdx b/docs/pages/fixtures/runtime/withBundler.mdx index 47c030e..b5d8127 100644 --- a/docs/pages/fixtures/runtime/withBundler.mdx +++ b/docs/pages/fixtures/runtime/withBundler.mdx @@ -8,7 +8,7 @@ description: Start a local Alto bundler connected to the current Anvil runtime. **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withBundler()` to enable ERC-4337 flows against a local bundler that is wired to your scenario runtime. +Use `withBundler()` when your business logic depends on ERC-4337 bundler behavior (entry points, user-op routing, or account-abstraction plumbing), but you do not want to run and wire a separate bundler process in every test by hand. ### Example ```ts twoslash [index.ts] @@ -18,7 +18,7 @@ import { scenario, withFork, withBundler } from "@st8craft/core"; const ENTRY_POINT = "0x0576a174D229E3cFA37253523E645A78A0C91B57"; test( - "bundler wiring is exposed on the chain context", + "wire a forked AA environment for user-op business logic tests", scenario( withFork({ rpcUrl: process.env.VITE_RPC_URL!, @@ -26,8 +26,10 @@ test( }), withBundler({ entryPoint: ENTRY_POINT, mode: "alto" }), async ({ chains }) => { - const ch = chains!.default; - expect(await ch.bundlerClient!.getSupportedEntryPoints()).toContain(ENTRY_POINT); + // In production, your app points to an external bundler endpoint. + // In this test, Statecraft spins up a local bundler tied to the fork + // so you can assert your AA wiring before writing full user-op flows. + expect(await chains!.default.bundlerClient!.getSupportedEntryPoints()).toContain(ENTRY_POINT); }, ), ); @@ -54,4 +56,5 @@ On `ctx.chains[chain]`: Managed lifecycle, the fixture starts a local bundler for the scenario and stops it in `finally`. ### Notes and caveats -Additional examples for user operations are planned. +- This fixture is best used to validate your account-abstraction integration points quickly; full user-operation execution examples are planned. +- Bundler behavior is local test infrastructure in this setup, not a guarantee of a specific third-party bundler service. diff --git a/docs/pages/fixtures/runtime/withChain.mdx b/docs/pages/fixtures/runtime/withChain.mdx index 8a0e379..0df5cea 100644 --- a/docs/pages/fixtures/runtime/withChain.mdx +++ b/docs/pages/fixtures/runtime/withChain.mdx @@ -8,7 +8,7 @@ description: Start a fresh local Anvil instance with an empty chain. **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withChain()` when you want fast, local integration tests without relying on any external RPC providers. +Use `withChain()` when you want a fast local EVM for business-logic tests that should not depend on a live RPC, pinned fork, or suite-level runtime setup. It is a good fit for greenfield protocol flows, local checkout rules, or contract interactions where the interesting part is your app logic, not remote chain state. ### Example ```ts twoslash [index.ts] @@ -17,17 +17,22 @@ import { parseEther } from "viem"; import { scenario, withChain, withFundedWallet } from "@st8craft/core"; test( - "funded wallet on local chain", + "validate a local payout rule without external RPC setup", scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default; - expect( - await ch.publicClient.getBalance({ address: ch.wallet! }), - ).toBe( - parseEther("1"), - ); + async ({ chains, publicClient }) => { + const operator = chains!.default.walletClient.account!.address; + const startingBalance = await publicClient.getBalance({ address: operator }); + + // In production, this account would usually come from wallet plumbing + // or test hooks you maintain yourself. In this test, Statecraft gives + // you a local chain plus a ready signer so you can jump straight to the rule. + const payoutAmount = parseEther("0.25"); + const reserveAfterPayout = startingBalance - payoutAmount; + + expect(startingBalance).toBe(parseEther("1")); + expect(reserveAfterPayout).toBe(parseEther("0.75")); }, ), ); @@ -49,6 +54,10 @@ Under `ctx.chains[chainKey]` (default key `default`): - `walletClient` - `testClient` +Top-level convenience aliases: +- `publicClient` (primary chain client) +- `altPublicClient` (secondary chain client when one exists) + ### Context requirements None; this fixture is the base runtime step. @@ -56,5 +65,6 @@ None; this fixture is the base runtime step. Managed lifecycle, the fixture starts and stops Anvil for the scenario. ### Notes and caveats -This is the simplest fixture for local work, but it does not pin to any remote state. +- This is the simplest fixture for local work, but it does not pin to any remote state. +- Prefer `withFork()` when the scenario depends on real deployed contracts, real storage layouts, or a specific historical block. diff --git a/docs/pages/fixtures/runtime/withExternalRuntime.mdx b/docs/pages/fixtures/runtime/withExternalRuntime.mdx index dc9ae0d..1f06906 100644 --- a/docs/pages/fixtures/runtime/withExternalRuntime.mdx +++ b/docs/pages/fixtures/runtime/withExternalRuntime.mdx @@ -8,7 +8,7 @@ description: Attach a caller-owned runtime handle and wire viem clients, without **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withExternalRuntime()` when your test suite (not the scenario) owns the Anvil lifecycle, so you can reuse one running runtime across many scenarios. +Use `withExternalRuntime()` when your test suite (not the scenario) owns runtime lifecycle and you want to reuse one running node across many tests. This matches real suites that keep one process warm for speed, then choose per test whether state should be shared or rolled back. This enables two important patterns: @@ -43,49 +43,47 @@ describe.sequential("external runtime lifecycle owned by the test file", () => { }); test( - "builds shared state for later tests", + "stores a suite-level checkpoint for later assertions", scenario( withExternalRuntime({ runtime: handle }), - async ({ chains }) => { - const ch = chains!.default; - const start = await ch.publicClient.getBlockNumber(); - await ch.testClient.mine({ blocks: 2 }); - sharedBlock = await ch.publicClient.getBlockNumber(); + async ({ chains, publicClient }) => { + const start = await publicClient.getBlockNumber(); + await chains!.default.testClient.mine({ blocks: 2 }); + sharedBlock = await publicClient.getBlockNumber(); expect(sharedBlock).toBe(start + 2n); }, ), ); test( - "depends on the previous test's state", + "reuses shared chain state without extra setup", scenario( withExternalRuntime({ runtime: handle }), - async ({ chains }) => { - expect(await chains!.default.publicClient.getBlockNumber()).toBe(sharedBlock); + async ({ publicClient }) => { + expect(await publicClient.getBlockNumber()).toBe(sharedBlock); }, ), ); test( - "runs isolated work with snapshot", + "executes one-off isolated work using snapshot", scenario( withExternalRuntime({ runtime: handle }), withSnapshot(), - async ({ chains }) => { - const ch = chains!.default; - const before = await ch.publicClient.getBlockNumber(); - await ch.testClient.mine({ blocks: 5 }); - expect(await ch.publicClient.getBlockNumber()).toBe(before + 5n); + async ({ chains, publicClient }) => { + const before = await publicClient.getBlockNumber(); + await chains!.default.testClient.mine({ blocks: 5 }); + expect(await publicClient.getBlockNumber()).toBe(before + 5n); }, ), ); test( - "proves isolated test did not leak shared state", + "confirms isolated work did not leak into shared flow", scenario( withExternalRuntime({ runtime: handle }), - async ({ chains }) => { - expect(await chains!.default.publicClient.getBlockNumber()).toBe(sharedBlock); + async ({ publicClient }) => { + expect(await publicClient.getBlockNumber()).toBe(sharedBlock); }, ), ); @@ -109,6 +107,10 @@ Under `ctx.chains[chainKey]` (default key `default`): - `walletClient` - `testClient` +Top-level convenience aliases: +- `publicClient` (primary chain client) +- `altPublicClient` (secondary chain client when one exists) + ### Context requirements None at the scenario level, but you must pass a live `runtime` handle. @@ -118,6 +120,7 @@ External lifecycle, the fixture never starts or stops Anvil. ### Notes and caveats - Add `withSnapshot()` when you want per-test rollback while reusing one external runtime handle. - Skip `withSnapshot()` when you intentionally want cumulative state across sequential tests in one file or `describe`. +- In production terms: the runtime process is shared, but Statecraft still lets each scenario declare whether it behaves like an isolated unit test or a progressive integration checkpoint. - Mixing both patterns in one `describe` lets you model realistic flows, cumulative checkpoints, and one-off isolated assertions without paying runtime startup cost for every test. - A common performance pattern is one runtime handle per test file (`beforeAll` and `afterAll`). This keeps state local to that file and still lets the test runner run different files in parallel, which can significantly reduce total suite time. diff --git a/docs/pages/fixtures/runtime/withFork.mdx b/docs/pages/fixtures/runtime/withFork.mdx index a00cf65..729edce 100644 --- a/docs/pages/fixtures/runtime/withFork.mdx +++ b/docs/pages/fixtures/runtime/withFork.mdx @@ -8,26 +8,40 @@ description: Start a local Anvil fork from a remote JSON-RPC endpoint at a pinne **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withFork()` to test against pinned, deterministic mainnet (or L2) state while still running locally via Anvil. +Use `withFork()` when your test depends on real deployed contracts or historical chain state, but you still want a deterministic local environment. It is a good fit for reproducing production bugs, validating integrations against canonical token or protocol addresses, and freezing a tricky block so the test stays stable. ### Example ```ts twoslash [index.ts] import { test, expect } from "vitest"; +import { erc20Abi, parseUnits } from "viem"; import { scenario, withFork, withFundedWallet } from "@st8craft/core"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48"; + test( - "funded wallet on pinned mainnet fork", + "freeze a mainnet checkout dependency at a pinned block", scenario( withFork({ rpcUrl: process.env.VITE_RPC_URL!, blockNumber: 22_000_000n, }), withFundedWallet({ balance: 1n }), - async ({ chains }) => { - const ch = chains!.default; - expect( - await ch.publicClient.getBalance({ address: ch.wallet! }), - ).toBe(1n); + async ({ chains, publicClient }) => { + const buyer = chains!.default.walletClient.account!.address; + const cartTotal = parseUnits("20", 6); + + // In production, your checkout flow would read the live USDC contract. + // In this test, the fork pins that dependency to one reproducible block + // so you can debug the business rule without chasing moving chain state. + const buyerUsdcBalance = await publicClient.readContract({ + address: USDC, + abi: erc20Abi, + functionName: "balanceOf", + args: [buyer], + }); + + expect(await publicClient.getBytecode({ address: USDC })).toBeTruthy(); + expect(buyerUsdcBalance < cartTotal).toBe(true); }, ), ); @@ -51,6 +65,10 @@ Under `ctx.chains[chainKey]` (default key `default`): - `walletClient` - `testClient` +Top-level convenience aliases: +- `publicClient` (primary chain client) +- `altPublicClient` (secondary chain client when one exists) + ### Context requirements None beyond being able to reach `rpcUrl` when you run tests. @@ -59,4 +77,5 @@ Managed lifecycle, the fixture starts and stops Anvil for the scenario. ### Notes and caveats - Prefer pinned `blockNumber` values for determinism. -- This fixture is intended for compatible local/forked runtimes (Anvil-style). +- This fixture is intended for compatible local/forked runtimes (Anvil-style). +- Forking gives you real remote state at a fixed block, but any extra balances or writes you add in the scenario are still local test-time mutations. diff --git a/docs/pages/fixtures/runtime/withMultiChain.mdx b/docs/pages/fixtures/runtime/withMultiChain.mdx index 4a5b21b..90222bd 100644 --- a/docs/pages/fixtures/runtime/withMultiChain.mdx +++ b/docs/pages/fixtures/runtime/withMultiChain.mdx @@ -3,12 +3,12 @@ title: withMultiChain description: Start or attach multiple Anvil runtimes and expose them as named entries on ctx.chains. --- -## withMultiChain(config) +## withMultiChain(config, options?) **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withMultiChain` when a single test needs more than one isolated EVM context (for example Ethereum plus an L2, or two pinned forks). Each key in `config` becomes `ctx.chains.` with its own `runtime`, `publicClient`, `walletClient`, and `testClient`. +Use `withMultiChain` when one test needs more than one isolated EVM context, such as an L1 plus L2 flow, a source and destination bridge path, or two pinned forks. Each key in `config` becomes `ctx.chains.` with its own `runtime`, `publicClient`, `walletClient`, and `testClient`, so you can describe the orchestration once instead of hand-wiring multiple runtimes in hooks. ### Example ```ts twoslash [index.ts] @@ -17,19 +17,26 @@ import { parseEther } from "viem"; import { scenario, withMultiChain, withFundedWallet } from "@st8craft/core"; test( - "two local chains", + "stage an L1 and L2 actor before testing cross-chain business logic", scenario( withMultiChain({ - a: { type: "chain", chainId: 31_337 }, - b: { type: "chain", chainId: 31_338 }, + l1: { type: "chain", chainId: 31_337 }, + l2: { type: "chain", chainId: 31_338 }, }), - withFundedWallet({ chain: "a", balance: parseEther("1") }), - withFundedWallet({ chain: "b", balance: parseEther("2") }), + withFundedWallet({ chain: "l1", balance: parseEther("1") }), + withFundedWallet({ chain: "l2", balance: parseEther("2") }), async ({ chains }) => { - const ba = await chains!.a.publicClient.getBalance({ address: chains!.a.wallet! }); - const bb = await chains!.b.publicClient.getBalance({ address: chains!.b.wallet! }); - expect(ba).toBe(parseEther("1")); - expect(bb).toBe(parseEther("2")); + const l1 = chains!.l1; + const l2 = chains!.l2; + + // In production, these might be the two environments behind a deposit + // or settlement flow. In tests, named chain keys make that setup explicit + // and keep the body focused on the orchestration you actually care about. + const l1Balance = await l1.publicClient.getBalance({ address: l1.wallet! }); + const l2Balance = await l2.publicClient.getBalance({ address: l2.wallet! }); + + expect(l1Balance).toBe(parseEther("1")); + expect(l2Balance).toBe(parseEther("2")); }, ), ); @@ -44,11 +51,37 @@ Each entry is a tagged union: | `"fork"` | Pinned fork (`rpcUrl`, `blockNumber`, optional `chainId`, `key`). | | `"external"` | Attach an existing `runtime` (optional `runtimeMode`, `clients`). | +### Options + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `publicClientAliasPolicy` | `"prefer-default-then-lexical" \| "lexical"` | `"prefer-default-then-lexical"` | How top-level `publicClient` / `altPublicClient` aliases are chosen. | + ### Lifecycle Runtimes started by this fixture are stopped in reverse startup order in `finally`. External runtimes are never stopped here. ### Adds to context - `chains`: a map of chain keys to full chain contexts (same fields as [`withChain`](/fixtures/runtime/withChain) per entry). +- `publicClient`: convenience alias to the primary chain client (`default` key when present, otherwise first key in lexical order). +- `altPublicClient`: optional convenience alias to a secondary chain client when one exists. ### Context requirements None; this fixture can be the first runtime step, or compose after other steps that already set `ctx.chains` (duplicate keys throw). + +### Determinism + constraints + +- Maximum two chain entries per scenario. +- Fork entries require pinned `blockNumber` for reproducibility. +- Alias selection strategy is explicit via `publicClientAliasPolicy`. + +### Machine-readable failure codes + +- `SC_CONSTRAINT_VIOLATION` (too many chains, duplicate/incompatible constraints) +- `SC_CONTEXT_MISSING` (downstream fixtures require missing chain context) +- `SC_PRECONDITION_FAILED` (invalid entry inputs) + +### Notes and caveats +- `withMultiChain(...)` accepts at most two entries; attempting more throws. +- Chain startup order is lexical by key, and owned runtimes are stopped in reverse order during teardown. +- External runtimes are attached but never stopped by this fixture. +- Prefer meaningful keys like `l1`, `l2`, `src`, or `dest` so the test body reads like the real workflow you are modeling. diff --git a/docs/pages/fixtures/tokens/index.mdx b/docs/pages/fixtures/tokens/index.mdx index 4df26e7..6fcfc89 100644 --- a/docs/pages/fixtures/tokens/index.mdx +++ b/docs/pages/fixtures/tokens/index.mdx @@ -9,6 +9,8 @@ description: Seed ERC-20 token balances in local/forked test state with withErc2 Use these fixtures to seed ERC-20 balances in compatible local and forked runtimes (for example, Anvil-style nodes). +This is most useful when token mint mechanics are incidental to the test and you want to start directly from business preconditions like "user has spendable token balance." + ## Fixtures - [`withErc20Balance({ token, amount[, to] })`](/fixtures/tokens/withErc20Balance) diff --git a/docs/pages/fixtures/tokens/withErc20Balance.mdx b/docs/pages/fixtures/tokens/withErc20Balance.mdx index b98eb0e..fe41647 100644 --- a/docs/pages/fixtures/tokens/withErc20Balance.mdx +++ b/docs/pages/fixtures/tokens/withErc20Balance.mdx @@ -8,7 +8,7 @@ description: Seed an ERC-20 balance for a recipient in local/forked test state. **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withErc20Balance()` to seed ERC-20 balances directly in test state, so you can exercise token transfers and contract logic without writing per-test minting or impersonation boilerplate. +Use `withErc20Balance()` when the scenario needs token balances as preconditions, but token minting mechanics are not the behavior under test. It lets you start from "user has X tokens" and focus on business rules like spending limits, routing, or settlement checks. ### Example ```ts twoslash [index.ts] @@ -19,7 +19,7 @@ import { scenario, withChain, withFundedWallet, withErc20Balance } from "@st8cra const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48"; test( - "funded wallet has USDC seeded", + "seed token balance to test checkout rules quickly", scenario( withChain(), withFundedWallet({ balance: 1n }), @@ -27,16 +27,29 @@ test( token: USDC, amount: 1_000_000n, }), - async ({ chains }) => { - const ch = chains!.default; + async ({ chains, publicClient }) => { + const buyer = chains!.default.walletClient.account!.address; + const cartTotal = 500_000n; + + // In production, you might mint on a local token, impersonate a whale, + // or call custom faucet logic. In this test, we seed balance state so + // the body can jump straight to purchase constraints. expect( - await ch.publicClient.readContract({ + await publicClient.readContract({ address: USDC, abi: erc20Abi, functionName: "balanceOf", - args: [ch.wallet!], + args: [buyer], }), ).toBe(1_000_000n); + expect( + (await publicClient.readContract({ + address: USDC, + abi: erc20Abi, + functionName: "balanceOf", + args: [buyer], + })) >= cartTotal, + ).toBe(true); }, ), ); @@ -62,3 +75,4 @@ Managed middleware that writes test-only token state on entry, then forwards to ### Notes and caveats This is test-only state manipulation: it rewrites token balance state on the node and is not a production mint path. It may fail for non-standard token implementations (for example rebasing tokens or unusual storage layouts). +- On a fresh local chain, using a mainnet token address in docs is a stand-in for "contract exists in test state"; use `withFork()` when you need true historical mainnet storage and behavior. diff --git a/docs/pages/fixtures/wallets/index.mdx b/docs/pages/fixtures/wallets/index.mdx index 85d20c8..a56b35d 100644 --- a/docs/pages/fixtures/wallets/index.mdx +++ b/docs/pages/fixtures/wallets/index.mdx @@ -9,6 +9,10 @@ description: Use funded and impersonated account fixtures to prepare scenario si Wallet and balance fixtures prepare the scenario account that later steps (token seeding and contract interactions) will use. +Choose between: +- `withFundedWallet` when you need a fresh deterministic actor with known balances +- `withImpersonation` when your test must behave as an existing on-chain actor (admin, treasury, whale) without private key access + ## Fixtures - [`withFundedWallet({ balance[, privateKey, erc20] })`](/fixtures/wallets/withFundedWallet) diff --git a/docs/pages/fixtures/wallets/withFundedWallet.mdx b/docs/pages/fixtures/wallets/withFundedWallet.mdx index 80d67d8..90c1b41 100644 --- a/docs/pages/fixtures/wallets/withFundedWallet.mdx +++ b/docs/pages/fixtures/wallets/withFundedWallet.mdx @@ -8,7 +8,7 @@ description: Create a funded scenario account, optionally seeding ERC-20 balance **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withFundedWallet()` to get a ready-to-sign scenario account and deterministic ETH (and optional ERC-20) balances, so your test body focuses on behavior instead of setup glue code. +Use `withFundedWallet()` when the scenario needs a known actor and deterministic balances, but managing private keys and funding steps is not the behavior under test. It lets you start from "the user has spendable funds" and go straight to business rules. ### Example ```ts twoslash [index.ts] @@ -17,15 +17,20 @@ import { parseEther } from "viem"; import { scenario, withChain, withFundedWallet } from "@st8craft/core"; test( - "wallet is funded", + "prepare a checkout actor with deterministic spend limits", scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default; - expect(await ch.publicClient.getBalance({ address: ch.wallet! })).toBe( - parseEther("1"), - ); + async ({ chains, publicClient }) => { + const buyer = chains!.default.walletClient.account!.address; + const orderTotal = parseEther("0.4"); + const buyerBalance = await publicClient.getBalance({ address: buyer }); + + // In production, this actor might come from wallet onboarding or test + // helpers spread across hooks. In this test, one fixture gives you a + // ready signer + funds so you can assert purchase logic immediately. + expect(buyerBalance).toBe(parseEther("1")); + expect(buyerBalance >= orderTotal).toBe(true); }, ), ); @@ -53,3 +58,4 @@ Managed with respect to the wallet setup, this fixture does not start or stop An ### Notes and caveats - This is test-only state manipulation: it sets balance storage on compatible local/forked runtimes. - If you call `withFundedWallet` multiple times for the same `chain` inside the same scenario, the last one wins for `wallet` and `walletClient` on that chain entry. +- Use `privateKey` when your scenario needs a stable actor identity across tests (for example allowlists, role checks, or deterministic address assertions). diff --git a/docs/pages/fixtures/wallets/withImpersonation.mdx b/docs/pages/fixtures/wallets/withImpersonation.mdx index 8a6b8f5..7008284 100644 --- a/docs/pages/fixtures/wallets/withImpersonation.mdx +++ b/docs/pages/fixtures/wallets/withImpersonation.mdx @@ -8,7 +8,7 @@ description: Impersonate an existing account for scenario signing, with optional **Test runners:** Examples use Vitest `test` and `expect`. `scenario(...)` returns an async function you can pass to any runner with a similar `test` callback (for example Jest or Node `node:test`). ### Why it is useful -Use `withImpersonation()` when you need to execute transactions as an existing address (for example a forked holder or protocol actor) without private key access. +Use `withImpersonation()` when a scenario depends on an existing on-chain actor (for example treasury signer, whale holder, or governance address) and you need to test behavior without owning that actor's private key. ### Example ```ts twoslash [index.ts] @@ -21,7 +21,7 @@ import { } from "@st8craft/core"; test( - "impersonated account can sign on fork", + "simulate protocol-admin actions without the real private key", scenario( withFork({ rpcUrl: process.env.VITE_RPC_URL!, @@ -31,10 +31,15 @@ test( address: "0x000000000000000000000000000000000000dead", balance: parseEther("1"), }), - async ({ chains }) => { - const ch = chains!.default; - expect(ch.walletClient.account!.address).toBe(ch.wallet); - const eth = await ch.publicClient.getBalance({ address: ch.wallet! }); + async ({ chains, publicClient }) => { + const admin = chains!.default.walletClient.account!.address; + + // In production, this signer might be a multisig owner or protocol role. + // In this test, impersonation gives you that actor immediately so you can + // assert authorization or safety checks without key-management setup. + expect(admin).toBe("0x000000000000000000000000000000000000dEaD"); + + const eth = await publicClient.getBalance({ address: admin }); expect(eth).toBe(parseEther("1")); }, ), @@ -63,3 +68,4 @@ This fixture does not start or stop Anvil. It manages impersonation lifecycle fo ### Notes and caveats - This behavior depends on Anvil test-client impersonation support. - If you set `stopOnExit: false`, impersonation remains active for the runtime until another step or test changes it. +- Prefer forked runtimes for realistic impersonation scenarios where the actor's existing contract relationships or balances matter. diff --git a/docs/pages/index.mdx b/docs/pages/index.mdx index d4c6d17..b9fb870 100644 --- a/docs/pages/index.mdx +++ b/docs/pages/index.mdx @@ -49,27 +49,29 @@ import { HomePage, Button } from "vocs/components"; > ```ts import { expect, test } from "vitest"; - import { erc20Abi, parseEther } from "viem"; + import { parseEther, parseUnits } from "viem"; import { scenario, - withChain, + withFork, withFundedWallet, withErc20Balance, } from "@st8craft/core"; test( - "fork + funded wallet + USDC balance", + "pinned fork + funded wallet + USDC balance", scenario( - withChain(), + withFork({ + rpcUrl: process.env.VITE_RPC_URL!, + blockNumber: 22_000_000n, + }), withFundedWallet({ balance: parseEther("1") }), withErc20Balance({ token: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // USDC - amount: parseUnits("1_000_000", 6), + amount: parseUnits("1000000", 6), }), - async ({ chains }) => { - const ch = chains!.default; - // ch.walletClient now has both 1 ETH and seeded USDC - const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); + async ({ chains, publicClient }) => { + // default chain wallet now has both 1 ETH and seeded USDC + const balance = await publicClient.getBalance({ address: chains!.default.walletClient.account!.address }); expect(balance).toBe(parseEther("1")); }, ), diff --git a/docs/pages/overview.mdx b/docs/pages/overview.mdx index d0f8cc7..b488f1b 100644 --- a/docs/pages/overview.mdx +++ b/docs/pages/overview.mdx @@ -38,10 +38,9 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default; - const balance = await ch.publicClient.getBalance({ - address: ch.wallet!, + async ({ chains, publicClient }) => { + const balance = await publicClient.getBalance({ + address: chains!.default.walletClient.account!.address, }); expect(balance).toBe(parseEther("1")); }, @@ -51,6 +50,12 @@ test( This is the same pattern you will reuse for forks, deployments, token seeding, and isolation. To move from local to a pinned mainnet state, swap `withChain()` for `withFork({ rpcUrl, blockNumber })`. +This first snippet is intentionally minimal. For realistic scenario-style examples (relay settlement, impersonated protocol actors, constructor-aware deployments, and rollback isolation), jump to: +- [withBridge](/fixtures/runtime/withBridge) +- [withImpersonation](/fixtures/wallets/withImpersonation) +- [withDeployments](/fixtures/contracts/withDeployments) +- [withSnapshot](/fixtures/isolation/withSnapshot) + ## Why this beats ad hoc setup Without Statecraft, you typically need to: @@ -69,10 +74,21 @@ As a rule of thumb, you should be able to read a test and answer "what state doe ## Type safety in practice -Because TypeScript understands the fixture order, it can infer which fields are available in your `async ({ ... })` callback. In the example above, `chains.default` includes viem clients after `withChain()`, and `chains.default.wallet` after `withFundedWallet()`. +Because TypeScript understands the fixture order, it can infer which fields are available in your `async ({ ... })` callback. In the example above, `publicClient` is available after `withChain()`, and `chains.default.walletClient.account.address` is available after `withFundedWallet()` on that chain. If you write custom fixtures, use `requireContext` to fail fast when a step expects keys that are not present. +## Agent-first contracts + +Statecraft exposes explicit machine-facing contracts for autonomous workflows: + +- structured `StatecraftError` values with `code`, `reason`, `context`, and `suggestedAction` +- explicit constraints via `describeScenarioConstraints()` +- preflight and execution primitives (`planCall`, `simulateCall`, `planDeployment`, `simulateDeployment`, `executePlan`) +- optional step-level run hooks in `scenario(...)` for deterministic traces + +For strict API contracts and error tables, see [Core API (Agent)](/core-api-agent). + ## Lifecycle terminology (when it matters) - Managed lifecycle: the fixture starts and stops Anvil (`withChain`, `withFork`). diff --git a/docs/pages/quickstart.mdx b/docs/pages/quickstart.mdx index d32a283..6fe34ef 100644 --- a/docs/pages/quickstart.mdx +++ b/docs/pages/quickstart.mdx @@ -80,10 +80,9 @@ test( scenario( withChain(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default; - const balance = await ch.publicClient.getBalance({ - address: ch.wallet!, + async ({ chains, publicClient }) => { + const balance = await publicClient.getBalance({ + address: chains!.default.walletClient.account!.address, }); expect(balance).toBe(parseEther("1")); @@ -102,10 +101,16 @@ bunx vitest run tests/quickstart.test.ts ## What happened (and what success looks like) -This test starts a fresh local Anvil chain, funds a scenario wallet with `withFundedWallet` on `ctx.chains.default`, reads the wallet balance with `chains.default.publicClient.getBalance`, and asserts it. +This test starts a fresh local Anvil chain, funds a scenario wallet with `withFundedWallet` on `ctx.chains.default`, reads the wallet balance with `publicClient.getBalance`, and asserts it. Success looks like a passing Vitest run for `funded wallet on local chain`. +This quickstart stays intentionally small so you can verify your toolchain first. After it passes, move to scenario-style examples that model production-adjacent flows without full infra setup: +- [withBridge](/fixtures/runtime/withBridge) +- [withImpersonation](/fixtures/wallets/withImpersonation) +- [withDeployments](/fixtures/contracts/withDeployments) +- [withSnapshot](/fixtures/isolation/withSnapshot) + ## Troubleshooting - `anvil: command not found`: install Foundry (which provides `anvil`), then restart your terminal so your `PATH` updates. @@ -129,5 +134,6 @@ withFork({ Keep `blockNumber` pinned to a bigint literal. Then continue with: - [Overview](/overview) +- [Core API (Agent)](/core-api-agent) - [withFork](/fixtures/runtime/withFork) - [Wallet fixtures](/fixtures/wallets) diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index 94ef84d..dd64a57 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -1,6 +1,7 @@ import { createServer } from "node:net"; import { randomUUID } from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { StatecraftError } from "../scenarios/errors.js"; /** How anvil is started: empty chain or fork from a remote RPC. */ export type RuntimeMode = "chain" | "fork"; @@ -37,11 +38,19 @@ const runtimeState = new WeakMap(); function assertForkConfig(config: RuntimeConfig): asserts config is RuntimeConfig & { mode: "fork"; rpcUrl: string; blockNumber: bigint } { if (!config.rpcUrl) { - throw new Error("withFork/startRuntime requires rpcUrl for fork mode."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withFork/startRuntime requires rpcUrl for fork mode.", + suggestedAction: "Provide a valid rpcUrl when mode is 'fork'.", + }); } if (config.blockNumber === undefined) { - throw new Error("withFork/startRuntime requires a pinned blockNumber in v1 for deterministic forks."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withFork/startRuntime requires a pinned blockNumber in v1 for deterministic forks.", + suggestedAction: "Set blockNumber to a bigint literal (for example 22_000_000n).", + }); } } @@ -54,13 +63,66 @@ async function getAvailablePort(): Promise { const address = server.address(); if (!address || typeof address === "string") { server.close(); - throw new Error("Failed to allocate a local port for runtime."); + throw new StatecraftError({ + code: "SC_RUNTIME_PORT_ALLOCATION_FAILED", + reason: "Failed to allocate a local port for runtime.", + suggestedAction: "Retry startup or free local ports before starting a runtime.", + }); } const port = address.port; await new Promise((resolve) => server.close(() => resolve())); return port; } +async function waitForJsonRpcReady(args: { rpcUrl: string; timeoutMs: number }): Promise { + const startedAt = Date.now(); + let lastError: string | undefined; + while (Date.now() - startedAt < args.timeoutMs) { + let controllerTimeout: ReturnType | undefined; + try { + const controller = new AbortController(); + controllerTimeout = setTimeout(() => controller.abort(), 500); + const response = await fetch(args.rpcUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "web3_clientVersion", + params: [], + }), + }); + clearTimeout(controllerTimeout); + if (response.ok) { + const payload = await response.json() as { result?: unknown; error?: unknown }; + if (payload.result !== undefined) { + return; + } + if (payload.error !== undefined) { + lastError = JSON.stringify(payload.error); + } + } else { + lastError = `HTTP ${response.status}`; + } + } catch (error) { + if (controllerTimeout) clearTimeout(controllerTimeout); + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new StatecraftError({ + code: "SC_RUNTIME_START_TIMEOUT", + reason: "Runtime JSON-RPC endpoint did not become ready in time.", + context: { + rpcUrl: args.rpcUrl, + timeoutMs: args.timeoutMs, + lastError, + }, + suggestedAction: "Inspect startup logs and ensure anvil can bind and answer JSON-RPC requests.", + }); +} + /** * Validates fork config and ensures `RuntimeConfig.key` (defaults to a new UUID). * Does not start anvil; use {@link startRuntime} for that. @@ -97,38 +159,57 @@ export async function startRuntime(input: RuntimeConfig): Promise const child = spawn("anvil", args, { stdio: "pipe" }); - let startError: Error | undefined; - const startup = await new Promise((resolve) => { - const timeout = setTimeout(() => { - startError = new Error("Timed out waiting for anvil to start. Ensure `anvil` is installed and on PATH."); - resolve(false); - }, 12_000); - - const onData = (chunk: Buffer) => { - const text = chunk.toString(); - if (text.includes("Listening on")) { - clearTimeout(timeout); - resolve(true); - } - }; + const diagnostics = { + mode: config.mode, + port, + args, + stderrTail: [] as string[], + }; + const onData = (chunk: Buffer) => { + const text = chunk.toString(); + diagnostics.stderrTail.push(text.trim()); + if (diagnostics.stderrTail.length > 20) { + diagnostics.stderrTail.shift(); + } + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); - child.stdout.on("data", onData); - child.stderr.on("data", onData); + const startupFailure = new Promise((_, reject) => { child.once("error", (error) => { - clearTimeout(timeout); - startError = new Error(`Failed to start anvil runtime: ${error.message}`); - resolve(false); + reject(new StatecraftError({ + code: "SC_RUNTIME_START_FAILED", + reason: `Failed to start anvil runtime: ${error.message}`, + context: diagnostics, + suggestedAction: "Verify anvil is executable and runtime args are valid.", + cause: error, + })); }); child.once("exit", (code, signal) => { - clearTimeout(timeout); - startError = new Error(`Anvil exited during startup (code: ${String(code)}, signal: ${String(signal)}).`); - resolve(false); + reject(new StatecraftError({ + code: "SC_RUNTIME_START_FAILED", + reason: `Anvil exited during startup (code: ${String(code)}, signal: ${String(signal)}).`, + context: { + ...diagnostics, + exitCode: code, + signal, + }, + suggestedAction: "Inspect anvil startup logs and validate your fork config.", + })); }); }); - if (!startup) { + try { + await Promise.race([ + waitForJsonRpcReady({ + rpcUrl, + timeoutMs: 12_000, + }), + startupFailure, + ]); + } catch (error) { child.kill("SIGTERM"); - throw startError ?? new Error("Failed to start runtime."); + throw error; } const handle: RuntimeHandle = { diff --git a/packages/core/src/scenarios/actions.test.ts b/packages/core/src/scenarios/actions.test.ts new file mode 100644 index 0000000..107f1c0 --- /dev/null +++ b/packages/core/src/scenarios/actions.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from "vitest"; +import { + describeActionSemantics, + planCall, + planDeployment, + simulateCall, + simulateDeployment, +} from "./actions.js"; +import { assertPreflight } from "./preflight.js"; + +describe("action primitives", () => { + test("plans call and deployment actions", () => { + const call = planCall({ + to: "0x00000000000000000000000000000000000000a1", + data: "0x1234", + }); + const deployment = planDeployment({ + abi: [], + bytecode: "0x6000", + account: { + address: "0x00000000000000000000000000000000000000a2", + type: "json-rpc", + } as any, + }); + expect(call.plan.kind).toBe("call"); + expect(deployment.plan.kind).toBe("deployment"); + }); + + test("simulateCall returns machine-readable failures", async () => { + const publicClient = { + call: vi.fn(async () => { + throw new Error("reverted"); + }), + } as any; + const result = await simulateCall({ + publicClient, + plan: { + kind: "call", + to: "0x00000000000000000000000000000000000000a1", + data: "0x1234", + }, + }); + expect(result.canExecute).toBe(false); + expect(result.reasons[0]?.code).toBe("CALL_SIMULATION_FAILED"); + expect(() => assertPreflight(result)).toThrow(/preflight failed/i); + }); + + test("simulateDeployment includes estimated effects", async () => { + const publicClient = { + estimateContractGas: vi.fn(async () => 123n), + getTransactionCount: vi.fn(async () => 7), + } as any; + const result = await simulateDeployment({ + publicClient, + plan: { + kind: "deployment", + abi: [], + bytecode: "0x6000", + account: { + address: "0x00000000000000000000000000000000000000a2", + type: "json-rpc", + } as any, + }, + }); + expect(result.canExecute).toBe(true); + expect(result.estimatedEffects).toMatchObject({ estimatedGas: 123n }); + }); + + test("exposes idempotency metadata for action kinds", () => { + const call = describeActionSemantics("call"); + const deployment = describeActionSemantics("deployment"); + expect(call.idempotency).toBe("not-idempotent"); + expect(deployment.idempotency).toBe("not-idempotent"); + }); +}); diff --git a/packages/core/src/scenarios/actions.ts b/packages/core/src/scenarios/actions.ts new file mode 100644 index 0000000..6fdc689 --- /dev/null +++ b/packages/core/src/scenarios/actions.ts @@ -0,0 +1,263 @@ +import { + encodeDeployData, + getContractAddress, + type Account, + type Address, + type Chain, + type Hex, + type PublicClient, + type TransactionReceipt, + type Transport, + type WalletClient, +} from "viem"; +import { StatecraftError } from "./errors.js"; + +export type ActionPlanKind = "call" | "deployment"; +export type ActionIdempotency = "idempotent-with-key" | "not-idempotent"; + +export type PreflightIssue = { + code: string; + reason: string; + context?: Record; +}; + +export type ActionPreflight = { + canExecute: boolean; + reasons: PreflightIssue[]; + assumptions: string[]; + estimatedEffects: Record; +}; + +export type CallActionPlan = { + kind: "call"; + to: Address; + data: Hex; + value?: bigint; + chainId?: number; +}; + +export type DeploymentActionPlan = { + kind: "deployment"; + abi: readonly unknown[]; + bytecode: Hex; + args?: readonly unknown[]; + account: Account; + chainId?: number; +}; + +export type ActionPlan = CallActionPlan | DeploymentActionPlan; +export type ActionSemantics = { + kind: ActionPlanKind; + idempotency: ActionIdempotency; + retryGuidance: string; +}; + +export type PlanCallArgs = { + to: Address; + data: Hex; + value?: bigint; + chainId?: number; +}; + +export type PlanDeploymentArgs = { + abi: readonly unknown[]; + bytecode: Hex; + args?: readonly unknown[]; + account: Account; + chainId?: number; +}; + +export type SimulateCallArgs = { + publicClient: PublicClient; + plan: CallActionPlan; + account?: Account; +}; + +export type SimulateDeploymentArgs = { + publicClient: PublicClient; + plan: DeploymentActionPlan; +}; + +export type ExecutePlanArgs = { + plan: ActionPlan; + publicClient: PublicClient; + walletClient: WalletClient; + account?: Account; +}; + +export type ExecutePlanResult = + | { + kind: "call"; + hash: Hex; + receipt: TransactionReceipt; + } + | { + kind: "deployment"; + hash: Hex; + receipt: TransactionReceipt; + contractAddress: Address | undefined; + }; + +export function planCall(args: PlanCallArgs): { plan: CallActionPlan } { + return { + plan: { + kind: "call", + to: args.to, + data: args.data, + value: args.value, + chainId: args.chainId, + }, + }; +} + +export function describeActionSemantics(kind: ActionPlanKind): ActionSemantics { + if (kind === "call") { + return { + kind, + idempotency: "not-idempotent", + retryGuidance: "Safe retries require an external idempotency key and nonce strategy.", + }; + } + return { + kind, + idempotency: "not-idempotent", + retryGuidance: "Deployments are nonce-sensitive; retry only after checking sender nonce and previous receipts.", + }; +} + +export function planDeployment(args: PlanDeploymentArgs): { plan: DeploymentActionPlan } { + return { + plan: { + kind: "deployment", + abi: args.abi, + bytecode: args.bytecode, + args: args.args ?? [], + account: args.account, + chainId: args.chainId, + }, + }; +} + +export async function simulateCall(args: SimulateCallArgs): Promise { + const reasons: PreflightIssue[] = []; + try { + await args.publicClient.call({ + to: args.plan.to, + data: args.plan.data, + value: args.plan.value, + account: args.account ?? undefined, + }); + } catch (error) { + reasons.push({ + code: "CALL_SIMULATION_FAILED", + reason: error instanceof Error ? error.message : "Call simulation failed.", + }); + } + + return { + canExecute: reasons.length === 0, + reasons, + assumptions: [ + "Simulation outcome assumes chain state and block context stay unchanged before execution.", + ], + estimatedEffects: { + kind: "call", + to: args.plan.to, + value: args.plan.value ?? 0n, + }, + }; +} + +export async function simulateDeployment(args: SimulateDeploymentArgs): Promise { + const reasons: PreflightIssue[] = []; + let estimatedGas: bigint | undefined; + let predictedAddress: Address | undefined; + try { + estimatedGas = await args.publicClient.estimateContractGas({ + abi: args.plan.abi as never, + bytecode: args.plan.bytecode, + args: (args.plan.args ?? []) as readonly unknown[], + account: args.plan.account, + }); + + const nonce = await args.publicClient.getTransactionCount({ + address: args.plan.account.address, + }); + predictedAddress = getContractAddress({ + from: args.plan.account.address, + nonce, + }); + } catch (error) { + reasons.push({ + code: "DEPLOYMENT_SIMULATION_FAILED", + reason: error instanceof Error ? error.message : "Deployment simulation failed.", + }); + } + + return { + canExecute: reasons.length === 0, + reasons, + assumptions: [ + "Predicted deployment address assumes sender nonce remains unchanged before execution.", + "Estimated gas assumes chain base fee and mempool conditions remain stable.", + ], + estimatedEffects: { + kind: "deployment", + estimatedGas, + predictedAddress, + }, + }; +} + +export async function executePlan(args: ExecutePlanArgs): Promise { + if (args.plan.kind === "call") { + const account = args.account ?? args.walletClient.account; + if (!account) { + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "executePlan(call) requires a wallet account.", + suggestedAction: "Pass ExecutePlanArgs.account or configure walletClient.account.", + }); + } + const hash = await args.walletClient.sendTransaction({ + account, + to: args.plan.to, + data: args.plan.data, + value: args.plan.value ?? 0n, + chain: args.walletClient.chain, + }); + const receipt = await args.publicClient.waitForTransactionReceipt({ hash }); + return { + kind: "call", + hash, + receipt, + }; + } + + const account = args.account ?? args.plan.account ?? args.walletClient.account; + if (!account) { + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "executePlan(deployment) requires an account.", + suggestedAction: "Provide an account in planDeployment(...) or executePlan(...).", + }); + } + + const data = encodeDeployData({ + abi: args.plan.abi as never, + bytecode: args.plan.bytecode, + args: (args.plan.args ?? []) as readonly unknown[], + }); + const hash = await args.walletClient.sendTransaction({ + account, + data, + chain: args.walletClient.chain, + }); + const receipt = await args.publicClient.waitForTransactionReceipt({ hash }); + return { + kind: "deployment", + hash, + receipt, + contractAddress: receipt.contractAddress, + }; +} diff --git a/packages/core/src/scenarios/errors.ts b/packages/core/src/scenarios/errors.ts new file mode 100644 index 0000000..a719311 --- /dev/null +++ b/packages/core/src/scenarios/errors.ts @@ -0,0 +1,52 @@ +export type StatecraftErrorCode = + | "SC_CONTEXT_MISSING" + | "SC_CONSTRAINT_VIOLATION" + | "SC_PRECONDITION_FAILED" + | "SC_RUNTIME_START_TIMEOUT" + | "SC_RUNTIME_START_FAILED" + | "SC_RUNTIME_PORT_ALLOCATION_FAILED" + | "SC_BUNDLER_UNSUPPORTED_MODE" + | "SC_BUNDLER_START_FAILED"; + +export type StatecraftErrorDetails = Record; + +export type StatecraftErrorInput = { + code: StatecraftErrorCode; + reason: string; + context?: StatecraftErrorDetails; + suggestedAction?: string; + cause?: unknown; +}; + +/** + * Structured SDK error designed for automated remediation and branching. + */ +export class StatecraftError extends Error { + readonly code: StatecraftErrorCode; + readonly reason: string; + readonly context: StatecraftErrorDetails; + readonly suggestedAction?: string; + + constructor(input: StatecraftErrorInput) { + super(input.reason, input.cause !== undefined ? { cause: input.cause } : undefined); + this.name = "StatecraftError"; + this.code = input.code; + this.reason = input.reason; + this.context = input.context ?? {}; + this.suggestedAction = input.suggestedAction; + } +} + +export function isStatecraftError(value: unknown): value is StatecraftError { + return value instanceof StatecraftError; +} + +export function toStatecraftError(error: unknown, fallback: Omit): StatecraftError { + if (isStatecraftError(error)) { + return error; + } + return new StatecraftError({ + ...fallback, + cause: error, + }); +} diff --git a/packages/core/src/scenarios/fixtures/withBridge.test.ts b/packages/core/src/scenarios/fixtures/withBridge.test.ts index 08a4767..90acaf4 100644 --- a/packages/core/src/scenarios/fixtures/withBridge.test.ts +++ b/packages/core/src/scenarios/fixtures/withBridge.test.ts @@ -157,6 +157,61 @@ describe("withBridge", () => { await expect(step({ chains: {} } as any, async () => {})).rejects.toThrow(/missing runtime clients for chain "src"/i); }); + + test("supports preflight before execution", async () => { + const srcBalances = new Map([[ALICE, 10n]]); + const destBalances = new Map([[BOB, 1n]]); + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }); + const ctx = { + chains: { + src: makeNativeChain({ wallet: ALICE, balances: srcBalances }), + dest: makeNativeChain({ wallet: BOB, balances: destBalances }), + }, + } as any; + + await step(ctx, async (nextCtx: any) => { + const preflight = await nextCtx.bridge.preflight({ amountIn: 2n, price: 1n }); + expect(preflight.canExecute).toBe(true); + expect(preflight.reasons).toEqual([]); + expect(preflight.estimatedEffects).toMatchObject({ + srcChain: "src", + destChain: "dest", + }); + }); + }); + + test("uses idempotency key to avoid duplicate execution", async () => { + const srcBalances = new Map([[ALICE, 10n]]); + const destBalances = new Map([[BOB, 1n]]); + const step = withBridge({ + srcChain: "src", + destChain: "dest", + fromToken: NATIVE_TOKEN_ADDRESS, + toToken: NATIVE_TOKEN_ADDRESS, + }); + const ctx = { + chains: { + src: makeNativeChain({ wallet: ALICE, balances: srcBalances }), + dest: makeNativeChain({ wallet: BOB, balances: destBalances }), + }, + } as any; + + await step(ctx, async (nextCtx: any) => { + const first = await nextCtx.bridge.execute({ amountIn: 4n, price: 1n, idempotencyKey: "k1" }); + const second = await nextCtx.bridge.execute({ amountIn: 4n, price: 1n, idempotencyKey: "k1" }); + expect(second).toEqual(first); + await expect( + nextCtx.bridge.execute({ amountIn: 5n, price: 1n, idempotencyKey: "k1" }), + ).rejects.toThrow(/idempotency key reused/i); + }); + expect(srcBalances.get(ALICE)).toBe(6n); + expect(destBalances.get(BOB)).toBe(5n); + }); }); function makeNativeChain({ diff --git a/packages/core/src/scenarios/fixtures/withBridge.ts b/packages/core/src/scenarios/fixtures/withBridge.ts index ee78c10..74b015d 100644 --- a/packages/core/src/scenarios/fixtures/withBridge.ts +++ b/packages/core/src/scenarios/fixtures/withBridge.ts @@ -4,6 +4,7 @@ import { NATIVE_TOKEN_ADDRESS, type BridgeExecuteArgs, type BridgeExecution, + type ScenarioChainContext, type ScenarioBridge, type ScenarioBridgeContext, type ScenarioRuntimeClientsContext, @@ -11,6 +12,9 @@ import { type WithBridgeConfig, } from "../types.js"; import { requireChainScopedRuntimeClients } from "../utils.js"; +import { StatecraftError } from "../errors.js"; +import type { ActionPreflight, PreflightIssue } from "../actions.js"; +import { labelScenarioStep } from "../stepMeta.js"; const NATIVE_TOKEN = NATIVE_TOKEN_ADDRESS as Address; @@ -30,10 +34,15 @@ export function withBridge( > { const priceScale = config.priceScale ?? 1n; if (priceScale <= 0n) { - throw new Error("withBridge(...) requires priceScale to be greater than zero."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withBridge(...) requires priceScale to be greater than zero.", + context: { priceScale: String(priceScale) }, + suggestedAction: "Provide a positive bigint priceScale.", + }); } - return async (ctx, next) => { + return labelScenarioStep(async (ctx, next) => { requireChainScopedRuntimeClients(ctx, config.srcChain); requireChainScopedRuntimeClients(ctx, config.destChain); @@ -41,31 +50,76 @@ export function withBridge( const dest = ctx.chains[config.destChain]!; if (ctx.bridge) { - throw new Error("withBridge(...) ctx.bridge is already defined. Compose at most one withBridge(...) per scenario."); + throw new StatecraftError({ + code: "SC_CONSTRAINT_VIOLATION", + reason: "withBridge(...) ctx.bridge is already defined. Compose at most one withBridge(...) per scenario.", + suggestedAction: "Use a single withBridge(...) step and invoke bridge.execute(...) multiple times as needed.", + }); } + const executionLedger = new Map(); const bridge: ScenarioBridge = { - execute: async ({ amountIn, price, from, to }: BridgeExecuteArgs): Promise => { - if (amountIn < 0n) { - throw new Error("withBridge(...).execute(...) requires amountIn to be non-negative."); - } - if (price < 0n) { - throw new Error("withBridge(...).execute(...) requires price to be non-negative."); + preflight: async ({ amountIn, price, from, to }: BridgeExecuteArgs): Promise => { + return preflightBridgeExecution({ + src, + dest, + config, + amountIn, + price, + from, + to, + priceScale, + }); + }, + execute: async ({ amountIn, price, from, to, idempotencyKey }: BridgeExecuteArgs): Promise => { + if (idempotencyKey) { + const fingerprint = buildExecutionFingerprint({ + amountIn, + price, + from, + to, + srcChain: config.srcChain, + destChain: config.destChain, + fromToken: config.fromToken, + toToken: config.toToken, + }); + const seen = executionLedger.get(idempotencyKey); + if (seen) { + if (seen.fingerprint !== fingerprint) { + throw new StatecraftError({ + code: "SC_CONSTRAINT_VIOLATION", + reason: "Bridge idempotency key reused with a different payload.", + context: { + idempotencyKey, + }, + suggestedAction: "Use a unique idempotency key per distinct bridge payload.", + }); + } + return seen.result; + } } - const fromAddress = from ?? config.from ?? src.wallet; - if (!fromAddress) { - throw new Error( - `withBridge(...).execute(...) requires a source recipient: pass \`from\`, configure \`config.from\`, or compose withFundedWallet(...) on source chain "${config.srcChain}".`, - ); + const preflight = await preflightBridgeExecution({ + src, + dest, + config, + amountIn, + price, + from, + to, + priceScale, + }); + if (!preflight.canExecute) { + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withBridge(...).execute(...) preflight failed.", + context: preflight, + suggestedAction: "Inspect preflight reasons and satisfy requirements before bridge.execute(...).", + }); } + const fromAddress = from ?? config.from ?? src.wallet; const toAddress = to ?? config.to ?? dest.wallet; - if (!toAddress) { - throw new Error( - `withBridge(...).execute(...) requires a destination recipient: pass \`to\`, configure \`config.to\`, or compose withFundedWallet(...) on destination chain "${config.destChain}".`, - ); - } const amountOut = (amountIn * price) / priceScale; @@ -84,7 +138,7 @@ export function withBridge( amount: amountOut, }); - return { + const result = { srcChain: config.srcChain, destChain: config.destChain, fromToken: config.fromToken, @@ -95,6 +149,22 @@ export function withBridge( amountOut, price, }; + if (idempotencyKey) { + executionLedger.set(idempotencyKey, { + fingerprint: buildExecutionFingerprint({ + amountIn, + price, + from, + to, + srcChain: config.srcChain, + destChain: config.destChain, + fromToken: config.fromToken, + toToken: config.toToken, + }), + result, + }); + } + return result; }, }; @@ -102,6 +172,90 @@ export function withBridge( ...ctx, bridge, }); + }, "withBridge"); +} + +function buildExecutionFingerprint(args: { + amountIn: bigint; + price: bigint; + from?: Address; + to?: Address; + srcChain: string; + destChain: string; + fromToken: Address; + toToken: Address; +}): string { + return [ + args.srcChain, + args.destChain, + args.fromToken.toLowerCase(), + args.toToken.toLowerCase(), + args.from?.toLowerCase() ?? "", + args.to?.toLowerCase() ?? "", + args.amountIn.toString(), + args.price.toString(), + ].join("|"); +} + +async function preflightBridgeExecution(args: { + src: ScenarioChainContext; + dest: ScenarioChainContext; + config: WithBridgeConfig; + amountIn: bigint; + price: bigint; + from?: Address; + to?: Address; + priceScale: bigint; +}): Promise { + const reasons: PreflightIssue[] = []; + if (args.amountIn < 0n) { + reasons.push({ + code: "BRIDGE_AMOUNT_INVALID", + reason: "Bridge amountIn must be non-negative.", + context: { amountIn: String(args.amountIn) }, + }); + } + if (args.price < 0n) { + reasons.push({ + code: "BRIDGE_PRICE_INVALID", + reason: "Bridge price must be non-negative.", + context: { price: String(args.price) }, + }); + } + + const fromAddress = args.from ?? args.config.from ?? args.src.wallet; + if (!fromAddress) { + reasons.push({ + code: "BRIDGE_FROM_MISSING", + reason: `Missing source recipient for chain "${args.config.srcChain}".`, + context: { chain: args.config.srcChain }, + }); + } + + const toAddress = args.to ?? args.config.to ?? args.dest.wallet; + if (!toAddress) { + reasons.push({ + code: "BRIDGE_TO_MISSING", + reason: `Missing destination recipient for chain "${args.config.destChain}".`, + context: { chain: args.config.destChain }, + }); + } + + return { + canExecute: reasons.length === 0, + reasons, + assumptions: [ + "Bridge transfer assumes source/destination chain state remains unchanged between preflight and execute.", + "Bridge pricing uses integer math amountOut = amountIn * price / priceScale.", + ], + estimatedEffects: { + amountIn: String(args.amountIn), + amountOut: String((args.amountIn * args.price) / args.priceScale), + srcChain: args.config.srcChain, + destChain: args.config.destChain, + fromToken: args.config.fromToken, + toToken: args.config.toToken, + }, }; } @@ -112,7 +266,7 @@ async function debitAsset({ amount, label, }: { - chain: ScenarioRuntimeClientsContext["chains"][string]; + chain: ScenarioChainContext; token: Address; owner: Address; amount: bigint; @@ -121,7 +275,12 @@ async function debitAsset({ if (isNativeToken(token)) { const current = await chain.publicClient.getBalance({ address: owner }); if (current < amount) { - throw new Error(`withBridge(...).execute(...) insufficient native ${label} balance: wanted ${amount}, got ${current}.`); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: `withBridge(...).execute(...) insufficient native ${label} balance.`, + context: { wanted: String(amount), got: String(current), label }, + suggestedAction: "Fund the source account or reduce amountIn.", + }); } await chain.testClient.setBalance({ address: owner, value: current - amount }); return; @@ -135,7 +294,12 @@ async function debitAsset({ }); if (current < amount) { - throw new Error(`withBridge(...).execute(...) insufficient ERC-20 ${label} balance: wanted ${amount}, got ${current}.`); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: `withBridge(...).execute(...) insufficient ERC-20 ${label} balance.`, + context: { wanted: String(amount), got: String(current), label, token }, + suggestedAction: "Seed token balance before execute(...) or reduce amountIn.", + }); } await dealErc20Balance({ @@ -152,7 +316,7 @@ async function creditAsset({ owner, amount, }: { - chain: ScenarioRuntimeClientsContext["chains"][string]; + chain: ScenarioChainContext; token: Address; owner: Address; amount: bigint; diff --git a/packages/core/src/scenarios/fixtures/withBundler.ts b/packages/core/src/scenarios/fixtures/withBundler.ts index 61e5070..75ece1f 100644 --- a/packages/core/src/scenarios/fixtures/withBundler.ts +++ b/packages/core/src/scenarios/fixtures/withBundler.ts @@ -3,8 +3,10 @@ import type { Address } from "viem"; import { requireChainScopedRuntimeClients } from "../utils.js"; import type { BundlerClient } from "../../clients/index.js"; import { createBundlerClient } from "../../clients/index.js"; -import type { ScenarioBundlerContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioBundlerOnChainContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; import { startBundler } from "../internal/startBundler.js"; +import { StatecraftError } from "../errors.js"; +import { labelScenarioStep } from "../stepMeta.js"; export type WithBundlerConfig = { /** Key on `ctx.chains` (default `default`). */ @@ -24,22 +26,48 @@ const DEFAULT_EXECUTOR_PRIVATE_KEY = * * Requires `@pimlico/alto` to be installed in the host project (declared as a peer dependency). */ -export function withBundler(config: WithBundlerConfig): ScenarioStep { +export function withBundler( + config: WithBundlerConfig & { chain?: undefined }, +): ScenarioStep>; +export function withBundler( + config: WithBundlerConfig & { chain: C }, +): ScenarioStep>; +export function withBundler( + config: WithBundlerConfig, +): ScenarioStep { const chainKey = config.chain ?? "default"; - return async (ctx, next) => { + return labelScenarioStep(async ( + ctx: ScenarioRuntimeClientsContext, + next: (ctx: ScenarioRuntimeClientsContext) => Promise, + ) => { requireChainScopedRuntimeClients(ctx, chainKey); const ch = ctx.chains[chainKey]!; if (ch.runtimeMode !== "fork") { - throw new Error("withBundler(...) requires withFork(...) (or a fork entry in withMultiChain) for that chain first."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withBundler(...) requires withFork(...) (or a fork entry in withMultiChain) for that chain first.", + context: { chain: chainKey, runtimeMode: ch.runtimeMode }, + suggestedAction: "Compose withFork(...) before withBundler(...) for this chain.", + }); } if (!config?.entryPoint) { - throw new Error("withBundler(...) requires `entryPoint`."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withBundler(...) requires `entryPoint`.", + context: { chain: chainKey }, + suggestedAction: "Pass an ERC-4337 entryPoint address to withBundler(...).", + }); } if (config.mode && config.mode !== "alto") { - throw new Error(`withBundler(...) only supports mode='alto'.`); + throw new StatecraftError({ + code: "SC_BUNDLER_UNSUPPORTED_MODE", + reason: "withBundler(...) only supports mode='alto'.", + context: { requestedMode: config.mode }, + suggestedAction: "Set mode to 'alto' or omit mode.", + }); } const executorAccount: PrivateKeyAccount = privateKeyToAccount(DEFAULT_EXECUTOR_PRIVATE_KEY); @@ -76,5 +104,5 @@ export function withBundler(config: WithBundlerConfig): ScenarioStep { expect(ch.publicClient).toBe(clients.publicClient); expect(ch.walletClient).toBe(clients.walletClient); expect(ch.testClient).toBe(clients.testClient); + expect(nextCtx.publicClient).toBe(clients.publicClient); + expect(nextCtx.altPublicClient).toBeUndefined(); expect(nextCtx.keep).toBe("me"); }); diff --git a/packages/core/src/scenarios/fixtures/withChain.ts b/packages/core/src/scenarios/fixtures/withChain.ts index 485acb0..b5576d3 100644 --- a/packages/core/src/scenarios/fixtures/withChain.ts +++ b/packages/core/src/scenarios/fixtures/withChain.ts @@ -1,6 +1,8 @@ import { startRuntime, stopRuntime } from "../../runtime/index.js"; import { createClients } from "../../clients/index.js"; -import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioChainContext, ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import { assertTwoChainLimit, resolvePublicClientAliases, type PublicClientAliasPolicy } from "../utils.js"; +import { labelScenarioStep } from "../stepMeta.js"; /** Options for starting a fresh chain (non-fork) anvil instance. */ export type WithChainConfig = { @@ -12,6 +14,8 @@ export type WithChainConfig = { chainId?: number; /** Stable id forwarded to `RuntimeConfig.key` on the runtime package for correlation across restarts. */ key?: string; + /** Policy for deriving top-level `publicClient`/`altPublicClient` aliases from `ctx.chains`. */ + publicClientAliasPolicy?: PublicClientAliasPolicy; }; /** @@ -19,31 +23,37 @@ export type WithChainConfig = { */ export function withChain(config: WithChainConfig = {}): ScenarioStep { const chainKey = config.chainKey ?? "default"; - return async (ctx, next) => { + return labelScenarioStep(async (ctx, next) => { const runtime = await startRuntime({ mode: "chain", ...(config.chainId !== undefined ? { chainId: config.chainId } : {}), ...(config.key !== undefined ? { key: config.key } : {}), }); const clients = createClients(runtime, config.chainId !== undefined ? { chainId: config.chainId } : {}); + const chainContext: ScenarioChainContext = { + runtime, + runtimeMode: "chain", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }; + const chains: Record = { + ...(ctx.chains ?? {}), + [chainKey]: chainContext, + }; + assertTwoChainLimit(chains); + const { publicClient, altPublicClient } = resolvePublicClientAliases(chains, config.publicClientAliasPolicy); try { await next({ ...ctx, - chains: { - ...(ctx.chains ?? {}), - [chainKey]: { - runtime, - runtimeMode: "chain", - chain: clients.publicClient.chain, - publicClient: clients.publicClient, - walletClient: clients.walletClient, - testClient: clients.testClient, - }, - }, + chains, + publicClient, + altPublicClient, }); } finally { await stopRuntime(runtime); } - }; + }, "withChain"); } diff --git a/packages/core/src/scenarios/fixtures/withContracts.ts b/packages/core/src/scenarios/fixtures/withContracts.ts index 37eb771..358c805 100644 --- a/packages/core/src/scenarios/fixtures/withContracts.ts +++ b/packages/core/src/scenarios/fixtures/withContracts.ts @@ -2,6 +2,7 @@ import { getContract, type Hex } from "viem"; import type { AfterSetCodeContext, ContractArtifact, + ScenarioContractsOnChainContext, ScenarioContracts, ScenarioRuntimeClientsContext, ScenarioStep, @@ -37,18 +38,30 @@ export type WithContractsConfig = contracts: WithContractsMap; }; -type WithContractsIn = ScenarioRuntimeClientsContext; -type WithContractsOut = ScenarioRuntimeClientsContext; - /** * Middleware: for each entry, `setCode` at `address`, then merge contract handles into `ctx.chains[chain].contracts`. * Requires a prior runtime fixture for that chain. */ export function withContracts( config: WithContractsConfig, -): ScenarioStep { +): ScenarioStep< + ScenarioRuntimeClientsContext, + ScenarioContractsOnChainContext +>; +export function withContracts( + config: { + chain: C; + contracts: WithContractsMap; + }, +): ScenarioStep>; +export function withContracts( + config: WithContractsConfig, +): ScenarioStep { const { chainKey, contracts: contractMap } = normalizeWithContractsConfig(config); - return async (ctx, next) => { + return async ( + ctx: ScenarioRuntimeClientsContext, + next: (ctx: ScenarioRuntimeClientsContext) => Promise, + ) => { requireChainScopedRuntimeClients(ctx, chainKey); const ch = ctx.chains[chainKey]!; const contracts: ScenarioContracts = { ...(ch.contracts ?? {}) }; diff --git a/packages/core/src/scenarios/fixtures/withDeployments.test.ts b/packages/core/src/scenarios/fixtures/withDeployments.test.ts index ef22692..7bfce6b 100644 --- a/packages/core/src/scenarios/fixtures/withDeployments.test.ts +++ b/packages/core/src/scenarios/fixtures/withDeployments.test.ts @@ -182,4 +182,39 @@ describe("withDeployments", () => { expect(deployContract).toHaveBeenCalledTimes(1); expect(waitForTransactionReceipt).toHaveBeenCalledTimes(1); }); + + test("supports strict preflight mode with machine-readable callback", async () => { + const onPreflight = vi.fn(); + const step = withDeployments({ + preflightMode: "strict", + onPreflight, + deployments: { + token: { + artifact: { abi: [], bytecode: "0x60016000f3" }, + }, + }, + }); + + await expect( + step( + { + chains: { + default: { + chain: { id: 31337 }, + runtime: { rpcUrl: "http://127.0.0.1:8545" }, + publicClient: {}, + walletClient: { account: { address: "0x0000000000000000000000000000000000000001" } }, + testClient: {}, + }, + }, + } as any, + async () => undefined, + ), + ).rejects.toThrow(/preflight failed/i); + + expect(onPreflight).toHaveBeenCalledTimes(1); + expect(onPreflight.mock.calls[0]?.[0]?.result).toMatchObject({ + canExecute: false, + }); + }); }); diff --git a/packages/core/src/scenarios/fixtures/withDeployments.ts b/packages/core/src/scenarios/fixtures/withDeployments.ts index 564db73..ae9d69f 100644 --- a/packages/core/src/scenarios/fixtures/withDeployments.ts +++ b/packages/core/src/scenarios/fixtures/withDeployments.ts @@ -4,10 +4,15 @@ import type { ContractArtifact, DeploymentArgsResolver, DeploymentRecord, + ScenarioDeploymentsOnChainContext, ScenarioRuntimeClientsContext, ScenarioStep, } from "../types.js"; import { extractBytecode, requireChainScopedRuntimeClients } from "../utils.js"; +import { simulateDeployment } from "../actions.js"; +import { assertPreflight } from "../preflight.js"; +import { StatecraftError } from "../errors.js"; +import { labelScenarioStep } from "../stepMeta.js"; /** * Declares one contract to deploy via `walletClient.deployContract` in declaration order. @@ -34,9 +39,11 @@ export type WithDeploymentsMap = Record; export type WithDeploymentsConfig = | WithDeploymentsMap | { - chain?: string; - deployments: WithDeploymentsMap; -}; + chain?: string; + preflightMode?: "none" | "warn" | "strict"; + onPreflight?: (args: { chain: string; name: string; result: Awaited> }) => void; + deployments: WithDeploymentsMap; + }; type DeploymentsMap = Record; @@ -48,24 +55,68 @@ export function withDeployments( config: WithDeploymentsConfig, ): ScenarioStep< C & { chains: C["chains"] }, - C & { chains: C["chains"] } -> { - const { chainKey, deployments: deploymentMap } = normalizeWithDeploymentsConfig(config); - return async (ctx, next) => { + ScenarioDeploymentsOnChainContext +>; +export function withDeployments( + config: { + chain: C; + deployments: WithDeploymentsMap; + }, +): ScenarioStep>; +export function withDeployments( + config: WithDeploymentsConfig, +): ScenarioStep { + const { chainKey, deployments: deploymentMap, preflightMode, onPreflight } = normalizeWithDeploymentsConfig(config); + return labelScenarioStep(async ( + ctx: ScenarioRuntimeClientsContext, + next: (ctx: ScenarioRuntimeClientsContext) => Promise, + ) => { requireChainScopedRuntimeClients(ctx, chainKey); const ch = ctx.chains[chainKey]!; const deployments: DeploymentsMap = { ...(ch.deployments ?? {}) }; for (const [name, spec] of Object.entries(deploymentMap)) { if (!spec.artifact.abi) { - throw new Error(`${name}.artifact.abi is required for deployment.`); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: `${name}.artifact.abi is required for deployment.`, + context: { name, chain: chainKey }, + suggestedAction: "Provide an ABI for each deployment artifact.", + }); } const bytecode = extractBytecode(spec.artifact.bytecode, `${name}.bytecode`); const args = typeof spec.args === "function" ? await spec.args({ deployments }) : spec.args ?? []; const account = ch.walletClient.account; if (!account) { - throw new Error("withDeployments(...) requires a walletClient account."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withDeployments(...) requires a walletClient account.", + context: { chain: chainKey, deployment: name }, + suggestedAction: "Compose withFundedWallet(...) before withDeployments(...).", + }); + } + + if (preflightMode !== "none") { + const preflight = await simulateDeployment({ + publicClient: ch.publicClient, + plan: { + kind: "deployment", + abi: spec.artifact.abi, + bytecode, + args, + account, + chainId: ch.chain.id, + }, + }); + onPreflight?.({ + chain: chainKey, + name, + result: preflight, + }); + if (preflightMode === "strict") { + assertPreflight(preflight); + } } const hash = await ch.walletClient.deployContract({ @@ -109,13 +160,20 @@ export function withDeployments( }, }, }); - }; + }, "withDeployments"); } -function normalizeWithDeploymentsConfig(config: WithDeploymentsConfig): { chainKey: string; deployments: WithDeploymentsMap } { +function normalizeWithDeploymentsConfig(config: WithDeploymentsConfig): { + chainKey: string; + deployments: WithDeploymentsMap; + preflightMode: "none" | "warn" | "strict"; + onPreflight?: (args: { chain: string; name: string; result: Awaited> }) => void; +} { const maybeScoped = config as { chain?: string; deployments?: unknown; + preflightMode?: "none" | "warn" | "strict"; + onPreflight?: (args: { chain: string; name: string; result: Awaited> }) => void; }; if (maybeScoped.deployments && typeof maybeScoped.deployments === "object") { const maybeLegacyDeploymentsKey = maybeScoped.deployments as { artifact?: unknown }; @@ -124,6 +182,8 @@ function normalizeWithDeploymentsConfig(config: WithDeploymentsConfig): { chainK return { chainKey: maybeScoped.chain ?? "default", deployments: maybeScoped.deployments as WithDeploymentsMap, + preflightMode: maybeScoped.preflightMode ?? "none", + onPreflight: maybeScoped.onPreflight, }; } } @@ -131,5 +191,6 @@ function normalizeWithDeploymentsConfig(config: WithDeploymentsConfig): { chainK return { chainKey: "default", deployments: config as WithDeploymentsMap, + preflightMode: "none", }; } diff --git a/packages/core/src/scenarios/fixtures/withErc20Balance.ts b/packages/core/src/scenarios/fixtures/withErc20Balance.ts index acb698a..7868f02 100644 --- a/packages/core/src/scenarios/fixtures/withErc20Balance.ts +++ b/packages/core/src/scenarios/fixtures/withErc20Balance.ts @@ -1,5 +1,10 @@ import type { Address } from "viem"; -import type { ScenarioContext, ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { + ScenarioContext, + ScenarioRuntimeClientsContext, + ScenarioStep, + ScenarioWalletOnChainContext, +} from "../types.js"; import { dealErc20Balance } from "../internal/dealErc20Balance.js"; import { requireChainScopedRuntimeClients } from "../utils.js"; @@ -23,14 +28,14 @@ export type WithErc20BalanceConfig = { }; export type WithErc20Balance = { - (config: WithErc20BalanceConfig & { to: Address }): ScenarioStep; - (config: Omit & { chain?: undefined }): ScenarioStep< - ScenarioFundedWalletContext<"default">, - ScenarioFundedWalletContext<"default"> + (config: WithErc20BalanceConfig & { to: Address }): ScenarioStep; + (config: Omit & { chain?: undefined }): ScenarioStep< + ScenarioWalletOnChainContext, + ScenarioWalletOnChainContext >; - (config: Omit & { chain: C }): ScenarioStep< - ScenarioFundedWalletContext, - ScenarioFundedWalletContext + (config: Omit & { chain: C }): ScenarioStep< + ScenarioWalletOnChainContext, + ScenarioWalletOnChainContext >; }; diff --git a/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts b/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts index ea136f4..782d42b 100644 --- a/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts +++ b/packages/core/src/scenarios/fixtures/withExternalRuntime.test.ts @@ -25,6 +25,8 @@ describe("withExternalRuntime", () => { expect(ch.publicClient).toBeDefined(); expect(ch.walletClient).toBeDefined(); expect(ch.testClient).toBeDefined(); + expect(ctx.publicClient).toBe(ch.publicClient); + expect(ctx.altPublicClient).toBeUndefined(); }); test("does not stop runtime lifecycle", async () => { diff --git a/packages/core/src/scenarios/fixtures/withExternalRuntime.ts b/packages/core/src/scenarios/fixtures/withExternalRuntime.ts index 8cca488..f9f43e4 100644 --- a/packages/core/src/scenarios/fixtures/withExternalRuntime.ts +++ b/packages/core/src/scenarios/fixtures/withExternalRuntime.ts @@ -1,6 +1,8 @@ import { createClients, type CreateClientsOptions } from "../../clients/index.js"; import type { RuntimeHandle, RuntimeMode } from "../../runtime/index.js"; -import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioChainContext, ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import { assertTwoChainLimit, resolvePublicClientAliases, type PublicClientAliasPolicy } from "../utils.js"; +import { labelScenarioStep } from "../stepMeta.js"; /** * Options for attaching an existing runtime handle to scenario context. @@ -20,6 +22,8 @@ export type WithExternalRuntimeConfig = { runtimeMode?: RuntimeMode; /** Optional client wiring overrides (chain identity and signer key). */ clients?: CreateClientsOptions; + /** Policy for deriving top-level `publicClient`/`altPublicClient` aliases from `ctx.chains`. */ + publicClientAliasPolicy?: PublicClientAliasPolicy; }; /** @@ -30,22 +34,28 @@ export type WithExternalRuntimeConfig = { */ export function withExternalRuntime(config: WithExternalRuntimeConfig): ScenarioStep { const chainKey = config.chainKey ?? "default"; - return async (ctx, next) => { + return labelScenarioStep(async (ctx, next) => { const clients = createClients(config.runtime, config.clients); + const chainContext: ScenarioChainContext = { + runtime: config.runtime, + runtimeMode: config.runtimeMode ?? "chain", + chain: clients.publicClient.chain, + publicClient: clients.publicClient, + walletClient: clients.walletClient, + testClient: clients.testClient, + }; + const chains: Record = { + ...(ctx.chains ?? {}), + [chainKey]: chainContext, + }; + assertTwoChainLimit(chains); + const { publicClient, altPublicClient } = resolvePublicClientAliases(chains, config.publicClientAliasPolicy); await next({ ...ctx, - chains: { - ...(ctx.chains ?? {}), - [chainKey]: { - runtime: config.runtime, - runtimeMode: config.runtimeMode ?? "chain", - chain: clients.publicClient.chain, - publicClient: clients.publicClient, - walletClient: clients.walletClient, - testClient: clients.testClient, - }, - }, + chains, + publicClient, + altPublicClient, }); - }; + }, "withExternalRuntime"); } diff --git a/packages/core/src/scenarios/fixtures/withFork.test.ts b/packages/core/src/scenarios/fixtures/withFork.test.ts index d0bca5c..3b7e489 100644 --- a/packages/core/src/scenarios/fixtures/withFork.test.ts +++ b/packages/core/src/scenarios/fixtures/withFork.test.ts @@ -69,6 +69,8 @@ describe("withFork", () => { expect(ch.publicClient).toBe(clients.publicClient); expect(ch.walletClient).toBe(clients.walletClient); expect(ch.testClient).toBe(clients.testClient); + expect(nextCtx.publicClient).toBe(clients.publicClient); + expect(nextCtx.altPublicClient).toBeUndefined(); }); await step({ seed: true } as any, next); @@ -82,4 +84,38 @@ describe("withFork", () => { expect(createClients).toHaveBeenCalledWith(runtime, { chainId: 1 }); expect(stopRuntime).toHaveBeenCalledWith(runtime); }); + + test("reuses identical runtime inputs across repeated runs", async () => { + const runtime = { rpcUrl: "http://127.0.0.1:8545" }; + const clients = { + publicClient: { chain: { id: 1 } }, + walletClient: {}, + testClient: {}, + }; + startRuntime.mockResolvedValue(runtime); + createClients.mockReturnValue(clients); + + const { withFork } = await import("./withFork.js"); + const step = withFork({ + rpcUrl: "https://eth-mainnet.example", + blockNumber: 20_000_000n, + key: "deterministic-fork", + }); + + await step({} as any, async () => undefined); + await step({} as any, async () => undefined); + + expect(startRuntime).toHaveBeenNthCalledWith(1, { + mode: "fork", + rpcUrl: "https://eth-mainnet.example", + blockNumber: 20_000_000n, + key: "deterministic-fork", + }); + expect(startRuntime).toHaveBeenNthCalledWith(2, { + mode: "fork", + rpcUrl: "https://eth-mainnet.example", + blockNumber: 20_000_000n, + key: "deterministic-fork", + }); + }); }); diff --git a/packages/core/src/scenarios/fixtures/withFork.ts b/packages/core/src/scenarios/fixtures/withFork.ts index bb37d0c..d43bd07 100644 --- a/packages/core/src/scenarios/fixtures/withFork.ts +++ b/packages/core/src/scenarios/fixtures/withFork.ts @@ -1,6 +1,9 @@ import { startRuntime, stopRuntime } from "../../runtime/index.js"; import { createClients } from "../../clients/index.js"; -import type { ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import type { ScenarioChainContext, ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import { assertTwoChainLimit, resolvePublicClientAliases, type PublicClientAliasPolicy } from "../utils.js"; +import { StatecraftError } from "../errors.js"; +import { labelScenarioStep } from "../stepMeta.js"; /** Options for an anvil instance forked from a remote JSON-RPC endpoint at a pinned block. */ export type WithForkConfig = { @@ -18,6 +21,8 @@ export type WithForkConfig = { chainId?: number; /** Stable id forwarded to `RuntimeConfig.key` on the runtime package. */ key?: string; + /** Policy for deriving top-level `publicClient`/`altPublicClient` aliases from `ctx.chains`. */ + publicClientAliasPolicy?: PublicClientAliasPolicy; }; /** @@ -25,13 +30,21 @@ export type WithForkConfig = { */ export function withFork(config: WithForkConfig): ScenarioStep { const chainKey = config.chainKey ?? "default"; - return async (ctx, next) => { + return labelScenarioStep(async (ctx, next) => { if (!config.rpcUrl) { - throw new Error("withFork(...) requires rpcUrl."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withFork(...) requires rpcUrl.", + suggestedAction: "Set a non-empty rpcUrl when configuring withFork(...).", + }); } if (config.blockNumber === undefined) { - throw new Error("withFork(...) requires a pinned blockNumber in v1."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withFork(...) requires a pinned blockNumber in v1.", + suggestedAction: "Set blockNumber to a bigint literal for deterministic forks.", + }); } const runtime = await startRuntime({ @@ -42,24 +55,30 @@ export function withFork(config: WithForkConfig): ScenarioStep = { + ...(ctx.chains ?? {}), + [chainKey]: chainContext, + }; + assertTwoChainLimit(chains); + const { publicClient, altPublicClient } = resolvePublicClientAliases(chains, config.publicClientAliasPolicy); try { await next({ ...ctx, - chains: { - ...(ctx.chains ?? {}), - [chainKey]: { - runtime, - runtimeMode: "fork", - chain: clients.publicClient.chain, - publicClient: clients.publicClient, - walletClient: clients.walletClient, - testClient: clients.testClient, - }, - }, + chains, + publicClient, + altPublicClient, }); } finally { await stopRuntime(runtime); } - }; + }, "withFork"); } diff --git a/packages/core/src/scenarios/fixtures/withFundedWallet.ts b/packages/core/src/scenarios/fixtures/withFundedWallet.ts index d9cd7a5..f475fba 100644 --- a/packages/core/src/scenarios/fixtures/withFundedWallet.ts +++ b/packages/core/src/scenarios/fixtures/withFundedWallet.ts @@ -1,9 +1,9 @@ import { createWalletClient, http, type Address, type Hex } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import type { - ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep, + ScenarioWalletOnChainContext, } from "../types.js"; import { dealErc20Balance } from "../internal/dealErc20Balance.js"; import { requireChainScopedRuntimeClients } from "../utils.js"; @@ -37,15 +37,24 @@ export type WithFundedWalletConfig = { */ export function withFundedWallet( config: WithFundedWalletConfig & { chain?: undefined }, -): ScenarioStep>; -export function withFundedWallet( +): ScenarioStep< + ScenarioRuntimeClientsContext, + ScenarioWalletOnChainContext +>; +export function withFundedWallet( config: WithFundedWalletConfig & { chain: C }, -): ScenarioStep>; +): ScenarioStep>; +export function withFundedWallet( + config: WithFundedWalletConfig & { chain?: undefined }, +): ScenarioStep>; export function withFundedWallet( config: WithFundedWalletConfig, -): ScenarioStep { +): ScenarioStep { const chainKey = config.chain ?? "default"; - return async (ctx, next) => { + return async ( + ctx: ScenarioRuntimeClientsContext, + next: (ctx: ScenarioRuntimeClientsContext) => Promise, + ) => { requireChainScopedRuntimeClients(ctx, chainKey); const ch = ctx.chains[chainKey]!; diff --git a/packages/core/src/scenarios/fixtures/withImpersonation.ts b/packages/core/src/scenarios/fixtures/withImpersonation.ts index d6b891f..64bb7b8 100644 --- a/packages/core/src/scenarios/fixtures/withImpersonation.ts +++ b/packages/core/src/scenarios/fixtures/withImpersonation.ts @@ -1,8 +1,8 @@ import { createWalletClient, http, type Address } from "viem"; import type { - ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep, + ScenarioWalletOnChainContext, } from "../types.js"; import { requireChainScopedRuntimeClients } from "../utils.js"; @@ -28,15 +28,24 @@ export type withImpersonationConfig = { */ export function withImpersonation( config: withImpersonationConfig & { chain?: undefined }, -): ScenarioStep>; -export function withImpersonation( +): ScenarioStep< + ScenarioRuntimeClientsContext, + ScenarioWalletOnChainContext +>; +export function withImpersonation( config: withImpersonationConfig & { chain: C }, -): ScenarioStep>; +): ScenarioStep>; +export function withImpersonation( + config: withImpersonationConfig & { chain?: undefined }, +): ScenarioStep>; export function withImpersonation( config: withImpersonationConfig, -): ScenarioStep { +): ScenarioStep { const chainKey = config.chain ?? "default"; - return async (ctx, next) => { + return async ( + ctx: ScenarioRuntimeClientsContext, + next: (ctx: ScenarioRuntimeClientsContext) => Promise, + ) => { requireChainScopedRuntimeClients(ctx, chainKey); const ch = ctx.chains[chainKey]!; diff --git a/packages/core/src/scenarios/fixtures/withMultiChain.test.ts b/packages/core/src/scenarios/fixtures/withMultiChain.test.ts index 3905fc0..2039142 100644 --- a/packages/core/src/scenarios/fixtures/withMultiChain.test.ts +++ b/packages/core/src/scenarios/fixtures/withMultiChain.test.ts @@ -29,6 +29,21 @@ describe("withMultiChain", () => { ).rejects.toThrow(/at least one chain entry/i); }); + test("throws when config has more than two chain entries", async () => { + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain({ + a: { type: "chain" }, + b: { type: "chain" }, + c: { type: "chain" }, + }); + + await expect( + step({} as any, async () => { + throw new Error("next should not run"); + }), + ).rejects.toThrow(/at most two chain entries/i); + }); + test("starts two chain runtimes, forwards ctx.chains, and stops both in reverse order", async () => { const runtimeA = { rpcUrl: "http://127.0.0.1:8545" }; const runtimeB = { rpcUrl: "http://127.0.0.1:8546" }; @@ -57,6 +72,8 @@ describe("withMultiChain", () => { expect(nextCtx.chains.a.runtimeMode).toBe("chain"); expect(nextCtx.chains.b.runtime).toBe(runtimeB); expect(nextCtx.chains.b.runtimeMode).toBe("chain"); + expect(nextCtx.publicClient).toBe(clientsA.publicClient); + expect(nextCtx.altPublicClient).toBe(clientsB.publicClient); }); await step({} as any, next); @@ -92,6 +109,71 @@ describe("withMultiChain", () => { ).rejects.toThrow(/duplicate chain key "a"/i); }); + test("prefers default chain as primary alias when present", async () => { + const runtimeA = { rpcUrl: "http://127.0.0.1:8545" }; + const runtimeB = { rpcUrl: "http://127.0.0.1:8546" }; + const clientsA = { + publicClient: { chain: { id: 1 } }, + walletClient: {}, + testClient: {}, + }; + const clientsB = { + publicClient: { chain: { id: 10 } }, + walletClient: {}, + testClient: {}, + }; + + startRuntime.mockResolvedValueOnce(runtimeA).mockResolvedValueOnce(runtimeB); + createClients.mockReturnValueOnce(clientsA).mockReturnValueOnce(clientsB); + + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain({ + alt: { type: "chain" }, + default: { type: "chain" }, + }); + + const next = vi.fn(async (nextCtx: any) => { + expect(nextCtx.publicClient).toBe(clientsB.publicClient); + expect(nextCtx.altPublicClient).toBe(clientsA.publicClient); + }); + + await step({} as any, next); + }); + + test("supports lexical alias policy override", async () => { + const runtimeA = { rpcUrl: "http://127.0.0.1:8545" }; + const runtimeB = { rpcUrl: "http://127.0.0.1:8546" }; + const clientsA = { + publicClient: { chain: { id: 1 } }, + walletClient: {}, + testClient: {}, + }; + const clientsB = { + publicClient: { chain: { id: 10 } }, + walletClient: {}, + testClient: {}, + }; + + startRuntime.mockResolvedValueOnce(runtimeA).mockResolvedValueOnce(runtimeB); + createClients.mockReturnValueOnce(clientsA).mockReturnValueOnce(clientsB); + + const { withMultiChain } = await import("./withMultiChain.js"); + const step = withMultiChain( + { + zeta: { type: "chain" }, + alpha: { type: "chain" }, + }, + { publicClientAliasPolicy: "lexical" }, + ); + + const next = vi.fn(async (nextCtx: any) => { + expect(nextCtx.publicClient).toBe(clientsA.publicClient); + expect(nextCtx.altPublicClient).toBe(clientsB.publicClient); + }); + + await step({} as any, next); + }); + test("attempts to stop all owned runtimes even when one stop fails", async () => { const runtimeA = { rpcUrl: "http://127.0.0.1:8545" }; const runtimeB = { rpcUrl: "http://127.0.0.1:8546" }; diff --git a/packages/core/src/scenarios/fixtures/withMultiChain.ts b/packages/core/src/scenarios/fixtures/withMultiChain.ts index 6fdf73c..cbbda7f 100644 --- a/packages/core/src/scenarios/fixtures/withMultiChain.ts +++ b/packages/core/src/scenarios/fixtures/withMultiChain.ts @@ -1,6 +1,9 @@ import { createClients, type CreateClientsOptions } from "../../clients/index.js"; import { startRuntime, stopRuntime, type RuntimeHandle, type RuntimeMode } from "../../runtime/index.js"; import type { ScenarioChainContext, ScenarioContext, ScenarioRuntimeClientsContext, ScenarioStep } from "../types.js"; +import { assertTwoChainLimit, resolvePublicClientAliases, type PublicClientAliasPolicy } from "../utils.js"; +import { StatecraftError } from "../errors.js"; +import { labelScenarioStep } from "../stepMeta.js"; /** * One chain entry for {@link withMultiChain}: either a fresh chain, a pinned fork, or an external runtime. @@ -33,16 +36,35 @@ export type WithMultiChainEntry = * Map of chain key → chain spec. Keys become `ctx.chains.`. */ export type WithMultiChainConfig = Record; +export type WithMultiChainOptions = { + /** Policy for deriving top-level `publicClient`/`altPublicClient` aliases from `ctx.chains`. */ + publicClientAliasPolicy?: PublicClientAliasPolicy; +}; /** * Middleware: starts or attaches multiple chain runtimes, wires viem clients under `ctx.chains`, runs `next`, * then stops only runtimes this fixture started (external entries are not stopped). */ -export function withMultiChain(config: WithMultiChainConfig): ScenarioStep { - return async (ctx, next) => { +export function withMultiChain( + config: WithMultiChainConfig, + options: WithMultiChainOptions = {}, +): ScenarioStep { + return labelScenarioStep(async (ctx, next) => { const keys = Object.keys(config); if (keys.length === 0) { - throw new Error("withMultiChain(...) requires at least one chain entry."); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "withMultiChain(...) requires at least one chain entry.", + suggestedAction: "Provide one or two chain entries.", + }); + } + if (keys.length > 2) { + throw new StatecraftError({ + code: "SC_CONSTRAINT_VIOLATION", + reason: "withMultiChain(...) supports at most two chain entries.", + context: { chainCount: keys.length }, + suggestedAction: "Split this workflow into separate scenarios.", + }); } const sortedKeys = [...keys].sort(); @@ -57,7 +79,12 @@ export function withMultiChain(config: WithMultiChainConfig): ScenarioStep ({ spawn: (...args: unknown[]) => spawnMock(...args), @@ -46,10 +47,17 @@ function mockSpawnWithDeferredSetup(setup: (child: FakeChild) => void) { describe("startBundler", () => { beforeEach(() => { spawnMock.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ result: [ENTRYPOINT] }), + } as Response); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); test("writes config, spawns Alto CLI, resolves when stdout shows listening, and stop removes temp dir", async () => { @@ -115,7 +123,7 @@ describe("startBundler", () => { rpcUrl: "http://127.0.0.1:8545", entryPoint: ENTRYPOINT, }), - ).rejects.toThrow(/cannot bind port/); + ).rejects.toThrow(/failed to start local bundler/i); const child = spawnMock.mock.results[0]?.value as FakeChild; expect(child.kill).toHaveBeenCalledWith("SIGTERM"); @@ -131,7 +139,7 @@ describe("startBundler", () => { rpcUrl: "http://127.0.0.1:8545", entryPoint: ENTRYPOINT, }), - ).rejects.toThrow(/exited during startup/); + ).rejects.toThrow(/failed to start local bundler/i); }); test("rejects when listening message never arrives (timeout)", async () => { @@ -154,7 +162,24 @@ describe("startBundler", () => { await vi.advanceTimersByTimeAsync(12_000); - await expect(pending).rejects.toThrow(/Timed out waiting for Alto/); + await expect(pending).rejects.toThrow(/failed to start local bundler/i); expect(fakeChild.kill).toHaveBeenCalledWith("SIGTERM"); }); + + test("rejects when json-rpc readiness probe never succeeds", async () => { + mockSpawnWithDeferredSetup((child) => { + child.stdout.emit("data", Buffer.from("Server listening at\n")); + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ error: { message: "not ready" } }), + } as Response); + + const pending = startBundler({ + rpcUrl: "http://127.0.0.1:8545", + entryPoint: ENTRYPOINT, + startupTimeoutMs: 100, + }); + await expect(pending).rejects.toThrow(/failed to start local bundler/i); + }); }); diff --git a/packages/core/src/scenarios/internal/startBundler.ts b/packages/core/src/scenarios/internal/startBundler.ts index 3c1d858..e791bca 100644 --- a/packages/core/src/scenarios/internal/startBundler.ts +++ b/packages/core/src/scenarios/internal/startBundler.ts @@ -6,6 +6,7 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import type { Address } from "viem"; +import { StatecraftError } from "../errors.js"; export type StartBundlerResult = { bundlerUrl: string; @@ -31,7 +32,11 @@ function getAvailablePort(): Promise { const address = server.address(); server.close(() => { if (!address || typeof address === "string") { - reject(new Error("Failed to allocate a local port for bundler.")); + reject(new StatecraftError({ + code: "SC_RUNTIME_PORT_ALLOCATION_FAILED", + reason: "Failed to allocate a local port for bundler.", + suggestedAction: "Retry startup or free local ports before starting the bundler.", + })); return; } resolvePort(address.port); @@ -95,7 +100,12 @@ function resolveAltoCliPath(): string { const message = `withBundler({ mode: "alto" }) requires "@pimlico/alto" to be installed in your project. ` + `Install it (for example, \`bun add -D @pimlico/alto\`) and ensure it is resolvable from your test runner.`; - throw new Error(message, { cause: err as any }); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: message, + suggestedAction: "Install @pimlico/alto as a dev dependency and re-run tests.", + cause: err, + }); } const resolvedPath = resolved.startsWith("file:") @@ -145,10 +155,60 @@ async function waitForListening(process: ChildProcessByStdio, ti }); } +async function waitForBundlerJsonRpcReady(args: { bundlerUrl: string; timeoutMs: number }): Promise { + const startedAt = Date.now(); + let lastError: string | undefined; + while (Date.now() - startedAt < args.timeoutMs) { + let controllerTimeout: ReturnType | undefined; + try { + const controller = new AbortController(); + controllerTimeout = setTimeout(() => controller.abort(), 500); + const response = await fetch(args.bundlerUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_supportedEntryPoints", + params: [], + }), + }); + clearTimeout(controllerTimeout); + if (response.ok) { + const payload = await response.json() as { result?: unknown; error?: unknown }; + if (payload.result !== undefined) { + return; + } + if (payload.error !== undefined) { + lastError = JSON.stringify(payload.error); + } + } else { + lastError = `HTTP ${response.status}`; + } + } catch (error) { + if (controllerTimeout) clearTimeout(controllerTimeout); + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new StatecraftError({ + code: "SC_BUNDLER_START_FAILED", + reason: "Bundler JSON-RPC endpoint did not become ready in time.", + context: { + bundlerUrl: args.bundlerUrl, + timeoutMs: args.timeoutMs, + lastError, + }, + suggestedAction: "Inspect Alto startup logs and config for startup issues.", + }); +} + export async function startBundler(args: { rpcUrl: string; entryPoint: Address; // Optional stable id could be wired later into temp dirs. + startupTimeoutMs?: number; }): Promise { const port = await getAvailablePort(); const bundlerUrl = `http://127.0.0.1:${port}`; @@ -167,11 +227,24 @@ export async function startBundler(args: { }); try { - await waitForListening(child, 12_000); + await waitForListening(child, args.startupTimeoutMs ?? 12_000); + await waitForBundlerJsonRpcReady({ + bundlerUrl, + timeoutMs: args.startupTimeoutMs ?? 12_000, + }); } catch (err) { child.kill("SIGTERM"); await rm(configDir, { recursive: true, force: true }); - throw err; + throw new StatecraftError({ + code: "SC_BUNDLER_START_FAILED", + reason: "Failed to start local bundler.", + context: { + bundlerUrl, + configPath, + }, + suggestedAction: "Verify @pimlico/alto is installed and check startup stderr output.", + cause: err, + }); } let stopped = false; diff --git a/packages/core/src/scenarios/preflight.ts b/packages/core/src/scenarios/preflight.ts new file mode 100644 index 0000000..4171576 --- /dev/null +++ b/packages/core/src/scenarios/preflight.ts @@ -0,0 +1,34 @@ +import { StatecraftError } from "./errors.js"; +import type { ActionPreflight, PreflightIssue } from "./actions.js"; + +export type PreflightSummary = { + ok: boolean; + failures: PreflightIssue[]; + assumptions: string[]; + estimatedEffects: Record; +}; + +export function summarizePreflight(result: ActionPreflight): PreflightSummary { + return { + ok: result.canExecute, + failures: result.reasons, + assumptions: result.assumptions, + estimatedEffects: result.estimatedEffects, + }; +} + +export function assertPreflight(result: ActionPreflight): void { + if (result.canExecute) { + return; + } + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: "Action preflight failed.", + context: { + reasons: result.reasons, + assumptions: result.assumptions, + estimatedEffects: result.estimatedEffects, + }, + suggestedAction: "Inspect preflight reasons and satisfy unmet assumptions before execute.", + }); +} diff --git a/packages/core/src/scenarios/requireContext.test.ts b/packages/core/src/scenarios/requireContext.test.ts index 0d07ead..ad213af 100644 --- a/packages/core/src/scenarios/requireContext.test.ts +++ b/packages/core/src/scenarios/requireContext.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { requireContext } from "./utils.js"; import type { ScenarioContext } from "./types.js"; import type { Chain } from "viem"; +import { StatecraftError } from "./errors.js"; describe("requireContext", () => { test("returns the same object when keys are present", () => { @@ -26,6 +27,15 @@ describe("requireContext", () => { test("throws when a key is missing", () => { const ctx: ScenarioContext = {}; - expect(() => requireContext(ctx, "chains")).toThrow(/missing required key:\s*chains/i); + try { + requireContext(ctx, "chains"); + throw new Error("Expected requireContext to throw"); + } catch (error) { + expect(error).toBeInstanceOf(StatecraftError); + const structured = error as StatecraftError; + expect(structured.code).toBe("SC_CONTEXT_MISSING"); + expect(structured.context.key).toBe("chains"); + expect(structured.suggestedAction).toMatch(/compose a fixture/i); + } }); }); diff --git a/packages/core/src/scenarios/scenario-typing.test.ts b/packages/core/src/scenarios/scenario-typing.test.ts index 3f17e6a..c19d534 100644 --- a/packages/core/src/scenarios/scenario-typing.test.ts +++ b/packages/core/src/scenarios/scenario-typing.test.ts @@ -9,7 +9,15 @@ import { withErc20Balance } from "./fixtures/withErc20Balance.js"; import { withBundler } from "./fixtures/withBundler.js"; import { withMultiChain } from "./fixtures/withMultiChain.js"; import { withBridge } from "./fixtures/withBridge.js"; -import type { ScenarioFundedWalletContext, ScenarioRuntimeClientsContext, ScenarioStep, ScenarioTest } from "./types.js"; +import { withDeployments } from "./fixtures/withDeployments.js"; +import type { + ScenarioDeploymentsOnChainContext, + ScenarioFundedWalletContext, + ScenarioRuntimeClientsContext, + ScenarioStep, + ScenarioTest, + ScenarioWalletOnChainContext, +} from "./types.js"; import { NATIVE_TOKEN_ADDRESS, type ScenarioBridgeContext, type ScenarioBundlerContext } from "./types.js"; const USDC_MAINNET = "0xA0b86991c6218b36c1d19D4a2e9Eb0ce3606eB48" as const; @@ -128,3 +136,44 @@ test("scenario(multichain, bridge, test) accepts ScenarioTest Promise>(); }); + +test("scenario(multichain, funded src, funded dest, test) accumulates wallet context on both chains", () => { + type SrcWallet = ScenarioWalletOnChainContext; + type SrcDestWallets = ScenarioWalletOnChainContext; + const t: ScenarioTest = async (_ctx) => {}; + + expectTypeOf( + scenario( + withMultiChain({ + src: { type: "chain", chainId: 31337 }, + dest: { type: "chain", chainId: 31338 }, + }), + withFundedWallet({ chain: "src", balance: 1n }), + withFundedWallet({ chain: "dest", balance: 1n }), + t, + ), + ).toEqualTypeOf<() => Promise>(); +}); + +test("scenario(multichain, funded src, erc20 on src without to, test) keeps chain-specific funded requirements", () => { + type SrcWallet = ScenarioWalletOnChainContext; + const t: ScenarioTest = async (_ctx) => {}; + + expectTypeOf( + scenario( + withMultiChain({ + src: { type: "chain", chainId: 31337 }, + dest: { type: "chain", chainId: 31338 }, + }), + withFundedWallet({ chain: "src", balance: 1n }), + withErc20Balance({ chain: "src", token: USDC_MAINNET, amount: 1n }), + t, + ), + ).toEqualTypeOf<() => Promise>(); +}); + +test("withDeployments output type marks default chain deployments as present", () => { + expectTypeOf>>>().toEqualTypeOf< + ScenarioDeploymentsOnChainContext + >(); +}); diff --git a/packages/core/src/scenarios/scenario.test.ts b/packages/core/src/scenarios/scenario.test.ts index e86f221..2c85e5a 100644 --- a/packages/core/src/scenarios/scenario.test.ts +++ b/packages/core/src/scenarios/scenario.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Hex } from "viem"; import { scenario } from "./scenario.js"; import type { EmptyScenarioContext, ScenarioContext, ScenarioStep } from "./types.js"; +import { StatecraftError } from "./errors.js"; describe("scenario composition", () => { test("runs steps in declared order and passes context", async () => { @@ -40,6 +41,43 @@ describe("scenario composition", () => { scenario(badStep, async () => { // noop })(), - ).rejects.toThrow("next() multiple times"); + ).rejects.toBeInstanceOf(StatecraftError); + }); + + test("emits step-level tracing hooks", async () => { + const onStepStart = vi.fn(); + const onStepSuccess = vi.fn(); + const onStepFailure = vi.fn(); + const onCleanup = vi.fn(); + + const stepA: ScenarioStep = async (ctx, next) => { + await next({ ...ctx, wallet: "0xabc" as Hex }); + }; + const stepB: ScenarioStep = async (ctx, next) => { + await next({ ...ctx, chains: {} as any }); + }; + + await scenario( + { + options: { + onStepStart, + onStepSuccess, + onStepFailure, + onCleanup, + }, + }, + stepA, + stepB, + async () => { + // noop + }, + )(); + + expect(onStepStart).toHaveBeenCalledTimes(2); + expect(onStepSuccess).toHaveBeenCalledTimes(2); + expect(onStepFailure).not.toHaveBeenCalled(); + expect(onCleanup).toHaveBeenCalledTimes(1); + const deltaKeys = onStepSuccess.mock.calls[0]?.[0]?.contextDeltaKeys as string[]; + expect(deltaKeys.some((k) => k.includes("wallet"))).toBe(true); }); }); diff --git a/packages/core/src/scenarios/scenario.ts b/packages/core/src/scenarios/scenario.ts index 84eacb3..22ddab6 100644 --- a/packages/core/src/scenarios/scenario.ts +++ b/packages/core/src/scenarios/scenario.ts @@ -4,10 +4,91 @@ import type { ScenarioStep, ScenarioTest, } from "./types.js"; +import { toStatecraftError, type StatecraftError } from "./errors.js"; +import { getScenarioStepLabel } from "./stepMeta.js"; -function compose(steps: ScenarioStep[], testFn: ScenarioTest): (ctx: ScenarioContext) => Promise { +export type ScenarioStepEvent = { + stepIndex: number; + stepLabel: string; + contextKeysBefore: string[]; +}; + +export type ScenarioStepSuccessEvent = ScenarioStepEvent & { + durationMs: number; + contextDeltaKeys: string[]; +}; + +export type ScenarioStepFailureEvent = ScenarioStepEvent & { + durationMs: number; + error: StatecraftError; +}; + +export type ScenarioCleanupEvent = { + finalContextKeys: string[]; + error?: StatecraftError; +}; + +export type ScenarioRunOptions = { + onStepStart?: (event: ScenarioStepEvent) => void; + onStepSuccess?: (event: ScenarioStepSuccessEvent) => void; + onStepFailure?: (event: ScenarioStepFailureEvent) => void; + onCleanup?: (event: ScenarioCleanupEvent) => void; +}; + +type ScenarioConfigInput = { + options?: ScenarioRunOptions; +}; + +function isScenarioConfigInput(value: unknown): value is ScenarioConfigInput { + if (!value || typeof value !== "object") { + return false; + } + if (typeof value === "function") { + return false; + } + const candidate = value as Record; + return "options" in candidate; +} + +function deriveStepLabel(step: ScenarioStep, index: number): string { + const labeled = getScenarioStepLabel(step); + if (labeled) { + return labeled; + } + return step.name?.trim() ? step.name : `step#${index + 1}`; +} + +function diffContextKeys(previous: ScenarioContext, next: ScenarioContext): string[] { + const previousKeys = new Set(Object.keys(previous)); + const nextKeys = new Set(Object.keys(next)); + const delta = new Set(); + for (const key of previousKeys) { + if (!nextKeys.has(key)) { + delta.add(`-${key}`); + } + } + for (const key of nextKeys) { + if (!previousKeys.has(key)) { + delta.add(`+${key}`); + } + } + for (const key of nextKeys) { + if (previousKeys.has(key) && previous[key as keyof ScenarioContext] !== next[key as keyof ScenarioContext]) { + delta.add(`~${key}`); + } + } + return [...delta]; +} + +function compose( + steps: ScenarioStep[], + testFn: ScenarioTest, + options?: ScenarioRunOptions, +): (ctx: ScenarioContext) => Promise { return async function run(ctx: ScenarioContext): Promise { let index = -1; + let lastContext = ctx; + let terminalError: StatecraftError | undefined; const dispatch = async (position: number, nextCtx: ScenarioContext): Promise => { if (position <= index) { throw new Error("Scenario middleware called next() multiple times."); @@ -16,14 +97,57 @@ function compose(steps: ScenarioStep[], testFn const step = steps[position]; if (!step) { + lastContext = nextCtx; await testFn(nextCtx); return; } - await step(nextCtx, (updatedCtx) => dispatch(position + 1, updatedCtx)); + const startedAt = Date.now(); + const stepLabel = deriveStepLabel(step, position); + options?.onStepStart?.({ + stepIndex: position, + stepLabel, + contextKeysBefore: Object.keys(nextCtx), + }); + try { + await step(nextCtx, async (updatedCtx) => { + options?.onStepSuccess?.({ + stepIndex: position, + stepLabel, + durationMs: Date.now() - startedAt, + contextKeysBefore: Object.keys(nextCtx), + contextDeltaKeys: diffContextKeys(nextCtx, updatedCtx), + }); + lastContext = updatedCtx; + await dispatch(position + 1, updatedCtx); + }); + } catch (error) { + const normalized = toStatecraftError(error, { + code: "SC_PRECONDITION_FAILED", + reason: `Scenario step "${stepLabel}" failed.`, + context: { stepIndex: position, stepLabel }, + suggestedAction: "Inspect the nested cause and scenario step order.", + }); + terminalError = normalized; + options?.onStepFailure?.({ + stepIndex: position, + stepLabel, + durationMs: Date.now() - startedAt, + contextKeysBefore: Object.keys(nextCtx), + error: normalized, + }); + throw normalized; + } }; - await dispatch(0, ctx); + try { + await dispatch(0, ctx); + } finally { + options?.onCleanup?.({ + finalContextKeys: Object.keys(lastContext), + error: terminalError, + }); + } }; } @@ -230,14 +354,18 @@ export function scenario< test: ScenarioTest, ): () => Promise; /** Fallback when custom steps use the default {@link ScenarioStep} shape (untyped pipeline). */ -export function scenario(...parts: [...ScenarioStep[], ScenarioTest]): () => Promise { +export function scenario(config: ScenarioConfigInput, ...parts: [...ScenarioStep[], ScenarioTest]): () => Promise; +export function scenario(...rawParts: [ScenarioConfigInput, ...ScenarioStep[], ScenarioTest] | [...ScenarioStep[], ScenarioTest]): () => Promise { + const [config, parts] = isScenarioConfigInput(rawParts[0]) + ? [rawParts[0], rawParts.slice(1)] + : [undefined, rawParts]; const testFn = parts.at(-1); if (!testFn || typeof testFn !== "function") { throw new Error("scenario(...) requires a final async test function."); } const steps = parts.slice(0, -1) as ScenarioStep[]; - const run = compose(steps, testFn as ScenarioTest); + const run = compose(steps, testFn as ScenarioTest, config?.options); return async () => { await run({}); diff --git a/packages/core/src/scenarios/stepMeta.ts b/packages/core/src/scenarios/stepMeta.ts new file mode 100644 index 0000000..046bc87 --- /dev/null +++ b/packages/core/src/scenarios/stepMeta.ts @@ -0,0 +1,12 @@ +import type { ScenarioStep } from "./types.js"; + +const STEP_NAME_FIELD = "__statecraftStepName"; + +export function labelScenarioStep(step: ScenarioStep, name: string): ScenarioStep { + (step as any)[STEP_NAME_FIELD] = name; + return step; +} + +export function getScenarioStepLabel(step: ScenarioStep): string | undefined { + return (step as any)[STEP_NAME_FIELD]; +} diff --git a/packages/core/src/scenarios/types.ts b/packages/core/src/scenarios/types.ts index 9256d13..7a89085 100644 --- a/packages/core/src/scenarios/types.ts +++ b/packages/core/src/scenarios/types.ts @@ -1,5 +1,6 @@ import type { RuntimeHandle, RuntimeMode } from "../runtime/index.js"; import type { BundlerClient } from "../clients/index.js"; +import type { ActionPreflight } from "./actions.js"; import type { PublicClient, WalletClient, @@ -98,6 +99,10 @@ export type ScenarioChainContext = { */ export type ScenarioContext = { chains?: Record; + /** Primary chain public client. Defaults to `chains.default.publicClient` when present. */ + publicClient?: ScenarioChainContext["publicClient"]; + /** Optional secondary chain public client (multi-chain convenience alias). */ + altPublicClient?: ScenarioChainContext["publicClient"] | undefined; bridge?: ScenarioBridge; }; @@ -106,19 +111,67 @@ export type ScenarioContext = { */ export type ScenarioRuntimeClientsContext = ScenarioContext & { chains: Record; + publicClient: ScenarioChainContext["publicClient"]; + altPublicClient?: ScenarioChainContext["publicClient"] | undefined; }; +type UpsertChainContext< + Ctx extends ScenarioRuntimeClientsContext, + ChainKey extends string, + ChainPatch extends object, +> = Omit & { + bridge?: Exclude; + chains: Omit & Record; +}; + +/** + * Context helper for fixtures that guarantee `wallet` on one chain. + */ +export type ScenarioWalletOnChainContext< + Ctx extends ScenarioRuntimeClientsContext, + ChainKey extends string, +> = UpsertChainContext; + +/** + * Context helper for fixtures that guarantee `contracts` on one chain. + */ +export type ScenarioContractsOnChainContext< + Ctx extends ScenarioRuntimeClientsContext, + ChainKey extends string, +> = UpsertChainContext; + +/** + * Context helper for fixtures that guarantee `deployments` on one chain. + */ +export type ScenarioDeploymentsOnChainContext< + Ctx extends ScenarioRuntimeClientsContext, + ChainKey extends string, +> = UpsertChainContext }>; + +/** + * Context helper for fixtures that guarantee bundler fields on one chain. + */ +export type ScenarioBundlerOnChainContext< + Ctx extends ScenarioRuntimeClientsContext, + ChainKey extends string, +> = UpsertChainContext; + /** * Context after {@link withFundedWallet}: the targeted chain entry includes `wallet`. Prefer reading `ctx.chains[chain].wallet`. */ -export type ScenarioFundedWalletContext = ScenarioRuntimeClientsContext & { - chains: ScenarioRuntimeClientsContext["chains"] & Record; -}; +export type ScenarioFundedWalletContext = ScenarioWalletOnChainContext< + ScenarioRuntimeClientsContext, + ChainKey +>; /** * Context after {@link withBundler}: the targeted chain entry includes bundler fields. */ -export type ScenarioBundlerContext = ScenarioRuntimeClientsContext; +export type ScenarioBundlerContext = ScenarioBundlerOnChainContext; /** * @deprecated Use {@link ScenarioBundlerContext}. Kept as a compatibility alias for older imports. @@ -165,6 +218,8 @@ export type BridgeExecuteArgs = { from?: Address; /** Optional destination account override for this execution. */ to?: Address; + /** Optional idempotency key used by bridge execution ledgers. */ + idempotencyKey?: string; }; /** @@ -186,6 +241,7 @@ export type BridgeExecution = { * Callable bridge test-double exposed to tests through scenario context. */ export type ScenarioBridge = { + preflight(args: BridgeExecuteArgs): Promise; execute(args: BridgeExecuteArgs): Promise; }; diff --git a/packages/core/src/scenarios/utils.test.ts b/packages/core/src/scenarios/utils.test.ts new file mode 100644 index 0000000..eeddc21 --- /dev/null +++ b/packages/core/src/scenarios/utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { describeScenarioConstraints, resolvePublicClientAliases } from "./utils.js"; + +describe("scenario constraints", () => { + test("exposes explicit machine-readable constraints", () => { + const constraints = describeScenarioConstraints(); + expect(constraints.maxChains).toBe(2); + expect(constraints.supportedBundlerModes).toContain("alto"); + expect(constraints.publicClientAliasPolicies).toContain("lexical"); + }); + + test("supports explicit lexical alias policy", () => { + const chains = { + zeta: { publicClient: { id: "zeta" } }, + alpha: { publicClient: { id: "alpha" } }, + } as any; + const aliases = resolvePublicClientAliases(chains, "lexical"); + expect(aliases.publicClient).toBe(chains.alpha.publicClient); + expect(aliases.altPublicClient).toBe(chains.zeta.publicClient); + }); +}); diff --git a/packages/core/src/scenarios/utils.ts b/packages/core/src/scenarios/utils.ts index 52c0a9b..f7b9ba0 100644 --- a/packages/core/src/scenarios/utils.ts +++ b/packages/core/src/scenarios/utils.ts @@ -1,5 +1,11 @@ import type { ContractArtifact, ScenarioChainContext, ScenarioContext } from "./types.js"; import type { Hex } from "viem"; +import { StatecraftError } from "./errors.js"; + +export const SCENARIO_MAX_CHAINS = 2; +export const BUNDLER_SUPPORTED_MODES = ["alto"] as const; +export const PUBLIC_CLIENT_ALIAS_POLICIES = ["prefer-default-then-lexical", "lexical"] as const; +export type PublicClientAliasPolicy = (typeof PUBLIC_CLIENT_ALIAS_POLICIES)[number]; /** * Context with the listed keys required (non-undefined). @@ -24,7 +30,12 @@ export function requireContext< >(ctx: Ctx, ...keys: K): RequireScenarioKeys { for (const key of keys) { if (ctx[key as keyof ScenarioContext] === undefined) { - throw new Error(`Scenario context is missing required key: ${String(key)}`); + throw new StatecraftError({ + code: "SC_CONTEXT_MISSING", + reason: `Scenario context is missing required key: ${String(key)}`, + context: { key: String(key) }, + suggestedAction: "Compose a fixture that populates this key before reading it.", + }); } } return ctx as RequireScenarioKeys; @@ -41,10 +52,92 @@ export function requireChainScopedRuntimeClients( } { const ch = ctx.chains?.[chainKey]; if (!ch?.runtime || !ch.publicClient || !ch.walletClient || !ch.testClient) { - throw new Error( - `Scenario context is missing runtime clients for chain "${chainKey}". Compose withChain(...), withFork(...), withExternalRuntime(...), or withMultiChain(...) first.`, - ); + throw new StatecraftError({ + code: "SC_CONTEXT_MISSING", + reason: `Scenario context is missing runtime clients for chain "${chainKey}".`, + context: { chainKey }, + suggestedAction: + "Compose withChain(...), withFork(...), withExternalRuntime(...), or withMultiChain(...) before this step.", + }); + } +} + +/** + * Derives deterministic top-level public client aliases from a chains map. + * Prefers `default` as primary when present; otherwise uses lexical key order. + */ +export function resolvePublicClientAliases(chains: Record): { + publicClient: ScenarioChainContext["publicClient"]; + altPublicClient: ScenarioChainContext["publicClient"] | undefined; +} +export function resolvePublicClientAliases( + chains: Record, + policy: PublicClientAliasPolicy, +): { + publicClient: ScenarioChainContext["publicClient"]; + altPublicClient: ScenarioChainContext["publicClient"] | undefined; +} +export function resolvePublicClientAliases( + chains: Record, + policy: PublicClientAliasPolicy = "prefer-default-then-lexical", +): { + publicClient: ScenarioChainContext["publicClient"]; + altPublicClient: ScenarioChainContext["publicClient"] | undefined; +} { + const keys = Object.keys(chains); + if (keys.length > SCENARIO_MAX_CHAINS) { + throw new StatecraftError({ + code: "SC_CONSTRAINT_VIOLATION", + reason: "Scenario context supports at most two chains.", + context: { chainCount: keys.length, maxChains: SCENARIO_MAX_CHAINS }, + suggestedAction: "Split this test into multiple scenarios or choose no more than two chains.", + }); } + + const sortedKeys = keys.sort(); + const primaryKey = + policy === "prefer-default-then-lexical" + ? sortedKeys.includes("default") + ? "default" + : sortedKeys[0] + : sortedKeys[0]; + if (!primaryKey) { + throw new StatecraftError({ + code: "SC_CONTEXT_MISSING", + reason: "Scenario context is missing runtime clients.", + suggestedAction: + "Compose withChain(...), withFork(...), withExternalRuntime(...), or withMultiChain(...) first.", + }); + } + + const altKey = sortedKeys.find((key) => key !== primaryKey); + return { + publicClient: chains[primaryKey]!.publicClient, + altPublicClient: altKey ? chains[altKey]!.publicClient : undefined, + }; +} + +/** + * Enforces the scenario two-chain cap. + */ +export function assertTwoChainLimit(chains: Record): void { + if (Object.keys(chains).length > SCENARIO_MAX_CHAINS) { + throw new StatecraftError({ + code: "SC_CONSTRAINT_VIOLATION", + reason: "Scenario context supports at most two chains.", + context: { chainCount: Object.keys(chains).length, maxChains: SCENARIO_MAX_CHAINS }, + suggestedAction: "Split this test into multiple scenarios or choose no more than two chains.", + }); + } +} + +export function describeScenarioConstraints() { + return { + maxChains: SCENARIO_MAX_CHAINS, + publicClientAliasPolicies: [...PUBLIC_CLIENT_ALIAS_POLICIES], + defaultAliasPolicy: "prefer-default-then-lexical" as const, + supportedBundlerModes: [...BUNDLER_SUPPORTED_MODES], + }; } export function extractBytecode(value: ContractArtifact["bytecode"] | ContractArtifact["deployedBytecode"], label: string): Hex { @@ -56,5 +149,10 @@ export function extractBytecode(value: ContractArtifact["bytecode"] | ContractAr return value.object; } - throw new Error(`${label} is missing usable bytecode (expected a 0x-prefixed hex string).`); + throw new StatecraftError({ + code: "SC_PRECONDITION_FAILED", + reason: `${label} is missing usable bytecode (expected a 0x-prefixed hex string).`, + context: { label }, + suggestedAction: "Provide bytecode as a 0x-prefixed hex string or artifact.object field.", + }); } diff --git a/packages/examples/examples/scenarios.test.ts b/packages/examples/examples/scenarios.test.ts index bbb9e21..e92064c 100644 --- a/packages/examples/examples/scenarios.test.ts +++ b/packages/examples/examples/scenarios.test.ts @@ -44,9 +44,8 @@ describe("suite-scoped runtime (external lifecycle)", () => { withExternalRuntime({ runtime, clients: { chainId: 31_337 } }), withSnapshot(), withFundedWallet({ balance: parseEther("2") }), - async ({ chains }) => { - const ch = chains!.default!; - const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); + async ({ chains, publicClient }) => { + const balance = await publicClient.getBalance({ address: chains!.default!.wallet! }); expect(balance).toBe(parseEther("2")); }, )(); @@ -57,14 +56,13 @@ describe("suite-scoped runtime (external lifecycle)", () => { withExternalRuntime({ runtime, clients: { chainId: 31_337 } }), withSnapshot(), withFundedWallet({ balance: parseEther("1") }), - async ({ chains }) => { - const ch = chains!.default!; - const original = await ch.publicClient.getBalance({ address: ch.wallet! }); - await ch.testClient.setBalance({ - address: ch.wallet!, + async ({ chains, publicClient }) => { + const original = await publicClient.getBalance({ address: chains!.default!.wallet! }); + await chains!.default!.testClient.setBalance({ + address: chains!.default!.wallet!, value: parseEther("9"), }); - const changed = await ch.publicClient.getBalance({ address: ch.wallet! }); + const changed = await publicClient.getBalance({ address: chains!.default!.wallet! }); expect(original).toBe(parseEther("1")); expect(changed).toBe(parseEther("9")); }, @@ -81,10 +79,9 @@ test( }), withBundler({ entryPoint: entryPoint4337, mode: "alto" }), async ({ chains }) => { - const ch = chains!.default!; - expect(ch.entryPoint).toBe(entryPoint4337); - expect(ch.bundlerUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - const supported = await ch.bundlerClient!.getSupportedEntryPoints(); + expect(chains!.default!.entryPoint).toBe(entryPoint4337); + expect(chains!.default!.bundlerUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + const supported = await chains!.default!.bundlerClient!.getSupportedEntryPoints(); const normalized = supported.map((a: `0x${string}`) => getAddress(a)); expect(normalized).toContain(entryPoint4337); }, @@ -99,9 +96,8 @@ test( withFundedWallet({ balance: parseEther("1"), }), - async ({ chains }) => { - const ch = chains!.default!; - const balance = await ch.publicClient.getBalance({ address: ch.wallet! }); + async ({ chains, publicClient }) => { + const balance = await publicClient.getBalance({ address: chains!.default!.wallet! }); expect(balance).toBe(parseEther("1")); }, ), @@ -117,13 +113,12 @@ test( withFundedWallet({ balance: parseEther("1"), }), - async ({ chains }) => { - const ch = chains!.default!; - const tokenBalance = await ch.publicClient.readContract({ + async ({ chains, publicClient }) => { + const tokenBalance = await publicClient.readContract({ address: wethAddress, abi: erc20Abi, functionName: "balanceOf", - args: [ch.wallet!], + args: [chains!.default!.wallet!], }); expect(tokenBalance).toBe(0n); @@ -147,13 +142,12 @@ test( }, ], }), - async ({ chains }) => { - const ch = chains!.default!; - const tokenBalance = await ch.publicClient.readContract({ + async ({ chains, publicClient }) => { + const tokenBalance = await publicClient.readContract({ address: usdcAddress, abi: erc20Abi, functionName: "balanceOf", - args: [ch.wallet!], + args: [chains!.default!.wallet!], }); expect(tokenBalance).toBe(1_000_000n); }, @@ -174,13 +168,12 @@ test( token: usdcAddress, amount: 1_000_000n, }), - async ({ chains }) => { - const ch = chains!.default!; - const tokenBalance = await ch.publicClient.readContract({ + async ({ chains, publicClient }) => { + const tokenBalance = await publicClient.readContract({ address: usdcAddress, abi: erc20Abi, functionName: "balanceOf", - args: [ch.wallet!], + args: [chains!.default!.wallet!], }); expect(tokenBalance).toBe(1_000_000n); }, diff --git a/scripts/generate-docs-seo.mjs b/scripts/generate-docs-seo.mjs index e80c3f1..aa0eca9 100644 --- a/scripts/generate-docs-seo.mjs +++ b/scripts/generate-docs-seo.mjs @@ -27,6 +27,7 @@ const routes = [ "/skill", "/overview", "/fixtures/runtime", + "/fixtures/runtime/withBridge", "/fixtures/wallets", "/fixtures/tokens", "/fixtures/contracts", diff --git a/vocs.config.ts b/vocs.config.ts index 51c95c0..a7f0af9 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -56,6 +56,11 @@ const pageSocial: Record< twitterDescription: "Choose withChain, withFork, or withExternalRuntime to provide the runtime and viem clients a scenario needs.", }, + "/fixtures/runtime/withBridge": { + twitterTitle: "withBridge fixture – Statecraft", + twitterDescription: + "Simulate deterministic cross-chain transfers by exposing ctx.bridge.execute(...) in multi-chain scenarios.", + }, "/fixtures/wallets": { twitterTitle: "Wallets & balance fixtures – Statecraft", twitterDescription: @@ -161,6 +166,7 @@ export default defineConfig({ link: "/fixtures/runtime/withExternalRuntime", }, { text: "withMultiChain", link: "/fixtures/runtime/withMultiChain" }, + { text: "withBridge", link: "/fixtures/runtime/withBridge" }, { text: "withBundler", link: "/fixtures/runtime/withBundler" }, ], },