Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/bundle-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/defineTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RawResult[]> {
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<RawResult[]> {
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<RawResult[]> {
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) => {
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/erc4626.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ describe('resolveErc4626Vault', () => {
// Step 1 calls: symbol, decimals, asset, balanceOf, maxWithdraw, maxRedeem = 6
const step1 = (executor.executeMulticall as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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)
Expand Down
135 changes: 135 additions & 0 deletions src/__tests__/fixtures/legacy-handlers/erc20.ts
Original file line number Diff line number Diff line change
@@ -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<Erc20TokenResolution> {
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<Erc20TokenResolution> & SingleUseCarrier
}
Loading
Loading