spam-stream subcommand for streaming tx specs (relayer use case)#589
Conversation
a171692 to
3c13137
Compare
|
really cool idea, thanks for writing this up @jelias2! re questions:
We probably don't need to modify
Yeah, I think a tagged/versioned envelope is the way to go. Low effort now to support more fields down the road.
Makes sense, maybe we could add a flag to enable structured output, or that could be the default for
Not sure what "drain-as-fast" means but I think I understand the question -- why do we only send one tx at a time when we could send txs in parallel (barring nonce contention)? Probably wouldn't hurt to send transactions from different accounts in parallel. Not sure how much benefit it would provide, though. Maybe in very high-tps scenarios, we could squeeze some benefit out of it but the RPC provider that sends these is shared by each account, so there'd only be one http connection if we used existing tools, so we'd need an http connection pool (maybe as a mod to the provider). Not sure it's worth the effort.
Probably worth doing now. Shouldn't be too much effort, and it's non-breaking. |
- Address review feedback on PR flashbots#589: - flashbots#2/flashbots#3: emit structured, versioned/tagged JSON envelope on stdout (one tx_result event per spec) so the schema can evolve; default for spam-stream mode (logs stay on stderr). - flashbots#5: replace the decoy zero-address TestConfig with a direct AgentStore pool, injecting signers into the scenario and syncing nonces from the RPC.
|
Thanks for the review! Addressed the actionable items (commit #2 — Versioned/tagged JSON envelope ✅ {"version":1,"type":"tx_result","idx":0,"tx_hash":"0x...","start_timestamp_ms":1733155200000,"kind":"validate","error":null}
#3 — Structured output ✅ (default for #5 — Decoy Deferred (per your guidance):
Docs (
|
|
Pushed
Build + clippy clean; |
2d5bc83 to
4c4bcef
Compare
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.
- Address review feedback on PR flashbots#589: - flashbots#2/flashbots#3: emit structured, versioned/tagged JSON envelope on stdout (one tx_result event per spec) so the schema can evolve; default for spam-stream mode (logs stay on stderr). - flashbots#5: replace the decoy zero-address TestConfig with a direct AgentStore pool, injecting signers into the scenario and syncing nonces from the RPC.
…ummary Address review gaps on the prototype: - record a real run via insert_run so dumped receipts aren't orphaned under run_id 0 (run_txs has a foreign key into runs) - cache the gas price, refreshing every 6s instead of once per tx - emit `backpressure` and a terminal `summary` event; track sent vs failed - reject blob (4844) / setCode (7702) specs up front - unify the stdin/file reader and drop dead run_id plumbing
4c4bcef to
3e53066
Compare
…sing TestScenario::sync_nonces() is gated on should_sync_nonces (= the sync_nonces_after_batch param). spam-stream set it false, making its two explicit sync_nonces() calls silent no-ops, so pool accounts' nonces were never loaded and prepare_tx_request failed every send with NonceMissing (surfaced as 'core error'). Stream mode sends one tx at a time and never hits the post-batch sync path, so enabling this only makes the initial pool-nonce sync run.
prepare_tx_request advances an account's local nonce before the send. When the send is rejected (e.g. an interop access-list filter rejecting a not-yet-valid or forged executing message), the tx never enters the mempool but the local nonce stays advanced, leaving a gap that stalls every later tx from that account behind it. Roll the nonce back by one on a failed send. The stream sends serially, so no concurrent send touched the account in between.
zeroXbrock
left a comment
There was a problem hiding this comment.
I'm seeing some odd behavior with the --tps flag, not sure if I'm misinterpreting the results or if there's a bug.
When I run the following:
echo '{"to":"0xdeAD000000000000000000000000000000000000","value":"1 wei","gas_limit":21000}' | cargo run -- spam-stream -p 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --tps 10if I provide --tps 0 or --tps 1000 it doesn't make a difference. I believe this is to be expected, because I only have one transaction to send, but the help menu doesn't explain this. Could you add some more detail in the long_help designation for the --tps flag?
I'm also seeing weird behavior with --skip-funding. I try to fund the accounts with --min-balance 10000000000000000000 in a prior step, then run again with --skip-funding instead of --min-balance, and I get an "insufficient funds" error. The accounts should be funded. It looks like we're not using the same RandSeed every time -- why not? We do this in every other command.
On top of that, --min-balance should support unit-value strings (e.g. "10 eth"). Use the parse_value function from utils -- it's used in several other places in the cli crate's command interfaces, via serde's deserialize_with macro.
--tps paces how fast specs are pulled off the input stream; each spec is sent exactly once, so a one-line input sends a single tx regardless of the value. Add long_help explaining this, plus tests asserting the help text is present and the clap arg config is valid (debug_assert).
…hbots#589) --min-balance now parses unit-value strings ("10 eth", "0.5 ether", "100 gwei") via util::parse_value, matching the other CLI balance/value flags; a plain number is still wei. Default expressed as "0.01 ether" (unchanged 1e16 wei). Tests cover unit parsing, the wei fallback, and the default round-trip through the value_parser.
…eview flashbots#589) When --seed was unset, spam-stream generated a fresh random RandSeed each invocation, so the executor pool's addresses differed every run. Funding the pool with --min-balance in one run then re-running with --skip-funding hit "insufficient funds" because the second run derived a different (unfunded) pool. Fall back to the persisted seedfile (data_dir/seed) when --seed is unset, matching spam/setup/campaign. Threads data_dir into spam_stream(). Pool addresses are now stable across invocations for a given data-dir. Test: build_pool_agent_store is deterministic per seed (same seed -> same addresses, different seed -> different). Manually verified against anvil: run 1 funds the pool + writes the seedfile; run 2 with --skip-funding and no funder key sends successfully (previously failed with insufficient funds).
|
Thanks @zeroXbrock! Addressed all three in separate commits on top of the branch:
Added unit tests for each (help text present, unit parsing + wei fallback + default round-trip, and pool-address determinism per seed); all |
Summary
Adds a new
spam-streamsubcommand that reads newline-delimited JSON tx specs from stdin (or a file) and spams them through the existingTestScenariopipeline. Each spec is aFunctionCallDefinition— the same schema as scenario TOML[[spam.tx]]— soaccess_list,signature/args,gas_limit,value, andfrom_poolall 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_runsentry, no integration with--rpc-batch-size/--send-raw-tx-sync. Seedocs/stream-mode.mdfor 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
External generators
Operations
Differential testing
Composition with existing contender features: keep
[[create]]and[[setup]]static in a scenario, deploy/fund once, then stream the dynamic spam phase.CLI
Flags:
-r/--rpc-url,-p/--priv-key,--from,--from-pool(defaultexecutors),--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
FunctionCallDefinitionper 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-keyat 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. Nocontender_corechanges. The flow:A no-op one-step
TestConfigis constructed soAgentPools::build_agent_storeproduces a pool with the requested name and size. The decoy spam step itself is never executed; we bypassload_txsentirely.We don't reuse
TimedSpammer/BlockwiseSpammerbecause theiron_spamloops pull from a pre-loadedVec<Vec<ExecutionRequest>>viaget_spam_tx_chunks. Stream mode is fundamentally stream-shaped. Adding a genericSpamSourceabstraction 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
Smoke test against
interop-bench-2-0:Tx
0x8742f5d94cec761fd927ddcbe1cfcad7ba45e352a81cfeb87277780523ed3646landed in block 131011, status0x1.Test plan
cargo fmt --checkcleanWhat's deferred (follow-up work)
[[spam.bundle]]) support--rpc-batch-size/--send-raw-tx-syncintegrationspam_runsTimedSpammer/BlockwiseSpammerto share aSpamSourcetraitOpen design questions
contender_core? The prototype keeps everything incli/. Moving it intocorewould letcampaignsconsume a stream too — but the existingSpammertrait wants aVec<Vec<ExecutionRequest>>upfront. Natural refactor is a newSpamSourcetrait thatTimedSpammer/BlockwiseSpammercould also adopt.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.--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 usepool_sizeparallel workers?AgentPools::build_agent_storeto accept an explicit pool list. Worth doing now or in a follow-up?