Skip to content

feat: pinBlock — run-start block resolution with onPin (F8) - #23

Merged
halaprix merged 2 commits into
mainfrom
feat/f8-pin-block
Jul 23, 2026
Merged

halaprix merged 2 commits into
mainfrom
feat/f8-pin-block

Conversation

@halaprix

Copy link
Copy Markdown
Owner

Summary

Completes 1.2.0's execution controls (spec F8):

  • pinBlock (opt-in): latest/safe/finalized/earliest tags resolve ONCE at run start (+1 round-trip) via the new optional StepExecutor.getBlockNumber(block?) (Eip1193Executor implements over eth_getBlockByNumber); the resolved number pins every step's multicall — cross-step atomicity documented in the new api-reference "Atomicity" section. pending + pinBlock throws. Explicit blockNumber/blockHash are no-ops (requireCanonical untouched, no extra RPC).
  • onPin(pinnedBlock): synchronous, exactly once per run — including empty submissions (both runners reconciled; run()'s legacy empty-shortcut quirk for batchSize validation preserved) — only when pinBlock: true. An onPin throw rejects the run with tasks consumed and zero batches dispatched.
  • Consumption ordering per the F2 pipeline: capability/pending failures throw BEFORE consumption (tasks stay fresh — tested); getBlockNumber rejection after consumption leaves tasks consumed with zero dispatches (regression-tested against seam reordering). PinnedBlock exported; exactOptionalPropertyTypes-correct construction (requireCanonical key absent when unset).
  • Composes with Presets.throughput spread; pinBlock: false paths byte-identical (compat-pinned).

Test evidence

280/280 (24 pinBlock tests incl. once-per-run accounting, empty-run parity, consumed-state boundaries in both directions), compat-vs-dist 38/38, full gate build-first. Gzip 13.9KB (<15KB).

Spec: spec.md § 1.2.0 F8.

halaprix added 2 commits July 23, 2026 20:41
Fill the two seams T9 reserved in src/core/internal.ts:

- validatePinCapability(options, executor): pre-consumption. Throws when
  pinBlock is requested against an executor lacking getBlockNumber
  (always, regardless of block shape — a predictable capability
  contract), and when block.blockTag is 'pending' (no stable number).
- resolvePinnedBlock(options, executor): post-consumption, pre-execute.
  No-op when pinBlock is false. When true: resolves an absent block or a
  latest/safe/finalized tag via one executor.getBlockNumber() call (+1
  RT) to a concrete blockNumber used for every step; an explicit
  blockNumber/blockHash is used as-is with no extra RPC. Reports the
  resolved PinnedBlock to onPin synchronously, exactly once, before any
  multicall batch is dispatched; a throwing onPin rejects the run with
  tasks left consumed.

Adds StepExecutor.getBlockNumber (optional, additive) and its
Eip1193Executor implementation (eth_getBlockByNumber; blockNumber
short-circuits with no RPC; blockHash throws — pinning never calls it
for hashes). Adds BatchOptions.pinBlock/onPin and the PinnedBlock type,
exported from index.ts. Both runners now thread the resolved block
through their executeBatch policy instead of options.block directly.

Docs: new "Atomicity" section in docs/api-reference.md (within-batch vs
cross-step consistency, the resolution/no-op mapping table, and a
pinBlock + Presets.throughput snippet); API Surface entries updated for
the new fields/method/type. README bundle-size badge refreshed for the
measured gzip delta.
… review)

Three P2 findings from external review, same branch:

1. runMultistepTasks's empty-tasks shortcut used to bypass F8 entirely,
   so onPin never fired for a zero-task pinBlock:true run; runSettled had
   the opposite bug — it always ran prepareRun (and validatePinCapability)
   for an empty submission but returned [] before ever reaching
   resolvePinnedBlock, so onPin never fired there either. Both fixed:
   onPin's "exactly once per run when pinBlock: true" contract now holds
   for zero-task runs in both runners. runMultistepTasks's 1.0-pinned
   quirk (invalid batchSize with zero tasks silently resolves to [])
   stays intact — only pinBlock's own two seams are exempted from that
   shortcut, nothing else.

2. Added the missing consumed-state test: executor.getBlockNumber
   rejecting (the post-consumption seam) rejects the run with that exact
   error, dispatches zero multicall batches, and leaves the task
   consumed — proven by a resubmission throwing DominoTaskReuseError
   instead of another transport failure. Covers both runners.

3. Docs: the getBlockNumber entry in api-reference.md now states its
   exact contract (absent/tag resolves via eth_getBlockByNumber; explicit
   blockNumber returns as-is with no RPC; blockHash rejects). Atomicity
   table and the matching source doc comments now correctly note that
   'earliest' resolves the same way as 'latest'/'safe'/'finalized' (any
   stable tag does) — only 'pending' throws.

Adds 7 tests to src/__tests__/pinBlock.test.ts (24 total in that file).
@halaprix
halaprix merged commit 641ed0b into main Jul 23, 2026
2 checks passed
@halaprix
halaprix deleted the feat/f8-pin-block branch July 23, 2026 18:54
@halaprix halaprix mentioned this pull request Jul 23, 2026
halaprix added a commit that referenced this pull request Jul 24, 2026
* feat: pinBlock — run-start block resolution with onPin (F8)

Fill the two seams T9 reserved in src/core/internal.ts:

- validatePinCapability(options, executor): pre-consumption. Throws when
  pinBlock is requested against an executor lacking getBlockNumber
  (always, regardless of block shape — a predictable capability
  contract), and when block.blockTag is 'pending' (no stable number).
- resolvePinnedBlock(options, executor): post-consumption, pre-execute.
  No-op when pinBlock is false. When true: resolves an absent block or a
  latest/safe/finalized tag via one executor.getBlockNumber() call (+1
  RT) to a concrete blockNumber used for every step; an explicit
  blockNumber/blockHash is used as-is with no extra RPC. Reports the
  resolved PinnedBlock to onPin synchronously, exactly once, before any
  multicall batch is dispatched; a throwing onPin rejects the run with
  tasks left consumed.

Adds StepExecutor.getBlockNumber (optional, additive) and its
Eip1193Executor implementation (eth_getBlockByNumber; blockNumber
short-circuits with no RPC; blockHash throws — pinning never calls it
for hashes). Adds BatchOptions.pinBlock/onPin and the PinnedBlock type,
exported from index.ts. Both runners now thread the resolved block
through their executeBatch policy instead of options.block directly.

Docs: new "Atomicity" section in docs/api-reference.md (within-batch vs
cross-step consistency, the resolution/no-op mapping table, and a
pinBlock + Presets.throughput snippet); API Surface entries updated for
the new fields/method/type. README bundle-size badge refreshed for the
measured gzip delta.

* fix: F8 pinBlock — onPin fires on empty runs, doc precision (external review)

Three P2 findings from external review, same branch:

1. runMultistepTasks's empty-tasks shortcut used to bypass F8 entirely,
   so onPin never fired for a zero-task pinBlock:true run; runSettled had
   the opposite bug — it always ran prepareRun (and validatePinCapability)
   for an empty submission but returned [] before ever reaching
   resolvePinnedBlock, so onPin never fired there either. Both fixed:
   onPin's "exactly once per run when pinBlock: true" contract now holds
   for zero-task runs in both runners. runMultistepTasks's 1.0-pinned
   quirk (invalid batchSize with zero tasks silently resolves to [])
   stays intact — only pinBlock's own two seams are exempted from that
   shortcut, nothing else.

2. Added the missing consumed-state test: executor.getBlockNumber
   rejecting (the post-consumption seam) rejects the run with that exact
   error, dispatches zero multicall batches, and leaves the task
   consumed — proven by a resubmission throwing DominoTaskReuseError
   instead of another transport failure. Covers both runners.

3. Docs: the getBlockNumber entry in api-reference.md now states its
   exact contract (absent/tag resolves via eth_getBlockByNumber; explicit
   blockNumber returns as-is with no RPC; blockHash rejects). Atomicity
   table and the matching source doc comments now correctly note that
   'earliest' resolves the same way as 'latest'/'safe'/'finalized' (any
   stable tag does) — only 'pending' throws.

Adds 7 tests to src/__tests__/pinBlock.test.ts (24 total in that file).

---------

Co-authored-by: halaprix <halaprix@users.noreply.github.com>
halaprix added a commit that referenced this pull request Jul 24, 2026
* feat: pinBlock — run-start block resolution with onPin (F8)

Fill the two seams T9 reserved in src/core/internal.ts:

- validatePinCapability(options, executor): pre-consumption. Throws when
  pinBlock is requested against an executor lacking getBlockNumber
  (always, regardless of block shape — a predictable capability
  contract), and when block.blockTag is 'pending' (no stable number).
- resolvePinnedBlock(options, executor): post-consumption, pre-execute.
  No-op when pinBlock is false. When true: resolves an absent block or a
  latest/safe/finalized tag via one executor.getBlockNumber() call (+1
  RT) to a concrete blockNumber used for every step; an explicit
  blockNumber/blockHash is used as-is with no extra RPC. Reports the
  resolved PinnedBlock to onPin synchronously, exactly once, before any
  multicall batch is dispatched; a throwing onPin rejects the run with
  tasks left consumed.

Adds StepExecutor.getBlockNumber (optional, additive) and its
Eip1193Executor implementation (eth_getBlockByNumber; blockNumber
short-circuits with no RPC; blockHash throws — pinning never calls it
for hashes). Adds BatchOptions.pinBlock/onPin and the PinnedBlock type,
exported from index.ts. Both runners now thread the resolved block
through their executeBatch policy instead of options.block directly.

Docs: new "Atomicity" section in docs/api-reference.md (within-batch vs
cross-step consistency, the resolution/no-op mapping table, and a
pinBlock + Presets.throughput snippet); API Surface entries updated for
the new fields/method/type. README bundle-size badge refreshed for the
measured gzip delta.

* fix: F8 pinBlock — onPin fires on empty runs, doc precision (external review)

Three P2 findings from external review, same branch:

1. runMultistepTasks's empty-tasks shortcut used to bypass F8 entirely,
   so onPin never fired for a zero-task pinBlock:true run; runSettled had
   the opposite bug — it always ran prepareRun (and validatePinCapability)
   for an empty submission but returned [] before ever reaching
   resolvePinnedBlock, so onPin never fired there either. Both fixed:
   onPin's "exactly once per run when pinBlock: true" contract now holds
   for zero-task runs in both runners. runMultistepTasks's 1.0-pinned
   quirk (invalid batchSize with zero tasks silently resolves to [])
   stays intact — only pinBlock's own two seams are exempted from that
   shortcut, nothing else.

2. Added the missing consumed-state test: executor.getBlockNumber
   rejecting (the post-consumption seam) rejects the run with that exact
   error, dispatches zero multicall batches, and leaves the task
   consumed — proven by a resubmission throwing DominoTaskReuseError
   instead of another transport failure. Covers both runners.

3. Docs: the getBlockNumber entry in api-reference.md now states its
   exact contract (absent/tag resolves via eth_getBlockByNumber; explicit
   blockNumber returns as-is with no RPC; blockHash rejects). Atomicity
   table and the matching source doc comments now correctly note that
   'earliest' resolves the same way as 'latest'/'safe'/'finalized' (any
   stable tag does) — only 'pending' throws.

Adds 7 tests to src/__tests__/pinBlock.test.ts (24 total in that file).

---------

Co-authored-by: halaprix <halaprix@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant