From 1ed3f77865242d0c2c086fc8140df0064fc13b67 Mon Sep 17 00:00:00 2001 From: parithosh Date: Sat, 9 May 2026 19:39:33 +0200 Subject: [PATCH] fix: builder payment quorum integer overflow at mainnet-scale stake `getBuilderPaymentQuorumThreshold` computed `totalActiveBalanceIncrements * EFFECTIVE_BALANCE_INCREMENT` as a JS `number`. The intermediate gwei product crosses `Number.MAX_SAFE_INTEGER` (2^53 - 1) once total active stake passes ~9M ETH, silently losing precision. Other clients compute the spec-exact uint64 result, so a Gloas-enabled mainnet would see Lodestar diverge on the post-state root at the first epoch transition that promotes builder payments through the quorum check, forking Lodestar nodes off the network. Two sites overflow: - `getBuilderPaymentQuorumThreshold` (gloas.ts) - the threshold itself. - `BuilderPendingPayment.weight` accumulator in `processAttestationsAltair.ts` - per-slot gwei weight is also in the 10^16 range at mainnet-scale stake. Fix: - Switch `BuilderPendingPayment.weight` SSZ field from `UintNum64` to `UintBn64`. On-wire encoding is identical (uint64 LE); local TS type becomes `bigint`, matching the spec's domain. - Use bigint arithmetic in `getBuilderPaymentQuorumThreshold` and in the per-slot weight accumulator. The threshold function now returns `bigint`; the comparison in `processBuilderPendingPayments` flows through unchanged. Scope: Gloas-only. `BuilderPendingPayment`, the threshold function, and the gloas branch in `processAttestationsAltair` are all unreachable pre-Gloas. Bug is dormant on `glamsterdam-devnet-3` (~50k ETH stake, two orders of magnitude below the precision boundary) and on any network without Gloas activated, so this can land before any divergence risk materialises. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/block/processAttestationsAltair.ts | 5 +-- .../src/block/processExecutionPayloadBid.ts | 2 +- packages/state-transition/src/util/gloas.ts | 14 ++++---- .../test/unit/util/gloas.test.ts | 35 +++++++++++++++++++ packages/types/src/gloas/sszTypes.ts | 3 +- 5 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 packages/state-transition/test/unit/util/gloas.test.ts diff --git a/packages/state-transition/src/block/processAttestationsAltair.ts b/packages/state-transition/src/block/processAttestationsAltair.ts index 0af7aaae496e..a6cb35f36f51 100644 --- a/packages/state-transition/src/block/processAttestationsAltair.ts +++ b/packages/state-transition/src/block/processAttestationsAltair.ts @@ -51,7 +51,8 @@ export function processAttestationsAltair( let newSeenAttesters = 0; let newSeenAttestersEffectiveBalance = 0; - const builderWeightMap: Map = new Map(); + // bigint accumulator: per-slot gwei weight can exceed Number.MAX_SAFE_INTEGER at mainnet-scale stake + const builderWeightMap: Map = new Map(); for (const attestation of attestations) { const data = attestation.data; @@ -150,7 +151,7 @@ export function processAttestationsAltair( const existingWeight = builderWeightMap.get(builderPendingPaymentIndex) ?? (state as CachedBeaconStateGloas).builderPendingPayments.get(builderPendingPaymentIndex).weight; - const updatedWeight = existingWeight + paymentWeightToAdd * EFFECTIVE_BALANCE_INCREMENT; + const updatedWeight = existingWeight + BigInt(paymentWeightToAdd) * BigInt(EFFECTIVE_BALANCE_INCREMENT); builderWeightMap.set(builderPendingPaymentIndex, updatedWeight); } } diff --git a/packages/state-transition/src/block/processExecutionPayloadBid.ts b/packages/state-transition/src/block/processExecutionPayloadBid.ts index c03513d00727..07d432d22e89 100644 --- a/packages/state-transition/src/block/processExecutionPayloadBid.ts +++ b/packages/state-transition/src/block/processExecutionPayloadBid.ts @@ -73,7 +73,7 @@ export function processExecutionPayloadBid(state: CachedBeaconStateGloas, block: if (amount > 0) { const pendingPaymentView = ssz.gloas.BuilderPendingPayment.toViewDU({ - weight: 0, + weight: 0n, withdrawal: ssz.gloas.BuilderPendingWithdrawal.toViewDU({ feeRecipient: bid.feeRecipient, amount, diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index 3fe8ddab7a34..6fb08c1a4b44 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -25,12 +25,14 @@ export function isBuilderWithdrawalCredential(withdrawalCredentials: Uint8Array) return withdrawalCredentials[0] === BUILDER_WITHDRAWAL_PREFIX; } -export function getBuilderPaymentQuorumThreshold(state: CachedBeaconStateGloas): number { - const quorum = - Math.floor((state.epochCtx.totalActiveBalanceIncrements * EFFECTIVE_BALANCE_INCREMENT) / SLOTS_PER_EPOCH) * - BUILDER_PAYMENT_THRESHOLD_NUMERATOR; - - return Math.floor(quorum / BUILDER_PAYMENT_THRESHOLD_DENOMINATOR); +export function getBuilderPaymentQuorumThreshold(state: CachedBeaconStateGloas): bigint { + // bigint to avoid f64 precision loss: totalActiveBalanceIncrements * EFFECTIVE_BALANCE_INCREMENT + // exceeds Number.MAX_SAFE_INTEGER once total active stake passes ~9M ETH. + const totalGwei = BigInt(state.epochCtx.totalActiveBalanceIncrements) * BigInt(EFFECTIVE_BALANCE_INCREMENT); + return ( + ((totalGwei / BigInt(SLOTS_PER_EPOCH)) * BigInt(BUILDER_PAYMENT_THRESHOLD_NUMERATOR)) / + BigInt(BUILDER_PAYMENT_THRESHOLD_DENOMINATOR) + ); } function hasBuilderIndexFlag(index: number): boolean { diff --git a/packages/state-transition/test/unit/util/gloas.test.ts b/packages/state-transition/test/unit/util/gloas.test.ts new file mode 100644 index 000000000000..0c1711b768ab --- /dev/null +++ b/packages/state-transition/test/unit/util/gloas.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, it} from "vitest"; +import { + BUILDER_PAYMENT_THRESHOLD_DENOMINATOR, + BUILDER_PAYMENT_THRESHOLD_NUMERATOR, + EFFECTIVE_BALANCE_INCREMENT, + SLOTS_PER_EPOCH, +} from "@lodestar/params"; +import {CachedBeaconStateGloas} from "../../../src/types.js"; +import {getBuilderPaymentQuorumThreshold} from "../../../src/util/gloas.js"; + +describe("getBuilderPaymentQuorumThreshold", () => { + function refQuorum(totalActiveBalanceIncrements: number): bigint { + const totalGwei = BigInt(totalActiveBalanceIncrements) * BigInt(EFFECTIVE_BALANCE_INCREMENT); + return ( + ((totalGwei / BigInt(SLOTS_PER_EPOCH)) * BigInt(BUILDER_PAYMENT_THRESHOLD_NUMERATOR)) / + BigInt(BUILDER_PAYMENT_THRESHOLD_DENOMINATOR) + ); + } + + function makeStateStub(totalActiveBalanceIncrements: number): CachedBeaconStateGloas { + return {epochCtx: {totalActiveBalanceIncrements}} as unknown as CachedBeaconStateGloas; + } + + // Stake levels chosen to bracket the f64 precision boundary: 9_007_199 ETH increments + // multiplied by EFFECTIVE_BALANCE_INCREMENT (1e9) equals 2^53 - 1. + it.each([ + {label: "tiny devnet (~50k ETH)", totalActiveBalanceIncrements: 50_000}, + {label: "below f64 boundary (~9M ETH)", totalActiveBalanceIncrements: 9_000_000}, + {label: "mainnet today (~35M ETH)", totalActiveBalanceIncrements: 35_000_000}, + {label: "MaxEB worst case (~64M ETH)", totalActiveBalanceIncrements: 64_000_000}, + ])("matches bigint reference at $label", ({totalActiveBalanceIncrements}) => { + const got = getBuilderPaymentQuorumThreshold(makeStateStub(totalActiveBalanceIncrements)); + expect(got).toEqual(refQuorum(totalActiveBalanceIncrements)); + }); +}); diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index d0ddc160fe4b..251e0de91c5c 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -71,7 +71,8 @@ export const BuilderPendingWithdrawal = new ContainerType( export const BuilderPendingPayment = new ContainerType( { - weight: UintNum64, + // bigint to avoid f64 precision loss when accumulating gwei weight at mainnet-scale stake + weight: UintBn64, withdrawal: BuilderPendingWithdrawal, }, {typeName: "BuilderPendingPayment", jsonCase: "eth2"}