Skip to content

derive and manage a fleet of builder keys from the entry key - #153

Open
pk910 wants to merge 27 commits into
pk910/reorg-handlingfrom
pk910/multi-key
Open

derive and manage a fleet of builder keys from the entry key#153
pk910 wants to merge 27 commits into
pk910/reorg-handlingfrom
pk910/multi-key

Conversation

@pk910

@pk910 pk910 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #152 (pk910/reorg-handling) — review after that one; merging #152 rebases this to a pure multi-key diff.

Gloas gossip only accepts the FIRST bid a builder publishes for a slot — a single-key builder gets exactly one shot per slot, which makes bid escalation, multi-candidate bidding (#152) and competitive-auction testing impossible. This PR turns buildoor's single BLS identity into a managed fleet of builder keys derived from the operator's entry key, with the lifecycle manager reconciling the on-chain fleet against a target count and the bidder spending a fresh key per bid.

Default behaviour is unchanged: --builder-keys-target 1 (the default) is the entry key alone.

Builder key registry (pkg/builder_keys/)

The identity dependency of every module that used to hold a single *signer.BLSSigner:

  • Derivation: the entry key comes from --builder-privkey or --builder-mnemonic + --builder-key-index. Internal index 0 IS the entry key; index n ≥ 1 is derive_child_SK(entry_sk, n) — one EIP-2333 node deeper than any participant's account path, so fleets are collision-free between builders sharing a mnemonic.
  • Two index spaces, never conflated: KeyIndex is our derivation index (stable forever); BuilderIndex is the beacon registry index, assigned at deposit and reused by other builders after an exit.
  • Status per key (unused / depositing / pending / active / exiting / exited / withdrawn) is resolved in one pass over the epoch snapshot — monitoring hundreds of keys costs the same beacon query as one.
  • Usage history persists via the kv_store builder_keys namespace, so a key deposited in an earlier run is recognised before the beacon state confirms it and exited indices are reused. A persisted pubkey disagreeing with the derived one is a hard startup error (the entry key changed). Startup discovery scans past the target for previously used keys, stopping after builder_keys.discovery_gap unused indices.
  • Selection (SelectForBid): round_robin (default) / single / random / least_used, excluding keys already committed for the slot. Balance is a preference, not a filter — underfunded keys sort last but are still offered, since deliberately underfunded bids are a scenario worth testing.

Lifecycle manager becomes the fleet reconciler (pkg/lifecycle/)

  • Keeps the managed count at builder_keys.target_count: deposits below it (auto_deposit), exits surplus above it (auto_exit, highest index first, skipping keys with pending payments the chain would silently ignore), tops up whichever key fell below the threshold.
  • At most one lifecycle transaction per reconcile pass — everything goes through the single funding wallet and waits for its receipt, so the fleet ramps instead of flooding the EIP-8282 deposit queue (whose fee grows with its length); deposit_max_fee backs the ramp off on its own. Deposits go out as batched transactions (pkg/wallet/batch.go).
  • Target changes wake the reconciler immediately via the settings OnChange wiring.
  • Early (pre-Gloas) onboarding covers the whole target set: the deposits sit in the pending queue together, so the pending-deposit simulation asks whether the batch's LAST entry survives the fork transition.

Multi-key bidding

  • Every bid takes a key that has not bid this slot yet — a key is SPENT once one of its bids reaches the network, because gossip ignores a builder's later bids for a slot. Escalated re-bids of the same payload therefore also take a fresh key, and every bid carries its own value, one increase above the last — gossip forwards only the highest bid seen for a (slot, parent) tuple, so bids sharing a value cannot all propagate no matter how many keys sign them.
  • epbs.bid_keys_per_step controls how many keys bid a payload per interval step (each one increment higher; 1 = walk the fleet up the price ladder, 0 = spend every remaining key at once); epbs.bid_keys_per_slot caps distinct keys per slot. One step's submissions are dispatched concurrently without blocking the 10ms scheduler tick, and a payload's bid slot is claimed before submission so concurrent steps can't double-spend a key.
  • Payments, reveals and win tracking are bound to the key whose bid actually won (per-builder-index payment tracking); the Builder API signs with its own strategy (builder_api.key_strategy, empty = follow the ePBS strategy).
  • Expected gossip rejections (the first-seen rule doing its job) log at debug instead of error.

API / UI

New Builder Keys view (fleet table with status, balances, usage, deposit/exit/top-up actions), /api/buildoor/builder-keys endpoints (list/deposit/exit/topup, auth + audit + swagger), builder_keys SSE updates, and a keys CLI helper wiring. The builder-info panel shows the fleet summary.

image

Config surface

builder_keys.* (target_count, max_index, discovery_gap, auto_deposit, auto_exit — auto-exit is irreversible, an exited key can never be reactivated), epbs.key_strategy, epbs.bid_keys_per_slot, epbs.bid_keys_per_step, builder_api.key_strategy; all mutable at runtime except the discovery gap.

Notes

Devnet findings

Validated on a six-client Gloas devnet (lodestar/nethermind, lighthouse/geth, teku/besu,
prysm/reth, lighthouse/erigon, grandine/ethrex). Two bugs this surfaced are fixed here:

  • Per-bid escalation. Giving a whole step one value meant 299 of 300 keys were wasted —
    every bid after the first to arrive is dropped as too low. Escalating per bid took
    acceptance from ~10% (64/606) to ~99% (253/256).
  • A ramping fleet could not bid. The lifecycle manager's deposit-pending callback pulled
    the p2p bidder's registration state back to pending on every deposit batch — single-key
    semantics. With a fleet ramping toward its target that suppressed bidding for the entire
    ramp despite active keys. It now only applies when no key of the fleet is active.

Fleet scale checked at 1000 keys (~10 min ramp, ~100 MB RSS, <10% CPU) and 300 keys bidding
120-300 escalating bids per slot with a stable mesh.

Testing

go build ./... / go test ./... pass — new tests for derivation (EIP-2333 vectors), registry state/persistence/discovery, selection strategies, per-key scheduler behaviour, payment binding, batch deposits, the pending-deposit simulation and the new API handlers; shared keyset_test.go fixtures across the bidder/builderapi/payload_bidder packages.

pk910 added 22 commits July 31, 2026 03:31
Builder keys are derived one EIP-2333 node below the operator's key, so
index 0 is the entry key itself and higher indices cannot alias another
participant's account path in a shared test setup.

The registry owns derivation, per-key on-chain state resolved from the
epoch snapshot in a single pass, and the usage history that makes a
withdrawn key reusable instead of pushing the highest index up forever.
Discovery scans past the target for keys used in an earlier run and stops
after a full run of never-used indices; those scanned-but-unused indices
are derived, not tracked, so the fleet view stays the size of the fleet.
Modules that held a single BLS signer now take the key registry and name
the key each operation acts on: deposits, exits and top-ups take a key
parameter, bid construction takes the key that signs, and the reveal
service resolves its signer from the registry.

Competitor bid comparisons exclude every managed key rather than one
builder index, so a second key of ours can never read as our fiercest
competitor. The deposit and exit commands gain --key-index.

Behaviour is unchanged at the default target of one key: call sites use
the primary key, which is internal index 0, which is the entry key.
Beacon nodes on the post-#624 beacon-API spec (e.g. the Lodestar on
glamsterdam-devnet-7) reject publishExecutionPayloadEnvelope with 400
"Eth-Blob-Data-Included header is required": the spec renamed the body
discriminator from Eth-Execution-Payload-Blinded to Eth-Blob-Data-Included
(true = stateless SignedExecutionPayloadEnvelopeContents body). Bump
go-eth2-client to a version that sends the new header (keeping the old one
for beacon nodes that predate the rename).
A block's execution payload bid names the builder index it committed to,
and that is the only thing that identifies which key won: several managed
keys can bid the very same payload, so the block hash cannot say.

BlockInfo now carries that index. The inclusion tracker resolves it to a
key and refuses the win outright when it belongs to somebody else, rather
than falling back to a key of ours — a validly-signed envelope for another
builder's bid is rejected on chain while the slot is lost silently. The
Builder API binds the same way at block submission.

Payment accounting is per key, so a payment is charged to the key that
owes it instead of draining a shared pool.
The lifecycle manager becomes a reconciler: it deposits keys until the
managed count reaches the target, exits surplus keys down to it, and tops
up whichever key fell below the threshold. Target changes wake it
immediately instead of waiting out the idle tick.

Everything is funded from one wallet, so a pass performs at most one
transaction and the fleet ramps rather than flooding the deposit queue,
whose fee grows with its length; the existing fee limit then backs the
ramp off on its own. A wallet that cannot cover the next deposit reports
once instead of failing a transaction per key.

Early onboarding covers the whole target set: the deposits sit in the
pending queue together and the fork transition converts them all, so the
queue simulation now asks whether the batch's last entry survives.
The gossip rules ignore every bid after a builder's first for a slot, so
one key can land only one bid however many candidates were built. Pairing
each candidate with a distinct key is what makes them all propagate.

The pairing is sticky per (slot, payload): an interval re-bid from another
key would be a fresh first-seen bid, leaving the original lower bid as the
one that actually reached the network. When there are fewer keys than
candidates a key is reused rather than dropping the bid — bidding several
candidates from one key remains a deliberate testing scenario.

Selection strategies (round_robin, single, random, least_used) decide
which keys cover the candidates, and bid_keys_per_slot caps how many bid
at all. Balance is a preference, not a filter: an underfunded key is still
offered when nothing else can cover the bid, since underfunded bids are a
scenario worth testing. The Builder API picks the same way, sticky per
(slot, parent tuple) so a polling proposer keeps seeing one builder.
Adds the builder-keys endpoints (list, target, per-key deposit/topup/exit)
and a builder_keys SSE event carrying the whole set on every change, so
the view stays live without polling.

The target is written through the settings service rather than a second
persistence path, which gets CLI/UI recency resolution and the audit log
for free. An exit optionally decrements the target in the same request,
and only after the exit landed — a failed exit must not shrink the fleet
the operator asked for.

Reading the key set works without lifecycle management: the keys are
derived either way, only mutating them needs the manager. Bid attempts
record the key index alongside the builder index, because a builder index
is reused by other builders after an exit and the mapping is only reliable
while the bid is being made.
The dashboard card gains an active/target key badge, an inline target
editor that warns before a lowering exits keys, and fleet balance totals.
Per-key operations move to a new Builder Keys page: one row per key with
status, balances, usage and deposit/top-up/exit buttons. The exit
confirmation names the consequences, because it cannot be undone, and
offers to lower the target so no replacement is deposited.

Bid popovers name the key that signed, and the stream now recognises a
bid as ours by any managed key rather than the primary one — otherwise a
second key's echoed bid renders as a competitor's.
The key strategy keys were declared but never wired into the field
registry, so the API rejected them as unknown and neither the UI nor the
state-db could reach them. Declaring a key without its field compiles and
reads as complete, which is how it slipped through.

The added test walks settings_keys.go and fails on any key missing from
Fields(), plus checks flag keys are unique — two fields sharing a viper
flag would resolve CLI changes against each other's last-seen value.
The balance service picks the top-up amount itself, falling back to the
threshold when no amount is configured, so callers crediting the live
balance with the configured amount credited the wrong number whenever
those differ. It now returns what it deposited.
Every lifecycle transaction is serialized on the same funding key, so
bringing a fleet up one confirmed transaction at a time costs a block per
key. Deposits now sign consecutive nonces in one go and go out together,
capped at ten per round.

Each transaction is still resolved on its own, because sharing the funding
key with other buildoor instances means any single nonce can be taken by a
foreign transaction: the displaced one is rebuilt on a fresh nonce and
retried while the ones that landed are left alone. Keys are claimed before
submission so a batch cannot pick the same candidate twice, and released
again when the batch never reached the chain.
The scheduler ticks every 10ms while a bid submission is a network call
taking tens of milliseconds. Marking the payload as bid only after the
call returned let the ticks that landed mid-flight pass the interval
check and gossip the same bid again — the beacon node rejects those as
already known, and each one burns a key's single bid for the slot.

Also stop exiting a lower key while higher-index ones are still on their
way to active: those in-flight keys are the surplus, and exiting a usable
key in their place burns a key we just paid for without shrinking the
fleet once they register.
The key was pinned to the payload, so every re-bid of that payload came
from the same key — and the gossip rules ignore a builder's later bids for
a slot, so each escalation step was dropped as already known and the
higher value never reached the network.

A key is spent when one of its bids reaches the network, so that is what
the slot now tracks. Every bid — an escalated re-bid of the same payload
just as much as another candidate — claims a key that has not bid yet, and
bidding stops once the fleet is exhausted rather than repeating a spent
key. A submission that never made it out hands its key back.

The escalation count moves to the payload too, so several candidates no
longer inherit each other's step.
The key was claimed before the interval gate, so every scheduler tick that
found nothing due still consumed one. At a 10ms tick and a 50ms interval
the whole fleet was spent within a few ticks and the slot could no longer
bid at all.

The bid is now gated first and the key claimed only once it is due, with
the payload's claim handed back when no key is left. Key readiness is
checked against the escalated bid value rather than the configured
minimum, so an underfunded key is not picked for a bid it cannot cover.
An exit takes a couple of epochs to show up as a withdrawable epoch in the
beacon state. Until then the key still read active, so every reconcile
pass picked it again and re-submitted the exit — paying the queue fee each
time. On the devnet that was one redundant exit request every six seconds
until the chain caught up.
The interval ladder is a single-key shape: one bid, wait, one more. With a
fleet the useful shapes run from that ladder all the way to "every active
key bids the moment the window opens", so a step now spends
bid_keys_per_step keys instead of exactly one, each a value increment
higher than the last.

The submissions of a step are independent keys on independent payloads, so
they go out concurrently — serialized they spread a slot's bids over tens
of milliseconds each and waste the window.
A submission takes tens of milliseconds while the scheduler ticks every
10ms, so waiting on a step's submissions stalled the next step — and the
next slot — for the duration of the slowest beacon call.

The tick now dispatches and returns. Everything a later tick reads is
already committed before any submission starts: the payload's interval
claim and each key's claim are taken under one lock while planning the
step. Shutdown drains the in-flight submissions separately.
A step's bids go out concurrently and reach the beacon node in arbitrary
order, so giving each one a higher value made them race: every bid landing
after a higher one came back BID_TOO_LOW. At 334 keys per step only a few
dozen of a thousand bids survived.

The escalation now counts steps, so one step bids one value from every key
it spends and the next step bids one increment above it.

A rejected bid also keeps its key spent. The node saw it and turned it
down on merit, so retrying it unchanged only repeats the rejection —
handing the key back had the slot spin through the whole fleet, reaching
1300 attempts for 1000 keys. Only a submission that never reached the node
releases its key, told apart by the typed API error rather than its text.
Bidding a whole fleet makes rejection the normal outcome: the beacon node
keeps only the best bid per (slot, parent), so every key beyond the first
to arrive at a given value is turned down. At 600 keys that produced over
10k error lines per run, burying the transport failures that actually need
attention. The per-attempt records on the slot result keep every rejection
inspectable.
Two fixes the multi-key devnet run exposed:

Bid values were escalated per interval step, so every key of a step signed
the same value. Gossip only forwards the highest bid seen for a (slot,
parent) tuple, so all but the first to arrive were dropped as too low --
spending 300 keys to propagate one bid. Each bid now lands one increase
above the last.

The lifecycle manager's deposit-pending callback pulled the p2p bidder's
registration state back to pending on every deposit batch. That is single
key semantics: a fleet ramping toward its target deposits continuously, so
bidding stayed suppressed for the whole ramp even with keys already active.
It now only applies when no key of the fleet is active.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant