Skip to content

feat: gasless mode + external-chain load-testing correctness (chainId, confirmations, force-stop)#44

Open
IvanBelyakoff wants to merge 10 commits into
mainfrom
feat/gasless-external-chain-loadtest
Open

feat: gasless mode + external-chain load-testing correctness (chainId, confirmations, force-stop)#44
IvanBelyakoff wants to merge 10 commits into
mainfrom
feat/gasless-external-chain-loadtest

Conversation

@IvanBelyakoff

Copy link
Copy Markdown
Contributor

Builds on the route-all/org-routing work (#41, #42) to make the load generator work correctly against an external chain via a privacy proxy (no bundled builder/preconf), and to make its tallies honest.

Gasless mode

  • feat(gasless): skip account funding, send 0-value eth-transfers, zero gas tip/fee caps (per-test gasless flag or GASLESS env) — for zero-fee chains that self-authorize senders by signature. eth-transfer only.

External-chain correctness

  • auto-detect chainId: query eth_chainId and adopt it, so signatures are valid on the target chain (a stale default made every send invalid).
  • poll chain for confirmations when no preconf WS: receipt/block polling fallback when the layer supports preconf but no WS is configured (external proxy).
  • tolerate missing auth token at startup: in route-all, defer the privacy client (token is pasted later) instead of failing to start.

Honest metrics (no successful tx mislabeled)

  • chart MGas/s & TPS from the chain poller: feed the live charts over HTTP from blocks the poller already fetches (no WebSocket) — zero extra RPC.
  • don't count shutdown-canceled sends as failed + adaptive confirmation grace (scaled to observed block time on the poller path).
  • resolve still-pending txs by on-chain receipt at test end: late-but-included txs become confirmed, not discarded — throughput stays windowed, success is per-receipt.
  • treat "already known" resubmits as sent: RPC-retry duplicates of an already-submitted tx are not failures.

Force-stop

  • Stop button force-stops the post-test sequence (skips grace + receipt resolution + verification); natural completion still runs the full pass.
  • Pending-tx receipt resolution is abortable on force-stop (cancels in-flight lookups + skips the rest). Unit-tested.

Pairs with the gasstorm PR (standalone stack + gasless dashboard toggle). Adds unit tests (already_known_test.go, resolve_pending_test.go); full loadgen suite green.

🤖 Generated with Claude Code

IvanBelyakoff and others added 10 commits June 26, 2026 15:56
External/prod privacy mode runs EXECUTION_LAYER=reth (preconf supported)
but has no builder preconf socket (PRECONF_WS_URL empty) — the proxy is
the only RPC endpoint. The receipt-polling fallback previously started
only when !SupportsPreconfirmations, so these runs had zero confirmation
sources and reported 0 confirmed even though txs landed on-chain.

Start the chain poller whenever no live preconf stream is available:
unsupported layer OR supported-but-no-URL configured.

Verified: 200 TPS route-all test now reports 2725 confirmed / 0 failed
(was 0 confirmed), confirmations sourced via eth_getBlockByNumber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a gasless mode for load-testing chains that have zero gas fees and
self-authorize senders by signature (eth_sendRawTransaction) — e.g. an
external privacy proxy fronting a gasless devnet. No faucet keys or
pre-funded/authorized accounts are needed.

When enabled (StartTestRequest.Gasless from the UI toggle, or GASLESS env):
- skip faucet funding and the 0-funded hard error (the blocker on gasless
  chains, where unfunded random accounts can't be funded);
- skip the warm-start balance check (always reads zero on a zero-fee chain)
  and the builder-nonce reset (a bundled-builder concern, meaningless here);
- send 0-value eth-transfers (sender needs no balance);
- use zero gas tip and fee caps, skipping the gas-price/baseFee probes that
  would otherwise force a non-zero fee cap.

Supports the eth-transfer transaction type only (contract types need a
funded deployer); other types / realistic pattern are rejected with a clear
error. The normal funding path is unchanged when gasless is off.

Verified: gasless test skips funding, sets zero gas, reaches running and
sends 0-value txs (rejected only on the local chain because its base fee is
7 wei > 0; accepted on a true base-fee-0 chain). Normal non-gasless test
still funds and confirms (regression check: sent 1001 / confirmed 981 / 0
failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The load generator signs every transaction with cfg.ChainID (default 42069).
Against an external chain (route-all / gasless mode) whose id differs, every
signature is invalid for that chain, so the chain rejects all sends — silently,
counted only as "failed" with no surfaced error. Observed on B3 (chainId
1485275539): reads/auth/nonce-init all succeeded through the proxy, base fee is
0 and eth_sendRawTransaction is permitted, yet 100% of sends failed purely
because they were signed for chain 42069.

Query eth_chainId at the start of each test (after the route-all client is
wired with the current token) and adopt the chain's reported id, overriding the
configured default with a warning. On the bundled chain the reported id equals
the default, so this is a no-op there. Best-effort: on query failure the
configured id is kept.

Removes the need for operators to know a third party's chainId when load-testing
their proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In external/gasless mode there is no block-metrics WebSocket (no builder, the
proxy is HTTP-only), so the live charts (MGas/s, TPS, block count, total gas)
were empty even though the test ran fine — those series are fed only by the WS
handlers. The chain poller, meanwhile, already fetches every block via
eth_getBlockByNumber for confirmation scanning and throws away everything but
the tx-hash list.

Record block metrics from the block the poller already has — no extra RPC. The
recorder mirrors the WS handler (cumulative gas, per-period buffer, rolling
windows), so it populates the same series the chart reads and leaves the
getBlockMetricsViaRPC fallback dormant (blockMetrics non-empty => no double
count). Block time is taken from the blocks' own timestamps, not wall-clock, so
MGas/s stays accurate even when the poller scans several blocks in one pass
while catching up.

Verified (poller path, local chain): live MGas/s ~8, block count and total gas
climbing each sample, confirmations unaffected. Previously all zero without a WS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… grace

Two correctness fixes for honest tallies on remote/polled chains (e.g. an
external privacy proxy fronting a gasless chain), where the bulk of reported
"failures" were measurement artifacts, not chain rejections.

1) Shutdown-canceled sends are no longer "failed". When a test ends, sends that
   are still in flight return context.Canceled (the worker context is canceled).
   Over a high-latency remote proxy many sends are in flight at the boundary, so
   they dominated the failure count (e.g. 78/987). These were never rejected by
   the chain — they were aborted by our own stop. The send callback now detects
   errors.Is(err, context.Canceled) and classifies them as discarded (the bucket
   for "sent, not confirmed"), without RecordTxFailed or tripping the circuit
   breaker. Verified the error reaches the callback as the bare context.Canceled
   sentinel through the NoBatch -> HTTPClient -> Call path, so errors.Is matches.

2) Adaptive confirmation grace on the poller path. With a preconf WebSocket,
   confirmations are instant and a 3s grace suffices. Polling blocks over HTTP
   lags the head, so the tail of a run needs several block intervals to be seen
   or it lands in "discarded". When there's no preconf WS, scale the grace to
   6x the observed block interval (clamped 6-30s) using the cadence recorded by
   the chain poller, instead of a fixed 3s.

Net effect: "failed" reflects real chain/proxy rejections; late-but-successful
txs are "confirmed" or "discarded", never "failed". Verified locally on the
poller path: adaptive grace = 6s, fail = 0, no regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separates per-tx success from throughput measurement. The on-chain verification
window is the block range [testStartBlock, testEndBlock] (head at the moment
sending stops), deliberately excluding later blocks so throughput isn't diluted
by post-test drain. But that same cut left the tail — txs sent in the final
moment that land a block or two later — reported as "discarded"/"pending" even
though they succeeded (observed on B3: onChainTxCount 920 vs live-confirmed 747).

After the grace period, look up each still-"pending" tx's receipt directly
(eth_getTransactionReceipt, bounded concurrency), independent of the block
window: a successful receipt reclassifies the tx as confirmed; no receipt (or a
revert) leaves it discarded. A still-"pending" map entry was never touched by the
poller, so its sent-time is intact and RecordTxConfirmed counts it. Logs a
breakdown (confirmedLate / notOnChain / reverted / lookupErrors).

Net: "discarded" means "actually never landed", not "landed just after our
cutoff" — while TPS/MGas/s stay measured over the [start,end] window.

Note: uses lg.l2Client, which on an external/route-all run is the proxy (a full
node serving receipts). The bundled block-builder endpoint does not serve
eth_getTransactionReceipt, so this is a no-op (lookupErrors) on the rarely-used
bundled poller path; the target external/gasless path works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A send can return "already known" / -32000 ALREADY_EXISTS when the RPC client's
internal retry resends a tx whose first attempt already reached the node (common
over a remote proxy: a 429/502/503/504 or lost response triggers a retry, but the
identical, deterministic signed tx is already in the mempool/chain). The tx
succeeded; only the duplicate resend errored — yet it was counted as failed
(and, with receipt-resolution, also later found on-chain, double-counting).

Detect it in the send callback (isAlreadyKnownTx) and treat it as a successful
submit: commit the nonce (the tx used it and landed) and leave the tx pending for
the confirmation/receipt path. Don't RecordTxFailed, don't trip the circuit
breaker. Unit-tested against B3's exact error string and negatives (nonce too
low / underpriced are NOT treated as duplicates).

Net: "failed" now excludes both shutdown-canceled sends and already-known
duplicates — it reflects only genuine chain/proxy rejections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ence

The dashboard Stop button and natural (duration) completion both called the same
StopTest(), so a user Stop still ran the full post-test sequence — the
confirmation grace period (now up to 30s on the poller path), per-tx receipt
resolution, and on-chain verification — making Stop feel unresponsive.

Split the two:
- StopTest() (the interface method the Stop button calls) sets a forceStop flag
  and runs the stop. Force-stop skips the grace period, receipt resolution, and
  on-chain verification, so the test ends promptly with the live counts.
- Natural completion (completionWatcher) calls the internal stopTest() without
  forceStop, so the full confirmation/verification still runs.

Also:
- The grace wait is now interruptible (sleepUnlessForced), so a Stop arriving
  mid-grace aborts it.
- stopTest() uses CAS on `stopping` to run the sequence once; a concurrent Stop
  during natural completion just sets forceStop, which the in-flight sequence
  observes. forceStop is reset at test start.

Verified locally: Stop mid-run reaches "completed" in ~0.1s with all three
skip markers; natural completion still runs grace + resolution + verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The receipt-resolution loop used context.Background() per lookup and ran to
completion via wg.Wait(), so a force stop (Stop button) arriving while we were
already inside the loop only set the flag — the loop kept fetching every pending
receipt. The grace was interruptible and the call-site skipped resolution if
already stopped, but the in-flight loop itself was not abortable.

Tie the per-lookup context to a cancelable resolveCtx watched by a goroutine
that cancels it when forceStop is set; also skip remaining items at the loop
head. So a Stop mid-resolution cancels in-flight lookups immediately and skips
the rest. Logs an abortedByStop count.

Verified by unit tests: a Stop mid-loop aborts in ~0.1s (17 canceled, 33
skipped) instead of running all 50 blocking lookups; a preset force stop makes
zero lookups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NewLoadGenerator built the route-all privacy client eagerly and failed to start
if the auth token file was absent. That's wrong for the standalone/paste flow:
the token is pasted in the dashboard after the stack is up, and
runInitialization already rebuilds the privacy client (reading the current
token) at the start of every test. The bundled stack hid this by pre-writing a
token, so it only surfaced standalone.

Build the route-all client at startup if the token is already there; otherwise
log a warning and fall back to plain clients, which runInitialization replaces
once a token exists. One client instance is shared across builder/l2 (matching
the route-all rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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