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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
All notable changes to this project will be documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [1.2.0] — 2026-07-23

### Added
- `maxConcurrentBatches` (default 1): within-step concurrency pool for physical batch dispatch; completion-order-independent result routing; fail-fast cancellation for `run` (queued batches undispatched, in-flight settle, deterministic error selection when exactly one batch fails); `runSettled` never cancels queued batches.
- `adaptiveBatching` (default false): bisection of transport-failed batches through the central queue, bounded by `maxBatchAttempts` (default `2·⌈log₂(batchSize)⌉+1`, every execution counts; exhaustion → coarse `kind: 'batch'` failures with `cause` = last transport error, never wrong data); deadlock-free by construction; reverts never retried.
- `dedupe` (default false): within-step cross-task merge of eligible calls keyed on `(target, calldata, canonical output signature)` with selector-resolved overloads; fan-out of success and failure to all subscribers; only `TypedCallSpec` calls eligible (legacy tasks never affected).
- `Presets.throughput = { maxConcurrentBatches: 5, adaptiveBatching: true, dedupe: true }` — ready-made preset for portfolio/index workloads.
- `pinBlock` (default false) + `onPin(pinnedBlock)` + `PinnedBlock` type + optional `StepExecutor.getBlockNumber` (Eip1193Executor implements): resolve one concrete block at run start (+1 RT) and reuse for every step; pending unsupported; explicit blockNumber/blockHash are no-ops; onPin fires exactly once per run.

### Changed
- Bundle budget is gzip-only (<15KB); current 13.9 KB gzip.
- Internal: `run`/`runSettled` unified onto one step engine + pool (no observable change at defaults; defaults byte-compatible with 1.1 — pinned by the compat suite).

## [1.1.0] — 2026-07-23

### Added
Expand Down Expand Up @@ -105,6 +118,7 @@ First public release of `@halaprix/domino`.
- `position.assets` is `bigint | undefined` — correctly represents the case where `balanceOf` succeeds but `convertToAssets` reverts.
- CI now runs real `tsc --noEmit` (typecheck); build step runs before tests so `dist/` exists for bundle-size checks on a clean checkout.

[1.2.0]: https://github.com/halaprix/domino/releases/tag/v1.2.0
[1.1.0]: https://github.com/halaprix/domino/releases/tag/v1.1.0
[1.0.1]: https://github.com/halaprix/domino/releases/tag/v1.0.1
[1.0.0]: https://github.com/halaprix/domino/releases/tag/v1.0.0
Expand Down
31 changes: 29 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,38 @@ Always present on both branches — never optional, even when empty (`{ optional
- **Hand-written `MultistepTask`:** rejected iff the task's own `buildStepCalls`/`consumeStepResults`/`finalize` throws (the "dead-task rule" — once one of these three throws for a task, no further hooks run for it, and it settles `{ status: 'rejected', error: <thrown value> }`; sibling tasks are unaffected).
- **Programmer errors reject the WHOLE call, not one task's settlement:** an invalid `batchSize`, a reused single-use task (`DominoTaskReuseError`), or a `StepExecutor` that resolves with the wrong number of results for a batch all reject the returned promise itself — none of these produce a `{ status: 'rejected' }` array entry.

### Batch-failure isolation (and its pre-1.2 limitation)
### Batch-failure isolation and adaptive bisection

When `executor.executeMulticall` rejects for a physical batch, `runSettled` does not throw. Every call in that batch becomes a failure carrying its own `DominoCallError` (`kind: 'batch'`) — every one of those errors shares the SAME underlying transport error as `cause`, but each call gets its own `DominoCallError` instance. Execution continues: later batches in the same step, and later steps, still run; tasks whose `finalize()` copes with the resulting failures (or `defineTask` tasks where none of the failed calls are reachable from the returned shape) still settle `fulfilled`.

**Current limitation:** failure isolation operates at physical-batch granularity, not per-call. If one call inside a failed batch would have succeeded on its own (say, only some other call in that batch triggered a malformed response or the RPC timed out), every call in that batch — including the ones that would have succeeded — is reported failed. Adaptive bisection (splitting a failed batch into smaller batches and retrying, to isolate exactly which calls failed) is planned for a future 1.2 release and is not implemented yet.
Adaptive bisection (`adaptiveBatching: true`, off by default) automatically splits a failed batch in half and retries both halves independently; rejected regions recurse until they succeed, reach a single call, or exhaust `maxBatchAttempts`, while successful sub-batches are kept immediately. See the [`adaptiveBatching` field](#batching-options) below for when to enable it and its failure semantics.

## Batching Options

`BatchOptions` — passed as the third parameter to `runMultistepTasks(executor, tasks, options)` — controls step batching, concurrency, adaptive isolation, deduplication, block pinning, and error handling. All fields are optional; defaults are shown below.

| Field | Type | Default | Rationale |
|---|---|---|---|
| `batchSize` | `number` | `100` | Batching keeps each aggregate `eth_call` under provider gas/payload/response limits. One very expensive call cannot be fixed by splitting; this parameter splits the logical step, not individual calls. Must be positive. |
| `maxConcurrentBatches` | `number` | `1` | Provider rate limits and executor design typically assume serial execution. Set to >1 only if your provider/executor supports parallelism; `run` fail-fast cancels queued batches, `runSettled` does not. |
| `adaptiveBatching` | `boolean` | `false` | Bisection cannot distinguish a per-call failure (e.g. out-of-gas) from a transient transport problem (e.g. HTTP 429 rate-limiting); under rate-limiting, retries amplify load by up to 2N−1 calls for a single original batch. Enable only when your transport failures are dominated by bad calls, not rate limits; a future failure-cause classification may allow enabling by default. |
| `maxBatchAttempts` | `number` | `2·⌈log₂(batchSize)⌉+1` | Bounds bisection recursion. Default is sized for the common single-bad-call case; batches with multiple failing calls may exhaust it before every one is isolated (producing coarser-grained `kind: 'batch'` failures instead, never wrong data). |
| `dedupe` | `boolean` | `false` | Cross-task call merging changes executor invocation counts seen by instrumentation/billing. Only `TypedCallSpec` calls (from `t.call` in `defineTask`) are ever eligible; legacy tasks are never affected. |
| `block` | `BlockParam` | `{ blockTag: 'latest' }` | Block to query at (EIP-1898). Every step's `executeMulticall` call uses this parameter; without `pinBlock: true`, stable tags like `'latest'` are re-resolved by the node on each separate `eth_call`, so the chain can advance between steps. |
| `pinBlock` | `boolean` | `false` | Resolve one concrete block at run start (+1 RPC) and reuse for every step, closing the "chain advanced between steps" gap. Requires `executor.getBlockNumber()`. |
| `onPin` | `(block: PinnedBlock) => void` | — | Synchronous callback reporting the resolved block (fired exactly once per run when `pinBlock: true`). Only invoked when `pinBlock: true`; supplying it without `pinBlock` is accepted but it is never called. |

See `Presets.throughput` (exports: `{ maxConcurrentBatches: 5, adaptiveBatching: true, dedupe: true }`) for a ready-made bundle suited to portfolio workloads:

```typescript
import { runMultistepTasks, Presets } from "@halaprix/domino"
import type { StepExecutor, MultistepTask } from "@halaprix/domino"

declare const executor: StepExecutor
declare const tasks: MultistepTask<unknown>[]

await runMultistepTasks(executor, tasks, { ...Presets.throughput, batchSize: 200 })
```

## Error taxonomy

Expand Down
6 changes: 3 additions & 3 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Bundle Size

`@halaprix/domino` is a lightweight wrapper. The package size (gzip) is **10.9 KB**. The library requires viem as a hard dependency.
`@halaprix/domino` is a lightweight wrapper. The package size (gzip) is **13.9 KB**. The library requires viem as a hard dependency.

```typescript
import { Eip1193Executor, MulticallResolver } from "@halaprix/domino"
Expand All @@ -15,7 +15,7 @@ const resolver = new MulticallResolver(executor)

| Package | Size (gzip) | Notes |
|---|---|---|
| `@halaprix/domino` | 10.9 KB | viem is a hard dependency; installed with the package, not bundled into dist |
| `@halaprix/domino` | 13.9 KB | viem is a hard dependency; installed with the package, not bundled into dist |

## Compared to Alternatives

Expand All @@ -25,7 +25,7 @@ const resolver = new MulticallResolver(executor)
| 2-step vault resolution | ✅ | ❌ | ❌ |
| Cross-entity step batching | ✅ | ❌ | ❌ |
| Framework-agnostic core | ✅ | ❌ | ❌ |
| Size (gzip) | 10.9 KB | ~40 KB | 0 (no dep) |
| Size (gzip) | 13.9 KB | ~40 KB | 0 (no dep) |

## RPC Round-Trip Counts

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@halaprix/domino",
"version": "1.1.0",
"version": "1.2.0",
"private": false,
"files": [
"dist",
Expand Down
Loading