diff --git a/README.md b/README.md index c21a4e8..59a2d80 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ _| _| _| _| _| _| _| _| _| _| _| _| [![CI](https://github.com/halaprix/domino/actions/workflows/ci.yml/badge.svg)](https://github.com/halaprix/domino/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@halaprix/domino)](https://www.npmjs.com/package/@halaprix/domino) -[![bundle size](https://img.shields.io/badge/gzip-17.1KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino) +[![bundle size](https://img.shields.io/badge/gzip-16.8KB-brightgreen)](https://www.npmjs.com/package/@halaprix/domino) [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue)](https://www.typescriptlang.org/) [![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) diff --git a/src/__tests__/bundle-size.test.ts b/src/__tests__/bundle-size.test.ts index f759e1b..2d90c1b 100644 --- a/src/__tests__/bundle-size.test.ts +++ b/src/__tests__/bundle-size.test.ts @@ -131,6 +131,33 @@ * before: 65,226 bytes (63.70KB) gzip 17,065 bytes (16.67KB) * after: 66,345 bytes (64.79KB) gzip 17,533 bytes (17.12KB) * delta: +1,119 bytes raw (+1.09KB) +468 bytes gzip (+0.46KB) + * + * 1.3 (G1 handler migration): `buildErc20Task`/`buildErc4626Task` reimplemented + * on `defineTask` (public signatures unchanged — see + * `src/__tests__/parity-g1.test.ts`). NET DECREASE, not neutral: the + * hand-written per-field `consumeStepResults`/`finalize` routing the two + * handlers used to carry is now expressed as `t.call`/`t.derive` composition + * over the already-bundled `defineTask` engine, so the marginal cost of each + * handler shrinks to its ABI arrays + four tiny coercion helpers. Measured + * delta: + * before: 66,345 bytes (64.79KB) gzip 17,533 bytes (17.12KB) + * after: 64,510 bytes (63.00KB) gzip 17,185 bytes (16.79KB) + * delta: -1,835 bytes raw (-1.79KB) -348 bytes gzip (-0.34KB) + * + * 1.3 (G1 external-review round, P1 — `resolveAll` v-undefined skip-chain): + * closes a gap where a "successful"-but-malformed executor value (e.g. + * `balanceOf` resolving to a non-bigint) demoted to `undefined` by a + * handler's own coercion derive could still reach a DEPENDENT call's own + * argument encoding undetected. `src/core/defineTask.ts`'s call-mode + * `resolveAll` now treats a `'v'`-with-`undefined`-value position the same + * as a `'u'`/`'f'` one (synthesizing a "argument resolved to undefined" + * cause); `erc4626`'s `convertToAssets` now takes the COERCED `balance` ref, + * not the raw call ref. Small net increase (new branch + doc comments + + * three new core tests do not ship, but the branch itself and the coercion + * re-route do). Measured delta: + * before: 64,510 bytes (63.00KB) gzip 17,185 bytes (16.79KB) + * after: 64,678 bytes (63.16KB) gzip 17,234 bytes (16.83KB) + * delta: +168 bytes raw (+0.16KB) +49 bytes gzip (+0.04KB) */ import { describe, expect, it } from 'vitest' diff --git a/src/__tests__/defineTask.test.ts b/src/__tests__/defineTask.test.ts index 09b6657..66b82b9 100644 --- a/src/__tests__/defineTask.test.ts +++ b/src/__tests__/defineTask.test.ts @@ -417,6 +417,118 @@ describe('defineTask — optional escape hatch', () => { }) }) +describe('defineTask — a `v`-undefined argument skip-chains a CALL (external review P1)', () => { + // Closes a gap where a "successful"-but-malformed upstream value (most + // commonly demoted to `undefined` by a handler's own coercion derive, e.g. + // `src/handlers/erc4626.ts`'s `asBigInt`) could otherwise reach as far as + // a dependent call's argument encoding — see `resolveAll`'s doc comment in + // `src/core/defineTask.ts`. + + it('a derive that legitimately resolves to undefined, feeding a non-optional CALL arg, skip-chains that call (synthesized cause, no upstream failure at all)', async () => { + let executeCount = 0 + const executor: StepExecutor = { + async executeMulticall(calls): Promise { + executeCount++ + // Every call "succeeds" — but with a value the coercion derive + // below will reject (not a bigint). + return calls.map((): RawResult => ({ status: 'success', value: 'not-a-number' })) + }, + } + + const task = defineTask((t) => { + const raw = t.call({ target: ADDR, abi: testAbi, functionName: 'getNum', args: [1n] }) + // Exactly the handler coercion-derive pattern: legitimately resolves + // to `undefined` for a malformed (non-bigint) upstream value. + const coerced = t.derive([raw], (v) => (typeof v === 'bigint' ? v : undefined)) + const dependent = t.call({ target: ADDR, abi: testAbi, functionName: 'getNum', args: [coerced] }) + return { dependent } + }) + + const [result] = await runSettled(executor, [task]) + + expect(result!.status).toBe('rejected') + const err = (result as { status: 'rejected'; error: unknown }).error as DominoCallError + expect(err.kind).toBe('skipped') + expect(err.cause).toBeInstanceOf(DominoCallError) + const cause = err.cause as DominoCallError + expect(cause.kind).toBe('skipped') + expect(cause.message).toBe('argument resolved to undefined') + // No real upstream failure exists to preserve — the synthesized cause + // carries no cause of its own (nothing was thrown, nothing reverted). + expect(cause.cause).toBeUndefined() + + // `dependent` (depth 2) was never dispatched — only `raw`'s single + // executor invocation (step 1) happened. + expect(executeCount).toBe(1) + }) + + it('optional chain: a derive-produced undefined feeding an OPTIONAL call demotes that call too, recorded in diagnostics with the synthesized cause (accepted-delta extension)', async () => { + let executeCount = 0 + const executor: StepExecutor = { + async executeMulticall(calls): Promise { + executeCount++ + return calls.map((): RawResult => ({ status: 'success', value: 'not-a-number' })) + }, + } + + const task = defineTask((t) => { + const raw = t.call({ target: ADDR, abi: testAbi, functionName: 'getNum', args: [1n], optional: true }) + const coerced = t.derive([raw], (v) => (typeof v === 'bigint' ? v : undefined)) + const dependent = t.call({ + target: ADDR, + abi: testAbi, + functionName: 'getNum', + args: [coerced], + optional: true, + }) + return { dependent } + }) + + const [result] = await runSettled(executor, [task]) + + expect(result!.status).toBe('fulfilled') + expect((result as { status: 'fulfilled'; value: { dependent: bigint | undefined } }).value).toEqual({ + dependent: undefined, + }) + // `dependent` (depth 2) is skip-chained before ever reaching the + // executor — only `raw`'s step-1 call is dispatched. + expect(executeCount).toBe(1) + + // `raw`'s OWN call succeeded (the executor returned `status: 'success'`) + // — only the coercion derive rejected its value, so `raw` itself never + // enters diagnostics. `dependent`'s skip-chain does — the accepted-delta + // extension: an optional call can now be demoted by a synthesized + // "argument resolved to undefined" cause, not just a real upstream + // DominoCallError. + expect(result!.diagnostics.optionalFailures).toHaveLength(1) + const entry = result!.diagnostics.optionalFailures[0]! + expect(entry.error.kind).toBe('skipped') + expect(entry.error.cause).toBeInstanceOf(DominoCallError) + expect((entry.error.cause as DominoCallError).message).toBe('argument resolved to undefined') + }) + + it('derives themselves are unaffected: a derive consuming a v-undefined input still computes normally (no skip)', async () => { + const executor: StepExecutor = { + async executeMulticall(calls): Promise { + return calls.map((): RawResult => ({ status: 'success', value: 'not-a-number' })) + }, + } + + const task = defineTask((t) => { + const raw = t.call({ target: ADDR, abi: testAbi, functionName: 'getNum', args: [1n] }) + const coerced = t.derive([raw], (v) => (typeof v === 'bigint' ? v : undefined)) + // A SECOND derive consuming the first derive's (legitimately + // undefined) output — derives can always consume `undefined`. + const fallback = t.derive([coerced], (v) => v ?? -1n) + return { coerced, fallback } + }) + + const [result] = await runMultistepTasks(executor, [task]) + + expect(result).toEqual({ coerced: undefined, fallback: -1n }) + }) +}) + describe('defineTask — internal dedupe-eligibility marker', () => { it('stamps every compiled StepCall with the internal marker; dedupe: false yields false', () => { const task = defineTask((t) => { diff --git a/src/__tests__/erc4626.test.ts b/src/__tests__/erc4626.test.ts index 359ac36..c343ad9 100644 --- a/src/__tests__/erc4626.test.ts +++ b/src/__tests__/erc4626.test.ts @@ -67,12 +67,14 @@ describe('resolveErc4626Vault', () => { // Step 1 calls: symbol, decimals, asset, balanceOf, maxWithdraw, maxRedeem = 6 const step1 = (executor.executeMulticall as ReturnType).mock.calls[0]?.[0] expect(step1).toHaveLength(6) - expect(step1[3]).toMatchObject({ key: 'balance', functionName: 'balanceOf' }) + // `key` is an internal defineTask-assigned id post-G1 migration (no + // longer the legacy semantic string) — assert functionName/shape only. + expect(step1[3]).toMatchObject({ functionName: 'balanceOf' }) // Step 2 calls: convertToAssets(balance) = 1 const step2 = (executor.executeMulticall as ReturnType).mock.calls[1]?.[0] expect(step2).toHaveLength(1) - expect(step2[0]).toMatchObject({ key: 'assets', functionName: 'convertToAssets' }) + expect(step2[0]).toMatchObject({ functionName: 'convertToAssets' }) expect(result.metadata.symbol).toBe('wstETH') expect(result.metadata.maxWithdraw).toBe(1000000000000000000n) diff --git a/src/__tests__/fixtures/legacy-handlers/erc20.ts b/src/__tests__/fixtures/legacy-handlers/erc20.ts new file mode 100644 index 0000000..deb2bca --- /dev/null +++ b/src/__tests__/fixtures/legacy-handlers/erc20.ts @@ -0,0 +1,135 @@ +/** + * G1 parity-test oracle — pre-migration `buildErc20Task` (verbatim + * task-construction closure, inlined ABI, and `KEYS`, moved unmodified from + * `src/handlers/erc20.ts` as of the last pre-G1 commit). + * + * **Purpose:** `src/__tests__/parity-g1.test.ts` runs the SAME scenario + * table through this legacy implementation and the new `defineTask`-based + * `buildErc20Task` (`src/handlers/erc20.ts`), asserting + * `expect(newResult).toStrictEqual(legacyResult)`. This file is the ORACLE + * side of that comparison — never the thing under test. + * + * **Lifecycle:** test-only import, never imported from `src/index.ts` or any + * runtime module — excluded from the public runtime path and the bundle + * (see `src/__tests__/bundle-size.test.ts`, which asserts the bundle does + * not grow materially, and the migration report's `grep dist` sanity check + * for the `KEYS`-specific string below). Retained for exactly one minor + * release after G1 ships (1.3.x), then deleted; the rollback path if G1 ever + * needs to be reverted is a patch release built from git history (this file + * pre-dates the deletion commit), not a runtime feature flag. + * + * Keeps the `SINGLE_USE` brand (imported from `core/internal`, same as the + * production handlers) so parity fixtures exercise the identical + * single-run/reuse-guard behavior on both sides of the comparison. + */ + +import type { Address, MultistepTask, StepCall, StepResult } from '../../../core/types' +import { SINGLE_USE } from '../../../core/internal' +import type { SingleUseCarrier } from '../../../core/internal' +import type { Erc20TokenResolution } from '../../../handlers/erc20' + +/** Minimal ERC20 ABI — only the functions used by buildErc20TaskLegacy. */ +const erc20Abi = [ + { + type: 'function', + name: 'symbol', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'string' }], + }, + { + type: 'function', + name: 'decimals', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint8' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, +] as const + +type Erc20Context = { + symbol?: string + decimals?: number + balance?: bigint +} + +// Typed accessor helpers — safe coercion from the untyped RawResult.value. +// These replace `as T` casts; returning undefined instead of producing wrong +// data when an executor returns an unexpected value type. +const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) +const asBigInt = (v: unknown): bigint | undefined => (typeof v === 'bigint' ? v : undefined) +const asNumber = (v: unknown): number | undefined => { + const n = Number(v) + return Number.isFinite(n) ? n : undefined +} + +// Routing key constants — typos in key strings would cause silent routing misses; +// using a const object makes them a compile error instead. +const KEYS = { + symbol: 'symbol', + decimals: 'decimals', + balance: 'balance', +} as const + +export function buildErc20TaskLegacy(params: { + token: Address + owner?: Address +}): MultistepTask { + const { token, owner } = params + const ctx: Erc20Context = {} + + return { + maxStep: 1, + + buildStepCalls(step) { + if (step !== 1) return [] + + const calls: StepCall[] = [ + { key: KEYS.symbol, target: token, abi: erc20Abi, functionName: 'symbol' }, + { key: KEYS.decimals, target: token, abi: erc20Abi, functionName: 'decimals' }, + ] + + if (owner) { + calls.push({ + key: KEYS.balance, + target: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + }) + } + + return calls + }, + + consumeStepResults(_step, results: StepResult[]) { + for (const result of results) { + if (result.status === 'failure') continue + // TypeScript narrows result to the success branch here. + // exactOptionalPropertyTypes: only assign when the value is defined. + const sym = result.key === KEYS.symbol ? asString(result.value) : undefined + if (sym !== undefined) ctx.symbol = sym + const dec = result.key === KEYS.decimals ? asNumber(result.value) : undefined + if (dec !== undefined) ctx.decimals = dec + const bal = result.key === KEYS.balance ? asBigInt(result.value) : undefined + if (bal !== undefined) ctx.balance = bal + } + }, + + finalize() { + return { + symbol: ctx.symbol, + decimals: ctx.decimals, + balance: ctx.balance, + } + }, + + [SINGLE_USE]: true, + } as MultistepTask & SingleUseCarrier +} diff --git a/src/__tests__/fixtures/legacy-handlers/erc4626.ts b/src/__tests__/fixtures/legacy-handlers/erc4626.ts new file mode 100644 index 0000000..6c4eb51 --- /dev/null +++ b/src/__tests__/fixtures/legacy-handlers/erc4626.ts @@ -0,0 +1,236 @@ +/** + * G1 parity-test oracle — pre-migration `buildErc4626Task` (verbatim + * task-construction closure, inlined ABIs, and `KEYS`, moved unmodified from + * `src/handlers/erc4626.ts` as of the last pre-G1 commit). + * + * **Purpose:** `src/__tests__/parity-g1.test.ts` runs the SAME scenario + * table through this legacy implementation and the new `defineTask`-based + * `buildErc4626Task` (`src/handlers/erc4626.ts`), asserting + * `expect(newResult).toStrictEqual(legacyResult)`. This file is the ORACLE + * side of that comparison — never the thing under test. In particular, this + * is the `finalize()` whose conditional-spread `position` shape (T11) the + * new `t.derive`-based implementation must reproduce exactly (absent keys, + * not present-with-`undefined`). + * + * **Lifecycle:** test-only import, never imported from `src/index.ts` or any + * runtime module — excluded from the public runtime path and the bundle + * (see `src/__tests__/bundle-size.test.ts`, which asserts the bundle does + * not grow materially, and the migration report's `grep dist` sanity check + * for the `KEYS`-specific string below). Retained for exactly one minor + * release after G1 ships (1.3.x), then deleted; the rollback path if G1 ever + * needs to be reverted is a patch release built from git history (this file + * pre-dates the deletion commit), not a runtime feature flag. + * + * Keeps the `SINGLE_USE` brand (imported from `core/internal`, same as the + * production handlers) so parity fixtures exercise the identical + * single-run/reuse-guard behavior on both sides of the comparison. + */ + +import type { Address, MultistepTask, StepCall, StepResult } from '../../../core/types' +import { SINGLE_USE } from '../../../core/internal' +import type { SingleUseCarrier } from '../../../core/internal' +import type { Erc4626VaultResolution } from '../../../handlers/erc4626' + +/** Minimal ERC20 ABI — only the functions used by buildErc4626TaskLegacy. */ +const erc20Abi = [ + { + type: 'function', + name: 'symbol', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'string' }], + }, + { + type: 'function', + name: 'decimals', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint8' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, +] as const + +/** Minimal ERC4626 ABI — only the functions used by buildErc4626TaskLegacy. */ +const erc4626Abi = [ + { + type: 'function', + name: 'asset', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'address' }], + }, + { + type: 'function', + name: 'maxWithdraw', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, + { + type: 'function', + name: 'maxRedeem', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, + { + type: 'function', + name: 'convertToAssets', + stateMutability: 'view', + inputs: [{ name: 'shares', type: 'uint256' }], + outputs: [{ type: 'uint256' }], + }, +] as const + +type Erc4626Context = { + symbol?: string + decimals?: number + balance?: bigint + maxWithdraw?: bigint + maxRedeem?: bigint + underlyingAsset?: Address + assets?: bigint +} + +// Typed accessor helpers — safe coercion from the untyped RawResult.value. +const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) +const asBigInt = (v: unknown): bigint | undefined => (typeof v === 'bigint' ? v : undefined) +const asNumber = (v: unknown): number | undefined => { + const n = Number(v) + return Number.isFinite(n) ? n : undefined +} +const asAddress = (v: unknown): Address | undefined => + typeof v === 'string' && v.startsWith('0x') ? (v as Address) : undefined + +// Routing key constants — compile-time protection against typos in key strings. +const KEYS = { + symbol: 'symbol', + decimals: 'decimals', + asset: 'asset', + balance: 'balance', + maxWithdraw: 'maxWithdraw', + maxRedeem: 'maxRedeem', + assets: 'assets', +} as const + +export function buildErc4626TaskLegacy(params: { + vault: Address + owner?: Address +}): MultistepTask { + const { vault, owner } = params + const ctx: Erc4626Context = {} + const hasOwner = !!owner + + return { + maxStep: hasOwner ? 2 : 1, + + buildStepCalls(step) { + if (step === 1) { + const calls: StepCall[] = [ + { key: KEYS.symbol, target: vault, abi: erc20Abi, functionName: 'symbol' }, + { key: KEYS.decimals, target: vault, abi: erc20Abi, functionName: 'decimals' }, + { key: KEYS.asset, target: vault, abi: erc4626Abi, functionName: 'asset' }, + ] + if (owner) { + calls.push( + { + key: KEYS.balance, + target: vault, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + }, + { + key: KEYS.maxWithdraw, + target: vault, + abi: erc4626Abi, + functionName: 'maxWithdraw', + args: [owner], + }, + { + key: KEYS.maxRedeem, + target: vault, + abi: erc4626Abi, + functionName: 'maxRedeem', + args: [owner], + }, + ) + } + return calls + } + + if (step === 2 && hasOwner) { + if (ctx.balance === undefined) return [] + return [ + { + key: KEYS.assets, + target: vault, + abi: erc4626Abi, + functionName: 'convertToAssets', + args: [ctx.balance], + }, + ] + } + + return [] + }, + + consumeStepResults(step, results: StepResult[]) { + for (const result of results) { + if (result.status === 'failure') continue + // TypeScript narrows result to the success branch here. + // exactOptionalPropertyTypes: only assign when the value is defined. + if (step === 1) { + const sym = result.key === KEYS.symbol ? asString(result.value) : undefined + if (sym !== undefined) ctx.symbol = sym + const dec = result.key === KEYS.decimals ? asNumber(result.value) : undefined + if (dec !== undefined) ctx.decimals = dec + const asset = result.key === KEYS.asset ? asAddress(result.value) : undefined + if (asset !== undefined) ctx.underlyingAsset = asset + if (hasOwner) { + const bal = result.key === KEYS.balance ? asBigInt(result.value) : undefined + if (bal !== undefined) ctx.balance = bal + const mw = result.key === KEYS.maxWithdraw ? asBigInt(result.value) : undefined + if (mw !== undefined) ctx.maxWithdraw = mw + const mr = result.key === KEYS.maxRedeem ? asBigInt(result.value) : undefined + if (mr !== undefined) ctx.maxRedeem = mr + } + } + if (step === 2 && result.key === KEYS.assets) { + const assets = asBigInt(result.value) + if (assets !== undefined) ctx.assets = assets + } + } + }, + + finalize(): Erc4626VaultResolution { + return { + metadata: { + symbol: ctx.symbol, + decimals: ctx.decimals, + underlyingAsset: ctx.underlyingAsset, + maxWithdraw: ctx.maxWithdraw, + maxRedeem: ctx.maxRedeem, + }, + position: + hasOwner && ctx.balance !== undefined + ? { + balance: ctx.balance, + assets: ctx.assets, + // exactOptionalPropertyTypes: conditional spread only when defined + ...(ctx.maxWithdraw !== undefined ? { maxWithdraw: ctx.maxWithdraw } : {}), + ...(ctx.maxRedeem !== undefined ? { maxRedeem: ctx.maxRedeem } : {}), + } + : undefined, + } + }, + + [SINGLE_USE]: true, + } as MultistepTask & SingleUseCarrier +} diff --git a/src/__tests__/live/handlers-fork.g1.test.ts b/src/__tests__/live/handlers-fork.g1.test.ts new file mode 100644 index 0000000..070abae --- /dev/null +++ b/src/__tests__/live/handlers-fork.g1.test.ts @@ -0,0 +1,80 @@ +/** + * G1 parity gate 2 — live mainnet fork test. + * + * Resolves a real ERC4626 vault (sDAI) and a real ERC20 token (USDC) through + * BOTH the legacy oracle (`src/__tests__/fixtures/legacy-handlers/`) and the + * new `defineTask`-based implementation (`src/handlers/`), via a real + * `Eip1193Executor` talking to an actual RPC endpoint — `toStrictEqual`s the + * two results against LIVE chain data, not mocks. + * + * **How to run:** + * ```sh + * RPC_URL=https://your-mainnet-rpc npm test -- --run src/__tests__/live/ + * ``` + * + * Gated behind `describe.runIf(!!process.env.RPC_URL)` — CI has no RPC + * secret configured, so this suite cleanly SKIPS (not fails) whenever + * `RPC_URL` is unset, including in the default `npm test` run. The merge + * gate for G1 is the offline parity fixtures (`src/__tests__/parity-g1.test.ts`); + * this file is an additional, opt-in confidence check against real chain + * state, run manually or in an environment with network egress. + */ + +import { describe, it, expect } from 'vitest' +import { Eip1193Executor } from '../../engine/eip1193' +import { resolveErc20Token } from '../../handlers/erc20' +import { resolveErc4626Vault } from '../../handlers/erc4626' +import { buildErc20TaskLegacy } from '../fixtures/legacy-handlers/erc20' +import { buildErc4626TaskLegacy } from '../fixtures/legacy-handlers/erc4626' +import { runMultistepTasks } from '../../core/runMultistepTasks' +import type { Address, Eip1193Provider } from '../../core/types' + +const RPC_URL = process.env['RPC_URL'] + +// Real mainnet contracts — sDAI (ERC4626) and USDC (ERC20). +const SDAI = '0x83F20F44975D03b1b09e64809B757c47f942BEeA' as Address +const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb004C35d5Cc4' as Address +// Any address works as an "owner" for this test — sDAI's maxWithdraw/maxRedeem +// and USDC's balanceOf both resolve to 0 for a non-holder, which is enough to +// exercise the with-owner code path identically on both sides. +const ZERO_OWNER = '0x0000000000000000000000000000000000000000' as Address + +/** Minimal fetch-based EIP-1193 shim — JSON-RPC POST per `request()` call. */ +function makeFetchProvider(url: string): Eip1193Provider { + return { + async request({ method, params }): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? [] }), + }) + const body = (await res.json()) as { result?: unknown; error?: { message: string } } + if (body.error) throw new Error(body.error.message) + return body.result + }, + } +} + +describe.runIf(!!RPC_URL)('G1 live fork parity (RPC_URL set)', () => { + it('resolveErc4626Vault (sDAI) : new impl matches legacy oracle against live chain state', async () => { + const executor = new Eip1193Executor(makeFetchProvider(RPC_URL!)) + + const fresh = await resolveErc4626Vault({ executor, vault: SDAI, owner: ZERO_OWNER }) + const [legacy] = await runMultistepTasks(executor, [ + buildErc4626TaskLegacy({ vault: SDAI, owner: ZERO_OWNER }), + ]) + + expect(fresh).toStrictEqual(legacy) + }) + + it('resolveErc20Token (USDC) : new impl matches legacy oracle against live chain state', async () => { + const executor = new Eip1193Executor(makeFetchProvider(RPC_URL!)) + + const fresh = await resolveErc20Token({ executor, token: USDC, owner: ZERO_OWNER }) + const [legacy] = await runMultistepTasks(executor, [ + buildErc20TaskLegacy({ token: USDC, owner: ZERO_OWNER }), + ]) + + expect(fresh).toStrictEqual(legacy) + }) +}) diff --git a/src/__tests__/multistepMulticall.test.ts b/src/__tests__/multistepMulticall.test.ts index 9ba0130..9552fad 100644 --- a/src/__tests__/multistepMulticall.test.ts +++ b/src/__tests__/multistepMulticall.test.ts @@ -394,8 +394,11 @@ describe('runMultistepTasks', () => { const mockExecutor: StepExecutor = { async executeMulticall(calls: StepCall[]): Promise { batchCounts.push(calls.length) + // Route by functionName, not `key` — post-G1 the handler's routing + // `key` is an internal defineTask-assigned id, no longer the legacy + // semantic string ('decimals'). return calls.map((c) => - c.key === 'decimals' + c.functionName === 'decimals' ? { status: 'success' as const, value: 6 } : { status: 'success' as const, value: 'TOK' }, ) diff --git a/src/__tests__/parity-g1.test.ts b/src/__tests__/parity-g1.test.ts new file mode 100644 index 0000000..bcec8f1 --- /dev/null +++ b/src/__tests__/parity-g1.test.ts @@ -0,0 +1,695 @@ +/** + * G1 parity gate — new (`defineTask`-based) `buildErc20Task`/`buildErc4626Task` + * vs. the pre-migration legacy oracle + * (`src/__tests__/fixtures/legacy-handlers/{erc20,erc4626}.ts`). + * + * Every scenario below runs the IDENTICAL positional mock `RawResult[]` + * sequence through both implementations (via the same `runMultistepTasks` + * runner) and asserts `expect(fresh).toStrictEqual(legacy)` — bigints, + * key presence (absent vs. `undefined`), and array order all distinguished. + * See `src/handlers/erc4626.ts`'s module doc for why `position`'s + * conditional-spread keys (T11) are the subtlest trap here. + * + * **Accepted deltas (documented, asserted explicitly below, NOT + * parity-breaking):** + * 1. Under `runSettled`, the new impl populates + * `diagnostics.optionalFailures` (legacy always reports `[]` — it + * carries no diagnostics channel at all). Resolved VALUES still match. + * 2. New handler calls are dedup-ELIGIBLE (`TypedCallSpec`-compiled) — + * `{ dedupe: true }` can now merge identical calls across bulk entries; + * legacy hand-authored `StepCall`s are never eligible. This is a + * non-parity, opt-in behavior upgrade (default `dedupe` is `false`, so + * every `toStrictEqual` scenario above runs at defaults / dedup off). + */ + +import { describe, it, expect, vi } from 'vitest' +import { runMultistepTasks } from '../core/runMultistepTasks' +import { runSettled } from '../core/runSettled' +import { DominoCallError } from '../core/errors' +import { buildErc20Task } from '../handlers/erc20' +import { buildErc4626Task } from '../handlers/erc4626' +import { buildErc20TaskLegacy } from './fixtures/legacy-handlers/erc20' +import { buildErc4626TaskLegacy } from './fixtures/legacy-handlers/erc4626' +import type { Address, MultistepTask, RawResult, StepExecutor } from '../core/types' + +const TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb004C35d5Cc4' as Address +const TOKEN2 = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' as Address +const TOKEN3 = '0x6B175474E89094C44Da98b954EedeAC495271d0F' as Address +const OWNER = '0x1234567890123456789012345678901234567890' as Address +const VAULT = '0x7f39c5812d3f46fCEa82257f5aE43fF59E7E9F8a' as Address +const ASSET = '0xae7ab96520de3a18e5e111b5eaab095312d7fe84' as Address + +function mockExecutorFrom(steps: RawResult[][]): StepExecutor { + const fn = vi.fn() + for (const batch of steps) fn.mockResolvedValueOnce(batch) + return { executeMulticall: fn } +} + +/** + * Runs `params` through both the legacy oracle and the new implementation, + * each against its OWN mock executor pre-loaded with the SAME positional + * `steps` sequence — so a call-creation-order mismatch between the two + * builders would desync the mock and surface as a wrong value, not just a + * shape mismatch. + */ +async function runBoth( + buildLegacy: (p: P) => MultistepTask, + buildNew: (p: P) => MultistepTask, + params: P, + steps: RawResult[][], +): Promise<{ legacy: R; fresh: R; legacyExecutor: StepExecutor; freshExecutor: StepExecutor }> { + const legacyExecutor = mockExecutorFrom(steps) + const freshExecutor = mockExecutorFrom(steps) + const [legacy] = await runMultistepTasks(legacyExecutor, [buildLegacy(params)]) + const [fresh] = await runMultistepTasks(freshExecutor, [buildNew(params)]) + return { legacy: legacy!, fresh: fresh!, legacyExecutor, freshExecutor } +} + +describe('G1 parity — erc20', () => { + it('without owner : symbol + decimals succeed', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN }, + [[ + { status: 'success', value: 'USDC' }, + { status: 'success', value: 6n }, // bigint on the wire — both impls coerce via asNumber + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: 'USDC', decimals: 6, balance: undefined }) + }) + + it('with owner : symbol + decimals + balance succeed', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + [[ + { status: 'success', value: 'USDC' }, + { status: 'success', value: 6n }, + { status: 'success', value: 1_000_000n }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: 'USDC', decimals: 6, balance: 1_000_000n }) + }) + + it('symbol call fails individually : demotes to undefined, siblings unaffected', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + [[ + { status: 'failure' }, + { status: 'success', value: 6n }, + { status: 'success', value: 1_000_000n }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: undefined, decimals: 6, balance: 1_000_000n }) + }) + + it('decimals call fails individually : demotes to undefined, siblings unaffected', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + [[ + { status: 'success', value: 'USDC' }, + { status: 'failure' }, + { status: 'success', value: 1_000_000n }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: 'USDC', decimals: undefined, balance: 1_000_000n }) + }) + + it('balance call fails individually : demotes to undefined, siblings unaffected', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + [[ + { status: 'success', value: 'USDC' }, + { status: 'success', value: 6n }, + { status: 'failure' }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: 'USDC', decimals: 6, balance: undefined }) + }) + + it('all calls fail : every field undefined', async () => { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + [[ + { status: 'failure' }, + { status: 'failure' }, + { status: 'failure' }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ symbol: undefined, decimals: undefined, balance: undefined }) + }) + + it('bulk (3 entries, batchSize 2) : slicing parity across 5 physical batches', async () => { + // 3 entries x 3 calls (symbol, decimals, balanceOf) = 9 calls -> batches of 2,2,2,2,1 + const entries = [ + { token: TOKEN, owner: OWNER }, + { token: TOKEN2, owner: OWNER }, + { token: TOKEN3, owner: OWNER }, + ] + const flat: RawResult[] = [ + { status: 'success', value: 'AAA' }, + { status: 'success', value: 6n }, + { status: 'success', value: 1n }, + { status: 'success', value: 'BBB' }, + { status: 'success', value: 18n }, + { status: 'success', value: 2n }, + { status: 'success', value: 'CCC' }, + { status: 'success', value: 8n }, + { status: 'success', value: 3n }, + ] + const batchSize = 2 + const batches: RawResult[][] = [] + for (let i = 0; i < flat.length; i += batchSize) batches.push(flat.slice(i, i + batchSize)) + + const legacyExecutor = mockExecutorFrom(batches) + const freshExecutor = mockExecutorFrom(batches) + + const legacyResults = await runMultistepTasks( + legacyExecutor, + entries.map((e) => buildErc20TaskLegacy(e)), + { batchSize }, + ) + const freshResults = await runMultistepTasks( + freshExecutor, + entries.map((e) => buildErc20Task(e)), + { batchSize }, + ) + + expect(freshResults).toStrictEqual(legacyResults) + expect(freshResults).toStrictEqual([ + { symbol: 'AAA', decimals: 6, balance: 1n }, + { symbol: 'BBB', decimals: 18, balance: 2n }, + { symbol: 'CCC', decimals: 8, balance: 3n }, + ]) + expect((legacyExecutor.executeMulticall as ReturnType).mock.calls).toHaveLength(5) + expect((freshExecutor.executeMulticall as ReturnType).mock.calls).toHaveLength(5) + }) +}) + +describe('G1 parity — erc4626', () => { + it('without owner : symbol + decimals + asset succeed, single step', async () => { + const { legacy, fresh, legacyExecutor, freshExecutor } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT }, + [[ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ + metadata: { symbol: 'wstETH', decimals: 18, underlyingAsset: ASSET, maxWithdraw: undefined, maxRedeem: undefined }, + position: undefined, + }) + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(1) + }) + + it('with owner : full 2-step resolution, all succeed', async () => { + const { legacy, fresh, legacyExecutor, freshExecutor } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + [ + [ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 500_000_000_000_000_000n }, + { status: 'success', value: 1_000_000_000_000_000_000n }, + { status: 'success', value: 900_000_000_000_000_000n }, + ], + [{ status: 'success', value: 501_234_567_890_123_456n }], + ], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ + metadata: { + symbol: 'wstETH', + decimals: 18, + underlyingAsset: ASSET, + maxWithdraw: 1_000_000_000_000_000_000n, + maxRedeem: 900_000_000_000_000_000n, + }, + position: { + balance: 500_000_000_000_000_000n, + assets: 501_234_567_890_123_456n, + maxWithdraw: 1_000_000_000_000_000_000n, + maxRedeem: 900_000_000_000_000_000n, + }, + }) + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(2) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(2) + }) + + const step1Fields: { name: string; index: number; expectSuccess: Omit }[] = [ + { name: 'symbol', index: 0, expectSuccess: { value: 'wstETH' } }, + { name: 'decimals', index: 1, expectSuccess: { value: 18n } }, + { name: 'asset', index: 2, expectSuccess: { value: ASSET } }, + { name: 'balanceOf', index: 3, expectSuccess: { value: 500_000_000_000_000_000n } }, + { name: 'maxWithdraw', index: 4, expectSuccess: { value: 1_000_000_000_000_000_000n } }, + { name: 'maxRedeem', index: 5, expectSuccess: { value: 900_000_000_000_000_000n } }, + ] + + for (const field of step1Fields) { + it(`${field.name} call fails individually (with owner) : demotes to undefined${field.name === 'balanceOf' ? ', step 2 skipped' : ''}`, async () => { + const base: RawResult[] = [ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 500_000_000_000_000_000n }, + { status: 'success', value: 1_000_000_000_000_000_000n }, + { status: 'success', value: 900_000_000_000_000_000n }, + ] + const step1 = base.map((r, i): RawResult => (i === field.index ? { status: 'failure' } : r)) + // Step 2 (convertToAssets) is only ever dispatched if balanceOf succeeded. + const steps: RawResult[][] = field.name === 'balanceOf' + ? [step1] + : [step1, [{ status: 'success', value: 501_234_567_890_123_456n }]] + + const { legacy, fresh, legacyExecutor, freshExecutor } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + steps, + ) + + expect(fresh).toStrictEqual(legacy) + if (field.name === 'balanceOf') { + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(fresh.position).toBeUndefined() + } else { + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(2) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(2) + } + }) + } + + it('convertToAssets (step 2) fails : assets undefined, balance/maxWithdraw/maxRedeem unaffected', async () => { + const { legacy, fresh } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + [ + [ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 500_000_000_000_000_000n }, + { status: 'success', value: 1_000_000_000_000_000_000n }, + { status: 'success', value: 900_000_000_000_000_000n }, + ], + [{ status: 'failure' }], + ], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh.position).toStrictEqual({ + balance: 500_000_000_000_000_000n, + assets: undefined, + maxWithdraw: 1_000_000_000_000_000_000n, + maxRedeem: 900_000_000_000_000_000n, + }) + }) + + it('all step-1 calls fail (with owner) : metadata all undefined, position undefined, no step 2', async () => { + const { legacy, fresh, legacyExecutor, freshExecutor } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + [[ + { status: 'failure' }, + { status: 'failure' }, + { status: 'failure' }, + { status: 'failure' }, + { status: 'failure' }, + { status: 'failure' }, + ]], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh).toStrictEqual({ + metadata: { + symbol: undefined, + decimals: undefined, + underlyingAsset: undefined, + maxWithdraw: undefined, + maxRedeem: undefined, + }, + position: undefined, + }) + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(1) + }) + + it('bulk (3 entries, batchSize 2) : slicing parity across steps 1 and 2', async () => { + const vault2 = '0x21dD1dB4FE11338FDE9Bf81DDCd046e228B436F5' as Address + const vault3 = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419' as Address + const entries = [ + { vault: VAULT, owner: OWNER }, + { vault: vault2, owner: OWNER }, + { vault: vault3, owner: OWNER }, + ] + + // Step 1: 6 calls x 3 entries = 18 -> batchSize 2 -> 9 batches + const step1Flat: RawResult[] = [ + { status: 'success', value: 'AAA' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 1n }, + { status: 'success', value: 10n }, + { status: 'success', value: 11n }, + { status: 'success', value: 'BBB' }, + { status: 'success', value: 8n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 2n }, + { status: 'success', value: 20n }, + { status: 'success', value: 21n }, + { status: 'success', value: 'CCC' }, + { status: 'success', value: 6n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 3n }, + { status: 'success', value: 30n }, + { status: 'success', value: 31n }, + ] + // Step 2: 1 call x 3 entries = 3 -> batchSize 2 -> 2 batches + const step2Flat: RawResult[] = [ + { status: 'success', value: 100n }, + { status: 'success', value: 200n }, + { status: 'success', value: 300n }, + ] + + const batchSize = 2 + const batchesOf = (flat: RawResult[]): RawResult[][] => { + const out: RawResult[][] = [] + for (let i = 0; i < flat.length; i += batchSize) out.push(flat.slice(i, i + batchSize)) + return out + } + const steps = [...batchesOf(step1Flat), ...batchesOf(step2Flat)] + + const legacyExecutor = mockExecutorFrom(steps) + const freshExecutor = mockExecutorFrom(steps) + + const legacyResults = await runMultistepTasks( + legacyExecutor, + entries.map((e) => buildErc4626TaskLegacy(e)), + { batchSize }, + ) + const freshResults = await runMultistepTasks( + freshExecutor, + entries.map((e) => buildErc4626Task(e)), + { batchSize }, + ) + + expect(freshResults).toStrictEqual(legacyResults) + expect(freshResults[0]?.position).toStrictEqual({ + balance: 1n, + assets: 100n, + maxWithdraw: 10n, + maxRedeem: 11n, + }) + expect(freshResults[2]?.position).toStrictEqual({ + balance: 3n, + assets: 300n, + maxWithdraw: 30n, + maxRedeem: 31n, + }) + }) +}) + +describe('G1 parity — malformed-success values (external review P1)', () => { + // A "successful"-but-malformed executor value (right `status`, wrong + // runtime type — e.g. `balanceOf` resolving to a string) is what the + // legacy oracle's `asString`/`asNumber`/`asBigInt`/`asAddress` helpers + // defensively coerce to `undefined`. The new impl must do the same for + // every field (see `src/handlers/erc20.ts`/`erc4626.ts`'s coercion + // derives) AND — for `balanceOf` specifically, since its value feeds + // `convertToAssets`'s arg — must never dispatch that dependent call with + // the bogus value (`src/core/defineTask.ts`'s call-mode `resolveAll` + // v-undefined skip-chain, external review P1). + + it('erc20 : symbol/decimals/balance each malformed individually still toStrictEqual the legacy-coerced result', async () => { + // symbol: bigint instead of string : decimals: non-numeric string : balance: non-bigint string. + const malformedCases: { name: string; steps: RawResult[][] }[] = [ + { + name: 'symbol malformed (bigint instead of string)', + steps: [[ + { status: 'success', value: 42n }, + { status: 'success', value: 6n }, + { status: 'success', value: 1_000_000n }, + ]], + }, + { + name: 'decimals malformed (non-numeric string)', + steps: [[ + { status: 'success', value: 'USDC' }, + { status: 'success', value: 'not-a-number' }, + { status: 'success', value: 1_000_000n }, + ]], + }, + { + name: 'balance malformed (string instead of bigint)', + steps: [[ + { status: 'success', value: 'USDC' }, + { status: 'success', value: 6n }, + { status: 'success', value: 'not-a-bigint' }, + ]], + }, + ] + + for (const { steps } of malformedCases) { + const { legacy, fresh } = await runBoth( + buildErc20TaskLegacy, + buildErc20Task, + { token: TOKEN, owner: OWNER }, + steps, + ) + expect(fresh).toStrictEqual(legacy) + } + + // Concrete expected shapes, spelled out (not just cross-impl equality): + expect( + (await runBoth(buildErc20TaskLegacy, buildErc20Task, { token: TOKEN, owner: OWNER }, malformedCases[0]!.steps)) + .fresh, + ).toStrictEqual({ symbol: undefined, decimals: 6, balance: 1_000_000n }) + expect( + (await runBoth(buildErc20TaskLegacy, buildErc20Task, { token: TOKEN, owner: OWNER }, malformedCases[1]!.steps)) + .fresh, + ).toStrictEqual({ symbol: 'USDC', decimals: undefined, balance: 1_000_000n }) + expect( + (await runBoth(buildErc20TaskLegacy, buildErc20Task, { token: TOKEN, owner: OWNER }, malformedCases[2]!.steps)) + .fresh, + ).toStrictEqual({ symbol: 'USDC', decimals: 6, balance: undefined }) + }) + + const malformedStep1Fields: { name: string; index: number; malformedValue: unknown }[] = [ + { name: 'symbol', index: 0, malformedValue: 42n }, + { name: 'decimals', index: 1, malformedValue: 'not-a-number' }, + { name: 'asset', index: 2, malformedValue: 42n }, // not a '0x...' string + { name: 'balanceOf', index: 3, malformedValue: 'not-a-bigint' }, + { name: 'maxWithdraw', index: 4, malformedValue: 'not-a-bigint' }, + { name: 'maxRedeem', index: 5, malformedValue: 'not-a-bigint' }, + ] + + for (const field of malformedStep1Fields) { + it(`erc4626 : ${field.name} malformed (success, wrong runtime type) : coerces to undefined like legacy${ + field.name === 'balanceOf' ? ', step 2 not dispatched, position undefined, fulfilled under run() AND runSettled' : '' + }`, async () => { + const wellFormed: RawResult[] = [ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 500_000_000_000_000_000n }, + { status: 'success', value: 1_000_000_000_000_000_000n }, + { status: 'success', value: 900_000_000_000_000_000n }, + ] + const step1 = wellFormed.map((r, i): RawResult => + i === field.index ? { status: 'success', value: field.malformedValue } : r, + ) + // Step 2 is only ever dispatched if balanceOf resolved to a genuine bigint. + const steps: RawResult[][] = field.name === 'balanceOf' + ? [step1] + : [step1, [{ status: 'success', value: 501_234_567_890_123_456n }]] + + const { legacy, fresh, legacyExecutor, freshExecutor } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + steps, + ) + + expect(fresh).toStrictEqual(legacy) + + if (field.name === 'balanceOf') { + // Codex repro, closed: malformed balance must NOT dispatch + // convertToAssets with a bogus arg — executor call-count parity + // with legacy (which also never issues a step-2 call here). + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(1) + expect(fresh.position).toBeUndefined() + + // Fulfilled (never rejects) under BOTH run() (already proven above, + // since `runBoth` uses `runMultistepTasks`/`run()`) AND runSettled. + const settledExecutor = mockExecutorFrom(steps) + const [settled] = await runSettled(settledExecutor, [buildErc4626Task({ vault: VAULT, owner: OWNER })]) + expect(settled!.status).toBe('fulfilled') + const settledValue = (settled as { status: 'fulfilled'; value: typeof fresh }).value + expect(settledValue).toStrictEqual(fresh) + + // Diagnostics: malformed values add NO optionalFailures noise beyond + // the documented accepted-delta extension — exactly ONE entry, for + // `convertToAssets`'s v-undefined skip (never for `balanceOf` itself, + // which genuinely SUCCEEDED at the StepResult level; only its + // COERCED value was rejected). The synthesized cause is the + // "argument resolved to undefined" DominoCallError from + // `src/core/defineTask.ts`'s call-mode `resolveAll`. + expect(settled!.diagnostics.optionalFailures).toHaveLength(1) + const entry = settled!.diagnostics.optionalFailures[0]! + expect(entry.functionName).toBe('convertToAssets') + expect(entry.error.kind).toBe('skipped') + expect(entry.error.cause).toBeInstanceOf(DominoCallError) + expect((entry.error.cause as DominoCallError).message).toBe('argument resolved to undefined') + } else { + expect(legacyExecutor.executeMulticall).toHaveBeenCalledTimes(2) + expect(freshExecutor.executeMulticall).toHaveBeenCalledTimes(2) + + // No optionalFailures noise at all for the other 5 fields — each + // one's OWN call succeeded; only its coercion derive rejected the + // value, and nothing downstream depends on it. + const settledExecutor = mockExecutorFrom(steps) + const [settled] = await runSettled(settledExecutor, [buildErc4626Task({ vault: VAULT, owner: OWNER })]) + expect(settled!.status).toBe('fulfilled') + expect(settled!.diagnostics.optionalFailures).toHaveLength(0) + } + }) + } + + it('erc4626 : convertToAssets (step 2) malformed success value : assets undefined, no cascade (nothing depends on it)', async () => { + const { legacy, fresh } = await runBoth( + buildErc4626TaskLegacy, + buildErc4626Task, + { vault: VAULT, owner: OWNER }, + [ + [ + { status: 'success', value: 'wstETH' }, + { status: 'success', value: 18n }, + { status: 'success', value: ASSET }, + { status: 'success', value: 500_000_000_000_000_000n }, + { status: 'success', value: 1_000_000_000_000_000_000n }, + { status: 'success', value: 900_000_000_000_000_000n }, + ], + [{ status: 'success', value: 'not-a-bigint' }], + ], + ) + expect(fresh).toStrictEqual(legacy) + expect(fresh.position).toStrictEqual({ + balance: 500_000_000_000_000_000n, + assets: undefined, + maxWithdraw: 1_000_000_000_000_000_000n, + maxRedeem: 900_000_000_000_000_000n, + }) + }) +}) + +describe('G1 accepted delta 1 — runSettled diagnostics', () => { + it('new impl retains the executor-produced DominoCallError in diagnostics.optionalFailures; legacy always reports []', async () => { + const revertError = new DominoCallError('execution reverted', { + kind: 'revert', + data: '0x08c379a0', + target: TOKEN, + functionName: 'symbol', + }) + + const steps: RawResult[][] = [[ + { status: 'failure', error: revertError }, + { status: 'success', value: 6n }, + { status: 'success', value: 1_000_000n }, + ]] + + const legacyExecutor = mockExecutorFrom(steps) + const freshExecutor = mockExecutorFrom(steps) + + const [legacySettled] = await runSettled(legacyExecutor, [ + buildErc20TaskLegacy({ token: TOKEN, owner: OWNER }), + ]) + const [freshSettled] = await runSettled(freshExecutor, [ + buildErc20Task({ token: TOKEN, owner: OWNER }), + ]) + + // VALUES still match legacy — the delta is diagnostics-only. + expect(legacySettled!.status).toBe('fulfilled') + expect(freshSettled!.status).toBe('fulfilled') + const legacyValue = (legacySettled as { status: 'fulfilled'; value: unknown }).value + const freshValue = (freshSettled as { status: 'fulfilled'; value: unknown }).value + expect(freshValue).toStrictEqual(legacyValue) + expect(freshValue).toStrictEqual({ symbol: undefined, decimals: 6, balance: 1_000_000n }) + + // Documented accepted delta: legacy carries no diagnostics channel at all. + expect(legacySettled!.diagnostics).toStrictEqual({ optionalFailures: [] }) + + // New impl: the executor-produced error passes through with kind/data + // toBe-identical and the cause chain preserved (failure-fixture clause). + expect(freshSettled!.diagnostics.optionalFailures).toHaveLength(1) + const entry = freshSettled!.diagnostics.optionalFailures[0]! + expect(entry.error).toBe(revertError) + expect(entry.error.kind).toBe(revertError.kind) + expect(entry.error.data).toBe(revertError.data) + expect(entry.error.cause).toBe(revertError.cause) + expect(entry.target).toBe(TOKEN) + expect(entry.functionName).toBe('symbol') + }) +}) + +describe('G1 accepted delta 2 — dedup eligibility (behavior upgrade, non-parity)', () => { + it('bulk + dedupe:true merges identical metadata calls across entries sharing the same token/owner', async () => { + const invocations: unknown[][] = [] + const executor: StepExecutor = { + async executeMulticall(calls) { + invocations.push(calls) + return calls.map((c): RawResult => { + if (c.functionName === 'symbol') return { status: 'success', value: 'USDC' } + if (c.functionName === 'decimals') return { status: 'success', value: 6n } + return { status: 'success', value: 42n } // balanceOf + }) + }, + } + + const entries = [ + { token: TOKEN, owner: OWNER }, + { token: TOKEN, owner: OWNER }, // identical target+owner -> merges under dedupe + ] + + const results = await runMultistepTasks( + executor, + entries.map((e) => buildErc20Task(e)), + { dedupe: true }, + ) + + // Without dedupe this would be 6 calls (3 per entry); with dedupe, the 3 + // distinct (target, calldata, outputSignature) keys merge to 3 physical + // calls, fanned out to both subscribers. + expect(invocations).toHaveLength(1) + expect(invocations[0]).toHaveLength(3) + expect(results).toStrictEqual([ + { symbol: 'USDC', decimals: 6, balance: 42n }, + { symbol: 'USDC', decimals: 6, balance: 42n }, + ]) + }) +}) diff --git a/src/core/defineTask.ts b/src/core/defineTask.ts index ebe3dc2..e413287 100644 --- a/src/core/defineTask.ts +++ b/src/core/defineTask.ts @@ -163,6 +163,14 @@ interface Node { * the same as `f`, see `resolveAll`). `f` = hard failure (own call failed * non-optionally, a derive threw, or skip-chained from an `f`/`u` upstream). * The original error is always preserved on `u`/`f` (never discarded). + * + * Note a `'v'` state's `value` can ALSO legitimately be `undefined` (a + * derive that computed `undefined` on purpose — e.g. a handler's coercion + * derive demoting a malformed executor value). That is not a distinct `St` + * variant — it is still `'v'` — but `resolveAll`, in call-mode only, treats + * a `'v'`-with-`undefined`-value input the same as `'u'`/`'f'` (a call + * cannot encode `undefined` regardless of which of the three reasons + * produced it). See `resolveAll`'s own doc comment. */ type St = | { readonly k: 'v'; readonly value: unknown } @@ -369,17 +377,43 @@ export function defineTask(build: (t: TaskBuilder) => S): MultistepTask * Shared by both `res`'s derive-input loop and `buildStepCalls`' target+arg * loop: resolve every position in `xs` (a plain value resolves to itself, * a `RefHandle` resolves via `res`), short-circuiting on the first - * unusable one. `deriveMode` controls the ONE behavioral difference - * between the two callers (see `St`'s doc comment): an `'u'` position - * resolves to `undefined` and the loop continues when `deriveMode` is - * true (a derive CAN consume `undefined`); otherwise `'u'` is treated - * exactly like `'f'` (a call cannot encode `undefined`). + * unusable one. `deriveMode` controls the behavioral difference between + * the two callers (see `St`'s doc comment): + * + * - An `'u'` position resolves to `undefined` and the loop continues + * when `deriveMode` is true (a derive CAN consume `undefined`); + * otherwise `'u'` is treated exactly like `'f'` (a call cannot encode + * `undefined`) — its ORIGINAL error (`r.error`, the upstream call's + * own `DominoCallError`) is forwarded as `c` unchanged, preserving + * today's cause chain. + * - (External review, P1) A `'v'` position whose VALUE happens to be + * `undefined` — most commonly a handler's coercion derive + * legitimately demoting a malformed/unexpected executor value to + * `undefined` — is ALSO unusable, but ONLY in call-mode: a call + * cannot encode `undefined` any more than it can encode a `'u'`/`'f'` + * state, regardless of WHY the value is `undefined`. Without this, a + * "successful"-but-malformed upstream value could reach as far as the + * executor's own arg encoder (`Eip1193Executor` encodes args + * synchronously inside `executeMulticall`) — a bad argument there can + * reject the WHOLE physical batch, not just the one call that + * depended on it. There is no real upstream error to forward here + * (nothing failed — the value is genuinely, successfully + * `undefined`), so a fresh `DominoCallError` is synthesized + * (`kind: 'skipped'`, message "argument resolved to undefined") to + * serve as `c` (and, via `skip()`, the eventual skip error's + * `cause`). In derive-mode this branch never triggers — a `'v'` + * position (undefined or not) is always pushed straight through, same + * as before: derives legitimately consume `undefined`, that's the + * whole point of `deriveMode`. */ function resolveAll(xs: readonly unknown[], deriveMode?: boolean): { vs: unknown[]; c?: E } { const vs: unknown[] = [] for (const x of xs) { const r: St = isRefHandle(x) ? res(x.id) : { k: 'v', value: x } if (r.k === 'f' || (r.k === 'u' && !deriveMode)) return { vs, c: r.error } + if (!deriveMode && r.k === 'v' && r.value === undefined) { + return { vs, c: new E('argument resolved to undefined', { kind: 'skipped' }) } + } vs.push(r.k === 'u' ? undefined : r.value) } return { vs } diff --git a/src/handlers/erc20.ts b/src/handlers/erc20.ts index 1326bba..0d115bc 100644 --- a/src/handlers/erc20.ts +++ b/src/handlers/erc20.ts @@ -6,12 +6,30 @@ * * Single-step task: * Step 1: symbol(), decimals(), balanceOf(owner?) + * + * (G1) Internally reimplemented on `defineTask` — public `buildErc20Task`/ + * `resolveErc20*` signatures and return shapes are unchanged from 1.0. Every + * contract call is `optional: true`, replicating 1.0's silent-undefined- + * per-field semantics: a failed call demotes to `undefined` in the result + * instead of rejecting the whole resolution. The pre-migration hand-written + * implementation is preserved as the parity-test oracle at + * `src/__tests__/fixtures/legacy-handlers/erc20.ts` (deleted after one minor + * — see its header comment). + * + * **Accepted behavioral delta (documented, not parity-breaking — see + * `src/__tests__/parity-g1.test.ts`):** under `runSettled`, each `optional` + * call's `DominoCallError` is now retained in `diagnostics.optionalFailures` + * instead of being silently destroyed (the legacy implementation carried no + * diagnostics channel at all, so `runSettled` always reported `[]` for these + * tasks). Resolved VALUES are byte-for-byte unchanged either way. New calls + * are also dedup-ELIGIBLE (`TypedCallSpec`-compiled) — `{ dedupe: true }` + * can now merge identical calls across bulk entries; legacy hand-authored + * `StepCall`s were never eligible. */ -import type { Address, MultistepTask, StepCall, StepResult, BlockParam } from '../core/types' +import type { Address, MultistepTask, BlockParam } from '../core/types' +import { defineTask } from '../core/defineTask' import { runMultistepTasks } from '../core/runMultistepTasks' -import { SINGLE_USE } from '../core/internal' -import type { SingleUseCarrier } from '../core/internal' import type { ExecutorParam } from './executorParam' import { resolveExecutor } from './executorParam' @@ -48,17 +66,14 @@ export interface Erc20TokenResolution { balance: bigint | undefined } -type Erc20Context = { - symbol?: string - decimals?: number - balance?: bigint -} - // ─── Internal helpers ───────────────────────────────────────────────────────── -// Typed accessor helpers — safe coercion from the untyped RawResult.value. -// These replace `as T` casts; returning undefined instead of producing wrong data -// when an executor returns an unexpected value type. +// Typed accessor helpers — safe coercion from the untyped call result. These +// replace `as T` casts; returning undefined instead of producing wrong data +// when an executor returns an unexpected value type. Unchanged from the +// pre-defineTask implementation (see the legacy oracle) — reused here via +// `t.derive` so 1.0's defensive coercion behavior survives the migration +// byte-for-byte (this is what the compat suite pins). const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) const asBigInt = (v: unknown): bigint | undefined => (typeof v === 'bigint' ? v : undefined) const asNumber = (v: unknown): number | undefined => { @@ -66,14 +81,6 @@ const asNumber = (v: unknown): number | undefined => { return Number.isFinite(n) ? n : undefined } -// Routing key constants — typos in key strings would cause silent routing misses; -// using a const object makes them a compile error instead. -const KEYS = { - symbol: 'symbol', - decimals: 'decimals', - balance: 'balance', -} as const - // ─── Domain layer ───────────────────────────────────────────────────────────── // buildErc20Task — pure MultistepTask factory; no orchestration dependency. // Safe to use in custom pipelines, test doubles, and non-engine contexts. @@ -83,56 +90,31 @@ export function buildErc20Task(params: { owner?: Address }): MultistepTask { const { token, owner } = params - const ctx: Erc20Context = {} - - return { - maxStep: 1, - - buildStepCalls(step) { - if (step !== 1) return [] - - const calls: StepCall[] = [ - { key: KEYS.symbol, target: token, abi: erc20Abi, functionName: 'symbol' }, - { key: KEYS.decimals, target: token, abi: erc20Abi, functionName: 'decimals' }, - ] - - if (owner) { - calls.push({ - key: KEYS.balance, - target: token, - abi: erc20Abi, - functionName: 'balanceOf', - args: [owner], - }) - } - - return calls - }, - consumeStepResults(_step, results: StepResult[]) { - for (const result of results) { - if (result.status === 'failure') continue - // TypeScript narrows result to the success branch here. - // exactOptionalPropertyTypes: only assign when the value is defined. - const sym = result.key === KEYS.symbol ? asString(result.value) : undefined - if (sym !== undefined) ctx.symbol = sym - const dec = result.key === KEYS.decimals ? asNumber(result.value) : undefined - if (dec !== undefined) ctx.decimals = dec - const bal = result.key === KEYS.balance ? asBigInt(result.value) : undefined - if (bal !== undefined) ctx.balance = bal - } - }, - - finalize() { - return { - symbol: ctx.symbol, - decimals: ctx.decimals, - balance: ctx.balance, - } - }, - - [SINGLE_USE]: true, - } as MultistepTask & SingleUseCarrier + return defineTask((t) => { + // Creation order matters (parity with 1.0's step-1 call order, and with + // positional mock-executor fixtures): symbol, decimals, balanceOf. + const symbolCall = t.call({ target: token, abi: erc20Abi, functionName: 'symbol', optional: true }) + const decimalsCall = t.call({ target: token, abi: erc20Abi, functionName: 'decimals', optional: true }) + + const symbol = t.derive([symbolCall], asString) + const decimals = t.derive([decimalsCall], asNumber) + + if (!owner) { + return { symbol, decimals, balance: undefined } + } + + const balanceCall = t.call({ + target: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + optional: true, + }) + const balance = t.derive([balanceCall], asBigInt) + + return { symbol, decimals, balance } + }) } // ─── Application layer ──────────────────────────────────────────────────────── diff --git a/src/handlers/erc4626.ts b/src/handlers/erc4626.ts index 84f5c7b..d18a6db 100644 --- a/src/handlers/erc4626.ts +++ b/src/handlers/erc4626.ts @@ -7,12 +7,48 @@ * Without owner: Step 1 only (symbol, decimals, asset) * With owner: Step 1 + Step 2 (symbol, decimals, asset, balanceOf, maxWithdraw, * maxRedeem → then convertToAssets(balance)) + * + * (G1) Internally reimplemented on `defineTask` — public `buildErc4626Task`/ + * `resolveErc4626*` signatures and return shapes are unchanged from 1.0. Every + * contract call is `optional: true`, replicating 1.0's silent-undefined- + * per-field semantics — and, crucially, EVERY consumed value (including + * ones that feed another call's `args`, like `balanceOf`'s result feeding + * `convertToAssets`) is routed through the same defensive coercion helper + * (`asString`/`asNumber`/`asBigInt`/`asAddress`, unchanged from the legacy + * oracle) the pre-migration handler used — never the raw call ref. This + * matters beyond output-shape parity: a "successful"-but-malformed executor + * value (e.g. `balanceOf` resolving to a non-bigint) must demote to + * `undefined` exactly like 1.0's `asBigInt`/etc. did, so it can never reach + * as far as `convertToAssets`'s own argument encoding — the core's + * call-mode `resolveAll` (`src/core/defineTask.ts`, external review P1) + * skip-chains a coerced-to-`undefined` argument the same way it already + * skip-chained an outright call failure, so `convertToAssets` is never + * dispatched with a bogus arg either way — matching 1.0's + * `if (ctx.balance === undefined) return []` for BOTH failure modes, not + * just the outright-failure one. + * `position`'s conditional-key shape (T11) is reproduced with `t.derive`: + * `undefined` when balance never resolved, else an object with `maxWithdraw`/ + * `maxRedeem` keys present ONLY when those calls resolved (never + * present-with-`undefined`) — see `src/__tests__/parity-g1.test.ts`, which + * `toStrictEqual`s this against the pre-migration oracle at + * `src/__tests__/fixtures/legacy-handlers/erc4626.ts` (deleted after one + * minor — see its header comment). + * + * **Accepted behavioral delta (documented, not parity-breaking — see the + * parity test):** under `runSettled`, each `optional` call's + * `DominoCallError` is now retained in `diagnostics.optionalFailures` instead + * of being silently destroyed (the legacy implementation carried no + * diagnostics channel at all). Resolved VALUES are byte-for-byte unchanged + * either way. New calls are also dedup-ELIGIBLE (`TypedCallSpec`-compiled) — + * `{ dedupe: true }` can now merge identical calls across bulk entries + * (e.g. two vaults sharing the same owner never merge, since `target` + * differs, but the metadata calls of two entries pointed at the SAME vault + * would); legacy hand-authored `StepCall`s were never eligible. */ -import type { Address, MultistepTask, StepCall, StepResult, BlockParam } from '../core/types' +import type { Address, MultistepTask, BlockParam } from '../core/types' +import { defineTask } from '../core/defineTask' import { runMultistepTasks } from '../core/runMultistepTasks' -import { SINGLE_USE } from '../core/internal' -import type { SingleUseCarrier } from '../core/internal' import type { ExecutorParam } from './executorParam' import { resolveExecutor } from './executorParam' @@ -97,19 +133,12 @@ export interface Erc4626VaultResolution { | undefined } -type Erc4626Context = { - symbol?: string - decimals?: number - balance?: bigint - maxWithdraw?: bigint - maxRedeem?: bigint - underlyingAsset?: Address - assets?: bigint -} - // ─── Internal helpers ───────────────────────────────────────────────────────── -// Typed accessor helpers — safe coercion from the untyped RawResult.value. +// Typed accessor helpers — safe coercion from the untyped call result. +// Unchanged from the pre-defineTask implementation (see the legacy oracle) — +// reused here via `t.derive` so 1.0's defensive coercion behavior survives +// the migration byte-for-byte (this is what the compat suite pins). const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) const asBigInt = (v: unknown): bigint | undefined => (typeof v === 'bigint' ? v : undefined) const asNumber = (v: unknown): number | undefined => { @@ -119,17 +148,6 @@ const asNumber = (v: unknown): number | undefined => { const asAddress = (v: unknown): Address | undefined => typeof v === 'string' && v.startsWith('0x') ? (v as Address) : undefined -// Routing key constants — compile-time protection against typos in key strings. -const KEYS = { - symbol: 'symbol', - decimals: 'decimals', - asset: 'asset', - balance: 'balance', - maxWithdraw: 'maxWithdraw', - maxRedeem: 'maxRedeem', - assets: 'assets', -} as const - // ─── Domain layer ───────────────────────────────────────────────────────────── // buildErc4626Task — pure MultistepTask factory; no orchestration dependency. // Safe to use in custom pipelines, test doubles, and non-engine contexts. @@ -139,115 +157,102 @@ export function buildErc4626Task(params: { owner?: Address }): MultistepTask { const { vault, owner } = params - const ctx: Erc4626Context = {} - const hasOwner = !!owner - return { - maxStep: hasOwner ? 2 : 1, + return defineTask((t) => { + // Creation order matters (parity with 1.0's step-1 call order, and with + // positional mock-executor fixtures): symbol, decimals, asset. + const symbolCall = t.call({ target: vault, abi: erc20Abi, functionName: 'symbol', optional: true }) + const decimalsCall = t.call({ target: vault, abi: erc20Abi, functionName: 'decimals', optional: true }) + const assetCall = t.call({ target: vault, abi: erc4626Abi, functionName: 'asset', optional: true }) - buildStepCalls(step) { - if (step === 1) { - const calls: StepCall[] = [ - { key: KEYS.symbol, target: vault, abi: erc20Abi, functionName: 'symbol' }, - { key: KEYS.decimals, target: vault, abi: erc20Abi, functionName: 'decimals' }, - { key: KEYS.asset, target: vault, abi: erc4626Abi, functionName: 'asset' }, - ] - if (owner) { - calls.push( - { - key: KEYS.balance, - target: vault, - abi: erc20Abi, - functionName: 'balanceOf', - args: [owner], - }, - { - key: KEYS.maxWithdraw, - target: vault, - abi: erc4626Abi, - functionName: 'maxWithdraw', - args: [owner], - }, - { - key: KEYS.maxRedeem, - target: vault, - abi: erc4626Abi, - functionName: 'maxRedeem', - args: [owner], - }, - ) - } - return calls - } - - if (step === 2 && hasOwner) { - if (ctx.balance === undefined) return [] - return [ - { - key: KEYS.assets, - target: vault, - abi: erc4626Abi, - functionName: 'convertToAssets', - args: [ctx.balance], - }, - ] - } + const symbol = t.derive([symbolCall], asString) + const decimals = t.derive([decimalsCall], asNumber) + const underlyingAsset = t.derive([assetCall], asAddress) - return [] - }, - - consumeStepResults(step, results: StepResult[]) { - for (const result of results) { - if (result.status === 'failure') continue - // TypeScript narrows result to the success branch here. - // exactOptionalPropertyTypes: only assign when the value is defined. - if (step === 1) { - const sym = result.key === KEYS.symbol ? asString(result.value) : undefined - if (sym !== undefined) ctx.symbol = sym - const dec = result.key === KEYS.decimals ? asNumber(result.value) : undefined - if (dec !== undefined) ctx.decimals = dec - const asset = result.key === KEYS.asset ? asAddress(result.value) : undefined - if (asset !== undefined) ctx.underlyingAsset = asset - if (hasOwner) { - const bal = result.key === KEYS.balance ? asBigInt(result.value) : undefined - if (bal !== undefined) ctx.balance = bal - const mw = result.key === KEYS.maxWithdraw ? asBigInt(result.value) : undefined - if (mw !== undefined) ctx.maxWithdraw = mw - const mr = result.key === KEYS.maxRedeem ? asBigInt(result.value) : undefined - if (mr !== undefined) ctx.maxRedeem = mr - } - } - if (step === 2 && result.key === KEYS.assets) { - const assets = asBigInt(result.value) - if (assets !== undefined) ctx.assets = assets - } - } - }, - - finalize(): Erc4626VaultResolution { + if (!owner) { return { metadata: { - symbol: ctx.symbol, - decimals: ctx.decimals, - underlyingAsset: ctx.underlyingAsset, - maxWithdraw: ctx.maxWithdraw, - maxRedeem: ctx.maxRedeem, + symbol, + decimals, + underlyingAsset, + maxWithdraw: undefined, + maxRedeem: undefined, }, - position: - hasOwner && ctx.balance !== undefined - ? { - balance: ctx.balance, - assets: ctx.assets, - // exactOptionalPropertyTypes: conditional spread only when defined - ...(ctx.maxWithdraw !== undefined ? { maxWithdraw: ctx.maxWithdraw } : {}), - ...(ctx.maxRedeem !== undefined ? { maxRedeem: ctx.maxRedeem } : {}), - } - : undefined, + position: undefined, } - }, + } + + // Continuing step-1 creation order: balanceOf, maxWithdraw, maxRedeem. + const balanceCall = t.call({ + target: vault, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + optional: true, + }) + const maxWithdrawCall = t.call({ + target: vault, + abi: erc4626Abi, + functionName: 'maxWithdraw', + args: [owner], + optional: true, + }) + const maxRedeemCall = t.call({ + target: vault, + abi: erc4626Abi, + functionName: 'maxRedeem', + args: [owner], + optional: true, + }) + + const maxWithdraw = t.derive([maxWithdrawCall], asBigInt) + const maxRedeem = t.derive([maxRedeemCall], asBigInt) + const balance = t.derive([balanceCall], asBigInt) - [SINGLE_USE]: true, - } as MultistepTask & SingleUseCarrier + // Depth 2 (step 2): args take the COERCED `balance` ref (post-asBigInt), + // not the raw `balanceCall` — see the module doc comment. This is what + // replicates 1.0's step-2 gating for BOTH a failed balanceOf call AND a + // "successful"-but-malformed one (e.g. balanceOf resolving to a + // non-bigint): `asBigInt` demotes the malformed case to `undefined` + // (still a 'v' state, never a call-level failure), and the core's + // call-mode `resolveAll` (`src/core/defineTask.ts`, external review P1) + // now skip-chains a 'v'-undefined argument exactly like a 'u'/'f' one — + // `convertToAssets` is never dispatched with a bogus arg either way. + const assetsCall = t.call({ + target: vault, + abi: erc4626Abi, + functionName: 'convertToAssets', + args: [balance], + optional: true, + }) + + const assets = t.derive([assetsCall], asBigInt) + + const position = t.derive( + [balance, assets, maxWithdraw, maxRedeem], + (balanceV, assetsV, maxWithdrawV, maxRedeemV) => { + if (balanceV === undefined) return undefined + return { + balance: balanceV, + assets: assetsV, + // exactOptionalPropertyTypes: conditional spread only when defined + ...(maxWithdrawV !== undefined ? { maxWithdraw: maxWithdrawV } : {}), + ...(maxRedeemV !== undefined ? { maxRedeem: maxRedeemV } : {}), + } + }, + ) + + return { + metadata: { + symbol, + decimals, + underlyingAsset, + maxWithdraw, + maxRedeem, + }, + position, + } + }) } // ─── Application layer ────────────────────────────────────────────────────────