You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
helix-builder has two config-selected roles today: merging (merges
relay-supplied blocks) and simulation (validates relay submissions over
SSZ, #527). Neither builds a block. There is no way to drive the relay's own
submission path — submit -> simulate -> get_header -> get_payload ->
publish — on a testnet without an external builder.
This issue adds a third role, building: it builds a block for the next slot
and submits it to the relay.
Why
To exercise the relay end to end on testnets with a builder we control.
Affected surface
crates/builder/src/config.rs — Roles becomes a struct of three Options. As an enum, a third role needs seven variants.
crates/builder/src/cli.rs — --build.config.
crates/builder/src/main.rs — spawn the role on the tokio runtime before spine.start(...), which blocks until its tiles stop.
crates/builder/src/engine/convert.rs — add the ethrex BlobsBundle -> helix_types::BlobsBundle conversion, the missing inverse of eblobs.
Reused unchanged: node::start (store, Arc<Blockchain> with a devp2p-fed
mempool, head watch), engine/convert.rs, helix_types::{payload_from_v3, requests_from_v4}, helix_common::signing::RelaySigningContext::sign_builder_message, helix_common::beacon::types::chain::PayloadAttributesEvent,
and testing.rs.
The ethrex primitives are all pub and all already driven by engine/session.rs: create_payload, PayloadBuildContext, apply_system_operations, fill_transactions, apply_tx_to_payload, extract_requests, apply_withdrawals, finalize_payload.
Design decisions
The builder is the coinbase, and the block ends with a payout to the
proposer. The simpler option — set the coinbase to the proposer's fee
recipient and omit the payout tx — passes our validator's paid_by_balance,
and the relay does not constrain the payload's coinbase. But the relay rejects value == 0 (ZeroValueBlock), so on an idle testnet that builder submits
nothing, which is when we most want it submitting. Instead:
subsidy_wei guarantees a non-zero bid every slot. This is also the model that
bidding and a builder cut need later, so stage 2 needs no rework.
Gas for the payout is reserved by lowering ctx.remaining_gas before fill_transactions and restoring it after. fill_transactions gates only on
that field, and it is pub. No ethrex change is needed.
Slot data comes from two sources, as HeadInfo carries no consensus
fields: the beacon node's payload_attributes SSE topic (parent hash,
timestamp, prev_randao, withdrawals, parent_beacon_block_root) and the
relay's get_validators (proposer pubkey, fee recipient, registered gas
limit). Passing the registered gas limit as BuildPayloadArgs::gas_ceil gets
the 1/1024 clamp for free, since create_payload applies calc_gas_limit.
New env vars.RELAY_KEY is already used for two different key types: the
merging role parses it as secp256k1, helix_common::config::load_keypair
parses it as BLS. The building role needs both kinds, so it takes BUILDER_BLS_KEY and BUILDER_PAYOUT_KEY, loaded eagerly before the node
boots.
Steps (each becomes one PR)
Step 1: Roles becomes a struct; add BuildingConfig, --build.config, build-config.example.yml, and eager loading of both
key env vars (tests: config parse and reject, resolve() combinations)
(PR: Select the building role from a supplied config #554)
Step 3: block assembly — create_payload -> context -> system ops ->
reserve gas -> fill_transactions -> payout tx -> requests ->
withdrawals -> finalize_payload (tests: mempool txs included; payout is
last and pays exactly the bid; the reservation holds under a full block;
zero payout refused; a failing payout aborts the slot) (PR: Build a block for each slot from the node's mempool #557)
Step 4: submission — blobs conversion, BidTrace, BLS signature, SSZ POST /relay/v1/builder/blocks (tests: SSZ round-trip, signature
verifies under the builder domain, BidTrace mirrors the payload,
blobs rejected without 128 cell proofs per blob) (PR: Sign the built block and submit it to the relay #558)
Self-validation was dropped: assembly already executes every
transaction and computes the state root, so re-simulating in the slot
buys nothing and costs latency. `self_validate` went with it.
Bundles and eth_sendBundle; custom ordering (stage 1 uses ethrex's tip-sorted fill_transactions); cancellations and replacement; top-of-block simulation and
the nonce-dependency DAG; optimistic submission and bid adjustments;
multi-relay submission; keeping a builder cut. The MVP is shaped so each is
additive.
Known limitations to carry into the PRs
The payout gas limit is a fixed payout_gas_reserve (default 21000). A
contract fee recipient that needs more makes the payout fail, and the slot is
skipped.
Blob transactions are included only when their sidecar arrived over devp2p,
because sidecars come from the mempool.
ethrex's impl AddAssign for BlobsBundle does not propagate version, so an
aggregated bundle always claims version 0. Assert 128 proofs per blob instead
of trusting that field.
Status
helix-builderhas two config-selected roles today: merging (mergesrelay-supplied blocks) and simulation (validates relay submissions over
SSZ, #527). Neither builds a block. There is no way to drive the relay's own
submission path — submit -> simulate ->
get_header->get_payload->publish — on a testnet without an external builder.
This issue adds a third role, building: it builds a block for the next slot
and submits it to the relay.
Why
To exercise the relay end to end on testnets with a builder we control.
Affected surface
crates/builder/src/config.rs—Rolesbecomes a struct of threeOptions. As an enum, a third role needs seven variants.crates/builder/src/cli.rs—--build.config.crates/builder/src/main.rs— spawn the role on the tokio runtime beforespine.start(...), which blocks until its tiles stop.crates/builder/src/building/(new) — slot context, block assembly,submission, slot loop.
crates/builder/src/engine/convert.rs— add the ethrexBlobsBundle->helix_types::BlobsBundleconversion, the missing inverse ofeblobs.Reused unchanged:
node::start(store,Arc<Blockchain>with a devp2p-fedmempool,
headwatch),engine/convert.rs,helix_types::{payload_from_v3, requests_from_v4},helix_common::signing::RelaySigningContext::sign_builder_message,helix_common::beacon::types::chain::PayloadAttributesEvent,and
testing.rs.The ethrex primitives are all
puband all already driven byengine/session.rs:create_payload,PayloadBuildContext,apply_system_operations,fill_transactions,apply_tx_to_payload,extract_requests,apply_withdrawals,finalize_payload.Design decisions
The builder is the coinbase, and the block ends with a payout to the
proposer. The simpler option — set the coinbase to the proposer's fee
recipient and omit the payout tx — passes our validator's
paid_by_balance,and the relay does not constrain the payload's coinbase. But the relay rejects
value == 0(ZeroValueBlock), so on an idle testnet that builder submitsnothing, which is when we most want it submitting. Instead:
subsidy_weiguarantees a non-zero bid every slot. This is also the model thatbidding and a builder cut need later, so stage 2 needs no rework.
Gas for the payout is reserved by lowering
ctx.remaining_gasbeforefill_transactionsand restoring it after.fill_transactionsgates only onthat field, and it is
pub. No ethrex change is needed.Slot data comes from two sources, as
HeadInfocarries no consensusfields: the beacon node's
payload_attributesSSE topic (parent hash,timestamp,
prev_randao, withdrawals,parent_beacon_block_root) and therelay's
get_validators(proposer pubkey, fee recipient, registered gaslimit). Passing the registered gas limit as
BuildPayloadArgs::gas_ceilgetsthe 1/1024 clamp for free, since
create_payloadappliescalc_gas_limit.New env vars.
RELAY_KEYis already used for two different key types: themerging role parses it as secp256k1,
helix_common::config::load_keypairparses it as BLS. The building role needs both kinds, so it takes
BUILDER_BLS_KEYandBUILDER_PAYOUT_KEY, loaded eagerly before the nodeboots.
Steps (each becomes one PR)
Step 1:
Rolesbecomes a struct; addBuildingConfig,--build.config,build-config.example.yml, and eager loading of bothkey env vars (tests: config parse and reject,
resolve()combinations)(PR: Select the building role from a supplied config #554)
Step 2: slot context —
payload_attributesSSE client plus aget_validatorspoll, merged into oneSlotContext(tests: merge,missing duty skips the slot, duplicate/stale event ignored, duty refresh
across epochs) (PR: Merge beacon payload attributes with relay duties into a slot context #556)
Step 3: block assembly —
create_payload-> context -> system ops ->reserve gas ->
fill_transactions-> payout tx -> requests ->withdrawals ->
finalize_payload(tests: mempool txs included; payout islast and pays exactly the bid; the reservation holds under a full block;
zero payout refused; a failing payout aborts the slot) (PR: Build a block for each slot from the node's mempool #557)
Step 4: submission — blobs conversion,
BidTrace, BLS signature, SSZPOST /relay/v1/builder/blocks(tests: SSZ round-trip, signatureverifies under the builder domain,
BidTracemirrors the payload,blobs rejected without 128 cell proofs per blob) (PR: Sign the built block and submit it to the relay #558)
Step 5: the slot loop — build and submit at configured offsets, and
resubmit only on a strictly higher value (tests: scheduling, including a
late event; the improvement gate, including a re-orged parent)
(PR: Build at each configured offset and resubmit only on a higher value #559)
Step 6: document the third role in the README (PR: Document the builder's third role #560)
Deliberately out of scope
Bundles and
eth_sendBundle; custom ordering (stage 1 uses ethrex's tip-sortedfill_transactions); cancellations and replacement; top-of-block simulation andthe nonce-dependency DAG; optimistic submission and bid adjustments;
multi-relay submission; keeping a builder cut. The MVP is shaped so each is
additive.
Known limitations to carry into the PRs
payout_gas_reserve(default 21000). Acontract fee recipient that needs more makes the payout fail, and the slot is
skipped.
because sidecars come from the mempool.
impl AddAssign for BlobsBundledoes not propagateversion, so anaggregated bundle always claims version 0. Assert 128 proofs per blob instead
of trusting that field.
Open questions
Rolesfrom Select the builder's roles from the supplied configs #528, so this stacks onod/builder-sim-docker-step10for now.