Skip to content

cluster-tool: pass --slots-in-an-epoch to the run-time anvil at spawn - #111

Merged
qhool merged 7 commits into
masterfrom
feature/eth_opp_calldata_delivery
Oct 21, 2026
Merged

qhool merged 7 commits into
masterfrom
feature/eth_opp_calldata_delivery

Conversation

@qhool

@qhool qhool commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two harness changes found while running the whole-envelope delivery branches end to end.

1. Pass --slots-in-an-epoch to the run-time anvil at spawn (22a07e71).

AnvilProcessSteps.runStart spawned anvil with only host/port/chain-id/state and switched interval mining on afterwards over RPC. anvil has no RPC for the epoch slot count, so every create and flow cluster ran on the default 32-slot epoch: finalized sat 64 blocks behind latest, and the batch operators — which read the outpost's outbound envelope at finalized — only saw each Ethereum envelope a minute after the tip that emitted it. Every depot epoch stretched to about two minutes waiting on that one hop.

AnvilProcess.SlotsInAnEpoch now rides the spawn (finality two blocks behind), exactly as the rendered start.sh already did for the run path; --block-time stays off the spawn so the Hardhat deploy keeps instamine and planEnableIntervalMining still turns mining on afterwards. Measured on flow-operator-collateral-deposit: tip-to-read 74s → 15s, flow 842s → 563s. Runner tests added in AnvilProcessSteps.test.ts.

2. flow-syndication-plugin-underwriting-cap runs on 72 s epochs (1bce3e4d; the derivation moved to cluster-tool in 07714858).

With epochs at their configured 60 s, this flow (added in #127) can fail on second-bonded. The batch-operator underwriter verifies each request's statement against the Solana outpost's outbound envelope log, which keeps the last 10 epochs (MAX_TRACKED_ENVELOPES in liqsol-core). The second request is bonded only after the first bond is paid back: a challenge window plus the approve, claim and bond passes after that bond. The window was 589 s, about ten 60 s epochs, so the second request's statement could leave the log first; the underwriter then reports it UNVERIFIED on every pass.

Whether it did depended on where a 15 s plugin pass fell inside an epoch:

Run Branches Epoch 13 (prunes epoch 3) Request 2's next pass Result
39447579858 the three calldata branches 16:41:15 16:41:30 ❌ UNVERIFIED
39479148320 sysio master, ethereum next, this PR's anvil commit only 19:55:30 19:55:30 ✅ bonded, same second

On master the flow looked stable only because the anvil finality lag above stretched epochs to about two minutes.

The flow now derives its own epoch: the shortest at which the window (one epoch plus its fixed 529 s), the three passes and one epoch of headroom fit inside the 10-epoch log. That is 72 s, which makes the first window 601 s and leaves about 74 s before the statement is pruned; slower epochs only add margin, since the log counts epochs. The derivation is BatchOperatorUnderwritingSteps.shortestRetainingEpochSec in cluster-tool, with SolanaRetainedEnvelopeEpochs mirroring the Solana constant, and jest pins it: the result fits with its headroom, one second less does not, and 60 s is refused. The README explains the epoch.

The durable fix is in the underwriter (wire-sysio #671): remembering a statement once verified would make the flow safe at any epoch length.

Gate on cf76628b: lint, build, jest 4471 tests in 456 suites, every test:unit (the cap flow's 50 included), and the pre-commit check — all green.

3. Runner tests without casts (59be394c). The two runStart tests build a real AnvilProcess (the real factory with /bin/true, start spied on the prototype) instead of a Partial<AnvilProcess> double cast.

4. valthon's review.

  • d090492f: the runStart tests take their Anvil port from the bind registry.
  • d26eb80c: AnvilProcess's class comment says finality rides every spawn and only interval mining stays off for the deploy.
  • d38fe72f: AnvilProcess.optionsFor(config, AnvilProcess.Launch.deploy | relaunch) is the one derivation of a cluster's Anvil options. runStart uses deploy; ClusterManager's run path and StartScriptSteps.resolveAnvilConfig use relaunch; DaemonConfig.planAnvil no longer reapplies the flags. Parity and consumer tests in jest.

5. No tests in the flow package (07714858). The cap flow's node:test suite and test:unit script are removed. Its cases on the shared verifies (planVerifyBonded, planVerifyFirstRelease) are ported to cluster-tool's BatchOperatorUnderwritingSteps.test.ts; the ones on the flow's own CapSteps are dropped, since the flow run covers them. The sysio.bond/sysio.synd row builders are now a shared test fixture.

Gate on 07714858: lint, build, jest 4516 tests in 457 suites, every test:unit, and the pre-commit check.

Companion PRs

E2E gate

e2e-tests run 39640361829: success. Full platform build plus every discovered flow, dispatched with BRANCH_WIRE_SYSIO, BRANCH_WIRE_ETHEREUM and BRANCH_WIRE_TOOLS_TS = feature/eth_opp_calldata_delivery, everything else on its manifest branch. Resolved revisions: wire-sysio d7a87bc39a, wire-ethereum 824bced09f, wire-tools-ts 59be394c, wire-libraries-ts f0e5773142, wire-solana d4b1d64a99.

Flow Result Duration
batch-operator-slashing ✅ 510s
batch-operator-termination ✅ 659s
emergency-stop ✅ 955s
emissions-soak ✅ 2284s
liq-syndication ✅ 511s
liq-yield ✅ 750s
node-owner-nft ✅ 513s
operator-collateral-deposit ✅ 508s
producer-registration ✅ 1095s
syndication-challenge ✅ 650s
syndication-plugin-underwriting ✅ 1021s
syndication-plugin-underwriting-cap ✅ 1406s
syndication-rate-limit ✅ 971s
syndication-underwriting ✅ 769s

All 14 flows passed. The review fixes after 59be394c (d090492f..07714858) touch the anvil spawn paths and the cap flow, so a smoke run on 07714858 covered those two: 39650122308, success (operator-collateral-deposit 505s, syndication-plugin-underwriting-cap 1404s).

🤖 Generated with Claude Code

@brianjohnson5972 brianjohnson5972 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer review alongside wire-ethereum#216 and wire-sysio#660. Looks good to merge. It is independent of the other two PRs and has no ordering constraint. The fix is correct and well tested. Inline: one FYI about production ETH finality latency, plus two test nits.

AnvilProcess.StateFilename
)
),
slotsInAnEpoch: AnvilProcess.SlotsInAnEpoch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks right. anvil has no RPC to change --slots-in-an-epoch, so it has to be set at spawn. --block-time correctly stays off the spawn, so the Hardhat deploy keeps instamine, and this now matches the rendered start.sh on the run path.

FYI, not a change request for this PR: the measurement here (tip-to-read 74 s → 15 s, flow 842 s → 563 s) shows how much of each epoch is the single ETH→depot hop, read at finalized. On mainnet finalized trails latest by ~12.8 minutes (two epochs), so in production every depot epoch waits at least one ETH finality period, whatever epoch_duration_sec is set to. Worth flagging to whoever sizes production epochs and the depot's path-2 boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, thanks. With mainnet's finalized about 12.8 minutes behind latest, the ETH→depot hop alone sets the floor on a production depot epoch, whatever epoch_duration_sec says. That belongs with production epoch sizing and the depot's path-2 boundary, not this harness PR.

// ProcessManager is a set-once singleton: one sandbox for the whole block.
beforeAll(() => {
dir = Fs.mkdtempSync(Path.join(Os.tmpdir(), "anvilsteps-"))
ProcessManager.setClusterPath(dir)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: ProcessManager.setClusterPath is set-once per process. That's fine here because jest gives each test file its own module registry, but a one-line note that this file must keep a single sandbox for the whole describe would stop someone adding a second describe with its own beforeAll from silently inheriting the first path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While @brianjohnson5972 is correct, this approach has been ignored by most of the engineers contributing to this repo; it may lead to random errors, if said errors arise, jest config cleanup could be helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block carries that note: "ProcessManager is a set-once singleton: one sandbox for the whole block." Agreed on jest config cleanup if this starts causing errors.

it("spawns anvil with the finality window and without interval mining", async () => {
const ctx = fixtureContext({ clusterPath: dir, dataPath: Path.join(dir, "data") }),
start = jest.fn(),
anvil = { start } as Partial<AnvilProcess> as AnvilProcess,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: { start } as Partial<AnvilProcess> as AnvilProcess (here and at line 76) is fine in a test. If jest.mocked / a typed stub helper already exists in the repo's test utilities, prefer it so the double cast doesn't spread.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad code and breaks the style guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9365fd. Neither test casts now: "spawns anvil…" spies AnvilProcess.create through to the real factory with /bin/true as the binary and spies start on the prototype, so a real AnvilProcess comes back and nothing spawns. The no-op case registers a real process in place of mocking get. afterEach stops the singleton manager's processes.

@qhool
qhool force-pushed the feature/eth_opp_calldata_delivery branch 5 times, most recently from a0b6f0b to 0613555 Compare October 13, 2026 17:25
@qhool
qhool force-pushed the feature/eth_opp_calldata_delivery branch from 0613555 to 22a07e7 Compare October 20, 2026 15:20
@qhool

qhool commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@brianjohnson5972 two commits landed after your approval on 22a07e71, so it needs a fresh look:

  • cf76628b: flow-syndication-plugin-underwriting-cap now runs on 72 s epochs. At the 60 s epochs this PR's anvil change brings back, the first 589 s window was about the Solana outbound log's 10-epoch depth, so the second request's statement could be pruned before the underwriter could bond it. The description has the race, the derivation, and the passing run 39493290735.
  • e9365fd6: the runStart tests build a real AnvilProcess instead of the double cast you and @jglanz flagged.

qhool and others added 3 commits October 21, 2026 13:03
`AnvilProcessSteps.runStart` spawned anvil with only host/port/chain-id/state
and switched interval mining on afterward over RPC. anvil has no RPC for the
epoch slot count, so every `create` and flow cluster ran on anvil's default
32-slot epoch: `finalized` sat 64 blocks behind `latest`, and the batch
operators — which read the outpost's outbound envelope at `finalized` — only
saw each ETH envelope a minute after the tip that emitted it. Every depot
epoch stretched to about two minutes waiting on that one hop.

Ride `AnvilProcess.SlotsInAnEpoch` on the spawn (finality two blocks behind),
exactly as the rendered start.sh already does for the `run` path, and keep
`--block-time` off the spawn so the Hardhat deploy keeps its instamine.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…he Solana envelope log allows

The underwriter verifies each request's statement against the Solana
outpost's outbound envelope log, which keeps the last 10 epochs
(`MAX_TRACKED_ENVELOPES` in liqsol-core). The second request is bonded only
after the first bond is paid back: a 589 s challenge window plus the approve,
claim and bond passes after that bond. At 60 s epochs the window alone is
about ten epochs, so request 2's statement can leave the log first. It then
reads UNVERIFIED on every pass and the flow times out on `second-bonded`.

Whether that happens depends on where a 15 s plugin pass falls inside an
epoch. Two runs show it, both with epochs at the configured 60 s:
- e2e 39447579858, which failed: the pass came 15 s after epoch 13 pruned
  epoch 3.
- e2e 39479148320, sysio master and ethereum next, which passed: the pass
  came in the same second as epoch 13.

On master the flow looked stable only because the harness's anvil
finality lag stretched epochs to about two minutes. The previous commit,
which passes `--slots-in-an-epoch` to anvil, removes that lag.

The flow now runs on its own epoch, derived as the shortest one at which
the window (one epoch plus its fixed 529 s), the three passes and one
epoch of headroom fit inside the log. That is 72 s, which makes the first
window 601 s. Slower epochs only add margin, since the log counts epochs,
not seconds.

The window's total with the two later 60 s windows is 721 s, so the old
`<= 3 * 240` assertion is replaced by tests that pin:
- the retention margin at the derived epoch;
- that one second less, or 60 s, fails;
- that the scenario runs on the flow's epoch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nstead of casting a stub

Both runStart cases passed a `{ start } as Partial<AnvilProcess> as
AnvilProcess` stub. Now:
- "spawns anvil…" spies `AnvilProcess.create` through to the real factory
  with `/bin/true` as the binary, and spies `start` on the prototype so
  nothing spawns.
- "is a no-op when already registered" registers a real process in place
  of mocking `get`.
- `afterEach` stops the singleton manager's processes.

The spawn-option assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@qhool
qhool force-pushed the feature/eth_opp_calldata_delivery branch from e9365fd to 59be394 Compare October 21, 2026 17:03

@valthon valthon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for two required corrections:

  1. Issue the new runner test's Anvil port through the bind registry. In packages/cluster-tool/tests/orchestration/steps/processes/AnvilProcessSteps.test.ts:56, fixtureContext() supplies DefaultAnvil directly. The test passes that port through to the real factory and asserts on it; supplying options.port bypasses BindConfigProvider.findAvailable(). The repository explicitly requires registry-issued ports in tests, including mocked spawns and assertions. Allocate the port through BindConfigProvider.findAvailable() and override the fixture's Anvil binding with it.

  2. Correct the contradictory AnvilProcess class documentation. In packages/cluster-tool/src/cluster/processes/AnvilProcess.ts:37-41, the class comment still says both slotsInAnEpoch and blockTimeSec “MUST NOT be set during the deploy phase.” This PR correctly sets slotsInAnEpoch during that phase. Update the comment to explain that finality is configured at spawn, while interval mining remains off until after deployment.

Strong recommendation for this revision: consolidate the cluster-derived Anvil configuration while revising the runner tests. The options are repeated in AnvilProcessSteps.runStart, ClusterManager, and StartScriptSteps.resolveAnvilConfig, and DaemonConfig.planAnvil reapplies the finality/mining settings. That duplication allowed create/flow startup to omit the finality setting while restart and generated scripts already supplied it. Share the cluster-to-Anvil option derivation with an explicit deployment/relaunch distinction controlling interval mining, and have the startup and script paths consume it. Add a parity test covering their common settings and the intentional mining difference. This would prevent the same drift from recurring; the port and documentation corrections above remain required independently.

Coverage: read every changed line in all 7 files at 59be394c5b88cc1e6329b007bb12e021e5494e5b, plus related startup/configuration and Solana retention/underwriter paths. The 4 focused Anvil step tests passed locally using existing dependencies; GitHub build-and-test checks are green. Full local gates and live flows were not rerun.

qhool and others added 2 commits October 21, 2026 13:37
…d registry

The runStart tests passed the persisted fixture's `DefaultAnvil` into a
real `AnvilProcess.create` and asserted on it, so the port never came
from the registry. The block now takes it from
`BindConfigProvider.findAvailable` and binds the fixture's anvil to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ry launch

The class comment still said neither `slotsInAnEpoch` nor `blockTimeSec`
may be set during the deploy phase, which this branch's runStart change
contradicts. Only interval mining stays off for the deploy; finality
rides every spawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@jglanz jglanz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@qhool there are 2 standards issues related to packages/flow-syndication-plugin-underwriting-cap/tests/SyndicationPluginUnderwritingCapSettlement.test.cjs

  • first, no tests are allowed under flow-* packages as each flow is a test
  • second, you are not using jest, the repo standard (your code const { test, afterEach, mock } = require("node:test"))

Due to the time pressure, I am approving, but I've been assured these will be resolved ASAP

qhool and others added 2 commits October 21, 2026 13:49
…oy or relaunch

Four paths built the cluster's anvil options separately:
- `AnvilProcessSteps.runStart`, the create and flow spawn;
- `ClusterManager`'s run path;
- `StartScriptSteps.resolveAnvilConfig`;
- `DaemonConfig.planAnvil`, which reapplied the finality and mining flags.
That is how the create path came to miss the finality flag the others
carried.

`AnvilProcess.optionsFor(config, AnvilProcess.Launch)` now derives host,
port, chain id, state file and finality from the cluster config. Only
interval mining depends on the launch: `deploy` leaves it off for the
Hardhat deploy, `relaunch` spawns with it. runStart uses `deploy`;
ClusterManager and resolveAnvilConfig use `relaunch`; planAnvil renders
the config it is given. The binary stays with each path, as before: PATH
for a live spawn, the persisted executable for a start.sh.

Tests: optionsFor's parity (the launches differ only in `blockTimeSec`,
in options and in argv), runStart's options, resolveAnvilConfig's
relaunch form, and the planned anvil's argv carrying both flags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…poch derivation and shared-verify coverage move to cluster-tool

A flow is itself the test, so a flow package carries no unit tests, and
unit tests run under jest. This flow's `node:test` suite is removed with
its `test:unit` script.

- `BatchOperatorUnderwritingSteps.shortestRetainingEpochSec` and
  `SolanaRetainedEnvelopeEpochs` (mirroring liqsol-core's
  `MAX_TRACKED_ENVELOPES`) move to cluster-tool. The flow's constants call
  them, and its epoch is still 72 s with a 601 s first window. Jest
  covers the derivation: the result fits with its headroom, one second
  less does not, and the 60 s shortest epoch is refused for a window about
  the log's own depth.
- The suite's cases for the shared verifies move to cluster-tool's
  `BatchOperatorUnderwritingSteps.test.ts`:
  - `planVerifyBonded` passes a funded-only bond, refuses an unfunded
    bonder, and refuses unpaid bonds over the cap.
  - `planVerifyFirstRelease` accepts the second envelope released not at
    all or in full, and refuses a partial release.
  The unfunded-holds case was already covered there.
- The rest exercised this flow's own `CapSteps`, which the flow run
  covers, and is dropped.
- The `sysio.bond`/`sysio.synd` row builders move from
  `WireSyndicationTool.test.ts` into `tests/tools/wire/syndicationRowFixture.ts`,
  shared by both suites.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@qhool

qhool commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@valthon thanks. All three are in, pushed as 59be394c..07714858:

  1. Registry-issued port (d090492f): the runStart block takes its Anvil port from BindConfigProvider.findAvailable(DefaultAnvil) in beforeAll and binds the fixture's anvil to it; both tests assert against that port.
  2. AnvilProcess doc (d26eb80c): the class comment now says finality (--slots-in-an-epoch) rides every spawn, and only interval mining stays off for the Hardhat deploy, turned on over RPC afterwards; a relaunch spawns with it.
  3. One derivation (d38fe72f): AnvilProcess.optionsFor(config, AnvilProcess.Launch.deploy | relaunch) derives host, port, chain id, state file and finality; only relaunch adds blockTimeSec. runStart uses deploy; ClusterManager's run path and StartScriptSteps.resolveAnvilConfig use relaunch; DaemonConfig.planAnvil renders its source as given instead of reapplying the flags. The binary stays per path, as before. Tests: the parity of the two launches (they differ only in blockTimeSec, in options and argv), runStart's options, resolveAnvilConfig's relaunch form, and the planned anvil's argv.

Separately, per the repo rule that a flow package carries no unit tests and unit tests run under jest (07714858): the cap flow's node:test suite is gone. The epoch derivation (shortestRetainingEpochSec, SolanaRetainedEnvelopeEpochs) moved to BatchOperatorUnderwritingSteps with jest coverage, and the suite's cases on the shared verifies (planVerifyBonded, planVerifyFirstRelease) are ported to BatchOperatorUnderwritingSteps.test.ts. The cases that exercised the flow's own CapSteps are dropped, since the flow run covers them.

Gate on 07714858: lint, build, jest 4516 tests in 457 suites, every test:unit. A smoke e2e (operator-collateral-deposit and the cap flow) is running: 39650122308.

@qhool
qhool requested a review from valthon October 21, 2026 18:09
@qhool

qhool commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

E2E on the rebased calldata set, both green:

  • e2e-tests 39640361829: success, full platform build and all 14 discovered flows. BRANCH_WIRE_SYSIO, BRANCH_WIRE_ETHEREUM and BRANCH_WIRE_TOOLS_TS = feature/eth_opp_calldata_delivery, everything else on its manifest branch. Resolved: wire-sysio d7a87bc39a (#660), wire-ethereum 824bced09f (#216), wire-tools-ts 59be394c (cluster-tool: pass --slots-in-an-epoch to the run-time anvil at spawn #111), wire-libraries-ts f0e5773142, wire-solana d4b1d64a99.

    Flow Result Duration
    batch-operator-slashing ✅ 510s
    batch-operator-termination ✅ 659s
    emergency-stop ✅ 955s
    emissions-soak ✅ 2284s
    liq-syndication ✅ 511s
    liq-yield ✅ 750s
    node-owner-nft ✅ 513s
    operator-collateral-deposit ✅ 508s
    producer-registration ✅ 1095s
    syndication-challenge ✅ 650s
    syndication-plugin-underwriting ✅ 1021s
    syndication-plugin-underwriting-cap ✅ 1406s
    syndication-rate-limit ✅ 971s
    syndication-underwriting ✅ 769s
  • e2e-tests 39650122308: success, a smoke run on wire-tools-ts 07714858, the current cluster-tool: pass --slots-in-an-epoch to the run-time anvil at spawn #111 head with its review fixes (anvil option derivation, cap-flow epoch derivation moved to cluster-tool), same sysio and ethereum heads. operator-collateral-deposit ✅ 505s, syndication-plugin-underwriting-cap ✅ 1404s.

@qhool
qhool merged commit 01b9c6a into master Oct 21, 2026
2 checks passed
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.

4 participants