Skip to content

fix(ethereum): broadcast L1 deploy txs one at a time on anvil to dodge automine race - #24378

Merged
spalladino merged 3 commits into
merge-train/spartan-v5from
spl/forge-broadcast-batch-size-1
Jun 29, 2026
Merged

fix(ethereum): broadcast L1 deploy txs one at a time on anvil to dodge automine race#24378
spalladino merged 3 commits into
merge-train/spartan-v5from
spl/forge-broadcast-batch-size-1

Conversation

@spalladino

@spalladino spalladino commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Context

anvil's automine races a batched forge script broadcast: it mines a block on the first ready tx and leaves txs that arrived just after the trigger sitting in the pool, so forge waits forever for their receipts. The previous workaround kept automine on and ran a JS txpool watchdog that evm_mined any stragglers — extra moving parts on every deploy.

This PR is a fix for an issue introduced in #24316.

Approach

Sidestep the race instead of policing it: on anvil, broadcast one tx at a time (--batch-size 1). With only a single tx in flight there is nothing for the auto-miner to strand, and automine still mines it instantly so deploys stay fast. Real chains keep forge's default batch size.

  • Drop the watchdog, the anvil_getAutomine probe, and all mining-mode manipulation; keep web3_clientVersion anvil detection only to choose the batch size.
  • Add a hard broadcast timeout (FORGE_BROADCAST_TIMEOUT_MS, default 120s anvil / 600s otherwise) that SIGTERMs then SIGKILLs forge and exits non-zero, so a hung broadcast fails fast instead of waiting out the jest hook budget.
  • Preserve the buffer-stdout-and-flush-after-exit behavior the TS deploy helper relies on to parse JSON DEPLOY RESULT:.

Validated locally: 20/20 fresh-anvil deploys clean at ~350ms (on par with the watchdog path, far faster than interval mining), and @aztec/ethereum rollup_cheat_codes.test.ts passes.

@spalladino
spalladino force-pushed the spl/forge-broadcast-batch-size-1 branch from 7e0256b to d9ed663 Compare June 29, 2026 18:27
The deploy wrapper script was matched by no rebuild pattern, so changes to
it left both the l1-contracts and yarn-project content hashes unchanged and
CI served cached (skipped) test runs. yarn-project's hash folds in
l1-contracts/.rebuild_patterns, so registering the file there covers both.
Broadcasting one tx at a time dodges anvil's automine batch race, but it
serializes the deploy to one tx per block under interval mining. The e2e
suites that deploy L1 contracts with automineL1Setup:false run anvil at a
4s block interval, so --batch-size 1 stalled each deploy tx for a full
block and blew past the broadcast timeout. Gate --batch-size 1 on
anvil_getAutomine so interval-mining anvil (and real chains) keep the
larger batch size, where no automine race exists.
@spalladino
spalladino force-pushed the spl/forge-broadcast-batch-size-1 branch from c293af9 to bab9650 Compare June 29, 2026 19:41
@spalladino
spalladino enabled auto-merge (squash) June 29, 2026 20:09
@spalladino
spalladino merged commit 395be37 into merge-train/spartan-v5 Jun 29, 2026
29 checks passed
@spalladino
spalladino deleted the spl/forge-broadcast-batch-size-1 branch June 29, 2026 20:39
spalladino pushed a commit that referenced this pull request Jun 29, 2026
…ut as the known flake (#24385)

## What

Extends the existing `.test_patterns.yml` flake entry for
`src/composed/e2e_token_bridge_tutorial_test.test.ts` so it also matches
the
viem `WaitForTransactionReceiptTimeoutError` confirmation-timeout
failure mode,
not just the jest per-test timeout.

## Why — the `merge-train/spartan-v5` CI red on this branch

The train CI run failed on this one test:

```
FAIL src/composed/e2e_token_bridge_tutorial_test.test.ts
  ✕ Deploys tokens & bridges to L1 & L2, mints & publicly bridges tokens (189531 ms)
    WaitForTransactionReceiptTimeoutError: Timed out while waiting for transaction
    with hash "0x01f3fb09…" to be confirmed.
    Version: viem@2.38.2
```

(test log: http://ci.aztec-labs.com/1643e817d7242e0e)

This test is **already known-flaky** — there is an entry for it owned by
@spalladino — but its `error_regex` only covered `Exceeded timeout of N
ms for a test` (the jest per-test budget). In this run the test failed
*faster* (~189s of its 900s budget) because viem's own per-tx
confirmation timeout fired first, producing a different error string.
The flake matcher (`grep -E` over the test log in `ci3/get_test_entry`)
therefore did not classify it, so the failure went red and blocked the
train.

The L1↔L2 bridging flow sends a series of L1 txs against an automining
anvil that the local-network sequencer and cheat codes also drive
concurrently. Under load a tx can sit unmined past viem's confirmation
window — the same automine-race class, surfacing here in the test's own
L1 txs. Both observed symptoms (jest timeout, viem confirmation timeout)
are the same flake.

## Not caused by #24378

The train HEAD is #24378 (`broadcast L1 deploy txs one at a time on
anvil`), but that change only touches
`l1-contracts/scripts/forge_broadcast.js` (the L1 *contract deploy*
path). In the failing run that deploy **succeeded** (`[forge_broadcast]
Broadcast succeeded` at 20:52:19); the failure is ~3.5 min later in the
test body, which never goes through `forge_broadcast.js`. In every
version (pre-#24316, #24316, #24378) anvil ends in automine after
deploy, so #24378 does not change the test-body mining environment.
#24378 was simply the train HEAD when this pre-existing flake surfaced
in a mode the pattern didn't catch.

## Verification

A full `./bootstrap.sh ci` run is neither meaningful nor feasible for a
flake-classification config change (and the test is non-deterministic by
nature). The meaningful check is that `ci3/get_test_entry` now
classifies the observed failure:

- `yq` parses the updated entry cleanly.
- `get_test_entry "<compose token_bridge_tutorial cmd>"
<log-with-WaitForTransactionReceiptTimeoutError>` now returns the entry
(→ flake, non-blocking).
- A negative control (an unrelated error line) still returns nothing, so
the broadened regex stays scoped to this test's timeout flake.


---
*Created by
[claudebox](https://claudebox.work/v2/sessions/c01cbe3dd422bf00) ·
group: `slackbot`*
PhilWindle pushed a commit that referenced this pull request Jun 30, 2026
Speeds up the slowest individual e2e tests. Shared clock-warp helpers
(`warpToEpochStart`, `warpNearSubmissionWindowEnd`) were also lifted
into `single_node_test_context.ts`. Further production and
shared-fixture speedup opportunities found along the way are catalogued
for a follow-up PR.

## Measured speedup

From full instrumented CI runs (per-test timing JSONL), comparing this
PR's green run (`40b792f7`, CI `1782821707760521`) against the nearest
healthy baseline on the same post-consolidation merge-train era
(`bab9650f` / #24378). Timings are **sums across parallel CI processes,
not wall-clock**.

- **Across the 24 changed suites: −38.8% total time** (11,002s → 6,735s,
≈71 min of summed process time saved per run), **−38.7% body**. Every
changed suite got faster.
- **Whole e2e suite (~630 tests): −20.1% overall** (8h 44m → 6h 59m),
**−21.1% body** — the changed suites account for ~85% of the total body
reduction; the rest is run-to-run noise across unchanged tests.

Biggest wins (total time, base → PR):

| Suite | Base | PR | Δ |
| -- | -- | -- | -- |
| `proof_boundary.parallel` | 2535s | 1333s | −1202s (−47%) |
| `multiple_validators_sentinel.parallel` | 617s | 123s | −495s (−80%)*
|
| `optimistic.parallel` | 2249s | 1775s | −474s (−21%) |
| `world_state_pruning` | 350s | 113s | −236s (−68%) |
| `publisher_funding_multi` | 253s | 17s | −236s (−93%) |
| `escape_hatch_vote_only` | 260s | 89s | −170s (−66%) |
| `sync_after_reorg` | 339s | 182s | −156s (−46%) |
| `sentinel_status_slash.parallel` | 306s | 165s | −140s (−46%) |
| `upload_failed_proof` | 187s | 67s | −120s (−64%) |
| `long_proving_time` | 390s | 270s | −119s (−31%) |

…and 14 more suites, all faster (full per-suite table in the Linear
"Times tracking" doc).

*`multiple_validators_sentinel` merged two overlapping attestation tests
into one (no coverage lost).

## Strategies used

- **Clock warping** — fast-forward cheat-clock waits (proof-submission
windows, epoch/proven-checkpoint tails, dead slots) instead of sleeping
in real time.
- **Slot-cadence cut** — shrink L1/L2 slot durations (down to the 4s/12s
floor) for single-node prover tests that march through empty checkpoints
in real wall-clock.
- **Shared setup** — collapse per-`it` `beforeEach` setups into a single
`beforeAll` where the tests are independent.
- **Drive the chain directly** — replace real-time tx-confirmation waits
with `NO_WAIT` send + `advanceToSlot`, and trim redundant tx counts.

A handful of speedups were reverted or tuned after CI showed they raced
the production sequencer (warp loops) or broke fresh-per-test state;
those are catalogued for the follow-up PR, along with a production
prover-shutdown defect found in passing.

## Tests changed

**Single-node**
- proving: `multi_proof`, `optimistic`, `proof_fails`,
`world_state_pruning`, `cross_chain_public_message`,
`long_proving_time`, `upload_failed_proof`
- partial-proofs: `multi_root`, `single_root`
- recovery: `manual_rollback`, `prune_when_cannot_build`,
`sync_after_reorg`
- l1-reorgs: `blocks`
- sequencer: `escape_hatch_vote_only`, `publisher_funding_multi`

**Multi-node**
- block-production: `blob_promotion`, `proof_boundary`
- governance: `add_rollup`
- slashing: `multiple_validators_sentinel`, `sentinel_status_slash`

**p2p**
- `gossip_network`, `fee_asset_price_oracle_gossip`,
`late_prover_tx_collection`

**automine**
- `ordering`

Speedups to `empty_blocks`, `block_building`, `account_init`,
`messages`, `l1_to_l2`, and `l2_to_l1` were reverted to baseline after
CI flagged instability, and are left for the follow-up PR.
PhilWindle pushed a commit that referenced this pull request Jun 30, 2026
## Context

The first checkpoint publish in e2e setup pays two independent ~2.2s KZG
trusted-setup inits serially (~4.4s total):

- **Ours** — the `@crate-crypto/node-eth-kzg` singleton (`getKzg()`), a
synchronous, event-loop-blocking precomp build that inits lazily on the
first blob commitment computation.
- **Anvil's own** — anvil loads its own KZG trusted setup the first time
it validates a blob sidecar in `eth_sendRawTransaction`.

Both land on the first publish, one after the other.

## Approach

Add `warmBlobKzg` to `@aztec/ethereum/test` and call it during
`setupInner`, just before the L1 contract deploy. It warms our kzg and
anvil's in parallel with each other:

- Warming anvil needs no real kzg — a pure-JS fake sidecar (48-byte
garbage commitment + proof) makes anvil load its trusted setup to
validate, then reject the tx. No block is mined and no nonce is burned.
- The recipe is deterministic: all RPC round trips happen up front
(`prepareTransactionRequest` + `signTransaction`), so only a single
socket write needs to flush (one `setImmediate` yield) before the
loop-blocking `getKzg()` call. That gives full overlap (~2.2s measured)
without a racy fixed delay — anvil warms in its own process while we
build our precomp tables.
- The warm-up reuses the injected publisher `l1Client`. The fake-sidecar
tx is rejected at admission, so it never enters the pool, mines no
block, and burns no nonce — sending it from the publisher account is
harmless.
- Best-effort: any failure is debug-logged and swallowed, leaving lazy
init as the fallback. Gated to locally-started anvil runs (`anvil &&
isAnvilTestChain`).

A behavior-based test (`blob_kzg_warmup.test.ts`) asserts the warm-up
resolves without throwing and that a subsequent real blob tx is accepted
and mined by anvil.

## Measured impact

From the per-process timing JSONL of this branch's full e2e CI run vs.
the nearest healthy full run on the same base (#24378). Both are
cache-busted full runs; figures are **sums across 316 parallel
processes** (~633 real tests), not wall-clock.

| Metric | #24378 (base) | This PR | Δ |
| -- | --: | --: | --: |
| Overall | 31,439,600 ms | 31,297,153 ms | −0.5% |
| Before-hooks (setup) | 8,448,072 ms | 8,158,701 ms | −3.4% |
| — of which `setup()` | 2,171,459 ms | 2,581,000 ms | +18.9% |
| Body | 22,550,764 ms | 22,630,788 ms | +0.4% |

The only metric that rises is time inside `setup()` (+18.9%), and that
is a **relocation of work, not new cost**: the warm-up moves the ~2.2s
`getKzg()` build out of lazy node-init / first-publish and into
`setup()`. Per suite, `setup()` time rises while other before-hook time
(node/sequencer init) falls by a comparable-or-larger amount — globally
`setup()` +410k ms vs. other before-hooks −699k ms — so total
before-hooks per process drops and overall is flat-to-slightly-better.
The per-process wall-clock win from overlapping anvil's init with ours
is real but not separately visible in these summed metrics.

Fixes A-1297
spalladino pushed a commit that referenced this pull request Jul 2, 2026
…encer nonce race (#24386)

## Root cause

`e2e_token_bridge_tutorial_test` flakes with
`WaitForTransactionReceiptTimeoutError: Timed out while waiting for
transaction … to be confirmed`
([example](http://ci.aztec-labs.com/1643e817d7242e0e)). It is **not**
caused by #24378 — that PR only touches `forge_broadcast.js` (the L1
*contract deploy*), which succeeds in the failing run; the timeout is
~3.5 min later on one of the test's own L1 txs.

The test runs against `aztec start --local-network`, whose
`AutomineSequencer` continuously publishes checkpoint txs to L1. In
`local-network.ts` the sequencer-publisher and validator keys are
derived from `DefaultMnemonic` (`'test test … junk'`) at **address index
0** — `0xf39Fd6…` (visible as the publisher in the log).

The test's L1 client was created with the **same** mnemonic and the
**same** default index 0:

```ts
const l1Client = createExtendedL1Client(ETHEREUM_HOSTS.split(','), MNEMONIC); // → account index 0
```

So the test and the node's sequencer send L1 txs from one account,
sharing a single nonce sequence. Against an automining anvil they race:
a test tx can land with a nonce the sequencer just consumed (dropped /
"already known"), or with a future-nonce gap that automine won't mine
until the gap fills. Either way the tx gets a hash but never confirms,
and viem's per-tx confirmation timeout fires. This is intermittent
because it only bites when a test tx and a sequencer publish overlap.

## Fix

Derive the test's L1 client from a **different** address index so its
nonce space is independent of the sequencer's:

```ts
const l1Client = createExtendedL1Client(ETHEREUM_HOSTS.split(','), mnemonicToAccount(MNEMONIC, { addressIndex: 1 }));
```

Index 1 (`0x70997970…`) is funded by anvil's default mnemonic and is
unused by the local-network node (publisher/validator = index 0; index 2
is the prover and 3+ are attesters by the existing `setup.ts` /
`setup_p2p_test.ts` convention, none of which run in local-network). The
test is fully self-contained on its own L1 account — it deploys and owns
the `TestERC20`, `FeeAssetHandler` and `TokenPortal`, and bridges
to/from `l1Client.account.address` — so nothing requires it to be
account 0. All of the test's L1 txs (including the `L1TokenManager` /
`L1TokenPortalManager`, built from `l1Client`) now go through index 1.

## Verification

This is an `compose` e2e test that needs docker + a full network; the
container here is a source-only checkout (no `node_modules`, no docker),
so I could not run the test or `./bootstrap.sh ci` locally — flagging
that explicitly per the repo's red/green policy. The fix is verified by
analysis: the timed-out tx is one of the test's own L1 txs, the
shared-account nonce race is the established cause of this exact viem
symptom, and account index 0 vs 1 are the well-known distinct anvil
accounts (idx 0 = `0xf39Fd6…`, the publisher in the log; idx 1 =
`0x70997970…`).

## Relationship to #24385

#24385 broadened this test's `.test_patterns.yml` flake pattern to keep
CI from going red on the residual flake. This PR removes the underlying
cause. The flake entry can stay as a safety net, or be narrowed back to
the jest-timeout-only form once this has soaked — happy to follow up
either way.


---
*Created by
[claudebox](https://claudebox.work/v2/sessions/c01cbe3dd422bf00) ·
group: `slackbot`*
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.

3 participants