Skip to content

Make the oracle Glamsterdam ready - #258

Open
Marketen wants to merge 9 commits into
mainfrom
marc/glamsterdam
Open

Marketen wants to merge 9 commits into
mainfrom
marc/glamsterdam

Conversation

@Marketen

@Marketen Marketen commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Makes the oracle Glamsterdam-ready. Glamsterdam activates on Hoodi at slot 4,235,264 (Oct 26 2026, 17:42 UTC). Without this PR, the oracle stops at that slot because it can't decode Gloas beacon blocks. The mainnet fork date isn't set yet.

Why so much changes

Gloas, Glamsterdam's consensus fork, takes the execution payload out of the beacon block (EIP-7732, ePBS):

  • The beacon block now carries a builder's bid, which commits to the payload by its hash and promises value to fee_recipient. The builder publishes the payload separately, a few seconds later.
  • A slot can now be full, empty (beacon block but no execution payload) or missed.
  • The protocol pays the bid's value from the builder's deposit as a withdrawal in a later execution payload. There is no transaction and no event.
    • If the payload is withheld, the bid is still paid when the beacon block got 60% of its slot's votes; otherwise the payment is dropped.
    • Slashing the proposer cancels a payment not yet settled.
  • Relays can still pay with a transaction (execution_payment), which nobody enforces.

Separately, EIP-7778 and EIP-8037 make a payload's gas_used differ from the sum of its receipts, which broke the vanilla reward formula.

Review commit by commit

Each commit builds, vets, and passes all tests on its own; this was checked commit by commit.

  1. Bump go-eth2-client to v0.30.2 for Gloas support. Go 1.25 in the Dockerfile and CI.
  2. Compute the vanilla reward from each tx's priority fee. A test proves it equals the old formula before Glamsterdam for every tx type, so published rewards don't change.
  3. Read execution payloads of Gloas slots.
    • Fork slot per chain (Hoodi only).
    • A slot is full when the next finalized beacon block builds on its payload; the payload comes from the envelope endpoint and is checked against the bid.
    • The main loop waits for each Gloas slot to settle (see below).
  4. Decide whether the bid of a Gloas slot pays the pool. For withheld payloads, the oracle reproduces the protocol's quorum decision exactly, from the beacon state the protocol decides on.
  5. Classify Gloas proposals from the builder bid. Rules below.
  6. Track builder payments until they reach the pool.
    • Credited payments are in flight until their withdrawal reaches the pool.
    • The onchain reconciliation counts them as pool assets, and stays exact.
    • The forced-payment detector treats builder withdrawals as explained inflow.

These three were added after a full self-review of 1–6:

  1. Count the oracle in sync against its latest settled slot. Fixes /status and OracleReady, which commit 3 would have broken (details below).
  2. Decide exactly whether an included bid pays the pool. Commit 4 assumed it always was. A slashing of the proposer included in its own block cancels it. Credited anyway, it would have sat in flight forever and hidden a shortfall instead of stopping the oracle.
  3. Ignore zero builder withdrawals, and warn about builder payments still in flight after 4 epochs. It only warns; why it doesn't stop is in the commit message.

How proposals are judged

Ban for what the proposer chose, never for what a builder failed to do.

What happened in the slot Outcome
No beacon block Missed, as today
Self-built payload As today, on the payload's fee recipient; missed if its payload wasn't included
Bid pays the pool, payload included OK. Reward = value (unless the proposer's slashing cancelled it) + anything the payload sent to the pool
Bid pays the pool, payload withheld, 60% of votes OK. Reward = value, which the protocol still pays
Bid pays the pool, payload withheld, under 60% Missed: no reward, no ban
Relay bid pays the pool, promised tx never came OK with what arrived, possibly 0. No ban: this changes today's behaviour
Bid pays someone else, but the payload paid the pool anyway OK, credited: the same rule as today, where any MEV reaching the pool counts
Bid pays someone else Wrong fee recipient, ban, as today

How rewards are shared doesn't change: still proportional to effective balance, with the same pool fee.

Behaviour changes to know about

  • The oracle trails finality by one more epoch after the fork. A Gloas slot is only settled once the epoch after it is finalized: whether its payload was included is decided by the next beacon block, and a withheld bid's payment at the end of the next epoch. When in sync, the oracle sits 33 slots (~6.5 min) behind finality, so roots go out ~6.5 min later. Nothing changes before the fork.
  • API.
    • is_oracle_in_sync, oracle_sync_distance_slots, the next-checkpoint countdown and OracleReady now count from the latest settled slot rather than the finalized one. Before the fork they're identical.
    • The distance_from_finalized_slot metric keeps its meaning, so it will read ~33 when in sync after the fork.
  • Beacon-state reads.
    • Every full slot whose bid pays the pool reads builder_pending_payments (64 entries).
    • Every empty slot whose bid pays the pool also needs the total active balance, which means downloading one beacon state. That's the same download RefreshBeaconValidators already does every 1,200 slots. From mainnet's last 90 days (~15 pool proposals a day), and assuming an empty-slot rate like today's missed rate, that's roughly once a week.
  • New stopping points (fail-safe):
    • a Gloas block on a chain with no fork slot configured, so mainnet needs GloasForkSlot before its fork;
    • the beacon node can't serve a full slot's payload;
    • a payload or beacon state doesn't match the bid;
    • a builder withdrawal to the pool matches no credited payment.
  • No rolling back. Once a Gloas builder payment is in the state file, older oracle versions refuse to load it (hash mismatch). States from before this PR load unchanged; a test checks that.
  • The library upgrade affects mainnet today. Reviewed against the go-eth2-client changelog:
    • missed-slot detection relies on the 404 error text, which is unchanged;
    • fetching all validators still downloads the beacon state, as before;
    • v0.30 caps POST responses at 64 MB, which only affects validator requests with indices, and the oracle sends those for small sets only.

Testing

go test ./... and go test -race ./... pass, with 113 new test cases (counting subtests). They cover:

  • every row of the table above, end to end through AdvanceStateToNextSlot, with both reconciliations;
  • a multi-slot lifecycle where payments arrive late, out of order and through empty slots;
  • the quorum and settlement math, the latter checked by brute force;
  • beacon API decoding against a local server;
  • the forced-payment detector with builder withdrawals;
  • the sync and readiness calculations.

staticcheck reports nothing in code this PR adds or changes.

Not done yet, needed before release:

  • Capture real Gloas fixtures (Sepolia now, Hoodi after Oct 26).
  • Smoke-test the library upgrade against mainnet and Hoodi nodes.
  • Confirm go-ethereum v1.15.7 decodes Glamsterdam headers and receipts.

🤖 Generated with Claude Code

Marketen and others added 9 commits October 22, 2026 17:46
Glamsterdam's consensus fork (Gloas) moves the execution payload out of
the beacon block. v0.30.2 is the first go-eth2-client release that can
decode Gloas beacon blocks and fetch execution payload envelopes; with
v0.27.1 the oracle stops at the first Gloas slot because the block can't
be decoded.

v0.30 requires Go 1.25, so the Dockerfile and CI move to it as well.
go-bitfield moved from prysmaticlabs to OffchainLabs upstream, which only
affects one test import. No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vanilla reward was calculated as every fee paid minus the block
gas_used times the base fee. That relies on the block gas_used being the
sum of the gas charged to each tx, which Glamsterdam breaks: EIP-7778
stops subtracting refunds from it, and EIP-8037 makes it the largest of
two gas dimensions. The pool would have been credited less than it
received, and the onchain reconciliation would refuse to freeze the
checkpoint.

Sum each tx's priority fee instead, using the gas its receipt charged.
Until Glamsterdam both calculations are equal, which the new tests check
for every supported tx type, so no published reward changes and the
change needs no activation slot.

It also fixes a tx/receipt hash mismatch silently returning a nil reward
with a nil error, as the error was wrapped from a nil one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From Gloas, Glamsterdam's consensus fork, the beacon block no longer
carries the execution payload (EIP-7732). It carries a builder's bid,
which commits to the payload by its hash, and the builder publishes the
payload separately. A slot can now be full, empty (beacon block but no
execution payload) or missed.

- GloasForkSlot holds the fork slot per chain, only Hoodi for now. The
  oracle stops at a beacon block whose version doesn't match it, so a
  chain must be listed before it forks and history is never processed
  with the wrong rules.
- A slot is full when the next beacon block builds on its payload. Its
  payload is then fetched from the beacon node's envelope endpoint and
  checked against the bid. Empty slots have no execution block, so no
  events, txs or receipts are fetched for them.
- The FullBlock getters read the payload of Gloas slots, and the code
  reading the execution block (donations, forced payments, the latest
  processed block) now also skips empty slots, not only missed ones.
- The outcome of a Gloas slot is only settled after it is finalized:
  its payload's inclusion is decided by the next beacon block, and a
  withheld payload's payment at the end of the next epoch. The main loop
  waits until that epoch is finalized, trailing finality by ~1 epoch.

Classifying Gloas proposals needs the builder payment, which the next
commits add. Until then the oracle stops at the first Gloas proposal
rather than classifying it with pre-Gloas rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From Gloas, a builder's bid promises a payment to the proposer's fee
recipient, which the protocol takes from the builder's deposit. It is
paid when the next beacon block builds on the bid's execution payload.
When the payload is withheld, the protocol still pays it if the beacon
block got 60% of its slot's stake in same-slot votes, deciding it in
the epoch transition after the next epoch. Slashing the proposer
cancels a payment not decided yet.

The oracle reproduces that decision instead of trusting it: it reads
the builder pending payments and the total active balance from the
beacon state right before the transition, at the last slot of the next
epoch, and applies the spec's quorum. Effective balances only change
after the payments are decided, so the quorum is the one the protocol
used. The active set is heavy to fetch, so this only happens for empty
slots whose bid paid the pool.

Only payments to the pool are recorded, in FullBlock.BuilderPayment.
Nothing uses them yet; the next commits classify proposals and track
the payments until they reach the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Gloas proposal is judged on what the proposer chose, the bid and who
it pays, and never on what a builder failed to do:

- Self-built payload: judged as before Gloas, on the payload's fee
  recipient. If its payload was not included, the slot counts as missed.
- Bid paying the pool: a valid proposal, rewarded with whatever reached
  the pool: the bid value when the protocol paid it, plus anything the
  payload itself paid (an execution_payment tx, or the tips when the
  builder made the pool its fee recipient). An unpaid execution_payment
  is the builder's fault, so it no longer gets the validator banned.
- Bid paying the pool but withheld payload and dropped payment: missed,
  as the beacon block was too late to collect its votes.
- Bid paying someone else: wrong fee recipient, as before. If the
  payload still paid the pool, that ETH is credited rather than left
  unallocated.

Missed and wrong fee keep their existing consequences, so no new block
types or state machine events are introduced and the API is unchanged.

The part of a reward paid from the builder's deposit is stored in the
new SummarizedBlock.BuilderPaymentWei, since it arrives with no event.
The offchain reconciliation counts it as an asset. The field is omitted
when nil, so states saved before this change keep their hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A builder payment is credited when its slot is processed, but reaches
the pool later, as a withdrawal in another execution payload: usually
the next one, about an epoch later when the payload was withheld. Until
then the oracle owes ETH the pool doesn't hold yet, and the exact
onchain reconciliation run before every checkpoint would fail.

- Credited builder payments are kept in the state as in flight. They
  are removed when a builder withdrawal to the pool with the same
  builder and amount is processed. A builder withdrawal to the pool that
  matches no credited payment rejects the slot, as that ETH would
  otherwise be left unallocated.
- The onchain reconciliation counts the payments in flight as part of
  what the pool holds. It stays exact, with no tolerance.
- The forced payment detector reads the pool balance across a block,
  so it now explains builder withdrawals to the pool instead of
  rejecting them. Any other withdrawal to the pool is still rejected.

The state only keeps the payments in flight, so it doesn't grow. The
new field is omitted when empty, and a test checks states saved before
Gloas still load with their hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the Gloas fork on, the oracle processes a slot only once the epoch
after it is finalized, so in sync it trails the finalized slot by 33
slots. The API still measured sync against the finalized slot:

- /status would report is_oracle_in_sync false forever, since it
  required the processed slot to equal the finalized one.
- OracleReady(64), which gates the proofs and validators endpoints,
  allows 64 slots behind finality. Right after each epoch is finalized
  the oracle is 65 behind until it processes it, so those endpoints
  would refuse requests briefly every epoch.

LatestSettledSlot returns the latest slot the oracle can process given
the finalized one. The main loop, /status and OracleReady now all use
it, so they can't disagree, and a test checks it against the per-slot
settlement rule by brute force. oracle_sync_distance_slots and the time
to the next checkpoint are counted from it too. Before the fork it is
the finalized slot, so nothing changes until then.

The distance_from_finalized_slot metric keeps its meaning, so from the
fork on it will read about 33 when the oracle is in sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bid whose payload the next beacon block built on was assumed paid.
The next block does settle it before anything else, but slashing the
proposer cancels a pending payment, and a slashing of the proposer
included in its own beacon block happens before that. It is not as
unlikely as it sounds: a validator run on two machines gets slashed
with evidence that its own node may then include.

Had it happened, the oracle would have credited ETH that never comes,
and the onchain reconciliation would have counted it in flight forever,
hiding that the pool lacks it rather than stopping.

So a full slot's payment is now checked like a withheld one's, against
the beacon state: it is paid if its builder_pending_payments entry
survived its own beacon block. The exception, also from the spec, is a
next beacon block two or more epochs later, which appends the payment
directly whatever that entry says. Only read for bids paying the pool,
and the endpoint returns 64 entries.

A cancelled payment is not credited nor tracked. The proposer still
chose a bid paying the pool, so it is not banned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A builder withdrawal of zero to the pool moves no ETH, so it is not
matched against credited payments. Before, it would have stopped the
oracle as an unknown payment. The forced payment check already skips
zero amounts.

Builder payments reach the pool within about two epochs. One credited
but still in flight after four is now logged once per epoch: it may
never arrive, and until it does it counts as a pool asset in the onchain
reconciliation. It only warns. Slots are processed in order, so stopping
would also stop the oracle from reaching the slot where a slow payment
lands, and with whether payments are made now decided exactly, one
missing would point at a consensus client bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant