Skip to content

fix(batch-builder): defer transactions already packed by a peer batch (#1329) - #1330

Open
MavenRain wants to merge 3 commits into
mainfrom
fix/1329-defer-peer-batched-txs
Open

fix(batch-builder): defer transactions already packed by a peer batch (#1329)#1330
MavenRain wants to merge 3 commits into
mainfrom
fix/1329-defer-peer-batched-txs

Conversation

@MavenRain

Copy link
Copy Markdown
Contributor

Closes #1329.

Problem

One signed transaction submitted to K committee validators lands in K batches. Every copy passes peer validation, every batch gets certified, and execution pays for the first copy only: the later copies are skipped with InvalidTxSkipReason::NonceTooLow at no cost to the sender (crates/tn-reth/src/env/execution.rs). The batch builder is timer driven (crates/batch-builder/src/lib.rs, one max_batch_delay tick), and nothing on the vote path tells the local builder that a peer already packed a transaction. So one 21,000-gas transfer paid once can occupy 21,000 gas of batch capacity on every validator, and a sender can amplify that to a full batch per validator with a burst of transfers.

No attacker is required: a wallet that broadcasts to several RPC endpoints (a common client pattern) produces the same amplification. A deliberate sender can do it to every validator at once.

Fix

Local, protocol neutral, always on. When this node validates a peer batch (any of the three validate_batch call sites: the vote path, the prefetched gossip batch, and a synced batch that is not yet certified), it remembers the batch's transaction hashes. The local builder skips a remembered hash and, through mark_invalid, that sender's later nonces for the current build; they wait as long as the deferred nonce does, which they could not execute ahead of anyway. When every pending transaction is deferred, the build task seals nothing for that tick instead of broadcasting an empty batch, which peers would score as a fatal validation failure. Once the peer batch executes, the pool drops the transaction; the memory only matters while the peer batch is in flight or lost.

Threat model:

  • Cheap first. The record happens only after every existing check passed, on transactions the validator already decoded and recovered. The builder side is one hash-map lookup per candidate, no hashing, no signature work.
  • Bounds come from real parameters. PEER_BATCH_DEFER_TTL is 10 s, the default batch_vote_timeout (crates/config/src/node.rs): a peer batch without its quorum by then has been abandoned by its producer, and one with quorum is on its way into a header. PEER_BATCH_SEEN_MAX_TXS is 65,536 hashes, about 46 full batches of 21,000-gas transfers at max_batch_gas, the same order as the engine's repack window cap.
  • A byzantine peer can delay, not censor. A peer that reports a batch it never certifies defers a transaction for at most one TTL per arming. An entry is never refreshed, so re-reporting the same hash cannot extend the deferral; an expired entry stays immune to re-arming until it is forgotten at twice the TTL, and during that immune half this builder packs the transaction if it is still pending. A peer that keeps reporting therefore adds at most one TTL of delay per two TTLs. An honest producer that misses its vote quorum rebuilds the same transactions and re-reports the same digest, a no-op inside the immune window.
  • Fails soft, never early. When the window is full of live entries, new hashes are not remembered until entries age out at twice the TTL, so the builder degrades to today's behaviour. Entries are never evicted early: validation checks neither balance nor nonce, so a flood of junk peer batches is cheap, and a window that evicted could be used to re-arm an immune entry every cycle. Memory stays under 7 MB at the cap and no build ever blocks.
  • Catch-up paths. Certified synced batches and fetched batches are stored without local validation and so are not recorded; they are certified already, and their transactions leave the pool at execution as before.
  • Residual. Validators whose builder tick fires inside the dissemination latency of the first batch still pack their own copy. Amplification drops from K to roughly one plus that race window, and execution keeps skipping the survivors for free as before.

Not in this PR, offered as follow-ups: sender-slot routing at RPC ingress (owning_validator in crates/tn-reth/src/forward.rs already computes the slot, but the RPC add path bypasses the Telcoin pool wrapper), and a config knob for the TTL if operators want one. #1268 (repack monitor) keeps measuring what remains.

Changes

  • crates/tn-reth/src/peer_batch.rs (new): PeerBatchTxs, a cheap-clone handle over a bounded, insertion-ordered seen set with record / is_deferred (the clock is read under the lock, so the order stays time-sorted across concurrent validators; explicit-Instant variants are private to the module's tests), plus the two constants and their derivations.
  • crates/batch-builder/src/lib.rs: the build task returns BuildOutcome::NothingToSeal instead of sealing when the built batch holds no transactions; the run loop treats it as a quiet tick (no pool update, no error log, no refusal backoff).
  • crates/tn-reth/src/txn_pool.rs: WorkerTxPool carries a PeerBatchTxs; the TxPool trait gains record_peer_batch and is_peer_deferred; BestTxns::peer_deferred marks the candidate invalid with a hand-rolled PeerBatchDeferred error (is_bad_transaction false), so the sender's descendants are held for this build only.
  • crates/batch-validator/src/validator.rs: validate_batch records the decoded hashes on full success only. Trait signature unchanged.
  • crates/batch-builder/src/batch.rs: the selection loop skips deferred hashes first; BatchBuilderOutput reports the count and BatchBuilderMetrics exposes it as peer_deferred_txs_total.
  • crates/batch-builder/src/test_utils.rs: TestPool implements the two new trait methods, and its best-transactions stand-in now tracks invalid senders the way reth's iterator does, so descendants are skipped in tests too.
  • crates/tn-reth/src/lib.rs: exports the new module and its two constants, and re-exports SenderId for the test pool.
  • READMEs for tn-reth and batch-builder describe the deferral next to the existing note that duplicates across workers stay tolerated at execution.

Testing

Under the pinned toolchain (rust-toolchain.toml, 1.94) with the shared target dir:

cargo +nightly fmt --all -- --check
cargo +nightly clippy -p tn-reth -p tn-batch-builder -p tn-batch-validator -p tn-worker --all-features --all-targets --no-deps -- -D warnings
cargo +1.94 test -p tn-reth --lib
cargo +1.94 test -p tn-batch-builder -p tn-batch-validator

New tests, all deterministic (explicit Instants, wait_until for convergence, no sleeps):

  • peer_batch unit tests (eight): fresh hash deferred, unknown hash not deferred, expiry at exactly the TTL, no refresh on re-record, immune window until twice the TTL, re-arm after that, a full window drops new hashes until entries age out, a full window cannot re-arm an immune entry, one oversized record keeps only the first cap hashes.
  • batch.rs unit test with TestPool: sender A's first nonce is recorded, the build packs only sender B, both of A's transactions stay out, the deferred count is one.
  • lib.rs unit test: a pool whose only pending transaction is deferred makes the build task return the no-work outcome and send nothing to the worker.
  • build_batches.rs integration test: a valid peer batch holding tx1 goes through BatchValidator::validate_batch, the next built batch holds only tx2, and the pool reports tx1 deferred and tx2 not.

Mutation check, three rounds: with is_peer_deferred forced to false on both pools, and separately with the builder's skip neutralised, the builder unit test and the integration test fail; with the empty-batch guard removed, the lib.rs test fails; with oldest-first eviction reintroduced, the immune-entry test fails. Restored, all pass.

🤖 Generated with Claude Code

…#1329)

One signed transaction submitted to K validators fills K certified
batches and pays once, because execution skips the later copies for
free and nothing on the vote path tells the local builder that a peer
already packed the transaction.

When a peer batch passes validation, remember its transaction hashes on
the worker pool for PEER_BATCH_DEFER_TTL (10 s, the default
batch_vote_timeout) and let the builder skip them, holding the sender's
later nonces for the current build through mark_invalid. When every
pending transaction is deferred the build task seals nothing for that
tick instead of broadcasting an empty batch. Entries are never
refreshed and stay immune to re-arming until twice the TTL, so a
byzantine peer adds at most one TTL of delay per two TTLs and cannot
censor. The set is capped at PEER_BATCH_SEEN_MAX_TXS (65,536); a full
window drops new hashes until entries age out and never evicts early,
so a flood of peer batches can only switch the deferral off.

Closes #1329.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
@MavenRain MavenRain self-assigned this Sep 4, 2026
Replace the two `match <bool> { true => .., false => .. }` blocks that
#1329 added with plain `if`/`else`:

- batch.rs `build_batch`: the peer-deferred vs gas-limit branch.
- lib.rs `spawn_execution_task`: the empty-batch vs seal branch.

No behavior change. The arm bodies are unchanged apart from indentation.
rustfmt (1.94) reports the crate clean.

Signed-off-by: Onyeka Obi <softwareengineerasaservant@isurvivable.cv>
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.

worker: one signed transaction submitted to K validators fills K certified batches and pays once (cross-validator duplicate amplification)

1 participant