Skip to content

Draft: spam-stream subcommand for streaming tx specs (relayer use case)#589

Draft
jelias2 wants to merge 2 commits into
flashbots:mainfrom
jelias2:jelias/spam-stream-mode
Draft

Draft: spam-stream subcommand for streaming tx specs (relayer use case)#589
jelias2 wants to merge 2 commits into
flashbots:mainfrom
jelias2:jelias/spam-stream-mode

Conversation

@jelias2
Copy link
Copy Markdown

@jelias2 jelias2 commented May 29, 2026

Summary

Adds a new spam-stream subcommand that reads newline-delimited JSON tx specs from stdin (or a file) and spams them through the existing TestScenario pipeline. Each spec is a FunctionCallDefinition — the same schema as scenario TOML [[spam.tx]] — so access_list, signature/args, gas_limit, value, and from_pool all work without any new schema.

This is a Draft PR for design feedback. It compiles, runs end-to-end against a real devnet, and lands tx receipts via the regular tx_actor flush loop. It is deliberately small: no bundle support, no fuzzing, no recorded spam_runs entry, no integration with --rpc-batch-size/--send-raw-tx-sync. See docs/stream-mode.md for the architecture note and scope list.

Motivation: a generic streaming primitive

Today, contender spam is generator-driven: a static scenario describes what to send, contender cycles through it. That's the right shape for synthetic throughput tests but not for cases where what to send is computed at runtime by something other than contender.

Stream mode lets any upstream process pipe specs into contender and reuse the existing agent pools, rate limiting, signer/nonce management, gas-price caching, receipt tracking, and Prometheus latency metrics. The upstream owns what to send; contender owns how to send it efficiently.

Use cases

Cross-chain / bridge / relayer workflows — the motivating case. Watch chain A for an event, compute a tx (with access list) for chain B, emit it. Works for OP-stack interop, Hyperlane, LayerZero, native rollup bridges, any "receive-and-forward" pattern. Also fits MEV relayers replaying captured bundles and AA bundlers feeding pre-signed UserOperations.

Tx replay

  • Replay a captured mainnet tx range against a forked node to reproduce a bug or audit a migration.
  • Replay mempool snapshots for performance regression testing.
  • Pin a flaky failure as a JSON file and replay it deterministically.

External generators

  • Fuzzers emit "interesting" tx specs; contender executes them at a controlled rate.
  • Property-based testers (QuickCheck-style) and state-space explorers that emit "drive the contract into state X" sequences.
  • Custom team DSLs that compile down to tx specs.

Operations

  • Production traffic sampling: pipe a sample of real mainnet txs through staging at N× speed.
  • Incident-response drills: replay pre-built "incident traffic" patterns through a copy of prod.
  • CI gating: a build job computes "expected txs for this release" and uses contender to exercise them against a testnet.

Differential testing

  • Same stream → multiple chains in parallel, diff the receipts (useful for proving rollup or client equivalence).
  • Same stream → same chain with varied client configs.

Composition with existing contender features: keep [[create]] and [[setup]] static in a scenario, deploy/fund once, then stream the dynamic spam phase.

CLI

contender spam-stream \
  -r https://chain-b \
  -p $FUNDING_KEY \
  --from <stdin|FILE> \
  --from-pool executors --pool-size 10 \
  --tps 5

Flags: -r/--rpc-url, -p/--priv-key, --from, --from-pool (default executors), --pool-size (default 10), --tps (default 0 = drain as fast as the stream emits), --min-balance, --seed, --skip-funding.

Stream format

Newline-delimited JSON, one FunctionCallDefinition per line. Empty lines and #-prefixed lines are ignored; malformed JSON logs a warning and the loop continues.

{
  "to": "0x4200000000000000000000000000000000000022",
  "signature": "validateMessage(bytes32)",
  "args": ["0x0102030405060708091011121314151617181920212223242526272829303132"],
  "access_list": [
    {
      "address": "0x4200000000000000000000000000000000000022",
      "storageKeys": ["0x0100000000000000000000000000000000000000000000000000000000000000"]
    }
  ],
  "gas_limit": 200000
}

Private keys never appear in the stream. Producers describe txs; contender signs with an agent from its own pool (derived from --seed, funded from --priv-key at startup). A compromised producer can spam txs but can't drain accounts beyond what the pre-funded agents hold.

Architecture

All new code lives in crates/cli/src/commands/spam_stream.rs. No contender_core changes. The flow:

stdin/file → reader task → mpsc<FunctionCallDefinition> → drive_stream loop
  for each spec:
    Generator::make_strict_call         (resolves from_pool + access_list)
    Templater::template_function_call   (encodes calldata, threads access_list)
    TestScenario::prepare_tx_request    (assigns nonce, gas, signs)
    txs_client.send_tx_envelope         (same path as the regular spammer)
    TxActorHandle::cache_run_tx         (queues for receipt polling)

A no-op one-step TestConfig is constructed so AgentPools::build_agent_store produces a pool with the requested name and size. The decoy spam step itself is never executed; we bypass load_txs entirely.

We don't reuse TimedSpammer/BlockwiseSpammer because their on_spam loops pull from a pre-loaded Vec<Vec<ExecutionRequest>> via get_spam_tx_chunks. Stream mode is fundamentally stream-shaped. Adding a generic SpamSource abstraction across the existing spammers would be a much larger change; see open questions below.

Dependency on #588

This PR depends on #588 (access_list field + placeholder resolution). The interop relayer use case needs access lists on executing-message calls, and one of the primary justifications for stream mode is that those access lists are computed per-message upstream.

Validation

cargo +1.94 test -p contender_cli spam_stream     # 4 passed
cargo +1.94 test -p contender_cli --lib           # 68 passed
cargo +1.94 fmt --check                           # clean
cargo +1.94 build --release --bin contender       # clean

Smoke test against interop-bench-2-0:

echo '{"to":"0xdeAD...","value":"1","gas_limit":21000}' | \
  contender spam-stream -r https://interop-bench-2-0.optimism.io -p $KEY --tps 1 --pool-size 2

Tx 0x8742f5d94cec761fd927ddcbe1cfcad7ba45e352a81cfeb87277780523ed3646 landed in block 131011, status 0x1.

Test plan

  • Unit tests pass (68 in contender_cli, 4 new for stream parsing)
  • cargo fmt --check clean
  • Manual smoke test: 1 tx via stdin lands on a real L2
  • CI on this branch
  • Manual: stream 100 lines from a file, all land
  • Manual: ctrl-c mid-stream — verify in-flight txs drain before exit
  • Manual: malformed JSON in stream — verify warning + continuation

What's deferred (follow-up work)

  • Bundle ([[spam.bundle]]) support
  • EIP-4844/7702 exercising
  • Fuzzing in stream mode
  • Gas-bump/nonce-shift retry logic
  • --rpc-batch-size / --send-raw-tx-sync integration
  • Recording the run in spam_runs
  • Refactoring TimedSpammer/BlockwiseSpammer to share a SpamSource trait

Open design questions

  1. Should stream mode live in contender_core? The prototype keeps everything in cli/. Moving it into core would let campaigns consume a stream too — but the existing Spammer trait wants a Vec<Vec<ExecutionRequest>> upfront. Natural refactor is a new SpamSource trait that TimedSpammer/BlockwiseSpammer could also adopt.
  2. JSON schema evolution. Today the stream is bare FunctionCallDefinition. A tagged envelope ({"v":1,"tx":{...}}) would give us room to add per-line metadata (e.g. correlation IDs back to the upstream event) without breaking compatibility.
  3. Backpressure feedback. The only feedback today is tx_actor's DB writes and stderr logs. A structured response stream (stdout JSON line per sent tx with hash + status) would unblock reactive callers.
  4. Concurrency for --tps 0. Drain-as-fast currently sends one tx at a time per loop iteration, bounded by the pool size only via nonce contention. Should it explicitly use pool_size parallel workers?
  5. Decoy TestConfig hack. Using a no-op spam entry to wire up the agent store. Cleaner would be to teach AgentPools::build_agent_store to accept an explicit pool list. Worth doing now or in a follow-up?

jelias2 added 2 commits May 29, 2026 17:05
Supersedes flashbots#581. Adds an `access_list` field to FunctionCallDefinition
that accepts {placeholder} strings in `address` and `storageKeys`,
resolved during the loose-to-strict conversion via the existing
templater + DB map.

Coexists with max_priority_fee_per_gas (flashbots#580) and alloy 2.0 (flashbots#561).
Reads newline-delimited JSON FunctionCallDefinitions from stdin or a
file and spams them via the existing TestScenario pipeline. Reuses
agent pools, rate limiting, nonce management, and receipt tracking.

See docs/stream-mode.md for the design note and scope.
@jelias2 jelias2 force-pushed the jelias/spam-stream-mode branch from a171692 to 3c13137 Compare May 29, 2026 21:11
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