diff --git a/README.md b/README.md
index 59a2d80..ccebb0a 100644
--- a/README.md
+++ b/README.md
@@ -266,6 +266,7 @@ Works with `blockHash`, `blockTag`, or `blockNumber`. Even on chains where Multi
- [Architecture & AI Context](CLAUDE.md)
- [API Reference](docs/api-reference.md)
- [Benchmarks](docs/benchmarks.md)
+- [Refinance example](examples/refinance.ts) — Aave v3/Spark/Morpho across chains, dynamic `Ref
` targets, `Presets.throughput`, `MultichainResolver`
- [Migration Guide](MIGRATION.md)
- [Changelog](CHANGELOG.md)
diff --git a/examples/refinance.ts b/examples/refinance.ts
new file mode 100644
index 0000000..a096ba4
--- /dev/null
+++ b/examples/refinance.ts
@@ -0,0 +1,449 @@
+/**
+ * G2 — Aave v3 + Spark + Morpho Blue refinance-rate example (v1.3.0 release
+ * gate: "the proof the whole 1.x surface composes on real protocols").
+ *
+ * Checked in CI against the BUILT dist types, exactly like `docs/snippets/*`
+ * (see `scripts/check-snippets.ts`) — that type-check is the merge gate.
+ * `main()` only runs on DIRECT execution (`tsx examples/refinance.ts`), never
+ * merely on import — see the guard at the bottom of this file — and `main()`
+ * itself validates `RPC_URL` (helpful error + non-zero exit when absent).
+ * These are two independent checks on purpose: `RPC_URL`-presence ALONE is
+ * not a safe import guard (a process that happens to have `RPC_URL` set —
+ * e.g. this file imported from a live-fork test suite — would otherwise
+ * fire a real run, `process.exit` included, as a side effect of importing
+ * it), and direct-execution ALONE doesn't validate the env var either.
+ *
+ * What this demonstrates, end to end:
+ * 1. `defineTask` + human-readable ABI (F3) + `t.derive` (F2) — Aave v3 and
+ * Spark (an Aave v3 fork sharing the same ABI) `getReserveData` bulk
+ * over three reserve assets each, extracting just the supply/borrow
+ * rate fields out of the full struct.
+ * 2. The **dynamic-target showcase**: Morpho Blue's IRM borrow rate is a
+ * genuine 2-step task — step 1 reads `idToMarketParams`/`market` on
+ * Morpho Blue itself; step 2 calls `borrowRateView` ON THE IRM
+ * CONTRACT, whose address is only known once step 1 resolves. That
+ * address is threaded through as `target: Ref` —
+ * see `buildMorphoIrmRateTask` below.
+ * 3. `Presets.throughput` (F7) — both the reserve bulk AND the Morpho task
+ * run through ONE `runMultistepTasks` call. Mixing a `maxStep: 1` task
+ * set with a `maxStep: 2` task in the same call is deliberate, not
+ * incidental — CLAUDE.md's "Mixed-depth batches" note applies exactly
+ * here: the reserve calls ride along in Morpho's own step-1 batch, so
+ * the whole comparison costs 2 round-trips total instead of 1 (reserves
+ * alone) + 2 (Morpho alone) = 3.
+ * 4. `MultichainResolver` + `snapshot()` + explicit per-chain `blocks`
+ * pinning (F9/F8) — `refinanceMultichain()` below, gated by the P9 env
+ * convention (`RPC_URL` for mainnet, `RPC_URL_8453` for Base).
+ *
+ * Two on-chain values below are given as literals and are NOT independently
+ * re-derived here — flagged inline with "verify at runtime" — because this
+ * file's merge gate is the type-check, not a live assertion (spec G2):
+ * the wstETH/WETH Morpho market id, and the Base Aave v3 Pool/USDC
+ * addresses used only by the optional multichain variant.
+ */
+
+import { fileURLToPath } from 'node:url'
+import { createPublicClient, http } from 'viem'
+import { mainnet, base } from 'viem/chains'
+import {
+ Eip1193Executor,
+ MultichainResolver,
+ Presets,
+ defineTask,
+ runMultistepTasks,
+} from '@halaprix/domino'
+import type { Address, BlockParam, MultistepTask, StepExecutor } from '@halaprix/domino'
+
+// ─── Protocol addresses (mainnet) ──────────────────────────────────────────
+
+const AAVE_V3_POOL: Address = '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2'
+const SPARK_POOL: Address = '0xC13e21B648A5Ee794902342038FF3aDAB66BE987'
+const MORPHO_BLUE: Address = '0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb'
+
+const WETH: Address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
+const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
+const DAI: Address = '0x6B175474E89094C44Da98b954EedeAC495271d0F'
+
+// wstETH/WETH market on Morpho Blue mainnet — verify at runtime before a live
+// run (only the type-check is the merge gate here, per spec G2).
+const WSTETH_WETH_MARKET_ID: `0x${string}` =
+ '0xc54d7acf14de29e0e5527cabd7a576506870346a78a11a6762e2cca66322ec41'
+
+// Base mainnet, used only by `refinanceMultichain()` — verify at runtime
+// before a live run against Base (same caveat as the market id above).
+const AAVE_V3_POOL_BASE: Address = '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5'
+const USDC_BASE: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
+
+// ─── ABIs (human-readable, inlined — CLAUDE.md handler convention) ─────────
+
+/**
+ * Aave v3 Pool.getReserveData — Spark is a same-ABI Aave v3 fork, so this one
+ * ABI is reused for both pools. `getReserveData` returns exactly ONE output,
+ * whose type is a `tuple` with named `components` — Solidity keeps this as a
+ * single nested struct (unlike `morphoBlueAbi` below), so it decodes to ONE
+ * JS object keyed by field name. The tuple is declared faithfully in full
+ * (all 15 fields, including the nested `ReserveConfigurationMap`) even
+ * though only `currentLiquidityRate`/`currentVariableBorrowRate` are read —
+ * a human-readable ABI's `returns` type must match the real struct shape for
+ * decoding to succeed at all.
+ */
+const aaveV3PoolAbi = [
+ 'struct ReserveConfigurationMap { uint256 data; }',
+ 'struct ReserveData { ReserveConfigurationMap configuration; uint128 liquidityIndex; uint128 currentLiquidityRate; uint128 variableBorrowIndex; uint128 currentVariableBorrowRate; uint128 currentStableBorrowRate; uint40 lastUpdateTimestamp; uint16 id; address aTokenAddress; address stableDebtTokenAddress; address variableDebtTokenAddress; address interestRateStrategyAddress; uint128 accruedToTreasury; uint128 unbacked; uint128 isolationModeTotalDebt; }',
+ 'function getReserveData(address asset) view returns (ReserveData)',
+] as const
+
+/**
+ * Morpho Blue's `idToMarketParams`/`market` are public-mapping getters over
+ * value-only structs — Solidity FLATTENS those into several separate named
+ * outputs (a well-known getter quirk), NOT a single nested tuple like
+ * `aaveV3PoolAbi`'s `getReserveData` above. Each decodes to a positional
+ * array/tuple at runtime (TS's output labels are for readability only), so
+ * `buildMorphoIrmRateTask` below indexes into it by position.
+ */
+const morphoBlueAbi = [
+ 'function idToMarketParams(bytes32 id) view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)',
+ 'function market(bytes32 id) view returns (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)',
+] as const
+
+/**
+ * Morpho's IRM (e.g. AdaptiveCurveIrm) — unlike the Morpho Blue getters
+ * above, this genuinely takes two struct-typed ARGUMENTS (declared here via
+ * `struct MarketParams`/`struct Market`), passed as named objects. Called on
+ * the IRM contract's OWN address — which is only known dynamically, from
+ * `idToMarketParams`'s step-1 result (see `buildMorphoIrmRateTask`).
+ */
+const morphoIrmAbi = [
+ 'struct MarketParams { address loanToken; address collateralToken; address oracle; address irm; uint256 lltv; }',
+ 'struct Market { uint128 totalSupplyAssets; uint128 totalSupplyShares; uint128 totalBorrowAssets; uint128 totalBorrowShares; uint128 lastUpdate; uint128 fee; }',
+ 'function borrowRateView(MarketParams marketParams, Market market) view returns (uint256)',
+] as const
+
+// ─── Aave v3 / Spark — getReserveData bulk ─────────────────────────────────
+
+export interface ReserveRate {
+ protocol: 'Aave v3' | 'Spark'
+ assetLabel: string
+ asset: Address
+ /** RAY-scaled (1e27) annualized supply rate, straight off the struct. */
+ supplyRateRay: bigint | undefined
+ /** RAY-scaled (1e27) annualized variable borrow rate. */
+ variableBorrowRateRay: bigint | undefined
+}
+
+export interface ReserveQuery {
+ protocol: ReserveRate['protocol']
+ pool: Address
+ assetLabel: string
+ asset: Address
+}
+
+/**
+ * One `defineTask` per (pool, asset) pair — extracts just the two rate
+ * fields out of `getReserveData`'s full struct via `t.derive` (F1/F2/F3
+ * composing together). `optional: true` so one bad reserve/pool never takes
+ * down the whole bulk read — matching the built-in handlers' convention
+ * (see `src/handlers/erc4626.ts`).
+ */
+export function buildReserveRateTask(query: ReserveQuery): MultistepTask {
+ return defineTask((t) => {
+ const reserve = t.call({
+ target: query.pool,
+ abi: aaveV3PoolAbi,
+ functionName: 'getReserveData',
+ args: [query.asset],
+ optional: true,
+ })
+
+ return {
+ protocol: query.protocol,
+ assetLabel: query.assetLabel,
+ asset: query.asset,
+ supplyRateRay: t.derive([reserve], (data) => data?.currentLiquidityRate),
+ variableBorrowRateRay: t.derive([reserve], (data) => data?.currentVariableBorrowRate),
+ }
+ })
+}
+
+// ─── Morpho Blue — 2-step IRM rate with a dynamic `Ref` target ───
+
+export interface MorphoIrmRate {
+ marketId: `0x${string}`
+ /**
+ * IRM contract address — resolved dynamically from `idToMarketParams` in
+ * step 1, then used AS THE CALL TARGET for `borrowRateView` in step 2.
+ * This is the spec's `Ref` dynamic-target showcase.
+ */
+ irm: Address | undefined
+ /** WAD-scaled (1e18) per-second borrow rate from the IRM. */
+ borrowRatePerSecondWad: bigint | undefined
+}
+
+export function buildMorphoIrmRateTask(marketId: `0x${string}`): MultistepTask {
+ return defineTask((t) => {
+ // Step 1 (depth 1): both calls target Morpho Blue itself and are mutually
+ // independent, so they land in the same step/batch.
+ const paramsCall = t.call({
+ target: MORPHO_BLUE,
+ abi: morphoBlueAbi,
+ functionName: 'idToMarketParams',
+ args: [marketId],
+ optional: true,
+ })
+ const marketCall = t.call({
+ target: MORPHO_BLUE,
+ abi: morphoBlueAbi,
+ functionName: 'market',
+ args: [marketId],
+ optional: true,
+ })
+
+ // `idToMarketParams` decodes to a flat 5-tuple (see morphoBlueAbi's doc
+ // comment) — position 3 is `irm`.
+ const irm = t.derive([paramsCall], (p) => p?.[3])
+
+ const marketParams = t.derive([paramsCall], (p) =>
+ p === undefined
+ ? undefined
+ : { loanToken: p[0], collateralToken: p[1], oracle: p[2], irm: p[3], lltv: p[4] },
+ )
+ const marketState = t.derive([marketCall], (m) =>
+ m === undefined
+ ? undefined
+ : {
+ totalSupplyAssets: m[0],
+ totalSupplyShares: m[1],
+ totalBorrowAssets: m[2],
+ totalBorrowShares: m[3],
+ lastUpdate: m[4],
+ fee: m[5],
+ },
+ )
+
+ // Step 2 (depth 2): THE showcase — `target` is `irm`, a `Ref` produced by step 1, not a literal address. If `irm` never
+ // resolved, the runtime skip-chain rule (src/core/defineTask.ts) simply
+ // skips this call rather than dispatching it with a bogus target.
+ const borrowRate = t.call({
+ target: irm,
+ abi: morphoIrmAbi,
+ functionName: 'borrowRateView',
+ args: [marketParams, marketState],
+ optional: true,
+ })
+
+ return {
+ marketId,
+ irm,
+ borrowRatePerSecondWad: borrowRate,
+ }
+ })
+}
+
+// ─── Orchestrator ───────────────────────────────────────────────────────────
+
+export interface RefinanceComparison {
+ reserves: ReserveRate[]
+ morpho: MorphoIrmRate
+}
+
+const RESERVE_QUERIES: ReserveQuery[] = [
+ { protocol: 'Aave v3', pool: AAVE_V3_POOL, assetLabel: 'WETH', asset: WETH },
+ { protocol: 'Aave v3', pool: AAVE_V3_POOL, assetLabel: 'USDC', asset: USDC },
+ { protocol: 'Aave v3', pool: AAVE_V3_POOL, assetLabel: 'DAI', asset: DAI },
+ { protocol: 'Spark', pool: SPARK_POOL, assetLabel: 'WETH', asset: WETH },
+ { protocol: 'Spark', pool: SPARK_POOL, assetLabel: 'USDC', asset: USDC },
+ { protocol: 'Spark', pool: SPARK_POOL, assetLabel: 'DAI', asset: DAI },
+]
+
+/**
+ * Runs the Aave v3 + Spark reserve-rate bulk AND the Morpho 2-step IRM rate
+ * task TOGETHER, in one `runMultistepTasks` call gated by `Presets.throughput`
+ * (F7 — concurrent batch dispatch + adaptive bisection + within-step dedup).
+ *
+ * The combined array is typed `MultistepTask[]` (mirroring
+ * `MultichainResolver`'s own single-`T`-generic note — mixed result shapes
+ * need one call per shape, or type erasure here) and the two known slices are
+ * cast back on the way out; see the module doc comment for why they're
+ * combined into one call in the first place (round-trip savings from mixed-
+ * depth batching, not just convenience).
+ */
+export async function refinance(executor: StepExecutor): Promise {
+ const reserveTasks = RESERVE_QUERIES.map(buildReserveRateTask)
+ const morphoTask = buildMorphoIrmRateTask(WSTETH_WETH_MARKET_ID)
+
+ const tasks: MultistepTask[] = [...reserveTasks, morphoTask]
+ const results = await runMultistepTasks(executor, tasks, Presets.throughput)
+
+ return {
+ reserves: results.slice(0, reserveTasks.length) as ReserveRate[],
+ morpho: results[results.length - 1] as MorphoIrmRate,
+ }
+}
+
+// ─── Multichain variant — MultichainResolver + snapshot()/blocks pinning ──
+
+export type MultichainRefinanceResult = Record
+
+/**
+ * Same Aave-v3-shaped reserve-rate read, fanned out across chains via
+ * `MultichainResolver` — mainnet always, plus Base when `RPC_URL_8453` is
+ * present (the P9 env convention: `RPC_URL` for mainnet, `RPC_URL_`
+ * for every other chain); runs single-chain when only `RPC_URL` is set.
+ *
+ * Uses `snapshot()` + an explicit per-chain `blocks` map RATHER THAN
+ * `pinBlock: true`: `pinBlock` resolves and pins one block fresh every time
+ * `runAll`/`runAllSettled` is called — fine for a single atomic call, but
+ * cross-chain rate COMPARISON wants ONE block per chain, observed once, then
+ * reused across however many calls make up the comparison (here: one
+ * `runAll`, but the same snapshot could just as well back a retry or a
+ * second pass without re-observing a possibly-different block per chain).
+ * `snapshot()` hands that per-chain block map back explicitly so the caller
+ * can hold onto and reuse it — `pinBlock` alone has no way to do that, since
+ * its resolved block never leaves the single run it was resolved for.
+ */
+export async function refinanceMultichain(): Promise {
+ const rpcUrl = process.env['RPC_URL']
+ if (!rpcUrl) {
+ throw new Error('refinanceMultichain: RPC_URL environment variable is required')
+ }
+
+ const chains: Record = {
+ [mainnet.id]: new Eip1193Executor(createPublicClient({ chain: mainnet, transport: http(rpcUrl) })),
+ }
+
+ const baseRpcUrl = process.env['RPC_URL_8453']
+ if (baseRpcUrl) {
+ chains[base.id] = new Eip1193Executor(createPublicClient({ chain: base, transport: http(baseRpcUrl) }))
+ }
+
+ const resolver = new MultichainResolver(chains)
+ // One observed block per chain, reused below for every task in this run —
+ // see the doc comment above for why this (not `pinBlock: true`) is the
+ // right tool for a cross-chain comparison. `snapshot()` returns block
+ // NUMBERS (`Record`); `blocks` wants `BlockParam`s, so each
+ // chain's number is wrapped as an explicit `{ blockNumber }`.
+ const chainBlocks = await resolver.snapshot()
+ const blocks: Record = Object.fromEntries(
+ Object.entries(chainBlocks).map(([chainId, blockNumber]) => [chainId, { blockNumber }]),
+ )
+
+ const plan: Record[]> = {
+ [mainnet.id]: [
+ buildReserveRateTask({ protocol: 'Aave v3', pool: AAVE_V3_POOL, assetLabel: 'USDC', asset: USDC }),
+ buildReserveRateTask({ protocol: 'Aave v3', pool: AAVE_V3_POOL, assetLabel: 'WETH', asset: WETH }),
+ ],
+ }
+ if (baseRpcUrl) {
+ plan[base.id] = [
+ buildReserveRateTask({
+ protocol: 'Aave v3',
+ pool: AAVE_V3_POOL_BASE,
+ assetLabel: 'USDC',
+ asset: USDC_BASE,
+ }),
+ ]
+ }
+
+ return resolver.runAll(plan, { ...Presets.throughput, blocks })
+}
+
+// ─── Console formatting ─────────────────────────────────────────────────────
+
+function rayToApr(rateRay: bigint | undefined): string {
+ if (rateRay === undefined) return '—'
+ return `${((Number(rateRay) / 1e27) * 100).toFixed(2)}%`
+}
+
+function wadPerSecondToAprEstimate(ratePerSecondWad: bigint | undefined): string {
+ if (ratePerSecondWad === undefined) return '—'
+ // Simple-interest per-second -> APR estimate (compounding ignored) purely
+ // for the printed comparison table — not a precision-critical figure.
+ const perSecond = Number(ratePerSecondWad) / 1e18
+ const apr = perSecond * 365 * 24 * 60 * 60 * 100
+ return `${apr.toFixed(2)}%`
+}
+
+function printRatesTable(label: string, reserves: ReserveRate[]): void {
+ console.log(`\n ${label}\n`)
+ const colW = [10, 8, 14, 20]
+ const headers = ['protocol', 'asset', 'supply APR', 'variable borrow APR']
+ const sep = colW.map((w) => '─'.repeat(w)).join('┼')
+
+ console.log(' ┌' + sep + '┐')
+ console.log(' │' + headers.map((h, i) => h.padEnd(colW[i]!)).join('│') + '│')
+ console.log(' ├' + sep + '┤')
+ for (const r of reserves) {
+ const cells = [
+ r.protocol.padEnd(colW[0]!),
+ r.assetLabel.padEnd(colW[1]!),
+ rayToApr(r.supplyRateRay).padEnd(colW[2]!),
+ rayToApr(r.variableBorrowRateRay).padEnd(colW[3]!),
+ ]
+ console.log(' │' + cells.join('│') + '│')
+ }
+ console.log(' └' + sep + '┘')
+}
+
+function printMorphoRate(morpho: MorphoIrmRate): void {
+ console.log('\n Morpho Blue — 2-step IRM rate (dynamic Ref target)\n')
+ console.log(` market: ${morpho.marketId}`)
+ console.log(` irm (from step 1): ${morpho.irm ?? '—'}`)
+ console.log(` borrow APR (est): ${wadPerSecondToAprEstimate(morpho.borrowRatePerSecondWad)}`)
+}
+
+// ─── main() — live run, gated by RPC_URL ───────────────────────────────────
+
+async function main(): Promise {
+ const rpcUrl = process.env['RPC_URL']
+ if (!rpcUrl) {
+ console.error(
+ '\nError: RPC_URL environment variable is required.\n\n' +
+ ' RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY npx tsx examples/refinance.ts\n',
+ )
+ process.exit(1)
+ }
+
+ console.log('\n━━━ domino refinance example (G2) ━━━')
+
+ const provider = createPublicClient({ chain: mainnet, transport: http(rpcUrl) })
+ const executor = new Eip1193Executor(provider)
+
+ const { reserves, morpho } = await refinance(executor)
+ printRatesTable('Aave v3 / Spark — getReserveData bulk (Presets.throughput)', reserves)
+ printMorphoRate(morpho)
+
+ if (process.env['RPC_URL_8453']) {
+ console.log('\n Multichain variant (mainnet + Base, snapshot() + blocks pinning)')
+ const perChain = await refinanceMultichain()
+ for (const [chainId, rates] of Object.entries(perChain)) {
+ printRatesTable(`chain ${chainId}`, rates)
+ }
+ } else {
+ console.log('\n (Set RPC_URL_8453 to also run the multichain variant against Base.)')
+ }
+
+ console.log('\nDone.\n')
+}
+
+// external review, P2: `RPC_URL`-presence is NOT a safe import guard — a
+// process that happens to have `RPC_URL` set (e.g. this module imported
+// from a live-fork test suite, or any other consumer) would otherwise fire
+// a real run — `process.exit` included — as a side effect of merely
+// importing it. The real guard is DIRECT EXECUTION: `main()` only runs when
+// this file is the process's actual entry point (`tsx examples/refinance.ts`
+// / `node examples/refinance.js`), detected by comparing this module's own
+// URL to `process.argv[1]` (the script Node/tsx was invoked with) via
+// `fileURLToPath` — never merely on import, regardless of what's in the
+// environment. `RPC_URL` itself is validated INSIDE `main()` (helpful error
+// + non-zero exit when absent — mirrors scripts/benchmark-live.ts's guard
+// style), independently of this direct-execution check.
+const isDirectExecution =
+ process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
+
+if (isDirectExecution) {
+ void main().catch((err: unknown) => {
+ console.error('\nFatal error:', err instanceof Error ? err.message : err)
+ process.exit(1)
+ })
+}
diff --git a/scripts/check-snippets.ts b/scripts/check-snippets.ts
index 07d1db3..c65fe9a 100644
--- a/scripts/check-snippets.ts
+++ b/scripts/check-snippets.ts
@@ -6,7 +6,9 @@
* consumer would hit it.
*
* Sources checked:
- * - Literal files: docs/snippets/*.ts
+ * - Literal files: docs/snippets/*.ts AND examples/*.ts (G2 — full runnable
+ * examples like `examples/refinance.ts` get the exact same dist-typed
+ * merge gate as a published snippet; see `discoverExampleFiles` below).
* - Fenced ```ts / ```typescript blocks extracted from README.md,
* MIGRATION.md, and docs/*.md (top-level only — docs/plans/*.md is
* internal planning material, not a published doc).
@@ -198,6 +200,64 @@ function discoverLiteralSnippets(): string[] {
return listFiles(join(ROOT, 'docs', 'snippets'), '.ts')
}
+/** G2 — `examples/*.ts` (e.g. `examples/refinance.ts`) get the same literal-
+ * file, dist-typed treatment as `docs/snippets/*.ts` above; see `materialize`. */
+function discoverExampleFiles(): string[] {
+ return listFiles(join(ROOT, 'examples'), '.ts')
+}
+
+// ─── Release-gate example discovery guard (external review, P1) ───────────
+//
+// `discoverExampleFiles` (→ `listFiles`) silently returns `[]` for a
+// missing/unreadable `examples/` dir — without an explicit check, this
+// script could exit 0 while a release-gate example (G2's
+// `examples/refinance.ts`) was silently never type-checked at all. Two hard
+// failures, checked after materialization:
+// 1. generic: `examples/` exists as a directory but yielded zero `.ts`
+// files (catches permission issues, an accidentally-emptied dir, a
+// `discoverExampleFiles`/`listFiles` regression, etc.) — any future
+// example added under `examples/` gets this same safety net for free.
+// 2. specific: every file in `REQUIRED_EXAMPLE_FILES` (currently just
+// G2's `refinance.ts`) must be present in the manifest BY NAME — the
+// generic check alone would stay silent if some OTHER unrelated file
+// happened to be discovered while the actual release-gate example was
+// missing (e.g. renamed or deleted by mistake).
+
+const REQUIRED_EXAMPLE_FILES = ['refinance.ts'] as const
+
+function assertExamplesDiscovered(manifest: Manifest[]): void {
+ const examplesDir = join(ROOT, 'examples')
+
+ let dirExists = false
+ try {
+ dirExists = statSync(examplesDir).isDirectory()
+ } catch {
+ dirExists = false
+ }
+
+ const discoveredExampleFiles = manifest
+ .map((m) => m.sourceFile)
+ .filter((f) => f.startsWith(`examples${sep}`) || f.startsWith('examples/'))
+
+ if (dirExists && discoveredExampleFiles.length === 0) {
+ throw new Error(
+ 'examples/ directory exists but yielded zero .ts files for snippet discovery — ' +
+ 'check discoverExampleFiles()/listFiles() permissions, or whether the directory is genuinely empty.',
+ )
+ }
+
+ for (const name of REQUIRED_EXAMPLE_FILES) {
+ const expected = relPath(join(examplesDir, name))
+ if (!discoveredExampleFiles.includes(expected)) {
+ throw new Error(
+ `release-gate example missing from snippet discovery: "${expected}" was not found — ` +
+ 'this file is supposed to be type-checked against the built dist types on every run ' +
+ '(same gate as docs/snippets/*); its absence here means that gate silently did not run.',
+ )
+ }
+ }
+}
+
// ─── Materialization ────────────────────────────────────────────────────────
function relPath(p: string): string {
@@ -224,17 +284,26 @@ function materialize(): { manifest: Manifest[]; checkedFiles: CheckedFile[]; tot
let totalChecked = 0
let totalSkipped = 0
- // Literal files — copied verbatim, already self-contained modules.
- for (const file of discoverLiteralSnippets()) {
- const dest = join(BUILD_DIR, 'snippets', basename(file))
- mkdirSync(dirname(dest), { recursive: true })
- writeFileSync(dest, readFileSync(file, 'utf8'))
- manifest.push({
- generatedFile: relPath(dest),
- sourceFile: relPath(file),
- sourceLines: 'whole file',
- })
- totalChecked += 1
+ // Literal files — copied verbatim, already self-contained modules. Two
+ // source dirs (docs/snippets/*.ts and, since G2, examples/*.ts) each
+ // materialize into their OWN BUILD_DIR subfolder (named after the source
+ // dir) so a same-named file in each location can never collide.
+ const literalGroups: readonly (readonly [string, string[]])[] = [
+ ['snippets', discoverLiteralSnippets()],
+ ['examples', discoverExampleFiles()],
+ ]
+ for (const [destSubdir, files] of literalGroups) {
+ for (const file of files) {
+ const dest = join(BUILD_DIR, destSubdir, basename(file))
+ mkdirSync(dirname(dest), { recursive: true })
+ writeFileSync(dest, readFileSync(file, 'utf8'))
+ manifest.push({
+ generatedFile: relPath(dest),
+ sourceFile: relPath(file),
+ sourceLines: 'whole file',
+ })
+ totalChecked += 1
+ }
}
// Fenced blocks extracted from markdown docs.
@@ -442,6 +511,11 @@ function main(): void {
console.log('')
}
+ // External review, P1: fails loudly (before tsc even runs) if the
+ // release-gate example(s) weren't actually discovered above — see
+ // `assertExamplesDiscovered`'s doc comment.
+ assertExamplesDiscovered(manifest)
+
console.log('── src/ escape check ──')
const srcEscapes = findSrcEscapes(manifest)
if (srcEscapes.length === 0) {
diff --git a/src/__tests__/refinance-example.test.ts b/src/__tests__/refinance-example.test.ts
new file mode 100644
index 0000000..b54b7d4
--- /dev/null
+++ b/src/__tests__/refinance-example.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect } from 'vitest'
+import { defineTask } from '../core/defineTask'
+import { runMultistepTasks } from '../core/runMultistepTasks'
+import { Presets } from '../core/presets'
+import type { Address, MultistepTask, StepCall, StepExecutor, RawResult } from '../core/types'
+
+/**
+ * G2 smoke test — exercises the SAME task-construction pattern used by
+ * `examples/refinance.ts` (an Aave-v3-shaped `getReserveData` struct
+ * extraction, and Morpho's dynamic `Ref` IRM target) against a mock
+ * `StepExecutor`, mirroring how `src/__tests__/erc4626.test.ts` and
+ * `src/__tests__/multichain.test.ts` mock the executor for the built-in
+ * handlers/resolver.
+ *
+ * **Why this duplicates, rather than imports, `examples/refinance.ts`'s ABIs
+ * and builder logic (brief-authorized fallback, reported as required):**
+ * the brief's preferred approach —
+ * `import { buildReserveRateTask, buildMorphoIrmRateTask } from
+ * '../../examples/refinance'` — was tried first and fails `npm run
+ * typecheck` (root `tsconfig.json`: `rootDir: "src"`, `include: ["src"]`,
+ * `declaration: true`, `outDir: "dist"`) with TS6059 ("File is not under
+ * 'rootDir'"): `examples/refinance.ts` sits outside `src/`, and the moment
+ * anything under `src/__tests__` references it, tsc pulls it into the same
+ * program and has to compute an output path for it under `rootDir` — which
+ * fails, since it isn't under `src/` at all.
+ *
+ * The brief's fallback — "only import types" via `import type { ... } from
+ * '../../examples/refinance'` — was tried next and hits the IDENTICAL
+ * TS6059. A type-only import still requires tsc to load and check the
+ * source module to extract its type declarations (type-only imports are
+ * elided at EMIT time, not at module-resolution/program-membership time),
+ * so the file is still added to the program and still needs an output path
+ * under `rootDir`. Neither of the brief's two options survives `npm run
+ * typecheck` (verified directly: both were tried against a throwaway probe
+ * file and both reproduced TS6059 before this file was written).
+ *
+ * This file therefore imports NOTHING from `examples/refinance.ts` — not
+ * even types — and re-declares the minimal ABI/task shapes inline instead,
+ * built from the SAME core primitives (`defineTask`, `runMultistepTasks`,
+ * `Presets`) the real example uses. `examples/refinance.ts` itself is still
+ * fully exercised by its own dedicated gate: `npm run check:snippets`
+ * type-checks it against the BUILT dist types exactly like `docs/snippets/*`
+ * (see `scripts/check-snippets.ts`, extended for G2 to also discover
+ * `examples/*.ts`) — that is the merge gate for the example file itself;
+ * this test's job is only to smoke-test that the PATTERN it demonstrates
+ * (struct-field derive, dynamic `Ref` target, mixed-depth batching
+ * under `Presets.throughput`) actually behaves as described at runtime.
+ */
+
+const POOL: Address = '0x1111111111111111111111111111111111111111'
+const ASSET: Address = '0x2222222222222222222222222222222222222222'
+const MORPHO: Address = '0x3333333333333333333333333333333333333333'
+const IRM: Address = '0x4444444444444444444444444444444444444444'
+const MARKET_ID = `0x${'5'.repeat(64)}` as const
+
+// Mirrors examples/refinance.ts's `aaveV3PoolAbi` — a single struct-typed
+// output, declared faithfully (all 15 fields) even though only two are read.
+const aaveV3PoolAbi = [
+ 'struct ReserveConfigurationMap { uint256 data; }',
+ 'struct ReserveData { ReserveConfigurationMap configuration; uint128 liquidityIndex; uint128 currentLiquidityRate; uint128 variableBorrowIndex; uint128 currentVariableBorrowRate; uint128 currentStableBorrowRate; uint40 lastUpdateTimestamp; uint16 id; address aTokenAddress; address stableDebtTokenAddress; address variableDebtTokenAddress; address interestRateStrategyAddress; uint128 accruedToTreasury; uint128 unbacked; uint128 isolationModeTotalDebt; }',
+ 'function getReserveData(address asset) view returns (ReserveData)',
+] as const
+
+// Mirrors examples/refinance.ts's `morphoBlueAbi` — flat multi-output
+// getters, NOT a single nested struct (see that file's doc comment).
+const morphoBlueAbi = [
+ 'function idToMarketParams(bytes32 id) view returns (address loanToken, address collateralToken, address oracle, address irm, uint256 lltv)',
+ 'function market(bytes32 id) view returns (uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee)',
+] as const
+
+// Mirrors examples/refinance.ts's `morphoIrmAbi`.
+const morphoIrmAbi = [
+ 'struct MarketParams { address loanToken; address collateralToken; address oracle; address irm; uint256 lltv; }',
+ 'struct Market { uint128 totalSupplyAssets; uint128 totalSupplyShares; uint128 totalBorrowAssets; uint128 totalBorrowShares; uint128 lastUpdate; uint128 fee; }',
+ 'function borrowRateView(MarketParams marketParams, Market market) view returns (uint256)',
+] as const
+
+interface ReserveRate {
+ supplyRateRay: bigint | undefined
+ variableBorrowRateRay: bigint | undefined
+}
+
+/** Mirrors examples/refinance.ts's `buildReserveRateTask`. */
+function buildReserveRateTask(pool: Address, asset: Address): MultistepTask {
+ return defineTask((t) => {
+ const reserve = t.call({
+ target: pool,
+ abi: aaveV3PoolAbi,
+ functionName: 'getReserveData',
+ args: [asset],
+ optional: true,
+ })
+ return {
+ supplyRateRay: t.derive([reserve], (data) => data?.currentLiquidityRate),
+ variableBorrowRateRay: t.derive([reserve], (data) => data?.currentVariableBorrowRate),
+ }
+ })
+}
+
+interface MorphoIrmRate {
+ irm: Address | undefined
+ borrowRatePerSecondWad: bigint | undefined
+}
+
+/** Mirrors examples/refinance.ts's `buildMorphoIrmRateTask` — the dynamic
+ * `Ref` target showcase: step 2's `target` is `irm`, resolved from
+ * step 1's `idToMarketParams` result. */
+function buildMorphoIrmRateTask(marketId: `0x${string}`): MultistepTask {
+ return defineTask((t) => {
+ const paramsCall = t.call({
+ target: MORPHO,
+ abi: morphoBlueAbi,
+ functionName: 'idToMarketParams',
+ args: [marketId],
+ optional: true,
+ })
+ const marketCall = t.call({
+ target: MORPHO,
+ abi: morphoBlueAbi,
+ functionName: 'market',
+ args: [marketId],
+ optional: true,
+ })
+
+ const irm = t.derive([paramsCall], (p) => p?.[3])
+ const marketParams = t.derive([paramsCall], (p) =>
+ p === undefined
+ ? undefined
+ : { loanToken: p[0], collateralToken: p[1], oracle: p[2], irm: p[3], lltv: p[4] },
+ )
+ const marketState = t.derive([marketCall], (m) =>
+ m === undefined
+ ? undefined
+ : {
+ totalSupplyAssets: m[0],
+ totalSupplyShares: m[1],
+ totalBorrowAssets: m[2],
+ totalBorrowShares: m[3],
+ lastUpdate: m[4],
+ fee: m[5],
+ },
+ )
+
+ const borrowRate = t.call({
+ target: irm,
+ abi: morphoIrmAbi,
+ functionName: 'borrowRateView',
+ args: [marketParams, marketState],
+ optional: true,
+ })
+
+ return { irm, borrowRatePerSecondWad: borrowRate }
+ })
+}
+
+// ─── Mock executor ──────────────────────────────────────────────────────────
+
+interface TrackingExecutor extends StepExecutor {
+ invocations: StepCall[][]
+}
+
+function makeExecutor(): TrackingExecutor {
+ const invocations: StepCall[][] = []
+ return {
+ invocations,
+ async executeMulticall(calls: StepCall[]): Promise {
+ invocations.push(calls)
+ return calls.map((c): RawResult => {
+ if (c.functionName === 'getReserveData') {
+ return {
+ status: 'success',
+ value: {
+ configuration: { data: 0n },
+ liquidityIndex: 0n,
+ currentLiquidityRate: 12_345n,
+ variableBorrowIndex: 0n,
+ currentVariableBorrowRate: 67_890n,
+ currentStableBorrowRate: 0n,
+ lastUpdateTimestamp: 0,
+ id: 0,
+ aTokenAddress: ASSET,
+ stableDebtTokenAddress: ASSET,
+ variableDebtTokenAddress: ASSET,
+ interestRateStrategyAddress: ASSET,
+ accruedToTreasury: 0n,
+ unbacked: 0n,
+ isolationModeTotalDebt: 0n,
+ },
+ }
+ }
+ if (c.functionName === 'idToMarketParams') {
+ return { status: 'success', value: [ASSET, ASSET, ASSET, IRM, 900_000_000_000_000_000n] }
+ }
+ if (c.functionName === 'market') {
+ return { status: 'success', value: [1000n, 1000n, 500n, 500n, 123n, 0n] }
+ }
+ if (c.functionName === 'borrowRateView') {
+ return { status: 'success', value: 31_709_791n }
+ }
+ return { status: 'failure' }
+ })
+ },
+ }
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+describe('G2 refinance example — task construction (dry, mocked executor)', () => {
+ it('buildReserveRateTask: extracts the two rate fields out of the full getReserveData struct', async () => {
+ const executor = makeExecutor()
+ const [result] = await runMultistepTasks(executor, [buildReserveRateTask(POOL, ASSET)])
+
+ expect(result?.supplyRateRay).toBe(12_345n)
+ expect(result?.variableBorrowRateRay).toBe(67_890n)
+ expect(executor.invocations).toHaveLength(1)
+ })
+
+ it('buildMorphoIrmRateTask: step 2 dispatches to the IRM address resolved dynamically in step 1 (Ref target)', async () => {
+ const executor = makeExecutor()
+ const [result] = await runMultistepTasks(executor, [buildMorphoIrmRateTask(MARKET_ID)])
+
+ // Two steps: step 1 (idToMarketParams + market), step 2 (borrowRateView).
+ expect(executor.invocations).toHaveLength(2)
+ expect(executor.invocations[0]).toHaveLength(2)
+ expect(executor.invocations[1]).toHaveLength(1)
+
+ // The showcase: borrowRateView's target is the DYNAMIC irm address from
+ // step 1 — never the literal Morpho Blue address.
+ const step2Call = executor.invocations[1]?.[0]
+ expect(step2Call?.target).toBe(IRM)
+ expect(step2Call?.target).not.toBe(MORPHO)
+
+ expect(result?.irm).toBe(IRM)
+ expect(result?.borrowRatePerSecondWad).toBe(31_709_791n)
+ })
+
+ it('mixed-depth batching under Presets.throughput: a maxStep-1 reserve task rides along in the maxStep-2 Morpho task\'s own step-1 batch', async () => {
+ const executor = makeExecutor()
+ const reserveTask = buildReserveRateTask(POOL, ASSET)
+ const morphoTask = buildMorphoIrmRateTask(MARKET_ID)
+
+ const tasks: MultistepTask[] = [reserveTask, morphoTask]
+ const results = await runMultistepTasks(executor, tasks, Presets.throughput)
+
+ // Exactly 2 round-trips total (not 1 + 2 = 3): the reserve task's single
+ // call is folded into Morpho's own step-1 batch. This is the concrete
+ // behavior CLAUDE.md's "Mixed-depth batches" note describes.
+ expect(executor.invocations).toHaveLength(2)
+ expect(executor.invocations[0]).toHaveLength(3) // getReserveData + idToMarketParams + market
+ expect(executor.invocations[1]).toHaveLength(1) // borrowRateView
+
+ const reserveResult = results[0] as ReserveRate
+ const morphoResult = results[1] as MorphoIrmRate
+ expect(reserveResult.supplyRateRay).toBe(12_345n)
+ expect(morphoResult.irm).toBe(IRM)
+ expect(morphoResult.borrowRatePerSecondWad).toBe(31_709_791n)
+ })
+})