feat: testing build source — exact blocks from a private tx intake via testing_buildBlockV1 - #186
Open
qu0b wants to merge 6 commits into
Open
feat: testing build source — exact blocks from a private tx intake via testing_buildBlockV1#186qu0b wants to merge 6 commits into
qu0b wants to merge 6 commits into
Conversation
…a testing_buildBlockV1 Adds `--build-source testing`: buildoor builds payloads from its own private transaction queue through geth's testing_buildBlockV1 instead of the txpool, proposes them through the unchanged ePBS / Builder API path, and verifies on the EL that every included block holds exactly the planned transactions in order. Any deviation is loud (slot result, metric, error log). - pkg/tx_intake: JSON-RPC intake proxy (POST /rpc), per-sender nonce-chain queue ruled by the parent state nonce, deterministic packer (fifo, fee, round_robin, as_given) with geth's caps, EL error attribution. - pkg/payload_builder/testing_build.go: bare fcU to the parent, pack, testing_buildBlockV1 with attributed retries under a slot deadline, built-payload verification against the plan. - pkg/tx_plan_verifier: included block vs plan on the EL; Gloas verdicts. - action plan build category: per-slot source, fill and explicit txs list. - config/flags/settings, API (tx-queue, config/testing), metrics, docs. - lifecycle RPC client tees the builder's own txs into the intake. - kurtosis e2e (minimal, gloas@1, geth+lighthouse) + script; unit tests. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
This was referenced Sep 2, 2026
- Pack takes the caller's queue snapshot instead of re-snapshotting. Build took one snapshot for the sender states and Pack took another, so a sender whose first tx arrived in between had no parent state and the whole slot failed to build — precisely under the intake load the source exists for. - An as_given plan is no longer trimmed by the EL-refusal retry path. Dropping the offender built a smaller block that the verifier then reported as "match" against the reduced plan, and struck the operator's own txs. A refusal now fails the build; fill policies keep the attributed retry. - Entry.Strikes is atomic. Snapshot entries are shared, so the API's read raced with Queue.Strike's write under -race. Regression tests for all three, incl. a concurrent striker/reader test. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
…n path Anything sent to the EL's normal RPC stays in the public txpool while the testing source is active, including a load generator's own wallet funding. Found by running the assertoor playbook with two execution endpoints: the group-less funding selection picked the plain EL and the scenario hung with one transaction pending in geth forever. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
This comment has been minimized.
This comment has been minimized.
…ing" Second review round: - The base-fee ceiling forced the fill to 50% for every policy, so an explicit as_given list that fits the block but not half of it failed with "does not fit: gas_cap" — the operator's exact plan broken by an unrelated sustainability knob. The reduction is now a fill-policy knob only, extracted into applyBaseFeeCeiling and covered per policy. - TxPlanNotIncluded was defined and exposed but never produced, so a testing slot whose bid simply lost sat at "pending" for its whole retention window — the one silent outcome in a feature whose point is being loud. The slot-results tick now records it once the slot is past the inclusion tracker's reorg window, only for slots that already carry a pending plan. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
This comment has been minimized.
This comment has been minimized.
…roxy Third review round: - The blob-cap skip did not stop the sender's chain, so the packer advanced past a transaction it had not added and put that sender's higher nonces in the plan at a gap. geth refuses such a list, the error attributes to the sender, and every one of its transactions is dropped and striked — an attempt wasted on each blob-heavy fill. Any skip now ends the chain, which is what the packer's own nonce rule always said. - The intake forwarded every non-intercepted method to the EL from an unauthenticated route on the API port. In testing-build mode the EL must serve the testing namespace, so anyone reaching the WebUI port could drive testing_buildBlockV1 and build blocks on the devnet EL. It now forwards only the sender-facing namespaces and refuses the rest, and it sits behind the same token as the mutating API endpoints when an auth provider is set. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
This comment has been minimized.
This comment has been minimized.
…ld marker Fourth review round: - The default build deadline was always derived from the ePBS bid window (bid start − 300 ms ≈ 700 ms BEFORE slot start). In a Builder-API-only run, which the README's own example shows, that bound sits before the slot even begins, so any attributes arriving near the slot start permanently skipped the build. It now follows whoever asks for the payload: the bid window when ePBS is enabled, otherwise the slot start, since getHeader can come that early. - executeCandidateBuild announced the build before resolving the testing spec, so a candidate the testing source refuses to build (only the canonical one is supported) left an in-progress marker on the slot graph with no outcome. The spec resolves first, so nothing is announced that will not run. Claude-Session: https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo
There was a problem hiding this comment.
Summary
feat: a private tx intake (JSON-RPC on /rpc) plus a geth testing_buildBlockV1 build path and an on-chain tx-plan verifier, so a buildoor slot's payload can be built from an exact operator-controlled transaction list and the included block is later checked against it. The code is unusually careful (snapshot/state coupling, nonce-chain gap rules, exact-as_given contracts, loud verifier) and I found no blockers; the two items below are a genuine but minor shutdown race and a config footgun.
Issues
- 🟡
pkg/tx_plan_verifier/verifier.go:140— WaitGroup Add races Wait on shutdown (inclusion tracker still live) — see the thread on that line - 🟢
pkg/action_plan/frozen.go:660— Global or per-slotas_givenpolicy without a tx list can never build — The only source of the explicit tx list is the per-slotbuild.txs(resolveSource at frozen.go:660), yettesting.policy: as_givenis accepted by settings/plan validation (config/settings.go ValidTestingPolicy). Any slot that resolvesas_givenwithoutbuild.txsfails every build with "policy as_given needs a non-empty tx list" (packer.go packExplicit); a global--testing-policy as_giventherefore bricks every un-overridden slot. Worth a validation guard (as_given requires a tx list) or a loud startup/plan warning.
Reviewed @ 1aad0a08
"All non-trivial abstractions, to some degree, are leaky." — Joel Spolsky
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Testing build source: controlled blocks from a private tx intake via
testing_buildBlockV1--build-source testingturns buildoor into a block load and edge-case tool for devnets and shadowforks. You decide exactly which transactions a block holds and in what order, buildoor gets the block proposed through its normal ePBS bid/reveal or Builder API path, and it verifies on the EL that the included block holds exactly the plan. Any deviation is loud.What changed
Tx intake (
pkg/tx_intake, served atPOST /rpcon the API port). A JSON-RPC 2.0 endpoint transaction sources point at instead of the EL.eth_sendRawTransactionlands in buildoor's private queue,eth_getTransactionCount(addr, "pending")answers from the queue on top of the EL's latest nonce, everything else is forwarded verbatim (batches too). geth gossips every RPC transaction and cannot be told not to, so a private queue is the only way to guarantee that nobody else includes the transactions. spamoor works unchanged against it. buildoor's own lifecycle transactions are teed into the queue as well, otherwise its intake-built blocks would never carry its deposits.Queue. Per-sender nonce chains keyed by (sender, nonce) and hash. Same hash is idempotent, same (sender, nonce) is replaced by the newer transaction. Nothing is removed at build time: the parent state nonce is the truth. At pack time nonces below it are evicted and gaps stop a sender's chain, which covers reorgs, lost slots, duplicates and restarts with no special cases. Bounded by capacity (reject) and age (evict).
Packer. Deterministic pre-filter with the caps geth will enforce: gas limit stepped toward the target, next base fee, blob base fee bound, blob cap from
eth_config, byte cap, per-sender budget. Policies:fifo,fee,round_robin,as_given.as_givenand the per-slotbuild.txslist are exact: any deviation is an error, never a trim.Build (
pkg/payload_builder/testing_build.go). Bare forkchoiceUpdated to the parent (geth builds on its head only), pack,testing_buildBlockV1, then verify the returned payload holds exactly the plan before it is bid or served. geth returns the getPayload envelope shape, so the whole existing tail (extraData stamp, bid gas-limit enforcement, beacon conversion, bids, reveals, transforms, slot results) is reused unchanged. An EL refusal is attributed to the sender or index, the attributed transactions get a strike, and the build retries withintesting.max_attempts. The build runs under a slot deadline (default: ePBS bid start minus 300 ms); a miss means no payload unlesstesting.on_failure: poolis chosen. Only the canonical candidate builds in this mode.Verifier (
pkg/tx_plan_verifier). On every included testing-built payload it fetches the block from the EL and compares transaction order and count with the plan. Gloasmissedandorphanedverdicts are mirrored. The verdict lands onSlotResult.tx_plan.status, inbuildoor_testing_plan_checks_total, and as an error log line prefixedTX PLAN CHECK FAILED.Control surface. Flags
--build-sourceand--testing-*, all live settings (POST /api/config/testing,build.source/testing.*keys). Per-slot overrides through the action planbuildcategory:source,fill: {gas_pct, max_txs, max_blobs, policy},txs(explicit ordered hashes).GET/DELETE /api/buildoor/tx-queue. Metrics inpkg/metrics.The mapping this guarantees
For every testing-built block there is one
tx_planon the slot result: the ordered list of transaction hashes buildoor asked geth to build. It is checked three times: the packer refuses to deviate from an explicit list, the built payload is compared with the plan before it leaves buildoor, and the canonical block is compared with the plan on the EL after inclusion. Expected order, expected count, or a recorded failure.Verified on a kurtosis devnet
.github/e2e/kurtosis-testing-build.yaml+.github/scripts/e2e-testing-build.sh: minimal preset, gloas at epoch 1, one geth (--http.api ...,testing) + lighthouse participant, one dedicated buildoor from this branch. The script switches the source to testing, runs spamooreoatxagainst the intake, and requires that every won block after the switch verified as an exact match with at least 6 blocks carrying 20+ transactions, then cross-checks one block's transaction list on the EL itself.Result of the run on this branch (image built from the worktree, ethereum-package main, geth
master, lighthouseunstable):skipped.gas_capeth_getBlockByHashtransaction list equalstx_plan.expected_hashesExplicit per-slot plan, same enclave: six transfers from two senders were queued while two slots ran on the pool source, slot 117 was planned with the interleaved order
s3[0], s2[0], s2[1], s3[1], s3[2], s2[2], and the included block holds exactly that list (tx_plan.status: match,expected_hashesequal to the plan,eth_getBlockByHashequal to the plan). A second plan naming a hash that was no longer queued failed the build before any bid:packing: tx 0 (0x82eb…) is not queued, slot resultbuild.status: failed,buildoor_testing_build_failures_total{reason="packing"} 1. Across the whole session: 50 plan checks, 50 matches.The assertoor playbook in ethpandaops/assertoor (
playbooks/dev/buildoor-testing-build.yaml, needs theexecutionGroupsendpoint field from the same PR) passed all nine tasks against this enclave: switch, spamoor through thebuildergroup, block proposals with 20+ transactions, zero mismatches, no forks, switch back.The run also caught a real gap: buildoor's lifecycle deposit went to geth's public pool and never landed in its own intake-built blocks, so the builder stayed unregistered after the fork. The lifecycle RPC client now tees every transaction it sends into the intake.
Unit tests cover the queue, the packer (fifo, nonce rule, fee and balance filters, exact
as_given), error attribution, the proxy (intercept, forward, batches), the block comparison and the gas-limit step.Review fixes (redpandabot)
All three findings were real and are fixed with regression tests:
Buildsnapshotted the queue for the sender states andPacksnapshotted again, so a sender whose first transaction arrived in between had no parent state and the entire slot build errored — under exactly the sustained intake load this exists for.Packnow takes the caller's snapshot, so one consistent view covers states and packing. Test asserts a sender arriving after the snapshot leaves the build unaffected and stays queued for the next block.as_givenwas trimmed by the retry path (🟡). The EL-refusal path attributed and dropped transactions for every policy, so an explicitbuild.txslist was silently reduced, its transactions accrued strikes, and the verifier then reportedmatchagainst the reduced plan. An exact plan now fails the build instead; fill policies keep the attributed retry. Two tests pin both halves of that contract.Strikesread (🟢). Snapshot entries are shared, so the API's read raced the queue's write.Strikesis now atomic behind an accessor, with a concurrent striker/reader test that runs under-race.The whole suite runs under
-race; docs updated for theas_givenrefusal behaviour and the snapshot contract.Second round, both fixed:
as_givenlist that fits the block but not half of it failed with "does not fit: gas_cap" — the operator's plan broken by an unrelated sustainability knob. The reduction is now a fill-policy knob only, extracted into a named helper and covered per policy.not_includedwas never produced (🟢). A testing slot whose bid simply lost sat atpendingfor its whole retention window, the one silent outcome in a feature whose point is being loud. The slot-results tick now records it once the slot is past the inclusion tracker's reorg window, and only for slots that already carry a pending plan, so it never races a real verdict or creates a record.Third round, both fixed:
testingnamespace, so anyone reaching the WebUI port could drivetesting_buildBlockV1and build blocks on the devnet EL. It now forwards only the sender-facing namespaces (eth_,net_,web3_,txpool_,rpc_) and refuses the rest, and it sits behind the same token as the mutating API endpoints when an auth provider is configured. Verified on the live devnet:testing_buildBlockV1anddebug_setHeadare refused,eth_chainIdstill answers, and the e2e passes unchanged.Fourth round, both fixed:
Notes for reviewers
testing_buildBlockV1today; startup fails fast when the EL does not serve thetestingnamespace. besu and nethermind are blocked upstream (state-root mismatch, missingdebug_setHead).--testing-base-fee-ceiling-gweidrops the fill to the 1559 target above a ceiling; a transaction source needs a high max fee for long max-fill runs.BuildBlockV1errors, and accept any parent whose state is available.tx_planfield; no schema migration, older records simply have none.https://claude.ai/code/session_01P3LSEorv3qsJrWb12ZKHNo