From 36c4eb662cdfb3c1186beafd7adf684301bd7bcc Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 22 May 2026 09:44:05 +0700 Subject: [PATCH 01/16] chore: benchmark upgradeStateToGloas() --- .../state-transition/src/testUtils/util.ts | 126 +++++++++++++++++- .../perf/slot/upgradeStateToGloas.test.ts | 53 ++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/state-transition/test/perf/slot/upgradeStateToGloas.test.ts diff --git a/packages/state-transition/src/testUtils/util.ts b/packages/state-transition/src/testUtils/util.ts index 3be60e7d5a6f..04df16706a3f 100644 --- a/packages/state-transition/src/testUtils/util.ts +++ b/packages/state-transition/src/testUtils/util.ts @@ -1,19 +1,23 @@ import {PublicKey, SecretKey} from "@chainsafe/blst"; import {BitArray, fromHexString} from "@chainsafe/ssz"; -import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; +import {BeaconConfig, createBeaconConfig, createChainForkConfig} from "@lodestar/config"; import {config} from "@lodestar/config/default"; import { + BUILDER_WITHDRAWAL_PREFIX, + DOMAIN_DEPOSIT, EPOCHS_PER_ETH1_VOTING_PERIOD, EPOCHS_PER_HISTORICAL_VECTOR, ForkName, ForkSeq, + GENESIS_SLOT, MAX_ATTESTATIONS, MAX_EFFECTIVE_BALANCE, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT, } from "@lodestar/params"; -import {BeaconState, Slot, phase0, ssz} from "@lodestar/types"; +import {BeaconState, Slot, electra, phase0, ssz} from "@lodestar/types"; import {getEffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; +import {ZERO_HASH} from "../constants/index.js"; import { computeCommitteeCount, computeEpochAtSlot, @@ -26,12 +30,15 @@ import { import { BeaconStateAltair, BeaconStateElectra, + BeaconStateFulu, BeaconStatePhase0, CachedBeaconStateAllForks, CachedBeaconStateAltair, CachedBeaconStateElectra, + CachedBeaconStateFulu, CachedBeaconStatePhase0, } from "../types.js"; +import {computeDomain, computeSigningRoot} from "../util/index.js"; import {getNextSyncCommittee} from "../util/syncCommittee.js"; import {getActiveValidatorIndices} from "../util/validator.js"; import {interopPubkeysCached} from "./interop.js"; @@ -541,3 +548,118 @@ export function generateTestCachedBeaconStateOnlyValidators({ {skipSyncPubkeys: true} ); } + +/** + * Fulu performance state. Mirrors `generatePerformanceStateElectra` with the single extra + * Fulu field `proposerLookahead`, which is left as the default zero vector since + * `upgradeStateToGloas` copies it verbatim and never reads it. + */ +export function generatePerformanceStateFulu(pubkeysArg?: Uint8Array[]): BeaconStateFulu { + const pubkeys = pubkeysArg || getPubkeys().pubkeys; + const fuluConfig = createChainForkConfig({ + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + }); + const state = ssz.fulu.BeaconState.defaultValue(); + + Object.assign(state, buildPerformanceStatePhase0(pubkeys)); + + state.fork.previousVersion = fuluConfig.ELECTRA_FORK_VERSION; + state.fork.currentVersion = fuluConfig.FULU_FORK_VERSION; + state.fork.epoch = fuluConfig.FULU_FORK_EPOCH; + state.previousEpochParticipation = newFilledArray(pubkeys.length, 0b111); + state.currentEpochParticipation = state.previousEpochParticipation; + state.inactivityScores = Array.from({length: pubkeys.length}, (_, i) => i % 2); + state.currentSyncCommittee = ssz.altair.SyncCommittee.defaultValue(); + state.nextSyncCommittee = state.currentSyncCommittee; + state.latestExecutionPayloadHeader = ssz.electra.ExecutionPayloadHeader.defaultValue(); + state.depositRequestsStartIndex = 2023n; + + let cached = ssz.fulu.BeaconState.toViewDU(state); + + const epoch = computeEpochAtSlot(state.slot); + const activeValidatorIndices = getActiveValidatorIndices(cached, epoch); + const effectiveBalanceIncrements = getEffectiveBalanceIncrements(cached); + const {syncCommittee} = getNextSyncCommittee( + ForkSeq.fulu, + cached, + activeValidatorIndices, + effectiveBalanceIncrements + ); + state.currentSyncCommittee = syncCommittee; + state.nextSyncCommittee = syncCommittee; + + cached = ssz.fulu.BeaconState.toViewDU(state); + cached.hashTreeRoot(); + return cached.clone(); +} + +/** + * Cached Fulu performance state used as the pre-state for `upgradeStateToGloas` benchmarks. + * `vc` is kept modest (default 16384) since these benchmarks exercise builder onboarding, + * not validator-heavy processing. + */ +export function generatePerfTestCachedStateFulu(opts?: {vc?: number}): CachedBeaconStateFulu { + const vc = opts?.vc ?? 16384; + const {pubkeys, pubkeysMod, pubkeysModObj} = getPubkeys(vc); + const {pubkeyCache} = getPubkeyCaches({pubkeys, pubkeysMod, pubkeysModObj}, vc); + + const fuluConfig = createChainForkConfig({ + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + }); + + const state = generatePerformanceStateFulu(pubkeys).clone(); + + return createCachedBeaconState(state, { + config: createBeaconConfig(fuluConfig, state.genesisValidatorsRoot), + pubkeyCache, + }); +} + +/** + * Build `count` builder `PendingDeposit` entries with valid deposit signatures, distinct + * pubkeys from interop indices `[startIndex, startIndex + count)`, builder withdrawal + * credentials (prefix `BUILDER_WITHDRAWAL_PREFIX`), and a 1 ETH amount. Used to fill + * `state.pendingDeposits` before benchmarking `upgradeStateToGloas` builder onboarding. + */ +export function generateBuilderPendingDeposits( + config: BeaconConfig, + count: number, + startIndex: number +): electra.PendingDeposit[] { + // Deposits use a fork-agnostic domain, see `isValidDepositSignature` + const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); + const amount = 1_000_000_000; // 1 ETH in Gwei + const deposits: electra.PendingDeposit[] = []; + + for (let i = 0; i < count; i++) { + const sk = interopSecretKey(startIndex + i); + const pubkey = sk.toPublicKey().toBytes(); + + const withdrawalCredentials = Buffer.alloc(32, 0); + withdrawalCredentials[0] = BUILDER_WITHDRAWAL_PREFIX; + // bytes [12, 32) hold the 20-byte builder execution address + withdrawalCredentials.fill((startIndex + i) & 0xff, 12, 32); + + const signingRoot = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); + + deposits.push({ + pubkey, + withdrawalCredentials, + amount, + signature: sk.sign(signingRoot).toBytes(), + slot: GENESIS_SLOT, + }); + } + + return deposits; +} diff --git a/packages/state-transition/test/perf/slot/upgradeStateToGloas.test.ts b/packages/state-transition/test/perf/slot/upgradeStateToGloas.test.ts new file mode 100644 index 000000000000..cdc1563e9535 --- /dev/null +++ b/packages/state-transition/test/perf/slot/upgradeStateToGloas.test.ts @@ -0,0 +1,53 @@ +import {bench, describe, setBenchOpts} from "@chainsafe/benchmark"; +import {ssz} from "@lodestar/types"; +import {CachedBeaconStateFulu} from "../../../src/index.js"; +import {upgradeStateToGloas} from "../../../src/slot/upgradeStateToGloas.js"; +import {generateBuilderPendingDeposits, generatePerfTestCachedStateFulu} from "../../../src/testUtils/util.js"; +import {beforeValue} from "../../utils/beforeValueBenchmark.js"; + +// End-to-end benchmark for `upgradeStateToGloas` onboarding builders at the Gloas fork. +// Exercises the real `onboardBuildersFromPendingDeposits` -> `applyDepositForBuilder` -> +// `isValidDepositSignature` (BLS verify, O(N)) -> `addBuilderToRegistry` (linear registry +// scan, O(N^2)) path. See the `// TODO GLOAS: handle 20k 1ETH deposits` note in +// `upgradeStateToGloas.ts`. + +const VALIDATOR_COUNT = 16384; +const BUILDER_COUNTS = [5000, 10000, 20000, 30000]; +const MAX_BUILDERS = Math.max(...BUILDER_COUNTS); + +describe("upgradeStateToGloas - onboard builders", () => { + // O(N^2) registry build + N BLS verifications is slow and noisy: few runs, no regression gate. + setBenchOpts({minRuns: 3, yieldEventLoopAfterEach: true, noThreshold: true}); + + // Sign MAX_BUILDERS deposits and build the Fulu base state once; slice prefixes per size. + const shared = beforeValue(() => { + const baseState = generatePerfTestCachedStateFulu({vc: VALIDATOR_COUNT}); + const deposits = generateBuilderPendingDeposits(baseState.config, MAX_BUILDERS, VALIDATOR_COUNT); + return {baseState, deposits}; + }, 300_000); + + for (const builderCount of BUILDER_COUNTS) { + bench({ + id: `upgradeStateToGloas - onboard ${builderCount} builders`, + before: () => { + const state = shared.value.baseState.clone(); + const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); + for (let i = 0; i < builderCount; i++) { + pendingDeposits.push(ssz.electra.PendingDeposit.toViewDU(shared.value.deposits[i])); + } + state.pendingDeposits = pendingDeposits; + state.commit(); + state.hashTreeRoot(); + return state; + }, + beforeEach: (state) => state.clone(), + fn: (state) => { + const gloasState = upgradeStateToGloas(state); + // In-benchmark guard: every deposit must actually be onboarded as a builder. + if (gloasState.builders.length !== builderCount) { + throw Error(`expected ${builderCount} builders onboarded, got ${gloasState.builders.length}`); + } + }, + }); + } +}); From 2a7dcc06b13c09b75fe3c4ab847fbd5521993454 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 22 May 2026 10:22:31 +0700 Subject: [PATCH 02/16] perf: avoid looping throuh builders at fork transition --- .../src/block/processDepositRequest.ts | 53 +++++-- .../src/slot/upgradeStateToGloas.ts | 29 ++-- .../state-transition/src/testUtils/util.ts | 4 +- .../test/unit/upgradeStateToGloas.test.ts | 150 ++++++++++++++++++ 4 files changed, 211 insertions(+), 25 deletions(-) create mode 100644 packages/state-transition/test/unit/upgradeStateToGloas.test.ts diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index da07cef13447..3b55ee6c12d0 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -1,5 +1,5 @@ import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; -import {BLSPubkey, Bytes32, UintNum64, electra, ssz} from "@lodestar/types"; +import {BLSPubkey, BuilderIndex, Bytes32, Epoch, UintNum64, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../util/gloas.js"; @@ -33,6 +33,20 @@ export function applyDepositForBuilder( } } +/** + * Create a new builder registry entry (a `Builder` view) from a deposit. + */ +function buildNewBuilder(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, depositEpoch: Epoch) { + return ssz.gloas.Builder.toViewDU({ + pubkey, + version: withdrawalCredentials[0], + executionAddress: withdrawalCredentials.subarray(12), + balance: amount, + depositEpoch, + withdrawableEpoch: FAR_FUTURE_EPOCH, + }); +} + /** * Add a new builder to the builders registry. * Reuses slots from exited and fully withdrawn builders if available. @@ -57,15 +71,7 @@ function addBuilderToRegistry( } } - // Create new builder - const newBuilder = ssz.gloas.Builder.toViewDU({ - pubkey, - version: withdrawalCredentials[0], - executionAddress: withdrawalCredentials.subarray(12), - balance: amount, - depositEpoch: depositEpoch, - withdrawableEpoch: FAR_FUTURE_EPOCH, - }); + const newBuilder = buildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); if (builderIndex < state.builders.length) { // Reuse existing slot @@ -76,6 +82,33 @@ function addBuilderToRegistry( } } +/** + * Apply a deposit for a builder whose registry index is already known by the caller. + * + * This is called at gloas fork transition only. + */ +export function applyDepositForBuilderIndex( + state: CachedBeaconStateGloas, + builderIndex: BuilderIndex | null, + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + signature: Bytes32, + slot: UintNum64 +): void { + if (builderIndex !== null) { + // Existing builder - increase balance + const builder = state.builders.get(builderIndex); + builder.balance += amount; + return; + } + + // New builder - verify signature and append directly to the registry + if (isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature)) { + state.builders.push(buildNewBuilder(pubkey, withdrawalCredentials, amount, computeEpochAtSlot(slot))); + } +} + // TODO GLOAS: the PendingDepositsLookup is currently scoped to a single envelope of // deposit-requests. We can track it as ephemeral within EpochCache and transfer to the next block // transition to reuse cached signature verifications. diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 786f2b76a8e0..eacf1438c318 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -1,7 +1,7 @@ import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {ssz} from "@lodestar/types"; +import {PubkeyHex, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {applyDepositForBuilder} from "../block/processDepositRequest.js"; +import {applyDepositForBuilderIndex} from "../block/processDepositRequest.js"; import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; @@ -89,9 +89,9 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea * Applies any pending deposits for builders to onboard builders during the fork transition * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork.md#new-onboard_builders_from_pending_deposits */ -function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { - // Track pubkeys of new builders added when applying deposits - const builderPubkeys = new Set(); +export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { + // Map of builder pubkey -> index in `state.builders` for builders added in this loop. + const builderIndexByPubkey = new Map(); const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); @@ -109,11 +109,12 @@ function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void continue; } - // `applyDepositForBuilder` can mutate the state and add a builder to the registry, so - // the set of builder pubkeys must be recomputed each iteration. `builderPubkeys` stands - // in for the spec's `[b.pubkey for b in state.builders]`: `state.builders` starts empty - // at the fork, so every builder is one added in a previous iteration of this loop. - if (!builderPubkeys.has(pubkeyHex)) { + // `?? null` is required to keep builder index 0. A known index means a top-up to an + // already-onboarded builder, applied regardless of withdrawal credential; otherwise + // this is a candidate new builder and the credential checks below apply. + const builderIndex = builderIndexByPubkey.get(pubkeyHex) ?? null; + + if (builderIndex === null) { // Deposits for non-builders stay in the pending queue. If there is a valid pending // deposit for a new validator with this pubkey, keep this deposit in the pending // queue to be applied to that validator later. @@ -130,10 +131,9 @@ function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void } const buildersLenBefore = state.builders.length; - // TODO GLOAS: handle 20k 1ETH deposits on time - // there is a note in the spec https://github.com/ethereum/consensus-specs/pull/5227 - applyDepositForBuilder( + applyDepositForBuilderIndex( state, + builderIndex, deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, @@ -141,7 +141,8 @@ function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void deposit.slot ); if (state.builders.length > buildersLenBefore) { - builderPubkeys.add(pubkeyHex); + // A new builder was appended; with direct push it lands at index `buildersLenBefore` + builderIndexByPubkey.set(pubkeyHex, buildersLenBefore); } } diff --git a/packages/state-transition/src/testUtils/util.ts b/packages/state-transition/src/testUtils/util.ts index 04df16706a3f..eb08db02be15 100644 --- a/packages/state-transition/src/testUtils/util.ts +++ b/packages/state-transition/src/testUtils/util.ts @@ -23,7 +23,6 @@ import { computeEpochAtSlot, createCachedBeaconState, createPubkeyCache, - interopSecretKey, newFilledArray, processSlots, } from "../index.js"; @@ -39,6 +38,9 @@ import { CachedBeaconStatePhase0, } from "../types.js"; import {computeDomain, computeSigningRoot} from "../util/index.js"; +// Import directly from the source module (not the `../index.js` barrel) to avoid a +// circular-import cycle that leaves `interopSecretKey` undefined under the vitest loader. +import {interopSecretKey} from "../util/interop.js"; import {getNextSyncCommittee} from "../util/syncCommittee.js"; import {getActiveValidatorIndices} from "../util/validator.js"; import {interopPubkeysCached} from "./interop.js"; diff --git a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts new file mode 100644 index 000000000000..00025474af5e --- /dev/null +++ b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts @@ -0,0 +1,150 @@ +import {describe, expect, it} from "vitest"; +import {getConfig} from "@lodestar/config/test-utils"; +import {ForkName} from "@lodestar/params"; +import {electra, ssz} from "@lodestar/types"; +import {toPubkeyHex, toRootHex} from "@lodestar/utils"; +import {applyDepositForBuilder} from "../../src/block/processDepositRequest.js"; +import {onboardBuildersFromPendingDeposits} from "../../src/slot/upgradeStateToGloas.js"; +import {createCachedBeaconStateTest} from "../../src/testUtils/state.js"; +import {generateBuilderPendingDeposits} from "../../src/testUtils/util.js"; +import {CachedBeaconStateGloas} from "../../src/types.js"; +import {isBuilderWithdrawalCredential} from "../../src/util/gloas.js"; +import {isValidatorKnown} from "../../src/util/index.js"; +import {PendingDepositsLookup} from "../../src/util/pendingDepositsLookup.js"; + +/** + * Verbatim copy of the original (pre-optimization) `onboardBuildersFromPendingDeposits`. + * Kept as the reference oracle for the differential test below: the optimized version + * must produce a byte-identical state. Reused unchanged to validate future improvements + * (e.g. batch BLS signature verification). + */ +function naiveOnboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { + // Track pubkeys of new builders added when applying deposits + const builderPubkeys = new Set(); + + const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); + const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); + + for (let i = 0; i < state.pendingDeposits.length; i++) { + const deposit = state.pendingDeposits.getReadonly(i); + + const validatorIndex = state.epochCtx.getValidatorIndex(deposit.pubkey); + const pubkeyHex = toPubkeyHex(deposit.pubkey); + + // Deposits for existing validators stay in the pending queue + if (isValidatorKnown(state, validatorIndex)) { + pendingDeposits.push(deposit); + pendingDepositsLookup.add(deposit, pubkeyHex); + continue; + } + + if (!builderPubkeys.has(pubkeyHex)) { + if (!isBuilderWithdrawalCredential(deposit.withdrawalCredentials)) { + pendingDeposits.push(deposit); + pendingDepositsLookup.add(deposit, pubkeyHex); + continue; + } + if (pendingDepositsLookup.hasPendingValidator(state.config, pubkeyHex)) { + pendingDeposits.push(deposit); + pendingDepositsLookup.add(deposit, pubkeyHex); + continue; + } + } + + const buildersLenBefore = state.builders.length; + applyDepositForBuilder( + state, + deposit.pubkey, + deposit.withdrawalCredentials, + deposit.amount, + deposit.signature, + deposit.slot + ); + if (state.builders.length > buildersLenBefore) { + builderPubkeys.add(pubkeyHex); + } + } + + state.pendingDeposits = pendingDeposits; +} + +describe("onboardBuildersFromPendingDeposits", () => { + /** 1 ETH in Gwei - the amount used by `generateBuilderPendingDeposits` */ + const builderAmount = 1_000_000_000; + + /** + * A Gloas state (empty builders registry) whose pendingDeposits interleave every branch: + * new builders, top-ups (incl. builder index 0), a non-builder deposit, an invalid signature. + */ + function buildGloasStateWithPendingDeposits(): CachedBeaconStateGloas { + const state = createCachedBeaconStateTest(ssz.gloas.BeaconState.defaultViewDU(), getConfig(ForkName.gloas), { + skipSyncCommitteeCache: true, + skipSyncPubkeys: true, + }); + + // 5 distinct, validly-signed builder deposits (interop indices 1000..1004) + const builderDeposits = generateBuilderPendingDeposits(state.config, 5, 1000); + + // A deposit with a non-builder withdrawal credential - must stay in the pending queue + const nonBuilderWc = Buffer.alloc(32); + nonBuilderWc[0] = 0x01; // eth1 withdrawal prefix, not a builder + const nonBuilderDeposit: electra.PendingDeposit = { + pubkey: Buffer.alloc(48, 0xaa), + withdrawalCredentials: nonBuilderWc, + amount: 32_000_000_000, + signature: Buffer.alloc(96), + slot: 0, + }; + + // A builder deposit with an invalid signature - must be dropped (not onboarded, not queued) + const invalidSigDeposit: electra.PendingDeposit = {...builderDeposits[4], signature: Buffer.alloc(96)}; + + const deposits: electra.PendingDeposit[] = [ + builderDeposits[0], // new builder -> index 0 + builderDeposits[1], // new builder -> index 1 + nonBuilderDeposit, // stays in the pending queue + builderDeposits[0], // top-up of builder index 0 + builderDeposits[2], // new builder -> index 2 + invalidSigDeposit, // dropped + builderDeposits[2], // top-up of builder index 2 + builderDeposits[3], // new builder -> index 3 + ]; + + const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); + for (const deposit of deposits) { + pendingDeposits.push(ssz.electra.PendingDeposit.toViewDU(deposit)); + } + state.pendingDeposits = pendingDeposits; + state.commit(); + + return state; + } + + it("optimized version produces the same state root as the naive reference", () => { + const state = buildGloasStateWithPendingDeposits(); + const stateNaive = state.clone(); + const stateOptimized = state.clone(); + + naiveOnboardBuildersFromPendingDeposits(stateNaive); + onboardBuildersFromPendingDeposits(stateOptimized); + + expect(toRootHex(stateOptimized.hashTreeRoot())).toBe(toRootHex(stateNaive.hashTreeRoot())); + }); + + it("onboards new builders, applies top-ups, and keeps non-builder deposits queued", () => { + const state = buildGloasStateWithPendingDeposits(); + onboardBuildersFromPendingDeposits(state); + state.commit(); + + // 4 new builders onboarded; the invalid-signature deposit is dropped + expect(state.builders.length).toBe(4); + // builders 0 and 2 were topped up once each + expect(state.builders.get(0).balance).toBe(2 * builderAmount); + expect(state.builders.get(2).balance).toBe(2 * builderAmount); + // builders 1 and 3 received a single deposit + expect(state.builders.get(1).balance).toBe(builderAmount); + expect(state.builders.get(3).balance).toBe(builderAmount); + // only the non-builder deposit remains in the queue + expect(state.pendingDeposits.length).toBe(1); + }); +}); From 58944203295be85ecc79500a5bd44ae63d2dc458 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 22 May 2026 14:29:53 +0700 Subject: [PATCH 03/16] perf: improve onboard builders --- .../src/block/processDepositRequest.ts | 84 ++++++++- .../src/slot/upgradeStateToGloas.ts | 90 +++++++-- .../test/unit/upgradeStateToGloas.test.ts | 178 +++++++++++++----- 3 files changed, 276 insertions(+), 76 deletions(-) diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 3b55ee6c12d0..566bc5d90ca4 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -1,9 +1,12 @@ -import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; +import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst"; +import {BeaconConfig} from "@lodestar/config"; +import {DOMAIN_DEPOSIT, FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; import {BLSPubkey, BuilderIndex, Bytes32, Epoch, UintNum64, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; +import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../util/gloas.js"; -import {computeEpochAtSlot, isValidatorKnown} from "../util/index.js"; +import {computeDomain, computeEpochAtSlot, computeSigningRoot, isValidatorKnown} from "../util/index.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; import {isValidDepositSignature} from "./processDeposit.js"; @@ -85,7 +88,10 @@ function addBuilderToRegistry( /** * Apply a deposit for a builder whose registry index is already known by the caller. * - * This is called at gloas fork transition only. + * This is called at gloas fork transition only. The signature is NOT verified here — the + * caller (`onboardBuildersFromPendingDeposits`) batch-verifies new-builder signatures + * before calling this. A new builder is appended directly, since the slot-reuse scan in + * `addBuilderToRegistry` is unnecessary at the fork (`state.builders` starts empty). */ export function applyDepositForBuilderIndex( state: CachedBeaconStateGloas, @@ -93,20 +99,80 @@ export function applyDepositForBuilderIndex( pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, - signature: Bytes32, slot: UintNum64 ): void { if (builderIndex !== null) { // Existing builder - increase balance - const builder = state.builders.get(builderIndex); - builder.balance += amount; + state.builders.get(builderIndex).balance += amount; return; } - // New builder - verify signature and append directly to the registry - if (isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature)) { - state.builders.push(buildNewBuilder(pubkey, withdrawalCredentials, amount, computeEpochAtSlot(slot))); + // New builder - signature already verified by the caller; append directly + state.builders.push(buildNewBuilder(pubkey, withdrawalCredentials, amount, computeEpochAtSlot(slot))); +} + +/** + * Verify a batch of deposit signatures. Tries batch verification first; on failure falls + * back to verifying each deposit individually so the valid deposits in a batch that + * contains an invalid one are still identified. Returns a boolean per input deposit. + */ +export function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] { + const results = new Array(deposits.length).fill(false); + // Deposit signatures use a fork-agnostic domain, see `isValidDepositSignature` + const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); + + const signatureSets: {publicKey: PublicKey; message: Uint8Array; signature: Uint8Array}[] = []; + const signatureSetDepositIndices: number[] = []; + for (let i = 0; i < deposits.length; i++) { + const {pubkey, withdrawalCredentials, amount, signature} = deposits[i]; + let publicKey: PublicKey; + try { + // Deposit pubkeys are untrusted: must be group + infinity checked + publicKey = PublicKey.fromBytes(pubkey, true); + } catch (_) { + // Malformed pubkey - invalid deposit, results[i] stays false + continue; + } + const message = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); + signatureSets.push({publicKey, message, signature}); + signatureSetDepositIndices.push(i); + } + + if (signatureSets.length === 0) { + return results; + } + + let batchValid: boolean; + try { + batchValid = + signatureSets.length >= 2 + ? verifyMultipleAggregateSignatures( + signatureSets.map((s) => ({pk: s.publicKey, msg: s.message, sig: Signature.fromBytes(s.signature, true)})) + ) + : verify( + signatureSets[0].message, + signatureSets[0].publicKey, + Signature.fromBytes(signatureSets[0].signature, true) + ); + } catch (_) { + batchValid = false; } + + if (batchValid) { + // Batch passed - every deposit with a well-formed pubkey is valid + for (const depositIndex of signatureSetDepositIndices) { + results[depositIndex] = true; + } + } else { + // Batch failed: at least one signature is invalid - verify each individually + for (let s = 0; s < signatureSets.length; s++) { + const depositIndex = signatureSetDepositIndices[s]; + const {pubkey, withdrawalCredentials, amount, signature} = deposits[depositIndex]; + results[depositIndex] = isValidDepositSignature(config, pubkey, withdrawalCredentials, amount, signature); + } + } + + return results; } // TODO GLOAS: the PendingDepositsLookup is currently scoped to a single envelope of diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index eacf1438c318..38aabe26abc8 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -1,7 +1,7 @@ import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {PubkeyHex, ssz} from "@lodestar/types"; +import {PubkeyHex, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {applyDepositForBuilderIndex} from "../block/processDepositRequest.js"; +import {applyDepositForBuilderIndex, verifyDepositSignatures} from "../block/processDepositRequest.js"; import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; @@ -85,17 +85,56 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea return stateGloas; } +/** Verify queued builder deposit signatures in batches of this size. */ +const BUILDER_DEPOSIT_BATCH_SIZE = 32; + /** * Applies any pending deposits for builders to onboard builders during the fork transition * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork.md#new-onboard_builders_from_pending_deposits + * + * New-builder deposits are verified lazily: signatures are queued and batch-verified + * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. */ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { - // Map of builder pubkey -> index in `state.builders` for builders added in this loop. + // Map of builder pubkey -> index in `state.builders` for builders already applied in this loop. const builderIndexByPubkey = new Map(); + // FIFO queue of new-builder deposits awaiting batch signature verification. Holds + // distinct pubkeys, a reappearing queued pubkey force-flushes the queue first. + const queuedBuilderDeposits = new Map(); const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); + // Batch-verify the queued deposits and apply the ones with valid signatures. + const flushQueue = (): void => { + if (queuedBuilderDeposits.size === 0) { + return; + } + const entries = Array.from(queuedBuilderDeposits); + const validResults = verifyDepositSignatures( + state.config, + entries.map(([, deposit]) => deposit) + ); + for (let j = 0; j < entries.length; j++) { + if (!validResults[j]) { + continue; + } + const [pubkeyHex, deposit] = entries[j]; + // With direct push (no slot reuse at the fork) the builder lands at the current length + const builderIndex = state.builders.length; + applyDepositForBuilderIndex( + state, + null, + deposit.pubkey, + deposit.withdrawalCredentials, + deposit.amount, + deposit.slot + ); + builderIndexByPubkey.set(pubkeyHex, builderIndex); + } + queuedBuilderDeposits.clear(); + }; + for (let i = 0; i < state.pendingDeposits.length; i++) { const deposit = state.pendingDeposits.getReadonly(i); @@ -109,6 +148,13 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas continue; } + // after the flush it is either an applied builder (-> top-up below) or absent (its + // queued deposit had an invalid signature -> re-evaluated as a fresh candidate). + // this ensures the functions work the same way to the spec + if (queuedBuilderDeposits.has(pubkeyHex)) { + flushQueue(); + } + // `?? null` is required to keep builder index 0. A known index means a top-up to an // already-onboarded builder, applied regardless of withdrawal credential; otherwise // this is a candidate new builder and the credential checks below apply. @@ -128,23 +174,33 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas pendingDepositsLookup.add(deposit, pubkeyHex); continue; } - } - const buildersLenBefore = state.builders.length; - applyDepositForBuilderIndex( - state, - builderIndex, - deposit.pubkey, - deposit.withdrawalCredentials, - deposit.amount, - deposit.signature, - deposit.slot - ); - if (state.builders.length > buildersLenBefore) { - // A new builder was appended; with direct push it lands at index `buildersLenBefore` - builderIndexByPubkey.set(pubkeyHex, buildersLenBefore); + // New builder candidate: queue it for lazy batch signature verification + queuedBuilderDeposits.set(pubkeyHex, { + pubkey: deposit.pubkey, + withdrawalCredentials: deposit.withdrawalCredentials, + amount: deposit.amount, + signature: deposit.signature, + slot: deposit.slot, + }); + if (queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE) { + flushQueue(); + } + } else { + // Top-up of an already-onboarded builder; no signature verification needed + applyDepositForBuilderIndex( + state, + builderIndex, + deposit.pubkey, + deposit.withdrawalCredentials, + deposit.amount, + deposit.slot + ); } } + // Verify and apply any remaining queued builder deposits + flushQueue(); + state.pendingDeposits = pendingDeposits; } diff --git a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts index 00025474af5e..0891d593e5ee 100644 --- a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts +++ b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts @@ -1,4 +1,5 @@ import {describe, expect, it} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; import {getConfig} from "@lodestar/config/test-utils"; import {ForkName} from "@lodestar/params"; import {electra, ssz} from "@lodestar/types"; @@ -13,10 +14,9 @@ import {isValidatorKnown} from "../../src/util/index.js"; import {PendingDepositsLookup} from "../../src/util/pendingDepositsLookup.js"; /** - * Verbatim copy of the original (pre-optimization) `onboardBuildersFromPendingDeposits`. - * Kept as the reference oracle for the differential test below: the optimized version - * must produce a byte-identical state. Reused unchanged to validate future improvements - * (e.g. batch BLS signature verification). + * Verbatim copy of the eager `onboardBuildersFromPendingDeposits` (pre batch-verification). + * The reference oracle for the differential tests below: the optimized (lazy + batched) + * version must produce a byte-identical state. Reused unchanged to validate future changes. */ function naiveOnboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { // Track pubkeys of new builders added when applying deposits @@ -72,67 +72,145 @@ describe("onboardBuildersFromPendingDeposits", () => { /** 1 ETH in Gwei - the amount used by `generateBuilderPendingDeposits` */ const builderAmount = 1_000_000_000; - /** - * A Gloas state (empty builders registry) whose pendingDeposits interleave every branch: - * new builders, top-ups (incl. builder index 0), a non-builder deposit, an invalid signature. - */ - function buildGloasStateWithPendingDeposits(): CachedBeaconStateGloas { - const state = createCachedBeaconStateTest(ssz.gloas.BeaconState.defaultViewDU(), getConfig(ForkName.gloas), { + const chainConfig = getConfig(ForkName.gloas); + // BeaconConfig used to sign the builder deposit pool. Deposit signatures use a + // fork-agnostic domain, so only GENESIS_FORK_VERSION matters and it matches the config + // of every state built by `buildGloasState`. + const beaconConfig = createBeaconConfig(chainConfig, Buffer.alloc(32)); + // Pool of 100 distinct, validly-signed builder deposits (interop indices 1000..1099) + const pool = generateBuilderPendingDeposits(beaconConfig, 100, 1000); + + /** A deposit with the same pubkey but an all-zero (invalid) signature. */ + function withInvalidSignature(deposit: electra.PendingDeposit): electra.PendingDeposit { + return {...deposit, signature: Buffer.alloc(96)}; + } + + /** A deposit with a deliberately malformed (non-curve-point) pubkey. */ + function withMalformedPubkey(deposit: electra.PendingDeposit): electra.PendingDeposit { + return {...deposit, pubkey: Buffer.alloc(48, 0xff)}; + } + + /** A deposit with the same pubkey but a non-builder (0x01) withdrawal credential. */ + function withNonBuilderCredentials(deposit: electra.PendingDeposit): electra.PendingDeposit { + const withdrawalCredentials = Buffer.alloc(32); + withdrawalCredentials[0] = 0x01; + return {...deposit, withdrawalCredentials}; + } + + /** A non-builder deposit with a distinct pubkey; always passed through to pendingDeposits. */ + function nonBuilderDeposit(seed: number): electra.PendingDeposit { + const withdrawalCredentials = Buffer.alloc(32); + withdrawalCredentials[0] = 0x01; + const pubkey = Buffer.alloc(48, 0xee); + pubkey.writeUInt32BE(seed >>> 0, 0); + return {pubkey, withdrawalCredentials, amount: 32_000_000_000, signature: Buffer.alloc(96), slot: 0}; + } + + function buildGloasState(deposits: electra.PendingDeposit[]): CachedBeaconStateGloas { + const state = createCachedBeaconStateTest(ssz.gloas.BeaconState.defaultViewDU(), chainConfig, { skipSyncCommitteeCache: true, skipSyncPubkeys: true, }); - - // 5 distinct, validly-signed builder deposits (interop indices 1000..1004) - const builderDeposits = generateBuilderPendingDeposits(state.config, 5, 1000); - - // A deposit with a non-builder withdrawal credential - must stay in the pending queue - const nonBuilderWc = Buffer.alloc(32); - nonBuilderWc[0] = 0x01; // eth1 withdrawal prefix, not a builder - const nonBuilderDeposit: electra.PendingDeposit = { - pubkey: Buffer.alloc(48, 0xaa), - withdrawalCredentials: nonBuilderWc, - amount: 32_000_000_000, - signature: Buffer.alloc(96), - slot: 0, - }; - - // A builder deposit with an invalid signature - must be dropped (not onboarded, not queued) - const invalidSigDeposit: electra.PendingDeposit = {...builderDeposits[4], signature: Buffer.alloc(96)}; - - const deposits: electra.PendingDeposit[] = [ - builderDeposits[0], // new builder -> index 0 - builderDeposits[1], // new builder -> index 1 - nonBuilderDeposit, // stays in the pending queue - builderDeposits[0], // top-up of builder index 0 - builderDeposits[2], // new builder -> index 2 - invalidSigDeposit, // dropped - builderDeposits[2], // top-up of builder index 2 - builderDeposits[3], // new builder -> index 3 - ]; - const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); for (const deposit of deposits) { pendingDeposits.push(ssz.electra.PendingDeposit.toViewDU(deposit)); } state.pendingDeposits = pendingDeposits; state.commit(); - return state; } - it("optimized version produces the same state root as the naive reference", () => { - const state = buildGloasStateWithPendingDeposits(); - const stateNaive = state.clone(); - const stateOptimized = state.clone(); - - naiveOnboardBuildersFromPendingDeposits(stateNaive); - onboardBuildersFromPendingDeposits(stateOptimized); + /** Run the naive and the optimized onboarding on identical states; assert they match. */ + function runDifferential(deposits: electra.PendingDeposit[]): void { + const state = buildGloasState(deposits); + const naive = state.clone(); + const optimized = state.clone(); + + naiveOnboardBuildersFromPendingDeposits(naive); + onboardBuildersFromPendingDeposits(optimized); + + // builders registry must match the naive version + expect(optimized.builders.toValue()).toEqual(naive.builders.toValue()); + // pendingDeposits must match the naive version + expect(optimized.pendingDeposits.toValue()).toEqual(naive.pendingDeposits.toValue()); + // full state root catches anything else + expect(toRootHex(optimized.hashTreeRoot())).toBe(toRootHex(naive.hashTreeRoot())); + } - expect(toRootHex(stateOptimized.hashTreeRoot())).toBe(toRootHex(stateNaive.hashTreeRoot())); - }); + // The 8-deposit mix reused by the differential table and the concrete-value test below + const mixedDeposits: electra.PendingDeposit[] = [ + pool[0], // new builder -> index 0 + pool[1], // new builder -> index 1 + nonBuilderDeposit(1), // stays in pendingDeposits + pool[0], // top-up of builder 0 + pool[2], // new builder -> index 2 + withInvalidSignature(pool[3]), // dropped + pool[2], // top-up of builder 2 + pool[4], // new builder -> index 3 + ]; + + const scenarios: {name: string; deposits: electra.PendingDeposit[]}[] = [ + {name: "empty pendingDeposits", deposits: []}, + {name: "only non-builder deposits", deposits: [nonBuilderDeposit(1), nonBuilderDeposit(2), nonBuilderDeposit(3)]}, + {name: "single valid builder", deposits: [pool[0]]}, + {name: "single invalid-signature builder", deposits: [withInvalidSignature(pool[0])]}, + {name: "five valid builders flushed at end of loop", deposits: pool.slice(0, 5)}, + { + name: "batch with one invalid signature", + deposits: [pool[0], pool[1], withInvalidSignature(pool[2]), pool[3], pool[4]], + }, + {name: "batch with all invalid signatures", deposits: pool.slice(0, 5).map(withInvalidSignature)}, + { + name: "batch with a malformed pubkey", + deposits: [pool[0], pool[1], withMalformedPubkey(pool[2]), pool[3], pool[4]], + }, + {name: "exactly 32 builders (one full batch)", deposits: pool.slice(0, 32)}, + {name: "33 builders (full batch + remainder)", deposits: pool.slice(0, 33)}, + {name: "70 builders (multiple batches)", deposits: pool.slice(0, 70)}, + { + // The first 32-batch passes; the second batch (8) falls back to one-by-one + name: "invalid signature in the second batch", + deposits: [...pool.slice(0, 35), withInvalidSignature(pool[35]), ...pool.slice(36, 40)], + }, + { + // Two full 32-batches verified independently: the first all-valid, the second all-invalid + name: "a fully valid batch followed by a fully invalid batch", + deposits: [...pool.slice(0, 32), ...pool.slice(32, 64).map(withInvalidSignature)], + }, + { + name: "builders interleaved with non-builder deposits", + deposits: [pool[0], nonBuilderDeposit(1), pool[1], nonBuilderDeposit(2), pool[2], nonBuilderDeposit(3)], + }, + {name: "top-up of builder index 0 after a flush", deposits: [...pool.slice(0, 32), pool[0]]}, + {name: "force-flush: queued pubkey reappears (valid)", deposits: [pool[0], pool[1], pool[0]]}, + { + name: "force-flush: queued pubkey reappears after invalid signature", + deposits: [withInvalidSignature(pool[0]), pool[1], pool[0]], + }, + { + name: "force-flush: queued pubkey reappears with non-builder credentials", + deposits: [pool[0], withNonBuilderCredentials(pool[0])], + }, + { + name: "force-flush: invalid queued pubkey reappears with non-builder credentials", + deposits: [withInvalidSignature(pool[0]), withNonBuilderCredentials(pool[0])], + }, + {name: "same pubkey three times", deposits: [pool[0], pool[0], pool[0]]}, + { + name: "top-up applied while builders are queued", + deposits: [...pool.slice(0, 32), pool[32], pool[33], pool[0], pool[34]], + }, + {name: "mixed: builders, top-ups, non-builder, invalid", deposits: mixedDeposits}, + ]; + + for (const {name, deposits} of scenarios) { + it(`optimized matches naive: ${name}`, () => { + runDifferential(deposits); + }); + } it("onboards new builders, applies top-ups, and keeps non-builder deposits queued", () => { - const state = buildGloasStateWithPendingDeposits(); + const state = buildGloasState(mixedDeposits); onboardBuildersFromPendingDeposits(state); state.commit(); From 3a60596ead2a005c76ed8e7524a9576ad0330192 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Fri, 22 May 2026 15:33:16 +0700 Subject: [PATCH 04/16] chore: address PR comment --- .../src/block/processDepositRequest.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 566bc5d90ca4..1d50c67a960a 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -121,20 +121,22 @@ export function verifyDepositSignatures(config: BeaconConfig, deposits: electra. // Deposit signatures use a fork-agnostic domain, see `isValidDepositSignature` const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); - const signatureSets: {publicKey: PublicKey; message: Uint8Array; signature: Uint8Array}[] = []; + const signatureSets: {pk: PublicKey; msg: Uint8Array; sig: Signature}[] = []; const signatureSetDepositIndices: number[] = []; for (let i = 0; i < deposits.length; i++) { const {pubkey, withdrawalCredentials, amount, signature} = deposits[i]; - let publicKey: PublicKey; + let pk: PublicKey; + let sig: Signature; try { - // Deposit pubkeys are untrusted: must be group + infinity checked - publicKey = PublicKey.fromBytes(pubkey, true); + // Deposit pubkeys and signatures are untrusted: must be group + infinity checked + pk = PublicKey.fromBytes(pubkey, true); + sig = Signature.fromBytes(signature, true); } catch (_) { - // Malformed pubkey - invalid deposit, results[i] stays false + // Malformed pubkey or signature - invalid deposit, results[i] stays false continue; } - const message = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); - signatureSets.push({publicKey, message, signature}); + const msg = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); + signatureSets.push({pk, msg, sig}); signatureSetDepositIndices.push(i); } @@ -146,29 +148,21 @@ export function verifyDepositSignatures(config: BeaconConfig, deposits: electra. try { batchValid = signatureSets.length >= 2 - ? verifyMultipleAggregateSignatures( - signatureSets.map((s) => ({pk: s.publicKey, msg: s.message, sig: Signature.fromBytes(s.signature, true)})) - ) - : verify( - signatureSets[0].message, - signatureSets[0].publicKey, - Signature.fromBytes(signatureSets[0].signature, true) - ); + ? verifyMultipleAggregateSignatures(signatureSets) + : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig); } catch (_) { batchValid = false; } if (batchValid) { - // Batch passed - every deposit with a well-formed pubkey is valid + // Batch passed - every deposit with a well-formed pubkey and signature is valid for (const depositIndex of signatureSetDepositIndices) { results[depositIndex] = true; } } else { // Batch failed: at least one signature is invalid - verify each individually for (let s = 0; s < signatureSets.length; s++) { - const depositIndex = signatureSetDepositIndices[s]; - const {pubkey, withdrawalCredentials, amount, signature} = deposits[depositIndex]; - results[depositIndex] = isValidDepositSignature(config, pubkey, withdrawalCredentials, amount, signature); + results[signatureSetDepositIndices[s]] = verify(signatureSets[s].msg, signatureSets[s].pk, signatureSets[s].sig); } } From c87b00093503d1b5d6c1b3f6e3786020c082d812 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Sat, 23 May 2026 15:01:53 +0700 Subject: [PATCH 05/16] perf: improve applyDepositForBuilder() and use for both flows --- .../src/block/processDepositRequest.ts | 79 ++++++++++--------- .../src/slot/upgradeStateToGloas.ts | 23 ++++-- .../test/unit/upgradeStateToGloas.test.ts | 8 +- 3 files changed, 64 insertions(+), 46 deletions(-) diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 1d50c67a960a..82dbb070185b 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -19,19 +19,23 @@ export function applyDepositForBuilder( pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, - signature: Bytes32, - slot: UintNum64 + signature: Bytes32 | null, + slot: UintNum64, + builderIndex: BuilderIndex | null, + shouldReuse: boolean ): void { - const builderIndex = findBuilderIndexByPubkey(state, pubkey); - if (builderIndex !== null) { // Existing builder - increase balance const builder = state.builders.get(builderIndex); builder.balance += amount; } else { // New builder - verify signature and add to registry - if (isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature)) { - addBuilderToRegistry(state, pubkey, withdrawalCredentials, amount, slot); + const validSignature = + signature !== null + ? isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature) + : true; + if (validSignature) { + addBuilderToRegistry(state, pubkey, withdrawalCredentials, amount, slot, shouldReuse); } } } @@ -59,11 +63,20 @@ function addBuilderToRegistry( pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, - slot: UintNum64 + slot: UintNum64, + // new param compared to the spec to speed up onboard builder flow at fork transition + shouldReuse: boolean ): void { const currentEpoch = computeEpochAtSlot(state.slot); const depositEpoch = computeEpochAtSlot(slot); + const newBuilder = buildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); + + if (!shouldReuse) { + state.builders.push(newBuilder); + return; + } + // Try to find a reusable slot from an exited builder with zero balance let builderIndex = state.builders.length; for (let i = 0; i < state.builders.length; i++) { @@ -74,8 +87,6 @@ function addBuilderToRegistry( } } - const newBuilder = buildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); - if (builderIndex < state.builders.length) { // Reuse existing slot state.builders.set(builderIndex, newBuilder); @@ -85,32 +96,6 @@ function addBuilderToRegistry( } } -/** - * Apply a deposit for a builder whose registry index is already known by the caller. - * - * This is called at gloas fork transition only. The signature is NOT verified here — the - * caller (`onboardBuildersFromPendingDeposits`) batch-verifies new-builder signatures - * before calling this. A new builder is appended directly, since the slot-reuse scan in - * `addBuilderToRegistry` is unnecessary at the fork (`state.builders` starts empty). - */ -export function applyDepositForBuilderIndex( - state: CachedBeaconStateGloas, - builderIndex: BuilderIndex | null, - pubkey: BLSPubkey, - withdrawalCredentials: Bytes32, - amount: UintNum64, - slot: UintNum64 -): void { - if (builderIndex !== null) { - // Existing builder - increase balance - state.builders.get(builderIndex).balance += amount; - return; - } - - // New builder - signature already verified by the caller; append directly - state.builders.push(buildNewBuilder(pubkey, withdrawalCredentials, amount, computeEpochAtSlot(slot))); -} - /** * Verify a batch of deposit signatures. Tries batch verification first; on failure falls * back to verifying each deposit individually so the valid deposits in a batch that @@ -193,7 +178,17 @@ export function processDepositRequest( if (isBuilder) { // Top up an existing builder regardless of withdrawal credential prefix - applyDepositForBuilder(stateGloas, pubkey, withdrawalCredentials, amount, signature, state.slot); + applyDepositForBuilder( + stateGloas, + pubkey, + withdrawalCredentials, + amount, + signature, + state.slot, + builderIndex, + // shouldReuse is irrelevent for top up flow + true + ); return; } @@ -203,7 +198,17 @@ export function processDepositRequest( !isValidator && !lookup.hasPendingValidator(state.config, pubkeyHex) ) { - applyDepositForBuilder(stateGloas, pubkey, withdrawalCredentials, amount, signature, state.slot); + applyDepositForBuilder( + stateGloas, + pubkey, + withdrawalCredentials, + amount, + signature, + state.slot, + builderIndex, + // should reuse builder index + true + ); return; } diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 38aabe26abc8..466ee294c7e8 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -1,7 +1,7 @@ import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {PubkeyHex, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {applyDepositForBuilderIndex, verifyDepositSignatures} from "../block/processDepositRequest.js"; +import {applyDepositForBuilder, verifyDepositSignatures} from "../block/processDepositRequest.js"; import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; @@ -122,13 +122,18 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas const [pubkeyHex, deposit] = entries[j]; // With direct push (no slot reuse at the fork) the builder lands at the current length const builderIndex = state.builders.length; - applyDepositForBuilderIndex( + applyDepositForBuilder( state, - null, deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, - deposit.slot + // signature = null means valid + null, + deposit.slot, + // this is new builder, top up flow was detected below + null, + // no previous builders at fork transition so no need to check for reuse + false ); builderIndexByPubkey.set(pubkeyHex, builderIndex); } @@ -188,13 +193,17 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas } } else { // Top-up of an already-onboarded builder; no signature verification needed - applyDepositForBuilderIndex( + applyDepositForBuilder( state, - builderIndex, deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, - deposit.slot + // top up flow, no need signature verification + null, + deposit.slot, + builderIndex, + // no previous builders at fork transition so no need to check for reuse + false ); } } diff --git a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts index 0891d593e5ee..0d1177a4e93c 100644 --- a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts +++ b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts @@ -9,7 +9,7 @@ import {onboardBuildersFromPendingDeposits} from "../../src/slot/upgradeStateToG import {createCachedBeaconStateTest} from "../../src/testUtils/state.js"; import {generateBuilderPendingDeposits} from "../../src/testUtils/util.js"; import {CachedBeaconStateGloas} from "../../src/types.js"; -import {isBuilderWithdrawalCredential} from "../../src/util/gloas.js"; +import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../../src/util/gloas.js"; import {isValidatorKnown} from "../../src/util/index.js"; import {PendingDepositsLookup} from "../../src/util/pendingDepositsLookup.js"; @@ -52,13 +52,17 @@ function naiveOnboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): } const buildersLenBefore = state.builders.length; + const builderIndex = findBuilderIndexByPubkey(state, deposit.pubkey); applyDepositForBuilder( state, deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.signature, - deposit.slot + deposit.slot, + builderIndex, + // per spec, should always reuse builder index if possible + true ); if (state.builders.length > buildersLenBefore) { builderPubkeys.add(pubkeyHex); From 0312f934cc14ba3be38ff9493d6758df745113b1 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Sat, 23 May 2026 17:26:30 +0700 Subject: [PATCH 06/16] chore: isolate logic to OnboardBuilder --- .../src/block/processDepositRequest.ts | 2 + .../src/slot/upgradeStateToGloas.ts | 87 +++--------------- .../src/util/onboardBuilder.ts | 92 +++++++++++++++++++ 3 files changed, 107 insertions(+), 74 deletions(-) create mode 100644 packages/state-transition/src/util/onboardBuilder.ts diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 82dbb070185b..fdc1717f98b4 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -66,6 +66,8 @@ function addBuilderToRegistry( slot: UintNum64, // new param compared to the spec to speed up onboard builder flow at fork transition shouldReuse: boolean + // TODO: use this to simplify + // reusedBuilderIndex: BuilderIndex | null ): void { const currentEpoch = computeEpochAtSlot(state.slot); const depositEpoch = computeEpochAtSlot(slot); diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 466ee294c7e8..5204c26d88f4 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -1,11 +1,11 @@ import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {PubkeyHex, electra, ssz} from "@lodestar/types"; +import {ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {applyDepositForBuilder, verifyDepositSignatures} from "../block/processDepositRequest.js"; import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; import {isValidatorKnown} from "../util/index.js"; +import {OnboardBuilder} from "../util/onboardBuilder.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; /** @@ -85,9 +85,6 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea return stateGloas; } -/** Verify queued builder deposit signatures in batches of this size. */ -const BUILDER_DEPOSIT_BATCH_SIZE = 32; - /** * Applies any pending deposits for builders to onboard builders during the fork transition * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork.md#new-onboard_builders_from_pending_deposits @@ -96,50 +93,11 @@ const BUILDER_DEPOSIT_BATCH_SIZE = 32; * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. */ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { - // Map of builder pubkey -> index in `state.builders` for builders already applied in this loop. - const builderIndexByPubkey = new Map(); - // FIFO queue of new-builder deposits awaiting batch signature verification. Holds - // distinct pubkeys, a reappearing queued pubkey force-flushes the queue first. - const queuedBuilderDeposits = new Map(); + const onboarder = new OnboardBuilder(state); const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); - // Batch-verify the queued deposits and apply the ones with valid signatures. - const flushQueue = (): void => { - if (queuedBuilderDeposits.size === 0) { - return; - } - const entries = Array.from(queuedBuilderDeposits); - const validResults = verifyDepositSignatures( - state.config, - entries.map(([, deposit]) => deposit) - ); - for (let j = 0; j < entries.length; j++) { - if (!validResults[j]) { - continue; - } - const [pubkeyHex, deposit] = entries[j]; - // With direct push (no slot reuse at the fork) the builder lands at the current length - const builderIndex = state.builders.length; - applyDepositForBuilder( - state, - deposit.pubkey, - deposit.withdrawalCredentials, - deposit.amount, - // signature = null means valid - null, - deposit.slot, - // this is new builder, top up flow was detected below - null, - // no previous builders at fork transition so no need to check for reuse - false - ); - builderIndexByPubkey.set(pubkeyHex, builderIndex); - } - queuedBuilderDeposits.clear(); - }; - for (let i = 0; i < state.pendingDeposits.length; i++) { const deposit = state.pendingDeposits.getReadonly(i); @@ -156,14 +114,14 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas // after the flush it is either an applied builder (-> top-up below) or absent (its // queued deposit had an invalid signature -> re-evaluated as a fresh candidate). // this ensures the functions work the same way to the spec - if (queuedBuilderDeposits.has(pubkeyHex)) { - flushQueue(); + if (onboarder.hasQueuedDeposit(pubkeyHex)) { + onboarder.flushQueue(); } - // `?? null` is required to keep builder index 0. A known index means a top-up to an - // already-onboarded builder, applied regardless of withdrawal credential; otherwise - // this is a candidate new builder and the credential checks below apply. - const builderIndex = builderIndexByPubkey.get(pubkeyHex) ?? null; + // A known index means a top-up to an already-onboarded builder, applied regardless of + // withdrawal credential; otherwise this is a candidate new builder and the credential + // checks below apply. + const builderIndex = onboarder.getBuilderIndex(pubkeyHex); if (builderIndex === null) { // Deposits for non-builders stay in the pending queue. If there is a valid pending @@ -181,35 +139,16 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas } // New builder candidate: queue it for lazy batch signature verification - queuedBuilderDeposits.set(pubkeyHex, { - pubkey: deposit.pubkey, - withdrawalCredentials: deposit.withdrawalCredentials, - amount: deposit.amount, - signature: deposit.signature, - slot: deposit.slot, - }); - if (queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE) { - flushQueue(); - } + // (auto-flushes when batch size is reached) + onboarder.addBuilderDeposit(pubkeyHex, deposit); } else { // Top-up of an already-onboarded builder; no signature verification needed - applyDepositForBuilder( - state, - deposit.pubkey, - deposit.withdrawalCredentials, - deposit.amount, - // top up flow, no need signature verification - null, - deposit.slot, - builderIndex, - // no previous builders at fork transition so no need to check for reuse - false - ); + onboarder.topupBuilder(builderIndex, deposit.amount); } } // Verify and apply any remaining queued builder deposits - flushQueue(); + onboarder.flushQueue(); state.pendingDeposits = pendingDeposits; } diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts new file mode 100644 index 000000000000..fda4244907f1 --- /dev/null +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -0,0 +1,92 @@ +import {BuilderIndex, PubkeyHex, UintNum64, electra} from "@lodestar/types"; +import {applyDepositForBuilder, verifyDepositSignatures} from "../block/processDepositRequest.js"; +import {CachedBeaconStateGloas} from "../types.js"; + +/** Verify queued builder deposit signatures in batches of this size. */ +const BUILDER_DEPOSIT_BATCH_SIZE = 32; + +/** + * Encapsulates the queue + applier state used while onboarding builders from pending deposits. + * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork.md#new-onboard_builders_from_pending_deposits + * + * New-builder deposits are verified lazily: signatures are queued and batch-verified + * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. + */ +export class OnboardBuilder { + // Map of builder pubkey -> index in `state.builders` for builders already applied via this instance. + private readonly builderIndexByPubkey = new Map(); + // FIFO queue of new-builder deposits awaiting batch signature verification. Holds + // distinct pubkeys; a reappearing queued pubkey force-flushes the queue first. + private readonly queuedBuilderDeposits = new Map(); + + constructor(private readonly state: CachedBeaconStateGloas) {} + + /** Builder index for a pubkey already applied by this instance, or null. */ + getBuilderIndex(pubkeyHex: PubkeyHex): number | null { + return this.builderIndexByPubkey.get(pubkeyHex) ?? null; + } + + /** Whether a deposit for this pubkey is currently queued for batch verification. */ + hasQueuedDeposit(pubkeyHex: PubkeyHex): boolean { + return this.queuedBuilderDeposits.has(pubkeyHex); + } + + /** + * Queue a new-builder deposit for lazy batch signature verification. + * Auto-flushes when the queue reaches BUILDER_DEPOSIT_BATCH_SIZE. + * Stores a POJO copy of the deposit fields (caller may pass a readonly view). + */ + addBuilderDeposit(pubkeyHex: PubkeyHex, deposit: electra.PendingDeposit): void { + this.queuedBuilderDeposits.set(pubkeyHex, { + pubkey: deposit.pubkey, + withdrawalCredentials: deposit.withdrawalCredentials, + amount: deposit.amount, + signature: deposit.signature, + slot: deposit.slot, + }); + if (this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE) { + this.flushQueue(); + } + } + + /** Top up an already-onboarded builder's balance. No signature verification needed. */ + topupBuilder(builderIndex: BuilderIndex, amount: UintNum64): void { + const builder = this.state.builders.get(builderIndex); + builder.balance += amount; + } + + /** Batch-verify the queued deposits and apply the ones with valid signatures. */ + flushQueue(): void { + if (this.queuedBuilderDeposits.size === 0) { + return; + } + const entries = Array.from(this.queuedBuilderDeposits); + const validResults = verifyDepositSignatures( + this.state.config, + entries.map(([, deposit]) => deposit) + ); + for (let j = 0; j < entries.length; j++) { + if (!validResults[j]) { + continue; + } + const [pubkeyHex, deposit] = entries[j]; + // With direct push (no slot reuse at the fork) the builder lands at the current length + const builderIndex = this.state.builders.length; + applyDepositForBuilder( + this.state, + deposit.pubkey, + deposit.withdrawalCredentials, + deposit.amount, + // signature = null means valid + null, + deposit.slot, + // this is new builder, top up flow was detected below + null, + // no previous builders at fork transition so no need to check for reuse + false + ); + this.builderIndexByPubkey.set(pubkeyHex, builderIndex); + } + this.queuedBuilderDeposits.clear(); + } +} From ff1794255666933c4751e55da11f62e80627e024 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Sat, 23 May 2026 17:32:01 +0700 Subject: [PATCH 07/16] chore: move processDepositRequest() to onboardBuilder.ts --- .../src/block/processDepositRequest.ts | 65 +----------------- .../src/util/onboardBuilder.ts | 68 ++++++++++++++++++- 2 files changed, 68 insertions(+), 65 deletions(-) diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index fdc1717f98b4..27931454b70e 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -1,12 +1,9 @@ -import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst"; -import {BeaconConfig} from "@lodestar/config"; -import {DOMAIN_DEPOSIT, FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; +import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; import {BLSPubkey, BuilderIndex, Bytes32, Epoch, UintNum64, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../util/gloas.js"; -import {computeDomain, computeEpochAtSlot, computeSigningRoot, isValidatorKnown} from "../util/index.js"; +import {computeEpochAtSlot, isValidatorKnown} from "../util/index.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; import {isValidDepositSignature} from "./processDeposit.js"; @@ -98,64 +95,6 @@ function addBuilderToRegistry( } } -/** - * Verify a batch of deposit signatures. Tries batch verification first; on failure falls - * back to verifying each deposit individually so the valid deposits in a batch that - * contains an invalid one are still identified. Returns a boolean per input deposit. - */ -export function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] { - const results = new Array(deposits.length).fill(false); - // Deposit signatures use a fork-agnostic domain, see `isValidDepositSignature` - const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); - - const signatureSets: {pk: PublicKey; msg: Uint8Array; sig: Signature}[] = []; - const signatureSetDepositIndices: number[] = []; - for (let i = 0; i < deposits.length; i++) { - const {pubkey, withdrawalCredentials, amount, signature} = deposits[i]; - let pk: PublicKey; - let sig: Signature; - try { - // Deposit pubkeys and signatures are untrusted: must be group + infinity checked - pk = PublicKey.fromBytes(pubkey, true); - sig = Signature.fromBytes(signature, true); - } catch (_) { - // Malformed pubkey or signature - invalid deposit, results[i] stays false - continue; - } - const msg = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); - signatureSets.push({pk, msg, sig}); - signatureSetDepositIndices.push(i); - } - - if (signatureSets.length === 0) { - return results; - } - - let batchValid: boolean; - try { - batchValid = - signatureSets.length >= 2 - ? verifyMultipleAggregateSignatures(signatureSets) - : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig); - } catch (_) { - batchValid = false; - } - - if (batchValid) { - // Batch passed - every deposit with a well-formed pubkey and signature is valid - for (const depositIndex of signatureSetDepositIndices) { - results[depositIndex] = true; - } - } else { - // Batch failed: at least one signature is invalid - verify each individually - for (let s = 0; s < signatureSets.length; s++) { - results[signatureSetDepositIndices[s]] = verify(signatureSets[s].msg, signatureSets[s].pk, signatureSets[s].sig); - } - } - - return results; -} - // TODO GLOAS: the PendingDepositsLookup is currently scoped to a single envelope of // deposit-requests. We can track it as ephemeral within EpochCache and transfer to the next block // transition to reuse cached signature verifications. diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index fda4244907f1..63f29c41357e 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -1,6 +1,12 @@ -import {BuilderIndex, PubkeyHex, UintNum64, electra} from "@lodestar/types"; -import {applyDepositForBuilder, verifyDepositSignatures} from "../block/processDepositRequest.js"; +import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst"; +import {BeaconConfig} from "@lodestar/config"; +import {DOMAIN_DEPOSIT} from "@lodestar/params"; +import {BuilderIndex, PubkeyHex, UintNum64, electra, ssz} from "@lodestar/types"; +import {applyDepositForBuilder} from "../block/processDepositRequest.js"; +import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateGloas} from "../types.js"; +import {computeDomain} from "./domain.js"; +import {computeSigningRoot} from "./signingRoot.js"; /** Verify queued builder deposit signatures in batches of this size. */ const BUILDER_DEPOSIT_BATCH_SIZE = 32; @@ -90,3 +96,61 @@ export class OnboardBuilder { this.queuedBuilderDeposits.clear(); } } + +/** + * Verify a batch of deposit signatures. Tries batch verification first; on failure falls + * back to verifying each deposit individually so the valid deposits in a batch that + * contains an invalid one are still identified. Returns a boolean per input deposit. + */ +function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] { + const results = new Array(deposits.length).fill(false); + // Deposit signatures use a fork-agnostic domain, see `isValidDepositSignature` + const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); + + const signatureSets: {pk: PublicKey; msg: Uint8Array; sig: Signature}[] = []; + const signatureSetDepositIndices: number[] = []; + for (let i = 0; i < deposits.length; i++) { + const {pubkey, withdrawalCredentials, amount, signature} = deposits[i]; + let pk: PublicKey; + let sig: Signature; + try { + // Deposit pubkeys and signatures are untrusted: must be group + infinity checked + pk = PublicKey.fromBytes(pubkey, true); + sig = Signature.fromBytes(signature, true); + } catch (_) { + // Malformed pubkey or signature - invalid deposit, results[i] stays false + continue; + } + const msg = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); + signatureSets.push({pk, msg, sig}); + signatureSetDepositIndices.push(i); + } + + if (signatureSets.length === 0) { + return results; + } + + let batchValid: boolean; + try { + batchValid = + signatureSets.length >= 2 + ? verifyMultipleAggregateSignatures(signatureSets) + : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig); + } catch (_) { + batchValid = false; + } + + if (batchValid) { + // Batch passed - every deposit with a well-formed pubkey and signature is valid + for (const depositIndex of signatureSetDepositIndices) { + results[depositIndex] = true; + } + } else { + // Batch failed: at least one signature is invalid - verify each individually + for (let s = 0; s < signatureSets.length; s++) { + results[signatureSetDepositIndices[s]] = verify(signatureSets[s].msg, signatureSets[s].pk, signatureSets[s].sig); + } + } + + return results; +} From 1ba43b47b1b94188a857350e0a50eab7ab1e552c Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Sun, 24 May 2026 19:23:08 +0700 Subject: [PATCH 08/16] chore: rename to BatchOnboardBuilder --- .../src/slot/upgradeStateToGloas.ts | 19 ++++++-------- .../src/util/onboardBuilder.ts | 26 +++++++++---------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 5204c26d88f4..27879f581fa2 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -5,7 +5,7 @@ import {getCachedBeaconState} from "../cache/stateCache.js"; import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js"; import {isValidatorKnown} from "../util/index.js"; -import {OnboardBuilder} from "../util/onboardBuilder.js"; +import {BatchOnboardBuilder} from "../util/onboardBuilder.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; /** @@ -93,7 +93,7 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. */ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { - const onboarder = new OnboardBuilder(state); + const batcher = new BatchOnboardBuilder(state); const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); @@ -111,17 +111,15 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas continue; } - // after the flush it is either an applied builder (-> top-up below) or absent (its + // after this, it is either an applied builder (-> top-up below) or absent (its // queued deposit had an invalid signature -> re-evaluated as a fresh candidate). // this ensures the functions work the same way to the spec - if (onboarder.hasQueuedDeposit(pubkeyHex)) { - onboarder.flushQueue(); - } + batcher.onboardBuildersIfQueued(pubkeyHex); // A known index means a top-up to an already-onboarded builder, applied regardless of // withdrawal credential; otherwise this is a candidate new builder and the credential // checks below apply. - const builderIndex = onboarder.getBuilderIndex(pubkeyHex); + const builderIndex = batcher.getAppliedBuilderIndex(pubkeyHex); if (builderIndex === null) { // Deposits for non-builders stay in the pending queue. If there is a valid pending @@ -139,16 +137,15 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas } // New builder candidate: queue it for lazy batch signature verification - // (auto-flushes when batch size is reached) - onboarder.addBuilderDeposit(pubkeyHex, deposit); + batcher.queueBuilderDeposit(pubkeyHex, deposit); } else { // Top-up of an already-onboarded builder; no signature verification needed - onboarder.topupBuilder(builderIndex, deposit.amount); + batcher.topupBuilder(builderIndex, deposit.amount); } } // Verify and apply any remaining queued builder deposits - onboarder.flushQueue(); + batcher.onboardBuilders(); state.pendingDeposits = pendingDeposits; } diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index 63f29c41357e..c4a793b85277 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -18,7 +18,7 @@ const BUILDER_DEPOSIT_BATCH_SIZE = 32; * New-builder deposits are verified lazily: signatures are queued and batch-verified * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. */ -export class OnboardBuilder { +export class BatchOnboardBuilder { // Map of builder pubkey -> index in `state.builders` for builders already applied via this instance. private readonly builderIndexByPubkey = new Map(); // FIFO queue of new-builder deposits awaiting batch signature verification. Holds @@ -28,21 +28,14 @@ export class OnboardBuilder { constructor(private readonly state: CachedBeaconStateGloas) {} /** Builder index for a pubkey already applied by this instance, or null. */ - getBuilderIndex(pubkeyHex: PubkeyHex): number | null { + getAppliedBuilderIndex(pubkeyHex: PubkeyHex): number | null { return this.builderIndexByPubkey.get(pubkeyHex) ?? null; } - /** Whether a deposit for this pubkey is currently queued for batch verification. */ - hasQueuedDeposit(pubkeyHex: PubkeyHex): boolean { - return this.queuedBuilderDeposits.has(pubkeyHex); - } - /** * Queue a new-builder deposit for lazy batch signature verification. - * Auto-flushes when the queue reaches BUILDER_DEPOSIT_BATCH_SIZE. - * Stores a POJO copy of the deposit fields (caller may pass a readonly view). */ - addBuilderDeposit(pubkeyHex: PubkeyHex, deposit: electra.PendingDeposit): void { + queueBuilderDeposit(pubkeyHex: PubkeyHex, deposit: electra.PendingDeposit): void { this.queuedBuilderDeposits.set(pubkeyHex, { pubkey: deposit.pubkey, withdrawalCredentials: deposit.withdrawalCredentials, @@ -51,7 +44,7 @@ export class OnboardBuilder { slot: deposit.slot, }); if (this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE) { - this.flushQueue(); + this.onboardBuilders(); } } @@ -61,8 +54,15 @@ export class OnboardBuilder { builder.balance += amount; } + /** Onboard queued builders if this pubkey is in the queue */ + onboardBuildersIfQueued(pubkeyHex: PubkeyHex): void { + if (this.queuedBuilderDeposits.has(pubkeyHex)) { + this.onboardBuilders(); + } + } + /** Batch-verify the queued deposits and apply the ones with valid signatures. */ - flushQueue(): void { + onboardBuilders(): void { if (this.queuedBuilderDeposits.size === 0) { return; } @@ -86,7 +86,7 @@ export class OnboardBuilder { // signature = null means valid null, deposit.slot, - // this is new builder, top up flow was detected below + // this is new builder null, // no previous builders at fork transition so no need to check for reuse false From a2a45c1c8fa4d2f883a049d6dce6907fb080e2e7 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 08:16:36 +0700 Subject: [PATCH 09/16] feat: implement addBuilderToRegistry in the Batcher --- .../src/util/onboardBuilder.ts | 72 ++++++++++++++----- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index c4a793b85277..2007356f5386 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -1,12 +1,12 @@ import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; -import {DOMAIN_DEPOSIT} from "@lodestar/params"; -import {BuilderIndex, PubkeyHex, UintNum64, electra, ssz} from "@lodestar/types"; -import {applyDepositForBuilder} from "../block/processDepositRequest.js"; +import {DOMAIN_DEPOSIT, FAR_FUTURE_EPOCH} from "@lodestar/params"; +import {BLSPubkey, BuilderIndex, Bytes32, Epoch, PubkeyHex, UintNum64, electra, gloas, ssz} from "@lodestar/types"; import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateGloas} from "../types.js"; import {computeDomain} from "./domain.js"; import {computeSigningRoot} from "./signingRoot.js"; +import { computeEpochAtSlot } from "./epoch.ts"; /** Verify queued builder deposit signatures in batches of this size. */ const BUILDER_DEPOSIT_BATCH_SIZE = 32; @@ -24,8 +24,13 @@ export class BatchOnboardBuilder { // FIFO queue of new-builder deposits awaiting batch signature verification. Holds // distinct pubkeys; a reappearing queued pubkey force-flushes the queue first. private readonly queuedBuilderDeposits = new Map(); + // this is use to scan for reused builder index + private preExistingBuilders: gloas.Builder[]; + private nextReuseIndexCheck = 0; - constructor(private readonly state: CachedBeaconStateGloas) {} + constructor(private readonly state: CachedBeaconStateGloas) { + this.preExistingBuilders = this.state.builders.getAllReadonlyValues(); + } /** Builder index for a pubkey already applied by this instance, or null. */ getAppliedBuilderIndex(pubkeyHex: PubkeyHex): number | null { @@ -52,6 +57,9 @@ export class BatchOnboardBuilder { topupBuilder(builderIndex: BuilderIndex, amount: UintNum64): void { const builder = this.state.builders.get(builderIndex); builder.balance += amount; + if (builderIndex < this.preExistingBuilders.length) { + this.preExistingBuilders[builderIndex].balance += amount; + } } /** Onboard queued builders if this pubkey is in the queue */ @@ -76,25 +84,51 @@ export class BatchOnboardBuilder { continue; } const [pubkeyHex, deposit] = entries[j]; - // With direct push (no slot reuse at the fork) the builder lands at the current length - const builderIndex = this.state.builders.length; - applyDepositForBuilder( - this.state, - deposit.pubkey, - deposit.withdrawalCredentials, - deposit.amount, - // signature = null means valid - null, - deposit.slot, - // this is new builder - null, - // no previous builders at fork transition so no need to check for reuse - false - ); + const builderIndex = this.addBuilderToRegistry(deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.slot); this.builderIndexByPubkey.set(pubkeyHex, builderIndex); } this.queuedBuilderDeposits.clear(); } + + addBuilderToRegistry( + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + slot: UintNum64, + ): BuilderIndex { + const currentEpoch = computeEpochAtSlot(this.state.slot); + const depositEpoch = computeEpochAtSlot(slot); + + const newBuilder = buildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); + + // Try to find a reusable slot from an exited builder with zero balance + for (let i = this.nextReuseIndexCheck; i < this.preExistingBuilders.length; i++) { + const builder = this.preExistingBuilders[i]; + if (builder.withdrawableEpoch <= currentEpoch && builder.balance === 0) { + this.state.builders.set(i, newBuilder); + this.preExistingBuilders[i] = newBuilder.toValue(); + this.nextReuseIndexCheck = i + 1; + return i; + } + } + + // don't have to scan again the next time + this.nextReuseIndexCheck = this.preExistingBuilders.length; + const builderIndex = this.state.builders.length; + this.state.builders.push(newBuilder); + return builderIndex; + } +} + +function buildNewBuilder(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, depositEpoch: Epoch) { + return ssz.gloas.Builder.toViewDU({ + pubkey, + version: withdrawalCredentials[0], + executionAddress: withdrawalCredentials.subarray(12), + balance: amount, + depositEpoch, + withdrawableEpoch: FAR_FUTURE_EPOCH, + }); } /** From ced86da95d61aa8ede33285fc1a5e0bfc1656f68 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 09:03:58 +0700 Subject: [PATCH 10/16] fix: sync builderIndexByPubkey --- .../src/util/onboardBuilder.ts | 29 +- .../test/unit/util/onboardBuilder.test.ts | 431 ++++++++++++++++++ 2 files changed, 446 insertions(+), 14 deletions(-) create mode 100644 packages/state-transition/test/unit/util/onboardBuilder.test.ts diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index 2007356f5386..93d61a9b63d6 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -2,11 +2,12 @@ import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@ import {BeaconConfig} from "@lodestar/config"; import {DOMAIN_DEPOSIT, FAR_FUTURE_EPOCH} from "@lodestar/params"; import {BLSPubkey, BuilderIndex, Bytes32, Epoch, PubkeyHex, UintNum64, electra, gloas, ssz} from "@lodestar/types"; +import {toPubkeyHex} from "@lodestar/utils"; import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateGloas} from "../types.js"; import {computeDomain} from "./domain.js"; +import {computeEpochAtSlot} from "./epoch.ts"; import {computeSigningRoot} from "./signingRoot.js"; -import { computeEpochAtSlot } from "./epoch.ts"; /** Verify queued builder deposit signatures in batches of this size. */ const BUILDER_DEPOSIT_BATCH_SIZE = 32; @@ -20,7 +21,7 @@ const BUILDER_DEPOSIT_BATCH_SIZE = 32; */ export class BatchOnboardBuilder { // Map of builder pubkey -> index in `state.builders` for builders already applied via this instance. - private readonly builderIndexByPubkey = new Map(); + private readonly builderIndexByPubkey: Map; // FIFO queue of new-builder deposits awaiting batch signature verification. Holds // distinct pubkeys; a reappearing queued pubkey force-flushes the queue first. private readonly queuedBuilderDeposits = new Map(); @@ -30,6 +31,10 @@ export class BatchOnboardBuilder { constructor(private readonly state: CachedBeaconStateGloas) { this.preExistingBuilders = this.state.builders.getAllReadonlyValues(); + this.builderIndexByPubkey = new Map(); + for (const [i, builder] of this.preExistingBuilders.entries()) { + this.builderIndexByPubkey.set(toPubkeyHex(builder.pubkey), i); + } } /** Builder index for a pubkey already applied by this instance, or null. */ @@ -83,19 +88,13 @@ export class BatchOnboardBuilder { if (!validResults[j]) { continue; } - const [pubkeyHex, deposit] = entries[j]; - const builderIndex = this.addBuilderToRegistry(deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.slot); - this.builderIndexByPubkey.set(pubkeyHex, builderIndex); + const [_, deposit] = entries[j]; + this.addBuilderToRegistry(deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.slot); } this.queuedBuilderDeposits.clear(); } - addBuilderToRegistry( - pubkey: BLSPubkey, - withdrawalCredentials: Bytes32, - amount: UintNum64, - slot: UintNum64, - ): BuilderIndex { + addBuilderToRegistry(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, slot: UintNum64): void { const currentEpoch = computeEpochAtSlot(this.state.slot); const depositEpoch = computeEpochAtSlot(slot); @@ -107,16 +106,18 @@ export class BatchOnboardBuilder { if (builder.withdrawableEpoch <= currentEpoch && builder.balance === 0) { this.state.builders.set(i, newBuilder); this.preExistingBuilders[i] = newBuilder.toValue(); + this.builderIndexByPubkey.delete(toPubkeyHex(builder.pubkey)); + this.builderIndexByPubkey.set(toPubkeyHex(newBuilder.pubkey), i); this.nextReuseIndexCheck = i + 1; - return i; + return; } } // don't have to scan again the next time this.nextReuseIndexCheck = this.preExistingBuilders.length; - const builderIndex = this.state.builders.length; + const newBuilderIndex = this.state.builders.length; this.state.builders.push(newBuilder); - return builderIndex; + this.builderIndexByPubkey.set(toPubkeyHex(newBuilder.pubkey), newBuilderIndex); } } diff --git a/packages/state-transition/test/unit/util/onboardBuilder.test.ts b/packages/state-transition/test/unit/util/onboardBuilder.test.ts new file mode 100644 index 000000000000..6c4e3a0960a2 --- /dev/null +++ b/packages/state-transition/test/unit/util/onboardBuilder.test.ts @@ -0,0 +1,431 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +import {BUILDER_WITHDRAWAL_PREFIX, FAR_FUTURE_EPOCH, ForkName, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {electra, ssz} from "@lodestar/types"; +import {toPubkeyHex} from "@lodestar/utils"; +import {createCachedBeaconStateTest} from "../../../src/testUtils/state.js"; +import {generateBuilderPendingDeposits} from "../../../src/testUtils/util.js"; +import {CachedBeaconStateGloas} from "../../../src/types.js"; +import {BatchOnboardBuilder} from "../../../src/util/onboardBuilder.js"; + +describe("BatchOnboardBuilder", () => { + const chainConfig = getConfig(ForkName.gloas); + const beaconConfig = createBeaconConfig(chainConfig, Buffer.alloc(32)); + // Pool of validly-signed builder deposits — distinct interop pubkeys [1000, 1099) + const pool = generateBuilderPendingDeposits(beaconConfig, 100, 1000); + + function withInvalidSignature(deposit: electra.PendingDeposit): electra.PendingDeposit { + return {...deposit, signature: Buffer.alloc(96)}; + } + + /** + * Build a Gloas state with optional pre-existing builders and a configurable slot. + * `slot` controls `currentEpoch` for reuse eligibility checks. + */ + function buildGloasState( + opts: { + preExistingBuilders?: Array<{ + pubkey: Uint8Array; + balance: number; + withdrawableEpoch: number; + }>; + slot?: number; + } = {} + ): CachedBeaconStateGloas { + const stateView = ssz.gloas.BeaconState.defaultViewDU(); + if (opts.slot !== undefined) { + stateView.slot = opts.slot; + } + for (const b of opts.preExistingBuilders ?? []) { + stateView.builders.push( + ssz.gloas.Builder.toViewDU({ + pubkey: b.pubkey, + version: BUILDER_WITHDRAWAL_PREFIX, + executionAddress: Buffer.alloc(20), + balance: b.balance, + depositEpoch: 0, + withdrawableEpoch: b.withdrawableEpoch, + }) + ); + } + const state = createCachedBeaconStateTest(stateView, chainConfig, { + skipSyncCommitteeCache: true, + skipSyncPubkeys: true, + }); + state.commit(); + return state; + } + + /** A pre-existing builder eligible for reuse (`balance === 0`, `withdrawableEpoch <= currentEpoch`). */ + function exitedBuilder(pubkey: Uint8Array, withdrawableEpoch = 0) { + return {pubkey, balance: 0, withdrawableEpoch}; + } + + /** A pre-existing builder NOT eligible for reuse (non-zero balance). */ + function activeBuilder(pubkey: Uint8Array, balance = 1_000_000_000) { + return {pubkey, balance, withdrawableEpoch: FAR_FUTURE_EPOCH}; + } + + describe("constructor", () => { + it("starts with empty map and cache when state has no builders", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + }); + + it("populates map and cache from pre-existing builders", () => { + const state = buildGloasState({ + preExistingBuilders: [activeBuilder(pool[0].pubkey), activeBuilder(pool[1].pubkey)], + }); + const batcher = new BatchOnboardBuilder(state); + + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(null); + }); + }); + + describe("getAppliedBuilderIndex", () => { + it("returns null for an unknown pubkey", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + }); + + it("returns the index after addBuilderToRegistry pushes", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + }); + }); + + describe("addBuilderToRegistry — push path", () => { + it("pushes a new builder when there are no pre-existing slots", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + + expect(state.builders.length).toBe(1); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + expect(state.builders.get(0).balance).toBe(pool[0].amount); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + }); + + it("pushes when no pre-existing slot is eligible", () => { + const state = buildGloasState({ + preExistingBuilders: [activeBuilder(pool[0].pubkey), activeBuilder(pool[1].pubkey)], + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + expect(state.builders.length).toBe(3); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[5].pubkey))).toBe(2); + // pre-existing builders untouched + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); + }); + }); + + describe("addBuilderToRegistry — reuse path", () => { + it("reuses an eligible slot and replaces its contents", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[0].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + // length unchanged — slot was reused + expect(state.builders.length).toBe(1); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + expect(state.builders.get(0).balance).toBe(pool[5].amount); + }); + + it("removes the displaced pubkey from the map and records the new one", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[0].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + // displaced pubkey is gone; new pubkey points at the reused slot + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[5].pubkey))).toBe(0); + }); + + it("skips ineligible slots and reuses the first eligible one", () => { + const state = buildGloasState({ + preExistingBuilders: [ + activeBuilder(pool[0].pubkey), // index 0: non-zero balance, skip + exitedBuilder(pool[1].pubkey), // index 1: eligible, reuse + exitedBuilder(pool[2].pubkey), // index 2: also eligible but reached after 1 + ], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + expect(state.builders.length).toBe(3); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(null); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[5].pubkey))).toBe(1); + }); + + it("reuses subsequent eligible slots on later calls (cursor advances)", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[0].pubkey), exitedBuilder(pool[1].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.addBuilderToRegistry(pool[6].pubkey, pool[6].withdrawalCredentials, pool[6].amount, pool[6].slot); + + expect(state.builders.length).toBe(2); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[6].pubkey)); + }); + + it("does not reuse a builder whose withdrawableEpoch > currentEpoch", () => { + const currentEpoch = 5; + const state = buildGloasState({ + preExistingBuilders: [ + // balance===0 but withdrawableEpoch=10 > currentEpoch=5 → not eligible + {pubkey: pool[0].pubkey, balance: 0, withdrawableEpoch: 10}, + ], + slot: currentEpoch * SLOTS_PER_EPOCH, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + // pushed at end, not reused + expect(state.builders.length).toBe(2); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + }); + + it("does not re-scan slots after a full scan with no eligible candidates", () => { + const state = buildGloasState({ + preExistingBuilders: [activeBuilder(pool[0].pubkey), activeBuilder(pool[1].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + // second call: cursor was set to preExisting.length; should push directly + batcher.addBuilderToRegistry(pool[6].pubkey, pool[6].withdrawalCredentials, pool[6].amount, pool[6].slot); + + expect(state.builders.length).toBe(4); + expect(toPubkeyHex(state.builders.get(2).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + expect(toPubkeyHex(state.builders.get(3).pubkey)).toBe(toPubkeyHex(pool[6].pubkey)); + }); + }); + + describe("topupBuilder", () => { + it("increments the balance of a pre-existing builder in state and cache", () => { + const state = buildGloasState({ + preExistingBuilders: [activeBuilder(pool[0].pubkey, 1_000_000_000)], + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.topupBuilder(0, 500_000_000); + + expect(state.builders.get(0).balance).toBe(1_500_000_000); + }); + + it("cache stays in sync so subsequent reuse-scans see the bumped balance", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[0].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + // top up the previously-eligible (balance=0) slot, making it ineligible + batcher.topupBuilder(0, 1_000_000_000); + + // now try to onboard a new builder — should push, not reuse slot 0 + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + expect(state.builders.length).toBe(2); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + expect(state.builders.get(0).balance).toBe(1_000_000_000); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + }); + + it("skips the cache write when the index is past preExistingBuilders.length", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + // Index 0 is past the empty preExistingBuilders array. Should not throw. + batcher.topupBuilder(0, 500_000_000); + + expect(state.builders.get(0).balance).toBe(pool[0].amount + 500_000_000); + }); + + it("topup of a freshly-reused slot updates both state and cache", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[0].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + // slot 0 now holds pool[5]; index 0 is still < preExistingBuilders.length + batcher.topupBuilder(0, 500_000_000); + + expect(state.builders.get(0).balance).toBe(pool[5].amount + 500_000_000); + }); + }); + + describe("queueBuilderDeposit + onboardBuilders", () => { + it("queues a deposit without flushing when under batch size", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + + // not yet flushed + expect(state.builders.length).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + }); + + it("auto-flushes when the queue reaches BUILDER_DEPOSIT_BATCH_SIZE (32)", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + for (let i = 0; i < 32; i++) { + batcher.queueBuilderDeposit(toPubkeyHex(pool[i].pubkey), pool[i]); + } + + expect(state.builders.length).toBe(32); + for (let i = 0; i < 32; i++) { + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[i].pubkey))).toBe(i); + } + }); + + it("onboardBuilders is a no-op when the queue is empty", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + batcher.onboardBuilders(); + + expect(state.builders.length).toBe(0); + }); + + it("onboardBuilders applies all queued valid deposits and clears the queue", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); + batcher.onboardBuilders(); + + expect(state.builders.length).toBe(2); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); + + // queue cleared — calling again is a no-op + batcher.onboardBuilders(); + expect(state.builders.length).toBe(2); + }); + + it("onboardBuilders drops deposits with invalid signatures (batch fallback)", () => { + const state = buildGloasState(); + const batcher = new BatchOnboardBuilder(state); + + const bad = withInvalidSignature(pool[1]); + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + batcher.queueBuilderDeposit(toPubkeyHex(bad.pubkey), bad); + batcher.queueBuilderDeposit(toPubkeyHex(pool[2].pubkey), pool[2]); + batcher.onboardBuilders(); + + // valid deposits onboarded, invalid one dropped + expect(state.builders.length).toBe(2); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(bad.pubkey))).toBe(null); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(1); + }); + + it("onboardBuilders reuses pre-existing eligible slots for the queued batch", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey), activeBuilder(pool[51].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); + batcher.onboardBuilders(); + + // pool[0] reuses slot 0 (eligible); pool[1] pushes to slot 2 (since cursor moved past 1 which is ineligible) + expect(state.builders.length).toBe(3); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[51].pubkey)); + expect(toPubkeyHex(state.builders.get(2).pubkey)).toBe(toPubkeyHex(pool[1].pubkey)); + + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[50].pubkey))).toBe(null); // displaced + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(2); + }); + }); + + describe("onboardBuildersIfQueued", () => { + let state: CachedBeaconStateGloas; + let batcher: BatchOnboardBuilder; + + beforeEach(() => { + state = buildGloasState(); + batcher = new BatchOnboardBuilder(state); + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + }); + + it("is a no-op when the pubkey is not in the queue", () => { + batcher.onboardBuildersIfQueued(toPubkeyHex(pool[99].pubkey)); + expect(state.builders.length).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + }); + + it("flushes the entire queue when the pubkey is queued", () => { + batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); + // Both pool[0] and pool[1] are queued. Trigger via pool[0]'s pubkey. + batcher.onboardBuildersIfQueued(toPubkeyHex(pool[0].pubkey)); + + // entire queue was flushed + expect(state.builders.length).toBe(2); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); + }); + }); + + describe("stale-entry regression (the reason this class is unit-tested)", () => { + // The original bug: reusing a slot left the displaced pubkey in the map, causing + // a later deposit for the displaced pubkey to be wrongly routed as a top-up. + it("a deposit for a displaced pubkey is NOT routed via the map after reuse", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + // Reuse slot 0 for pool[5] + batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + + // The displaced pubkey must not resolve to slot 0 anymore — otherwise a + // top-up for pool[50] would silently credit pool[5]'s balance. + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[50].pubkey))).toBe(null); + + // Sanity: slot 0 actually holds pool[5] now + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); + }); + }); +}); From 40b03ebfd43a9db07f539c1705eb406f5163ffba Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 09:13:14 +0700 Subject: [PATCH 11/16] fix: faster loop through state pending deposits --- packages/state-transition/src/slot/upgradeStateToGloas.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 27879f581fa2..f95613d0b3b6 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -98,9 +98,7 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas const pendingDeposits = ssz.electra.PendingDeposits.defaultViewDU(); const pendingDepositsLookup = PendingDepositsLookup.buildEmpty(); - for (let i = 0; i < state.pendingDeposits.length; i++) { - const deposit = state.pendingDeposits.getReadonly(i); - + for (const deposit of state.pendingDeposits.getAllReadonly()) { const validatorIndex = state.epochCtx.getValidatorIndex(deposit.pubkey); const pubkeyHex = toPubkeyHex(deposit.pubkey); From fb0aaad41dadd8d2038c5d2ee98b4993ada23a87 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 10:49:37 +0700 Subject: [PATCH 12/16] feat: apply BatchOnboardBuilder for processDepositRequest() --- .../src/block/processDepositRequest.ts | 139 +++--------------- .../block/processParentExecutionPayload.ts | 6 +- packages/state-transition/src/util/gloas.ts | 15 -- .../src/util/onboardBuilder.ts | 28 +++- .../test/unit/upgradeStateToGloas.test.ts | 93 ++++++++++-- .../test/unit/util/onboardBuilder.test.ts | 119 +++++++++++++++ 6 files changed, 256 insertions(+), 144 deletions(-) diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 27931454b70e..ed98da3a55a7 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -1,99 +1,11 @@ -import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; -import {BLSPubkey, BuilderIndex, Bytes32, Epoch, UintNum64, electra, ssz} from "@lodestar/types"; +import {ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; +import {electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; -import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../util/gloas.js"; -import {computeEpochAtSlot, isValidatorKnown} from "../util/index.js"; +import {isBuilderWithdrawalCredential} from "../util/gloas.js"; +import {isValidatorKnown} from "../util/index.js"; +import {BatchOnboardBuilder} from "../util/onboardBuilder.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; -import {isValidDepositSignature} from "./processDeposit.js"; - -/** - * Apply a deposit for a builder. Either increases balance for existing builder or adds new builder to registry. - * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.1/specs/gloas/beacon-chain.md#new-apply_deposit_for_builder - */ -export function applyDepositForBuilder( - state: CachedBeaconStateGloas, - pubkey: BLSPubkey, - withdrawalCredentials: Bytes32, - amount: UintNum64, - signature: Bytes32 | null, - slot: UintNum64, - builderIndex: BuilderIndex | null, - shouldReuse: boolean -): void { - if (builderIndex !== null) { - // Existing builder - increase balance - const builder = state.builders.get(builderIndex); - builder.balance += amount; - } else { - // New builder - verify signature and add to registry - const validSignature = - signature !== null - ? isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature) - : true; - if (validSignature) { - addBuilderToRegistry(state, pubkey, withdrawalCredentials, amount, slot, shouldReuse); - } - } -} - -/** - * Create a new builder registry entry (a `Builder` view) from a deposit. - */ -function buildNewBuilder(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, depositEpoch: Epoch) { - return ssz.gloas.Builder.toViewDU({ - pubkey, - version: withdrawalCredentials[0], - executionAddress: withdrawalCredentials.subarray(12), - balance: amount, - depositEpoch, - withdrawableEpoch: FAR_FUTURE_EPOCH, - }); -} - -/** - * Add a new builder to the builders registry. - * Reuses slots from exited and fully withdrawn builders if available. - */ -function addBuilderToRegistry( - state: CachedBeaconStateGloas, - pubkey: BLSPubkey, - withdrawalCredentials: Bytes32, - amount: UintNum64, - slot: UintNum64, - // new param compared to the spec to speed up onboard builder flow at fork transition - shouldReuse: boolean - // TODO: use this to simplify - // reusedBuilderIndex: BuilderIndex | null -): void { - const currentEpoch = computeEpochAtSlot(state.slot); - const depositEpoch = computeEpochAtSlot(slot); - - const newBuilder = buildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); - - if (!shouldReuse) { - state.builders.push(newBuilder); - return; - } - - // Try to find a reusable slot from an exited builder with zero balance - let builderIndex = state.builders.length; - for (let i = 0; i < state.builders.length; i++) { - const builder = state.builders.getReadonly(i); - if (builder.withdrawableEpoch <= currentEpoch && builder.balance === 0) { - builderIndex = i; - break; - } - } - - if (builderIndex < state.builders.length) { - // Reuse existing slot - state.builders.set(builderIndex, newBuilder); - } else { - // Append to end - state.builders.push(newBuilder); - } -} // TODO GLOAS: the PendingDepositsLookup is currently scoped to a single envelope of // deposit-requests. We can track it as ephemeral within EpochCache and transfer to the next block @@ -103,33 +15,30 @@ export function processDepositRequest( fork: ForkSeq, state: CachedBeaconStateElectra | CachedBeaconStateGloas, depositRequest: electra.DepositRequest, - pendingDepositsLookup?: PendingDepositsLookup + pendingDepositsLookup?: PendingDepositsLookup, + batcher?: BatchOnboardBuilder ): void { const {pubkey, withdrawalCredentials, amount, signature} = depositRequest; if (fork >= ForkSeq.gloas) { const stateGloas = state as CachedBeaconStateGloas; const lookup = pendingDepositsLookup ?? PendingDepositsLookup.build(stateGloas); + const ownsBatcher = batcher === undefined; + const onboarder = batcher ?? new BatchOnboardBuilder(stateGloas); const pubkeyHex = toPubkeyHex(pubkey); - const builderIndex = findBuilderIndexByPubkey(stateGloas, pubkey); const validatorIndex = state.epochCtx.getValidatorIndex(pubkey); - - const isBuilder = builderIndex !== null; const isValidator = isValidatorKnown(state, validatorIndex); - if (isBuilder) { + // after this, it is either an applied builder (-> top-up below) or absent (its + // queued deposit had an invalid signature -> re-evaluated as a fresh candidate). + // this ensures the function works the same way as the spec + onboarder.onboardBuildersIfQueued(pubkeyHex); + + const builderIndex = onboarder.getAppliedBuilderIndex(pubkeyHex); + + if (builderIndex !== null) { // Top up an existing builder regardless of withdrawal credential prefix - applyDepositForBuilder( - stateGloas, - pubkey, - withdrawalCredentials, - amount, - signature, - state.slot, - builderIndex, - // shouldReuse is irrelevent for top up flow - true - ); + onboarder.topupBuilder(builderIndex, amount); return; } @@ -139,17 +48,15 @@ export function processDepositRequest( !isValidator && !lookup.hasPendingValidator(state.config, pubkeyHex) ) { - applyDepositForBuilder( - stateGloas, + onboarder.queueBuilderDeposit(pubkeyHex, { pubkey, withdrawalCredentials, amount, signature, - state.slot, - builderIndex, - // should reuse builder index - true - ); + slot: state.slot, + }); + // this is for the spec test where we want to eagerly onboard builder immediately + if (ownsBatcher) onboarder.onboardBuilders(); return; } diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 0781bb68113b..ea9b58126207 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -3,6 +3,7 @@ import {BeaconBlock, electra, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {CachedBeaconStateGloas} from "../types.js"; import {computeEpochAtSlot} from "../util/epoch.js"; +import {BatchOnboardBuilder} from "../util/onboardBuilder.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; import {processConsolidationRequest} from "./processConsolidationRequest.js"; import {processDepositRequest} from "./processDepositRequest.js"; @@ -56,9 +57,12 @@ export function applyParentExecutionPayload(state: CachedBeaconStateGloas, reque // requests are processed at state.slot (child's slot), not the parent's slot. if (requests.deposits.length > 0) { const pendingDepositsLookup = PendingDepositsLookup.build(state); + const batcher = new BatchOnboardBuilder(state); for (const deposit of requests.deposits) { - processDepositRequest(fork, state, deposit, pendingDepositsLookup); + processDepositRequest(fork, state, deposit, pendingDepositsLookup, batcher); } + // Flush any queued deposits remaining + batcher.onboardBuilders(); } for (const withdrawal of requests.withdrawals) { diff --git a/packages/state-transition/src/util/gloas.ts b/packages/state-transition/src/util/gloas.ts index 1be5623c295d..0bbcf56fdd3e 100644 --- a/packages/state-transition/src/util/gloas.ts +++ b/packages/state-transition/src/util/gloas.ts @@ -167,21 +167,6 @@ export function initiateBuilderExit(state: CachedBeaconStateGloas, builderIndex: builder.withdrawableEpoch = currentEpoch + state.config.MIN_BUILDER_WITHDRAWABILITY_DELAY; } -/** - * Find the index of a builder by their public key. - * Returns null if not found. - * - * May consider builder pubkey cache if performance becomes an issue. - */ -export function findBuilderIndexByPubkey(state: CachedBeaconStateGloas, pubkey: Uint8Array): BuilderIndex | null { - for (let i = 0; i < state.builders.length; i++) { - if (byteArrayEquals(state.builders.getReadonly(i).pubkey, pubkey)) { - return i; - } - } - return null; -} - export function isAttestationSameSlot(state: CachedBeaconStateGloas, data: AttestationData): boolean { if (data.slot === 0) return true; diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index 93d61a9b63d6..3754fd36cf5e 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -32,9 +32,18 @@ export class BatchOnboardBuilder { constructor(private readonly state: CachedBeaconStateGloas) { this.preExistingBuilders = this.state.builders.getAllReadonlyValues(); this.builderIndexByPubkey = new Map(); + const currentEpoch = computeEpochAtSlot(this.state.slot); + // Sentinel = preExistingBuilders.length means "no eligible slot found yet". + // Since i increases monotonically, (i < firstReuseIdx) is true only until we + // assign, so this records the FIRST eligible slot's index. + let firstReuseIdx = this.preExistingBuilders.length; for (const [i, builder] of this.preExistingBuilders.entries()) { this.builderIndexByPubkey.set(toPubkeyHex(builder.pubkey), i); + if (i < firstReuseIdx && isBuilderExited(builder, currentEpoch)) { + firstReuseIdx = i; + } } + this.nextReuseIndexCheck = firstReuseIdx; } /** Builder index for a pubkey already applied by this instance, or null. */ @@ -44,6 +53,13 @@ export class BatchOnboardBuilder { /** * Queue a new-builder deposit for lazy batch signature verification. + * + * Flush conditions: + * - reuse is still possible: applying NOW preserves eager (spec) ordering + * against any subsequent topup that this onboard might displace. + * - queue is at batch size: standard batch-verification trigger. + * Once cursor reaches preExistingBuilders.length, reuse is permanently + * exhausted and batching becomes safe. */ queueBuilderDeposit(pubkeyHex: PubkeyHex, deposit: electra.PendingDeposit): void { this.queuedBuilderDeposits.set(pubkeyHex, { @@ -53,7 +69,10 @@ export class BatchOnboardBuilder { signature: deposit.signature, slot: deposit.slot, }); - if (this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE) { + if ( + this.nextReuseIndexCheck < this.preExistingBuilders.length || + this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE + ) { this.onboardBuilders(); } } @@ -103,7 +122,7 @@ export class BatchOnboardBuilder { // Try to find a reusable slot from an exited builder with zero balance for (let i = this.nextReuseIndexCheck; i < this.preExistingBuilders.length; i++) { const builder = this.preExistingBuilders[i]; - if (builder.withdrawableEpoch <= currentEpoch && builder.balance === 0) { + if (isBuilderExited(builder, currentEpoch)) { this.state.builders.set(i, newBuilder); this.preExistingBuilders[i] = newBuilder.toValue(); this.builderIndexByPubkey.delete(toPubkeyHex(builder.pubkey)); @@ -121,6 +140,11 @@ export class BatchOnboardBuilder { } } +/** A builder slot is reusable when it is exited and fully withdrawn. */ +function isBuilderExited(builder: gloas.Builder, currentEpoch: Epoch): boolean { + return builder.withdrawableEpoch <= currentEpoch && builder.balance === 0; +} + function buildNewBuilder(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, depositEpoch: Epoch) { return ssz.gloas.Builder.toViewDU({ pubkey, diff --git a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts index 0d1177a4e93c..b144234eefd7 100644 --- a/packages/state-transition/test/unit/upgradeStateToGloas.test.ts +++ b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts @@ -1,18 +1,93 @@ import {describe, expect, it} from "vitest"; +import {byteArrayEquals} from "@chainsafe/ssz"; import {createBeaconConfig} from "@lodestar/config"; import {getConfig} from "@lodestar/config/test-utils"; -import {ForkName} from "@lodestar/params"; -import {electra, ssz} from "@lodestar/types"; +import {FAR_FUTURE_EPOCH, ForkName} from "@lodestar/params"; +import {BLSPubkey, BuilderIndex, Bytes32, Epoch, UintNum64, electra, ssz} from "@lodestar/types"; import {toPubkeyHex, toRootHex} from "@lodestar/utils"; -import {applyDepositForBuilder} from "../../src/block/processDepositRequest.js"; +import {isValidDepositSignature} from "../../src/block/processDeposit.js"; import {onboardBuildersFromPendingDeposits} from "../../src/slot/upgradeStateToGloas.js"; import {createCachedBeaconStateTest} from "../../src/testUtils/state.js"; import {generateBuilderPendingDeposits} from "../../src/testUtils/util.js"; import {CachedBeaconStateGloas} from "../../src/types.js"; -import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../../src/util/gloas.js"; -import {isValidatorKnown} from "../../src/util/index.js"; +import {isBuilderWithdrawalCredential} from "../../src/util/gloas.js"; +import {computeEpochAtSlot, isValidatorKnown} from "../../src/util/index.js"; import {PendingDepositsLookup} from "../../src/util/pendingDepositsLookup.js"; +// --------------------------------------------------------------------------- +// Naive oracle helpers — verbatim copies of the eager pre-Gloas-batching logic +// previously living in src/block/processDepositRequest.ts. Kept inline here so +// the production module no longer carries dead code; this file owns the oracle +// it differentially compares against. +// --------------------------------------------------------------------------- + +function naiveFindBuilderIndexByPubkey(state: CachedBeaconStateGloas, pubkey: Uint8Array): BuilderIndex | null { + for (let i = 0; i < state.builders.length; i++) { + if (byteArrayEquals(state.builders.getReadonly(i).pubkey, pubkey)) { + return i; + } + } + return null; +} + +function naiveBuildNewBuilder( + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + depositEpoch: Epoch +) { + return ssz.gloas.Builder.toViewDU({ + pubkey, + version: withdrawalCredentials[0], + executionAddress: withdrawalCredentials.subarray(12), + balance: amount, + depositEpoch, + withdrawableEpoch: FAR_FUTURE_EPOCH, + }); +} + +function naiveAddBuilderToRegistry( + state: CachedBeaconStateGloas, + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + slot: UintNum64 +): void { + const currentEpoch = computeEpochAtSlot(state.slot); + const depositEpoch = computeEpochAtSlot(slot); + const newBuilder = naiveBuildNewBuilder(pubkey, withdrawalCredentials, amount, depositEpoch); + + // Try to find a reusable slot from an exited builder with zero balance + for (let i = 0; i < state.builders.length; i++) { + const builder = state.builders.getReadonly(i); + if (builder.withdrawableEpoch <= currentEpoch && builder.balance === 0) { + state.builders.set(i, newBuilder); + return; + } + } + state.builders.push(newBuilder); +} + +function naiveApplyDepositForBuilder( + state: CachedBeaconStateGloas, + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + signature: Bytes32 | null, + slot: UintNum64, + builderIndex: BuilderIndex | null +): void { + if (builderIndex !== null) { + state.builders.get(builderIndex).balance += amount; + return; + } + const validSignature = + signature !== null ? isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature) : true; + if (validSignature) { + naiveAddBuilderToRegistry(state, pubkey, withdrawalCredentials, amount, slot); + } +} + /** * Verbatim copy of the eager `onboardBuildersFromPendingDeposits` (pre batch-verification). * The reference oracle for the differential tests below: the optimized (lazy + batched) @@ -52,17 +127,15 @@ function naiveOnboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): } const buildersLenBefore = state.builders.length; - const builderIndex = findBuilderIndexByPubkey(state, deposit.pubkey); - applyDepositForBuilder( + const builderIndex = naiveFindBuilderIndexByPubkey(state, deposit.pubkey); + naiveApplyDepositForBuilder( state, deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.signature, deposit.slot, - builderIndex, - // per spec, should always reuse builder index if possible - true + builderIndex ); if (state.builders.length > buildersLenBefore) { builderPubkeys.add(pubkeyHex); diff --git a/packages/state-transition/test/unit/util/onboardBuilder.test.ts b/packages/state-transition/test/unit/util/onboardBuilder.test.ts index 6c4e3a0960a2..c660913379a8 100644 --- a/packages/state-transition/test/unit/util/onboardBuilder.test.ts +++ b/packages/state-transition/test/unit/util/onboardBuilder.test.ts @@ -407,6 +407,125 @@ describe("BatchOnboardBuilder", () => { }); }); + describe("queueBuilderDeposit — flush-when-reuse-possible (spec ordering)", () => { + // queueBuilderDeposit must flush immediately when a pre-existing slot may still + // be reused, otherwise a later topup of a pre-existing builder would silently + // win the race for that slot (chain-split vs. eager). Once the cursor exhausts + // preExistingBuilders, deferring is safe. + + it("flushes immediately when a pre-existing eligible slot exists", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + + // Onboarded immediately into the reused slot — NOT still queued + expect(state.builders.length).toBe(1); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); + // The displaced pre-existing pubkey is no longer in the map + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[50].pubkey))).toBe(null); + }); + + it("defers when no pre-existing slot is eligible (reuse exhausted)", () => { + const state = buildGloasState({ + preExistingBuilders: [activeBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + + // Still queued — pre-existing is full and ineligible, so deferral is safe + expect(state.builders.length).toBe(1); // just the pre-existing builder + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); + + batcher.onboardBuilders(); + expect(state.builders.length).toBe(2); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(1); + }); + + it("[newD, topupA] preserves spec order: D reuses A's slot, A re-routes as new onboard", () => { + // Pre-existing A eligible at slot 0. Envelope mirrors the spec-ordering bug: + // batched-naive would defer D and let topupA hit slot 0 first — eager (spec) + // gives D slot 0 and routes A's deposit as a new onboard. + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + // Simulate the processDepositRequest routing: + // Step 1 — newD: queueBuilderDeposit flushes immediately, reuses slot 0. + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + + // Step 2 — topupA: getAppliedBuilderIndex(A) is now null (displaced), + // so the caller routes A's deposit as a new-builder candidate. + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[50].pubkey))).toBe(null); + + // pool[50]'s pubkey doesn't have a real signed deposit in the pool; reuse pool[1] + // as the new-onboard deposit to keep BLS happy. The point is the ordering, not the pubkey. + batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); + batcher.onboardBuilders(); + + // Final state: D at slot 0 (reused), the re-routed deposit at slot 1 (pushed) + expect(state.builders.length).toBe(2); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[1].pubkey)); + }); + + it("[topupA, newD] preserves spec order: A topped up, D pushed at end", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + // topupA — caller sees getAppliedBuilderIndex(A)=0 and calls topupBuilder + batcher.topupBuilder(0, 500_000_000); + + // newD — cursor is still 0 (topup doesn't advance it), so queueBuilderDeposit + // flushes; the scan finds slot 0 ineligible (balance>0 after topup) and pushes + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + + expect(state.builders.length).toBe(2); + // slot 0 = the topped-up pre-existing builder + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[50].pubkey)); + expect(state.builders.get(0).balance).toBe(500_000_000); + // slot 1 = D, pushed + expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + }); + + it("transitions from flush-eagerly to batch once cursor exhausts preExistingBuilders", () => { + const state = buildGloasState({ + preExistingBuilders: [exitedBuilder(pool[50].pubkey)], + slot: 0, + }); + const batcher = new BatchOnboardBuilder(state); + + // First queue: cursor=0 < 1 → immediate flush, reuses slot 0, cursor=1 + batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); + expect(state.builders.length).toBe(1); + expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); + + // Subsequent queues: cursor=1 >= 1 → deferred + batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); + batcher.queueBuilderDeposit(toPubkeyHex(pool[2].pubkey), pool[2]); + expect(state.builders.length).toBe(1); // still just the reused slot + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(null); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(null); + + // End-of-envelope flush applies the deferred batch + batcher.onboardBuilders(); + expect(state.builders.length).toBe(3); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); + expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(2); + }); + }); + describe("stale-entry regression (the reason this class is unit-tested)", () => { // The original bug: reusing a slot left the displaced pubkey in the map, causing // a later deposit for the displaced pubkey to be wrongly routed as a top-up. From c7dc0f9af5ca53545ccdf87a017c65b13593ffed Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 11:10:13 +0700 Subject: [PATCH 13/16] perf: use ContainerNodeStruct --- .../perf/epoch/processPendingDeposits.test.ts | 83 +++++++++++++++++++ packages/types/src/electra/sszTypes.ts | 7 +- packages/types/src/gloas/sszTypes.ts | 3 +- 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 packages/state-transition/test/perf/epoch/processPendingDeposits.test.ts diff --git a/packages/state-transition/test/perf/epoch/processPendingDeposits.test.ts b/packages/state-transition/test/perf/epoch/processPendingDeposits.test.ts new file mode 100644 index 000000000000..7c455eba373c --- /dev/null +++ b/packages/state-transition/test/perf/epoch/processPendingDeposits.test.ts @@ -0,0 +1,83 @@ +import {bench, describe} from "@chainsafe/benchmark"; +import {ContainerNodeStructType, ContainerType, ListCompositeType} from "@chainsafe/ssz"; +import {PENDING_DEPOSITS_LIMIT} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; + +// PERF: Cost is O(pendingDeposits.length). In the worst case (large deposit queue after a big network event), +// this can be 50_000 items. Each item requires reading all 5 fields. +// +// Benchmarks ContainerType vs ContainerNodeStructType (current) for field access performance. +// ContainerNodeStructType stores items as plain JS objects, avoiding tree traversal on every field read. + +const NUM_DEPOSITS = 50_000; +const CHUNK = 100; + +// Reuse the same field types as the existing PendingDeposit SSZ type +const fields = ssz.electra.PendingDeposit.fields; + +const PendingDepositContainer = new ContainerType(fields, {typeName: "PendingDeposit", jsonCase: "eth2"}); +const PendingDepositNodeStruct = new ContainerNodeStructType(fields, {typeName: "PendingDeposit", jsonCase: "eth2"}); + +const ListContainer = new ListCompositeType(PendingDepositContainer, PENDING_DEPOSITS_LIMIT); +const ListNodeStruct = new ListCompositeType(PendingDepositNodeStruct, PENDING_DEPOSITS_LIMIT); + +function buildList(listType: typeof ListContainer): ReturnType; +function buildList(listType: typeof ListNodeStruct): ReturnType; +function buildList(listType: typeof ListContainer | typeof ListNodeStruct) { + const view = listType.defaultViewDU(); + const defaultDeposit = ssz.electra.PendingDeposit.defaultValue(); + for (let i = 0; i < NUM_DEPOSITS; i++) { + if (listType === ListContainer) { + view.push(PendingDepositContainer.toViewDU(defaultDeposit)); + } else { + view.push(PendingDepositNodeStruct.toViewDU(defaultDeposit)); + } + } + view.commit(); + return view; +} + +describe.skip(`processPendingDeposits - iterate ${NUM_DEPOSITS} deposits, access all fields`, () => { + const containerListView = buildList(ListContainer); + const nodeStructListView = buildList(ListNodeStruct); + + bench({ + id: `ContainerType - getReadonlyByRange chunk=${CHUNK}`, + yieldEventLoopAfterEach: true, + fn: () => { + let sum = 0; + for (let i = 0; i < NUM_DEPOSITS; i += CHUNK) { + const deposits = containerListView.getReadonlyByRange(i, CHUNK); + for (const deposit of deposits) { + sum += deposit.amount + deposit.slot; + void deposit.pubkey; + void deposit.withdrawalCredentials; + void deposit.signature; + } + } + if (sum === Number.MIN_SAFE_INTEGER) { + throw new Error("unreachable"); + } + }, + }); + + bench({ + id: `ContainerNodeStructType - getReadonlyByRange chunk=${CHUNK}`, + yieldEventLoopAfterEach: true, + fn: () => { + let sum = 0; + for (let i = 0; i < NUM_DEPOSITS; i += CHUNK) { + const deposits = nodeStructListView.getReadonlyByRange(i, CHUNK); + for (const deposit of deposits) { + sum += deposit.amount + deposit.slot; + void deposit.pubkey; + void deposit.withdrawalCredentials; + void deposit.signature; + } + } + if (sum === Number.MIN_SAFE_INTEGER) { + throw new Error("unreachable"); + } + }, + }); +}); diff --git a/packages/types/src/electra/sszTypes.ts b/packages/types/src/electra/sszTypes.ts index 9176fb7c76e4..d98435171eb8 100644 --- a/packages/types/src/electra/sszTypes.ts +++ b/packages/types/src/electra/sszTypes.ts @@ -1,6 +1,7 @@ import { BitListType, BitVectorType, + ContainerNodeStructType, ContainerType, ListBasicType, ListCompositeType, @@ -270,7 +271,7 @@ export const SignedBuilderBid = new ContainerType( {typeName: "SignedBuilderBid", jsonCase: "eth2"} ); -export const PendingDeposit = new ContainerType( +export const PendingDeposit = new ContainerNodeStructType( { pubkey: BLSPubkey, withdrawalCredentials: Bytes32, @@ -285,7 +286,7 @@ export const PendingDeposit = new ContainerType( export const PendingDeposits = new ListCompositeType(PendingDeposit, PENDING_DEPOSITS_LIMIT); -export const PendingPartialWithdrawal = new ContainerType( +export const PendingPartialWithdrawal = new ContainerNodeStructType( { validatorIndex: ValidatorIndex, amount: Gwei, @@ -299,7 +300,7 @@ export const PendingPartialWithdrawals = new ListCompositeType( PENDING_PARTIAL_WITHDRAWALS_LIMIT ); -export const PendingConsolidation = new ContainerType( +export const PendingConsolidation = new ContainerNodeStructType( { sourceIndex: ValidatorIndex, targetIndex: ValidatorIndex, diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index f6661f25aa43..738c508f10d4 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -1,6 +1,7 @@ import { BitVectorType, ByteListType, + ContainerNodeStructType, ContainerType, ListBasicType, ListCompositeType, @@ -48,7 +49,7 @@ const { EpochInf, } = primitiveSsz; -export const Builder = new ContainerType( +export const Builder = new ContainerNodeStructType( { pubkey: BLSPubkey, version: Uint8, From f75587078723e12cb8c1bd4078f385c4291873e1 Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Mon, 25 May 2026 11:17:46 +0700 Subject: [PATCH 14/16] perf: improve verifyDepositSignatures() --- .../src/util/onboardBuilder.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index 3754fd36cf5e..425241448ff5 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -173,11 +173,12 @@ function verifyDepositSignatures(config: BeaconConfig, deposits: electra.Pending let pk: PublicKey; let sig: Signature; try { - // Deposit pubkeys and signatures are untrusted: must be group + infinity checked - pk = PublicKey.fromBytes(pubkey, true); - sig = Signature.fromBytes(signature, true); + // Parse without group/infinity checks; deferred to the verify call below so + // it can be batched across all sets. + pk = PublicKey.fromBytes(pubkey); + sig = Signature.fromBytes(signature); } catch (_) { - // Malformed pubkey or signature - invalid deposit, results[i] stays false + // Malformed pubkey or signature bytes - invalid deposit, results[i] stays false continue; } const msg = computeSigningRoot(ssz.phase0.DepositMessage, {pubkey, withdrawalCredentials, amount}, domain); @@ -189,12 +190,15 @@ function verifyDepositSignatures(config: BeaconConfig, deposits: electra.Pending return results; } + // Deposit pubkeys and signatures are untrusted, so group + infinity checks are + // required. The trailing (true, true) args delegate those checks to blst, which + // amortizes them across the whole batch. let batchValid: boolean; try { batchValid = signatureSets.length >= 2 - ? verifyMultipleAggregateSignatures(signatureSets) - : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig); + ? verifyMultipleAggregateSignatures(signatureSets, true, true) + : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig, true, true); } catch (_) { batchValid = false; } @@ -207,7 +211,13 @@ function verifyDepositSignatures(config: BeaconConfig, deposits: electra.Pending } else { // Batch failed: at least one signature is invalid - verify each individually for (let s = 0; s < signatureSets.length; s++) { - results[signatureSetDepositIndices[s]] = verify(signatureSets[s].msg, signatureSets[s].pk, signatureSets[s].sig); + results[signatureSetDepositIndices[s]] = verify( + signatureSets[s].msg, + signatureSets[s].pk, + signatureSets[s].sig, + true, + true + ); } } From a40d26b3a8b8afe5f7ba00d2ff86c521cff187cd Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 26 May 2026 10:45:18 +0700 Subject: [PATCH 15/16] fix: ethspecify --- specrefs/.ethspecify.yml | 1 + specrefs/functions.yml | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 1dcdf1a78e23..f9b3a3689e84 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -322,6 +322,7 @@ exceptions: # gloas - add_builder_to_registry#gloas + - apply_deposit_for_builder#gloas - apply_withdrawals#gloas - compute_balance_weighted_selection#gloas - compute_fork_version#gloas diff --git a/specrefs/functions.yml b/specrefs/functions.yml index ed47d7825511..8010037252fd 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -167,9 +167,7 @@ - name: apply_deposit_for_builder#gloas - sources: - - file: packages/state-transition/src/block/processDepositRequest.ts - search: export function applyDepositForBuilder( + sources: [] spec: | def apply_deposit_for_builder( From 693e53b76a871a4c1e1df61283cf5f1d197fab7d Mon Sep 17 00:00:00 2001 From: Tuyen Nguyen Date: Tue, 26 May 2026 15:26:28 +0700 Subject: [PATCH 16/16] feat: pre-verify builder deposits leading to fork transition --- .../beacon-node/src/chain/prepareNextSlot.ts | 34 +++ .../src/block/processDepositRequest.ts | 2 +- .../block/processParentExecutionPayload.ts | 2 +- .../state-transition/src/cache/epochCache.ts | 14 ++ .../src/cache/onboardBuildersCache.ts | 56 +++++ .../src/slot/upgradeStateToGloas.ts | 14 +- .../src/stateView/beaconStateView.ts | 15 ++ .../src/stateView/interface.ts | 10 + packages/state-transition/src/util/index.ts | 1 + .../src/util/onboardBuilder.ts | 150 +++++++++++- .../unit/cache/onboardBuildersCache.test.ts | 113 +++++++++ .../test/unit/util/onboardBuilder.test.ts | 215 ++++++++++++++++-- 12 files changed, 589 insertions(+), 37 deletions(-) create mode 100644 packages/state-transition/src/cache/onboardBuildersCache.ts create mode 100644 packages/state-transition/test/unit/cache/onboardBuildersCache.test.ts diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 67fff1809e7e..649f520fbd4e 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -3,12 +3,15 @@ import {ChainForkConfig} from "@lodestar/config"; import {getSafeExecutionBlockHash} from "@lodestar/fork-choice"; import {ForkPostBellatrix, ForkSeq, SLOTS_PER_EPOCH, isForkPostBellatrix} from "@lodestar/params"; import { + GLOAS_PREVERIFY_WINDOW_EPOCHS, IBeaconStateView, IBeaconStateViewBellatrix, + MAX_BUILDER_DEPOSITS_PER_SLOT, StateHashTreeRootSource, computeEpochAtSlot, computeTimeAtSlot, isStatePostBellatrix, + isStatePostElectra, isStatePostGloas, } from "@lodestar/state-transition"; import {Bytes32, Slot} from "@lodestar/types"; @@ -269,6 +272,37 @@ export class PrepareNextSlotScheduler { precomputeEpochTransitionTimer?.(); } + + if (isStatePostElectra(prepareState)) { + const cache = prepareState.gloaOnboardBuilderCache; + const gloasEpoch = this.config.GLOAS_FORK_EPOCH; + const finalizedEpoch = this.chain.forkChoice.getFinalizedCheckpoint().epoch; + + if (finalizedEpoch >= gloasEpoch) { + // The Gloas transition can no longer be reorged. Cheap no-op when + // already empty. + if (cache.lastVerifiedSlot !== 0) cache.clear(); + } else if ( + !isEpochTransition && // epoch boundaries already tight; skip + ForkSeq[fork] < ForkSeq.gloas && + computeEpochAtSlot(clockSlot) >= gloasEpoch - GLOAS_PREVERIFY_WINDOW_EPOCHS && + computeEpochAtSlot(clockSlot) < gloasEpoch + ) { + const result = prepareState.preVerifyBuilderDeposits(MAX_BUILDER_DEPOSITS_PER_SLOT); + if (result.verifiedCount > 0 || result.invalidCount > 0) { + this.logger.verbose("PrepareNextSlotScheduler pre-verified builder deposit signatures", { + clockSlot, + fromSlot: result.fromSlot, + toSlot: result.toSlot, + verifiedCount: result.verifiedCount, + invalidCount: result.invalidCount, + }); + } else { + // No new builder deposits to verify this slot + this.logger.verbose("PrepareNextSlotScheduler pre-verify builder deposit scan: nothing new", {clockSlot}); + } + } + } } catch (e) { if (!isErrorAborted(e) && !isQueueErrorAborted(e)) { this.metrics?.precomputeNextEpochTransition.count.inc({result: "error"}, 1); diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index ed98da3a55a7..a1b04178eedf 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -56,7 +56,7 @@ export function processDepositRequest( slot: state.slot, }); // this is for the spec test where we want to eagerly onboard builder immediately - if (ownsBatcher) onboarder.onboardBuilders(); + if (ownsBatcher) onboarder.onboardQueuedBuilders(); return; } diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index ea9b58126207..5e32084282f1 100644 --- a/packages/state-transition/src/block/processParentExecutionPayload.ts +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -62,7 +62,7 @@ export function applyParentExecutionPayload(state: CachedBeaconStateGloas, reque processDepositRequest(fork, state, deposit, pendingDepositsLookup, batcher); } // Flush any queued deposits remaining - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); } for (const withdrawal of requests.withdrawals) { diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 7be17881c753..3e08636e3216 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -55,6 +55,7 @@ import {computeBaseRewardPerIncrement, computeSyncParticipantReward} from "../ut import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalance.js"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js"; import {EpochTransitionCache} from "./epochTransitionCache.js"; +import {GloaOnboardBuilderCache} from "./onboardBuildersCache.js"; import {PubkeyCache, createPubkeyCache, syncPubkeys} from "./pubkeyCache.js"; import {CachedBeaconStateAllForks, CachedBeaconStateFulu, CachedBeaconStateGloas} from "./stateCache.js"; import { @@ -112,6 +113,13 @@ export class EpochCache { * Couples both index→pubkey and pubkey→index lookups, keeping them in sync atomically. */ pubkeyCache: PubkeyCache; + /** + * Shared across all clones of the same chain head. Holds builder deposit signatures + * pre-verified by the prepareForNextSlot scheduler in the 2 epochs leading up to + * GLOAS_FORK_EPOCH, so onboardBuildersFromPendingDeposits() at the fork transition + * can skip the bulk verification cost. There should only exist one for the entire application. + */ + gloaOnboardBuilderCache: GloaOnboardBuilderCache; /** * Indexes of the block proposers for the current epoch. * For pre-fulu, this is computed and cached from the current shuffling. @@ -245,6 +253,7 @@ export class EpochCache { constructor(data: { config: BeaconConfig; pubkeyCache: PubkeyCache; + gloaOnboardBuilderCache: GloaOnboardBuilderCache; proposers: number[]; proposersPrevEpoch: number[] | null; proposersNextEpoch: ProposersDeferred; @@ -277,6 +286,7 @@ export class EpochCache { }) { this.config = data.config; this.pubkeyCache = data.pubkeyCache; + this.gloaOnboardBuilderCache = data.gloaOnboardBuilderCache; this.proposers = data.proposers; this.proposersPrevEpoch = data.proposersPrevEpoch; this.proposersNextEpoch = data.proposersNextEpoch; @@ -510,6 +520,8 @@ export class EpochCache { return new EpochCache({ config, pubkeyCache, + // Created once per application. + gloaOnboardBuilderCache: new GloaOnboardBuilderCache(), proposers, // On first epoch, set to null to prevent unnecessary work since this is only used for metrics proposersPrevEpoch: null, @@ -554,6 +566,8 @@ export class EpochCache { config: this.config, // Common append-only structures shared with all states, no need to clone pubkeyCache: this.pubkeyCache, + // Singleton per application + gloaOnboardBuilderCache: this.gloaOnboardBuilderCache, // Immutable data proposers: this.proposers, proposersPrevEpoch: this.proposersPrevEpoch, diff --git a/packages/state-transition/src/cache/onboardBuildersCache.ts b/packages/state-transition/src/cache/onboardBuildersCache.ts new file mode 100644 index 000000000000..5d23ca70be13 --- /dev/null +++ b/packages/state-transition/src/cache/onboardBuildersCache.ts @@ -0,0 +1,56 @@ +import {RootHex, Slot, electra, ssz} from "@lodestar/types"; +import {MapDef, toRootHex} from "@lodestar/utils"; + +/** + * Caches pre-verified builder-deposit signatures so the Fulu → Gloas fork + * transition can skip the bulk verification cost. + * + * Lifecycle: + * - Producer: `preVerifyBuilderDeposits()` driven by `prepareForNextSlot` + * over the 2 epochs leading up to GLOAS_FORK_EPOCH. + * - Consumer: `onboardBuildersFromPendingDeposits()` (upgradeStateToGloas) + * checks each candidate deposit against the cache, taking the + * pre-verified fast path when hit. + * - Disposer: `clear()` is called from `prepareForNextSlot` once the + * finalized epoch reaches GLOAS_FORK_EPOCH — at that point no further + * Gloas transitions can occur on any fork. + * + * Membership key is `hashTreeRoot(PendingDeposit)` which covers + * (pubkey, withdrawalCredentials, amount, signature, slot) — byte-identical + * deposits in any fork's pendingDeposits look up correctly. + * + * Single instance across application (created in `EpochCache.createFromState`, + * shared by-reference through `clone()`). + */ +export class GloaOnboardBuilderCache { + private verifiedRootsBySlot: MapDef> = new MapDef(() => new Set()); + private _lastVerifiedSlot: Slot = 0; + + get lastVerifiedSlot(): Slot { + return this._lastVerifiedSlot; + } + + set lastVerifiedSlot(slot: Slot) { + if (slot > this._lastVerifiedSlot) { + this._lastVerifiedSlot = slot; + } + } + + setVerifiedDeposit(deposit: electra.PendingDeposit): void { + const verifiedRoots = this.verifiedRootsBySlot.getOrDefault(deposit.slot); + verifiedRoots.add(toRootHex(ssz.electra.PendingDeposit.hashTreeRoot(deposit))); + } + + isBuilderDepositVerified(deposit: electra.PendingDeposit): boolean { + const verifiedRoots = this.verifiedRootsBySlot.get(deposit.slot); + if (!verifiedRoots) { + return false; + } + return verifiedRoots.has(toRootHex(ssz.electra.PendingDeposit.hashTreeRoot(deposit))); + } + + clear(): void { + this.verifiedRootsBySlot.clear(); + this._lastVerifiedSlot = 0; + } +} diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index f95613d0b3b6..4e58a751193f 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -134,8 +134,14 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas continue; } - // New builder candidate: queue it for lazy batch signature verification - batcher.queueBuilderDeposit(pubkeyHex, deposit); + // New builder candidate. If the prepareForNextSlot scanner already + // signature-verified this exact deposit in the n epochs leading up to + // the fork, fast-path it; otherwise queue for lazy batch verification. + if (state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(deposit)) { + batcher.onboardBuilderVerifiedSignature(deposit); + } else { + batcher.queueBuilderDeposit(pubkeyHex, deposit); + } } else { // Top-up of an already-onboarded builder; no signature verification needed batcher.topupBuilder(builderIndex, deposit.amount); @@ -143,7 +149,9 @@ export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas } // Verify and apply any remaining queued builder deposits - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); state.pendingDeposits = pendingDeposits; + + // NOTE: we intentionally do NOT clear gloaOnboardBuilderCache here, let beacon-node handle it } diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 5fcdea7258f0..c9b9e8864f70 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -33,6 +33,7 @@ import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processV import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; +import {GloaOnboardBuilderCache} from "../cache/onboardBuildersCache.js"; import {RewardCache} from "../cache/rewardCache.js"; import { CachedBeaconStateAllForks, @@ -66,6 +67,7 @@ import { } from "../util/execution.js"; import {canBuilderCoverBid} from "../util/gloas.js"; import {loadState} from "../util/loadState/loadState.js"; +import {PreVerifyBuilderDepositsResult, preVerifyBuilderDeposits} from "../util/onboardBuilder.js"; import {getRandaoMix} from "../util/seed.js"; import {getLatestWeakSubjectivityCheckpointEpoch} from "../util/weakSubjectivity.js"; import {IBeaconStateView, IBeaconStateViewGloas, IBeaconStateViewLatestFork, isStatePostGloas} from "./interface.js"; @@ -550,6 +552,19 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return this.cachedState.epochCtx.effectiveBalanceIncrements; } + get gloaOnboardBuilderCache(): GloaOnboardBuilderCache { + return this.cachedState.epochCtx.gloaOnboardBuilderCache; + } + + preVerifyBuilderDeposits(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult { + // Cast: this method is exposed on IBeaconStateViewElectra so callers narrow first; + // the underlying cached state has pendingDeposits available. + return preVerifyBuilderDeposits( + this.cachedState as CachedBeaconStateElectra | CachedBeaconStateFulu, + maxBuilderDeposits + ); + } + getEffectiveBalanceIncrementsZeroInactive(): EffectiveBalanceIncrements { return getEffectiveBalanceIncrementsZeroInactive(this.cachedState); } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 7f5fd7006939..d8937ecac082 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -44,11 +44,13 @@ import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {VoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; +import {GloaOnboardBuilderCache} from "../cache/onboardBuildersCache.js"; import {RewardCache} from "../cache/rewardCache.js"; import {SyncCommitteeCache} from "../cache/syncCommitteeCache.js"; import {SyncCommitteeWitness} from "../lightClient/types.js"; import {StateTransitionModules, StateTransitionOpts} from "../stateTransition.js"; import {EpochShuffling} from "../util/epochShuffling.js"; +import {PreVerifyBuilderDepositsResult} from "../util/onboardBuilder.js"; /** * A read-only view of the BeaconState. @@ -231,6 +233,14 @@ export interface IBeaconStateViewElectra extends IBeaconStateViewDeneb { pendingPartialWithdrawalsCount: number; pendingConsolidations: electra.PendingConsolidations; pendingConsolidationsCount: number; + gloaOnboardBuilderCache: GloaOnboardBuilderCache; + /** + * Pre-verify a slice of builder-prefix pending deposits and stash the verified + * roots on `gloaOnboardBuilderCache`. Driven by the prepareForNextSlot scheduler + * in the GLOAS_PREVERIFY_WINDOW_EPOCHS epochs before GLOAS_FORK_EPOCH. + * See `preVerifyBuilderDeposits` in util/onboardBuilder.ts for full semantics. + */ + preVerifyBuilderDeposits(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult; } /** Fulu+ state fields — use isStatePostFulu() guard */ diff --git a/packages/state-transition/src/util/index.ts b/packages/state-transition/src/util/index.ts index 3f619ce394c1..cb2b600c2357 100644 --- a/packages/state-transition/src/util/index.ts +++ b/packages/state-transition/src/util/index.ts @@ -18,6 +18,7 @@ export * from "./genesis.js"; export * from "./gloas.js"; export * from "./interop.js"; export * from "./loadState/index.js"; +export * from "./onboardBuilder.js"; export * from "./pendingDepositsLookup.js"; export * from "./rootCache.js"; export * from "./seed.js"; diff --git a/packages/state-transition/src/util/onboardBuilder.ts b/packages/state-transition/src/util/onboardBuilder.ts index 425241448ff5..ea40e82e8d60 100644 --- a/packages/state-transition/src/util/onboardBuilder.ts +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -1,17 +1,49 @@ import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; import {DOMAIN_DEPOSIT, FAR_FUTURE_EPOCH} from "@lodestar/params"; -import {BLSPubkey, BuilderIndex, Bytes32, Epoch, PubkeyHex, UintNum64, electra, gloas, ssz} from "@lodestar/types"; +import { + BLSPubkey, + BuilderIndex, + Bytes32, + Epoch, + PubkeyHex, + Slot, + UintNum64, + electra, + gloas, + ssz, +} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; import {ZERO_HASH} from "../constants/index.js"; -import {CachedBeaconStateGloas} from "../types.js"; +import {CachedBeaconStateElectra, CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js"; import {computeDomain} from "./domain.js"; import {computeEpochAtSlot} from "./epoch.ts"; +import {isBuilderWithdrawalCredential} from "./gloas.js"; import {computeSigningRoot} from "./signingRoot.js"; /** Verify queued builder deposit signatures in batches of this size. */ const BUILDER_DEPOSIT_BATCH_SIZE = 32; +/** + * Per-slot cap on builder deposits the prepareForNextSlot scanner will verify. + * ~2.7s for 10_000 BLS verifications on a typical server — fits within the slot budget + * while letting the pre-window (see GLOAS_PREVERIFY_WINDOW_EPOCHS) cover up to ~620k + * deposits (10_000 * 31 non-epoch-transition slots * 2 epochs). + */ +export const MAX_BUILDER_DEPOSITS_PER_SLOT = 10_000; + +/** + * Number of epochs before GLOAS_FORK_EPOCH during which the prepareForNextSlot scanner + * pre-verifies builder-deposit signatures. Two is chosen because deposits arriving inside + * this window cannot collude with validator deposits — the validator-vs-builder routing + * is decided at the fork boundary based on what is already in pendingDeposits, so anything + * submitted this late is unambiguous. + * + * TODO GLOAS: revisit if observed builder-deposit volumes push us past ~620k preverifiable + * deposits (= MAX_BUILDER_DEPOSITS_PER_SLOT * 31 * this value). + */ +export const GLOAS_PREVERIFY_WINDOW_EPOCHS = 2; + /** * Encapsulates the queue + applier state used while onboarding builders from pending deposits. * Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.8/specs/gloas/fork.md#new-onboard_builders_from_pending_deposits @@ -73,10 +105,16 @@ export class BatchOnboardBuilder { this.nextReuseIndexCheck < this.preExistingBuilders.length || this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE ) { - this.onboardBuilders(); + this.onboardQueuedBuilders(); } } + /** Consumer should verify builder deposit's signature before calling this function */ + onboardBuilderVerifiedSignature(deposit: electra.PendingDeposit): void { + this.onboardQueuedBuilders(); + this.addBuilderToRegistry(deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.slot); + } + /** Top up an already-onboarded builder's balance. No signature verification needed. */ topupBuilder(builderIndex: BuilderIndex, amount: UintNum64): void { const builder = this.state.builders.get(builderIndex); @@ -89,12 +127,12 @@ export class BatchOnboardBuilder { /** Onboard queued builders if this pubkey is in the queue */ onboardBuildersIfQueued(pubkeyHex: PubkeyHex): void { if (this.queuedBuilderDeposits.has(pubkeyHex)) { - this.onboardBuilders(); + this.onboardQueuedBuilders(); } } /** Batch-verify the queued deposits and apply the ones with valid signatures. */ - onboardBuilders(): void { + onboardQueuedBuilders(): void { if (this.queuedBuilderDeposits.size === 0) { return; } @@ -113,7 +151,15 @@ export class BatchOnboardBuilder { this.queuedBuilderDeposits.clear(); } - addBuilderToRegistry(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amount: UintNum64, slot: UintNum64): void { + /** + * Consumer should not call this function directly because we need to onboard any pending builder deposits before this. + */ + private addBuilderToRegistry( + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + slot: UintNum64 + ): void { const currentEpoch = computeEpochAtSlot(this.state.slot); const depositEpoch = computeEpochAtSlot(slot); @@ -161,7 +207,7 @@ function buildNewBuilder(pubkey: BLSPubkey, withdrawalCredentials: Bytes32, amou * back to verifying each deposit individually so the valid deposits in a batch that * contains an invalid one are still identified. Returns a boolean per input deposit. */ -function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] { +export function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] { const results = new Array(deposits.length).fill(false); // Deposit signatures use a fork-agnostic domain, see `isValidDepositSignature` const domain = computeDomain(DOMAIN_DEPOSIT, config.GENESIS_FORK_VERSION, ZERO_HASH); @@ -223,3 +269,93 @@ function verifyDepositSignatures(config: BeaconConfig, deposits: electra.Pending return results; } + +/** Summary of a single `preVerifyBuilderDeposits` call. */ +export type PreVerifyBuilderDepositsResult = { + /** Number of builder-prefix deposits whose signatures passed verification (added to the cache). */ + verifiedCount: number; + /** + * Number of builder-prefix deposits whose signatures failed verification (dropped silently here; + * the fork-transition path will re-verify and similarly drop them). Non-zero is worth surfacing — + * legitimate deposits should not have invalid signatures, so a spike likely indicates abuse. + */ + invalidCount: number; + /** Inclusive lower bound of `deposit.slot` values processed in this call. `null` when nothing was processed. */ + fromSlot: Slot | null; + /** Inclusive upper bound of `deposit.slot` values processed in this call. `null` when nothing was processed. */ + toSlot: Slot | null; +}; + +/** + * Scanner driven by `prepareForNextSlot` over the n epochs leading up to GLOAS_FORK_EPOCH. + * + * Walks `state.pendingDeposits` (ascending by deposit.slot), picks builder-prefix + * deposits not yet covered by the cache, and signature-verifies them in chunks of + * BUILDER_DEPOSIT_BATCH_SIZE. Verified roots are stashed on + * `state.gloaOnboardBuilderCache` so that `onboardBuildersFromPendingDeposits` + * at the fork transition can skip the bulk verification cost. + * + * Cuts off at `maxBuilderDeposits` on a slot boundary so `lastVerifiedSlot` only + * advances over fully-completed slots (slight overage within a single slot is + * acceptable; it keeps the resume cursor unambiguous). + * + * Caller must guarantee the fork-epoch + non-epoch-transition gates. + * + * Returns counts and the inclusive deposit-slot range processed so callers can + * log progress and surface invalid-signature counts as an anomaly signal. + */ +export function preVerifyBuilderDeposits( + state: CachedBeaconStateElectra | CachedBeaconStateFulu, + maxBuilderDeposits: number +): PreVerifyBuilderDepositsResult { + const cache = state.epochCtx.gloaOnboardBuilderCache; + const cursor = cache.lastVerifiedSlot; + + // Phase 1: collect builder-prefix deposits whose slot > cursor, cutting at a slot boundary. + // Iterate via getAllReadonly() — light-weight tree views, no full materialization. + const queue: electra.PendingDeposit[] = []; + let minSlotInQueue: Slot | null = null; + let maxSlotInQueue = cursor; + for (const deposit of state.pendingDeposits.getAllReadonly()) { + if (deposit.slot <= cursor) continue; + if (!isBuilderWithdrawalCredential(deposit.withdrawalCredentials)) continue; + + // Stop only on a slot boundary: once we've hit the cap and the next deposit's + // slot is strictly greater than what's already queued, the current slot is + // fully accounted for — safe to break. + if (queue.length >= maxBuilderDeposits && deposit.slot > maxSlotInQueue) break; + + queue.push(deposit); + if (minSlotInQueue === null) minSlotInQueue = deposit.slot; + if (deposit.slot > maxSlotInQueue) maxSlotInQueue = deposit.slot; + } + + if (queue.length === 0) { + return {verifiedCount: 0, invalidCount: 0, fromSlot: null, toSlot: null}; + } + + // Phase 2: verify in BUILDER_DEPOSIT_BATCH_SIZE chunks and record verified deposits inline + // (no intermediate filtered array — the cache exposes a singular setter on purpose). + // Chunking caps the fallback blast radius: a single bad signature only forces 32 + // individual re-verifications, not the full maxBuilderDeposits. + let verifiedCount = 0; + for (let start = 0; start < queue.length; start += BUILDER_DEPOSIT_BATCH_SIZE) { + const end = Math.min(start + BUILDER_DEPOSIT_BATCH_SIZE, queue.length); + const chunk = queue.slice(start, end); + const results = verifyDepositSignatures(state.epochCtx.config, chunk); + for (let j = 0; j < chunk.length; j++) { + if (results[j]) { + cache.setVerifiedDeposit(chunk[j]); + verifiedCount++; + } + } + } + cache.lastVerifiedSlot = maxSlotInQueue; + + return { + verifiedCount, + invalidCount: queue.length - verifiedCount, + fromSlot: minSlotInQueue, + toSlot: maxSlotInQueue, + }; +} diff --git a/packages/state-transition/test/unit/cache/onboardBuildersCache.test.ts b/packages/state-transition/test/unit/cache/onboardBuildersCache.test.ts new file mode 100644 index 000000000000..fee11826994e --- /dev/null +++ b/packages/state-transition/test/unit/cache/onboardBuildersCache.test.ts @@ -0,0 +1,113 @@ +import {describe, expect, it} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +import {ForkName} from "@lodestar/params"; +import {electra} from "@lodestar/types"; +import {GloaOnboardBuilderCache} from "../../../src/cache/onboardBuildersCache.js"; +import {generateBuilderPendingDeposits} from "../../../src/testUtils/util.js"; + +describe("GloaOnboardBuilderCache", () => { + const beaconConfig = createBeaconConfig(getConfig(ForkName.gloas), Buffer.alloc(32)); + // Pool of validly-signed builder deposits — distinct interop pubkeys [2000, 2009) + const pool = generateBuilderPendingDeposits(beaconConfig, 10, 2000); + + /** Make a copy of a pool deposit at a specific slot. */ + function atSlot(deposit: electra.PendingDeposit, slot: number): electra.PendingDeposit { + return {...deposit, slot}; + } + + describe("lastVerifiedSlot", () => { + it("defaults to 0", () => { + const cache = new GloaOnboardBuilderCache(); + expect(cache.lastVerifiedSlot).toBe(0); + }); + + it("setter is monotonic — accepts strictly greater values, ignores smaller", () => { + const cache = new GloaOnboardBuilderCache(); + + cache.lastVerifiedSlot = 10; + expect(cache.lastVerifiedSlot).toBe(10); + + cache.lastVerifiedSlot = 20; + expect(cache.lastVerifiedSlot).toBe(20); + + // Lower values ignored + cache.lastVerifiedSlot = 5; + expect(cache.lastVerifiedSlot).toBe(20); + + // Equal value ignored (strict-greater contract) + cache.lastVerifiedSlot = 20; + expect(cache.lastVerifiedSlot).toBe(20); + }); + }); + + describe("setVerifiedDeposit + isBuilderDepositVerified", () => { + it("returns false for an unseen deposit", () => { + const cache = new GloaOnboardBuilderCache(); + expect(cache.isBuilderDepositVerified(atSlot(pool[0], 5))).toBe(false); + }); + + it("returns true after setVerifiedDeposit on the same (slot, content)", () => { + const cache = new GloaOnboardBuilderCache(); + const d = atSlot(pool[0], 5); + cache.setVerifiedDeposit(d); + expect(cache.isBuilderDepositVerified(d)).toBe(true); + }); + + it("returns false when a different deposit at the same slot is queried", () => { + const cache = new GloaOnboardBuilderCache(); + cache.setVerifiedDeposit(atSlot(pool[0], 5)); + // Same slot, different pubkey → root differs → not in cache + expect(cache.isBuilderDepositVerified(atSlot(pool[1], 5))).toBe(false); + }); + + it("returns false when the same content is queried at a different slot", () => { + const cache = new GloaOnboardBuilderCache(); + cache.setVerifiedDeposit(atSlot(pool[0], 5)); + // Slot is part of the hash-tree-root, so a different slot looks up to a different bucket + expect(cache.isBuilderDepositVerified(atSlot(pool[0], 6))).toBe(false); + }); + + it("distinguishes two deposits with same pubkey/slot but different signatures", () => { + const cache = new GloaOnboardBuilderCache(); + const d1 = atSlot(pool[0], 5); + const d2: electra.PendingDeposit = {...d1, signature: Buffer.alloc(96, 0xff)}; + cache.setVerifiedDeposit(d1); + expect(cache.isBuilderDepositVerified(d1)).toBe(true); + expect(cache.isBuilderDepositVerified(d2)).toBe(false); + }); + + it("holds entries across multiple slots independently", () => { + const cache = new GloaOnboardBuilderCache(); + const d5 = atSlot(pool[0], 5); + const d6 = atSlot(pool[1], 6); + cache.setVerifiedDeposit(d5); + cache.setVerifiedDeposit(d6); + expect(cache.isBuilderDepositVerified(d5)).toBe(true); + expect(cache.isBuilderDepositVerified(d6)).toBe(true); + }); + }); + + describe("clear", () => { + it("empties the verified-roots map and resets lastVerifiedSlot to 0", () => { + const cache = new GloaOnboardBuilderCache(); + const d = atSlot(pool[0], 5); + cache.setVerifiedDeposit(d); + cache.lastVerifiedSlot = 42; + + cache.clear(); + + expect(cache.lastVerifiedSlot).toBe(0); + expect(cache.isBuilderDepositVerified(d)).toBe(false); + }); + + it("monotonic setter still works after clear (counter truly reset)", () => { + const cache = new GloaOnboardBuilderCache(); + cache.lastVerifiedSlot = 100; + cache.clear(); + // After clear, a smaller value than the pre-clear max should now be accepted + cache.lastVerifiedSlot = 10; + expect(cache.lastVerifiedSlot).toBe(10); + }); + }); +}); diff --git a/packages/state-transition/test/unit/util/onboardBuilder.test.ts b/packages/state-transition/test/unit/util/onboardBuilder.test.ts index c660913379a8..c585fea43437 100644 --- a/packages/state-transition/test/unit/util/onboardBuilder.test.ts +++ b/packages/state-transition/test/unit/util/onboardBuilder.test.ts @@ -6,8 +6,12 @@ import {electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; import {createCachedBeaconStateTest} from "../../../src/testUtils/state.js"; import {generateBuilderPendingDeposits} from "../../../src/testUtils/util.js"; -import {CachedBeaconStateGloas} from "../../../src/types.js"; -import {BatchOnboardBuilder} from "../../../src/util/onboardBuilder.js"; +import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../../../src/types.js"; +import { + BatchOnboardBuilder, + MAX_BUILDER_DEPOSITS_PER_SLOT, + preVerifyBuilderDeposits, +} from "../../../src/util/onboardBuilder.js"; describe("BatchOnboardBuilder", () => { const chainConfig = getConfig(ForkName.gloas); @@ -97,7 +101,7 @@ describe("BatchOnboardBuilder", () => { it("returns the index after addBuilderToRegistry pushes", () => { const state = buildGloasState(); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + batcher.onboardBuilderVerifiedSignature(pool[0]); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); }); }); @@ -107,7 +111,7 @@ describe("BatchOnboardBuilder", () => { const state = buildGloasState(); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + batcher.onboardBuilderVerifiedSignature(pool[0]); expect(state.builders.length).toBe(1); expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); @@ -121,7 +125,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); expect(state.builders.length).toBe(3); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[5].pubkey))).toBe(2); @@ -139,7 +143,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // length unchanged — slot was reused expect(state.builders.length).toBe(1); @@ -154,7 +158,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // displaced pubkey is gone; new pubkey points at the reused slot expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); @@ -172,7 +176,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); expect(state.builders.length).toBe(3); expect(toPubkeyHex(state.builders.get(1).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); @@ -187,8 +191,8 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); - batcher.addBuilderToRegistry(pool[6].pubkey, pool[6].withdrawalCredentials, pool[6].amount, pool[6].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); + batcher.onboardBuilderVerifiedSignature(pool[6]); expect(state.builders.length).toBe(2); expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); @@ -206,7 +210,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // pushed at end, not reused expect(state.builders.length).toBe(2); @@ -220,9 +224,9 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // second call: cursor was set to preExisting.length; should push directly - batcher.addBuilderToRegistry(pool[6].pubkey, pool[6].withdrawalCredentials, pool[6].amount, pool[6].slot); + batcher.onboardBuilderVerifiedSignature(pool[6]); expect(state.builders.length).toBe(4); expect(toPubkeyHex(state.builders.get(2).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); @@ -253,7 +257,7 @@ describe("BatchOnboardBuilder", () => { batcher.topupBuilder(0, 1_000_000_000); // now try to onboard a new builder — should push, not reuse slot 0 - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); expect(state.builders.length).toBe(2); expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[0].pubkey)); @@ -265,7 +269,7 @@ describe("BatchOnboardBuilder", () => { const state = buildGloasState(); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[0].pubkey, pool[0].withdrawalCredentials, pool[0].amount, pool[0].slot); + batcher.onboardBuilderVerifiedSignature(pool[0]); // Index 0 is past the empty preExistingBuilders array. Should not throw. batcher.topupBuilder(0, 500_000_000); @@ -279,7 +283,7 @@ describe("BatchOnboardBuilder", () => { }); const batcher = new BatchOnboardBuilder(state); - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // slot 0 now holds pool[5]; index 0 is still < preExistingBuilders.length batcher.topupBuilder(0, 500_000_000); @@ -317,7 +321,7 @@ describe("BatchOnboardBuilder", () => { const state = buildGloasState(); const batcher = new BatchOnboardBuilder(state); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); expect(state.builders.length).toBe(0); }); @@ -328,14 +332,14 @@ describe("BatchOnboardBuilder", () => { batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); expect(state.builders.length).toBe(2); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(0); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); // queue cleared — calling again is a no-op - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); expect(state.builders.length).toBe(2); }); @@ -347,7 +351,7 @@ describe("BatchOnboardBuilder", () => { batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); batcher.queueBuilderDeposit(toPubkeyHex(bad.pubkey), bad); batcher.queueBuilderDeposit(toPubkeyHex(pool[2].pubkey), pool[2]); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); // valid deposits onboarded, invalid one dropped expect(state.builders.length).toBe(2); @@ -365,7 +369,7 @@ describe("BatchOnboardBuilder", () => { batcher.queueBuilderDeposit(toPubkeyHex(pool[0].pubkey), pool[0]); batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); // pool[0] reuses slot 0 (eligible); pool[1] pushes to slot 2 (since cursor moved past 1 which is ineligible) expect(state.builders.length).toBe(3); @@ -443,7 +447,7 @@ describe("BatchOnboardBuilder", () => { expect(state.builders.length).toBe(1); // just the pre-existing builder expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(null); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); expect(state.builders.length).toBe(2); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[0].pubkey))).toBe(1); }); @@ -469,7 +473,7 @@ describe("BatchOnboardBuilder", () => { // pool[50]'s pubkey doesn't have a real signed deposit in the pool; reuse pool[1] // as the new-onboard deposit to keep BLS happy. The point is the ordering, not the pubkey. batcher.queueBuilderDeposit(toPubkeyHex(pool[1].pubkey), pool[1]); - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); // Final state: D at slot 0 (reused), the re-routed deposit at slot 1 (pushed) expect(state.builders.length).toBe(2); @@ -519,7 +523,7 @@ describe("BatchOnboardBuilder", () => { expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(null); // End-of-envelope flush applies the deferred batch - batcher.onboardBuilders(); + batcher.onboardQueuedBuilders(); expect(state.builders.length).toBe(3); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[1].pubkey))).toBe(1); expect(batcher.getAppliedBuilderIndex(toPubkeyHex(pool[2].pubkey))).toBe(2); @@ -537,7 +541,7 @@ describe("BatchOnboardBuilder", () => { const batcher = new BatchOnboardBuilder(state); // Reuse slot 0 for pool[5] - batcher.addBuilderToRegistry(pool[5].pubkey, pool[5].withdrawalCredentials, pool[5].amount, pool[5].slot); + batcher.onboardBuilderVerifiedSignature(pool[5]); // The displaced pubkey must not resolve to slot 0 anymore — otherwise a // top-up for pool[50] would silently credit pool[5]'s balance. @@ -547,4 +551,165 @@ describe("BatchOnboardBuilder", () => { expect(toPubkeyHex(state.builders.get(0).pubkey)).toBe(toPubkeyHex(pool[5].pubkey)); }); }); + + describe("preVerifyBuilderDeposits", () => { + /** Pool of validly-signed builder deposits — distinct interop pubkeys [3000, 3099). */ + const scannerPool = generateBuilderPendingDeposits(beaconConfig, 100, 3000); + + function depositAtSlot(deposit: electra.PendingDeposit, slot: number): electra.PendingDeposit { + return {...deposit, slot}; + } + + /** + * Build a pre-Gloas cached state with pre-populated pendingDeposits. A Gloas state + * cast as CachedBeaconStateElectra works — preVerifyBuilderDeposits only touches + * pendingDeposits + epochCtx.gloaOnboardBuilderCache, which are present on all + * post-Electra forks. + */ + function buildStateWithDeposits(deposits: electra.PendingDeposit[]): CachedBeaconStateElectra { + const stateView = ssz.gloas.BeaconState.defaultViewDU(); + for (const d of deposits) { + stateView.pendingDeposits.push(ssz.electra.PendingDeposit.toViewDU(d)); + } + const cachedState = createCachedBeaconStateTest(stateView, chainConfig, { + skipSyncCommitteeCache: true, + skipSyncPubkeys: true, + }); + cachedState.commit(); + return cachedState as unknown as CachedBeaconStateElectra; + } + + it("returns zero counts and null slot range when there are no pending deposits", () => { + const state = buildStateWithDeposits([]); + const result = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + expect(result).toEqual({verifiedCount: 0, invalidCount: 0, fromSlot: null, toSlot: null}); + expect(state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot).toBe(0); + }); + + it("verifies builder-prefix deposits and stashes them on the cache", () => { + const deposits = [ + depositAtSlot(scannerPool[0], 1), + depositAtSlot(scannerPool[1], 1), + depositAtSlot(scannerPool[2], 2), + ]; + const state = buildStateWithDeposits(deposits); + + const result = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + expect(result.verifiedCount).toBe(3); + expect(result.invalidCount).toBe(0); + expect(result.fromSlot).toBe(1); + expect(result.toSlot).toBe(2); + expect(state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot).toBe(2); + for (const d of deposits) { + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(d)).toBe(true); + } + }); + + it("skips deposits whose withdrawal credential prefix is not BUILDER_WITHDRAWAL_PREFIX", () => { + const validatorDeposit: electra.PendingDeposit = { + ...scannerPool[0], + // 0x01 = eth1 withdrawal credential (validator, not builder) + withdrawalCredentials: Buffer.concat([Buffer.from([0x01]), Buffer.alloc(31, 0)]), + slot: 1, + }; + const builderDeposit = depositAtSlot(scannerPool[1], 2); + const state = buildStateWithDeposits([validatorDeposit, builderDeposit]); + + const result = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + // Only the builder deposit was processed + expect(result.verifiedCount).toBe(1); + expect(result.fromSlot).toBe(2); + expect(result.toSlot).toBe(2); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(validatorDeposit)).toBe(false); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(builderDeposit)).toBe(true); + }); + + it("skips deposits with slot <= lastVerifiedSlot (cursor honored)", () => { + const deposits = [ + depositAtSlot(scannerPool[0], 1), + depositAtSlot(scannerPool[1], 2), + depositAtSlot(scannerPool[2], 3), + ]; + const state = buildStateWithDeposits(deposits); + + // Pretend slots 1 and 2 were already verified in a prior tick + state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot = 2; + + const result = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + expect(result.verifiedCount).toBe(1); + expect(result.fromSlot).toBe(3); + expect(result.toSlot).toBe(3); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(deposits[0])).toBe(false); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(deposits[2])).toBe(true); + }); + + it("counts invalid signatures separately and excludes them from the cache", () => { + const good1 = depositAtSlot(scannerPool[0], 1); + const bad: electra.PendingDeposit = {...depositAtSlot(scannerPool[1], 1), signature: Buffer.alloc(96)}; + const good2 = depositAtSlot(scannerPool[2], 1); + const state = buildStateWithDeposits([good1, bad, good2]); + + const result = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + expect(result.verifiedCount).toBe(2); + expect(result.invalidCount).toBe(1); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(good1)).toBe(true); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(bad)).toBe(false); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(good2)).toBe(true); + }); + + it("stops on a slot boundary when maxBuilderDeposits is exceeded", () => { + // Two deposits at slot 1, two at slot 2, two at slot 3 + const deposits = [ + depositAtSlot(scannerPool[0], 1), + depositAtSlot(scannerPool[1], 1), + depositAtSlot(scannerPool[2], 2), + depositAtSlot(scannerPool[3], 2), + depositAtSlot(scannerPool[4], 3), + depositAtSlot(scannerPool[5], 3), + ]; + const state = buildStateWithDeposits(deposits); + + // Cap at 3 — the third deposit (slot 2) pushes us past the cap, but we keep going + // until slot transitions to a strictly greater one. So we should pick up both slot-2 + // deposits (4 total) and stop before slot 3. + const result = preVerifyBuilderDeposits(state, 3); + + expect(result.verifiedCount).toBe(4); + expect(result.invalidCount).toBe(0); + expect(result.fromSlot).toBe(1); + expect(result.toSlot).toBe(2); + // lastVerifiedSlot advances only over fully-completed slots + expect(state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot).toBe(2); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(deposits[4])).toBe(false); + expect(state.epochCtx.gloaOnboardBuilderCache.isBuilderDepositVerified(deposits[5])).toBe(false); + }); + + it("subsequent call resumes at lastVerifiedSlot + 1", () => { + const deposits = [ + depositAtSlot(scannerPool[0], 1), + depositAtSlot(scannerPool[1], 2), + depositAtSlot(scannerPool[2], 2), + depositAtSlot(scannerPool[3], 3), + ]; + const state = buildStateWithDeposits(deposits); + + // First call: cap at 1, so we pick up slot 1 only (1 deposit, finishes that slot) + const r1 = preVerifyBuilderDeposits(state, 1); + expect(r1.verifiedCount).toBe(1); + expect(r1.fromSlot).toBe(1); + expect(r1.toSlot).toBe(1); + expect(state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot).toBe(1); + + // Second call: resumes at slot 2 onward + const r2 = preVerifyBuilderDeposits(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + expect(r2.verifiedCount).toBe(3); + expect(r2.fromSlot).toBe(2); + expect(r2.toSlot).toBe(3); + expect(state.epochCtx.gloaOnboardBuilderCache.lastVerifiedSlot).toBe(3); + }); + }); });