diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 4b3d815c5314..c5f63347c81d 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,9 +1,11 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus, getSafeExecutionBlockHash} from "@lodestar/fork-choice"; -import {DataAvailabilityStatus, isStatePostGloas} from "@lodestar/state-transition"; +import {DataAvailabilityStatus, isBuilderWithdrawalCredential, isStatePostGloas} from "@lodestar/state-transition"; +import {electra} from "@lodestar/types"; import {isErrorAborted} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {callInNextEventLoop} from "../../util/eventLoop.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; import {RegenCaller} from "../regen/interface.js"; @@ -282,6 +284,33 @@ export async function importExecutionPayload( blockHash: blockHashHex, delaySec, }); + + // 10. Optional, fire-and-forget: pre-verify builder-prefix deposit signatures from this + // envelope so the next block's processDepositRequest can skip the queued batch verify. + const builderDeposits: electra.PendingDepositNoSlot[] = envelope.executionRequests.deposits + .filter((d) => isBuilderWithdrawalCredential(d.withdrawalCredentials)) + .map((d) => ({ + pubkey: d.pubkey, + withdrawalCredentials: d.withdrawalCredentials, + amount: d.amount, + signature: d.signature, + })); + if (builderDeposits.length > 0) { + callInNextEventLoop(() => { + try { + const result = blockState.preVerifyPayloadBuilderDeposits(blockHashHex, builderDeposits); + this.logger.verbose("Envelope builder deposit pre-verification", { + slot, + blockHash: blockHashHex, + builderDeposits: builderDeposits.length, + verifiedCount: result.verifiedCount, + invalidCount: result.invalidCount, + }); + } catch (e) { + this.logger.debug("preVerifyPayloadBuilderDeposits failed", {slot, blockHash: blockHashHex}, e as Error); + } + }); + } } /** diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index 67fff1809e7e..82569e4b43ab 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.builderDepositSignatureCache; + 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.clearPreGloasCache(); + } 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.preVerifyBuilderDepositsPreGloas(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 da07cef13447..7928d84ef2c4 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -1,80 +1,11 @@ -import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; -import {BLSPubkey, Bytes32, UintNum64, electra, ssz} from "@lodestar/types"; -import {toPubkeyHex} from "@lodestar/utils"; +import {ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; +import {electra, ssz} from "@lodestar/types"; +import {toPubkeyHex, toRootHex} 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, - slot: UintNum64 -): 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); - } - } -} - -/** - * 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 -): void { - const currentEpoch = computeEpochAtSlot(state.slot); - const depositEpoch = computeEpochAtSlot(slot); - - // 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; - } - } - - // 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, - }); - - 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 @@ -84,23 +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); + onboarder.topupBuilder(builderIndex, amount); return; } @@ -110,7 +48,25 @@ export function processDepositRequest( !isValidator && !lookup.hasPendingValidator(state.config, pubkeyHex) ) { - applyDepositForBuilder(stateGloas, pubkey, withdrawalCredentials, amount, signature, state.slot); + const pendingDeposit = {pubkey, withdrawalCredentials, amount, signature, slot: state.slot}; + const payloadBlockHash = toRootHex(stateGloas.latestExecutionPayloadBid.blockHash); + const cachedResult = stateGloas.epochCtx.builderDepositSignatureCache.getPayloadResult( + payloadBlockHash, + pendingDeposit + ); + // true → fast-path onboard + if (cachedResult === true) { + onboarder.onboardBuilderVerifiedSignature(pendingDeposit); + return; + } + if (cachedResult === false) { + // false → drop silently (cached as invalid) + return; + } + // null → not yet verified, queue for batch verification + onboarder.queueBuilderDeposit(pubkeyHex, pendingDeposit); + // this is for the spec test where we want to eagerly onboard builder immediately + if (ownsBatcher) onboarder.onboardQueuedBuilders(); return; } diff --git a/packages/state-transition/src/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts index 0781bb68113b..5e32084282f1 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.onboardQueuedBuilders(); } for (const withdrawal of requests.withdrawals) { diff --git a/packages/state-transition/src/cache/builderDepositSignatureCache.ts b/packages/state-transition/src/cache/builderDepositSignatureCache.ts new file mode 100644 index 000000000000..9b54072bf1a7 --- /dev/null +++ b/packages/state-transition/src/cache/builderDepositSignatureCache.ts @@ -0,0 +1,103 @@ +import {RootHex, Slot, electra, ssz} from "@lodestar/types"; +import {MapDef, pruneSetToMax, toRootHex} from "@lodestar/utils"; + +/** + * Upper bound on the number of distinct payload blockHashes for which we cache verified + * builder-deposit signatures. Each block consumes the cache for its parent payload exactly + * once, so 32 covers normal head progression and a healthy margin for short-lived forks. + */ +const MAX_VERIFIED_PAYLOAD_BLOCK_HASHES = 32; + +/** + * Caches builder-deposit signature-verification results — both passes (`true`) and + * failures (`false`) — so the Fulu → Gloas fork transition and post-Gloas block + * processing can skip the bulk verification cost AND skip re-verifying deposits already + * proven invalid. + * + * Two sub-caches with distinct lifecycles: + * + * - `preGloasResultsBySlot` — produced by `preVerifyBuilderDepositsPreGloas()` driven by + * `prepareForNextSlot` over the `GLOAS_PREVERIFY_WINDOW_EPOCHS` epochs leading up to + * GLOAS_FORK_EPOCH; consumed by `onboardBuildersFromPendingDeposits()` at the fork boundary. + * Cleared by `clearPreGloasCache()` once the finalized epoch reaches GLOAS_FORK_EPOCH. + * + * - `payloadResultsByBlockHash` — produced by `preVerifyPayloadBuilderDeposits()` when an + * execution payload envelope is imported (block N); consumed by `processDepositRequest()` + * on the next block (block N+1) via `state.latestExecutionPayloadBid.blockHash`. + * Self-rolling: FIFO-bounded to `MAX_VERIFIED_PAYLOAD_BLOCK_HASHES` and intentionally not + * touched by `clearPreGloasCache()`. + * + * Both sub-caches hash deposit entries via `hashTreeRoot(PendingDepositNoSlot)` — + * the deposit's slot is either already encoded in the outer Map key (pre-Gloas) or + * unknown at producer time (payload), and signature verification doesn't depend on slot. + * + * Producers must call `setPreGloasResult` / `setPayloadResult` for **every** deposit they + * verify (pass or fail), so a `null` result from `getPreGloasResult` / `getPayloadResult` + * unambiguously means "this deposit hasn't been verified yet" rather than "this deposit + * was verified and rejected". + * + * Single instance across application (created in `EpochCache.createFromState`, + * shared by-reference through `clone()`). + */ +export class BuilderDepositSignatureCache { + private preGloasResultsBySlot: MapDef> = new MapDef(() => new Map()); + // Plain Map (not MapDef) so insertion order is usable for FIFO eviction via pruneSetToMax. + private payloadResultsByBlockHash = new Map>(); + + private _lastVerifiedSlot: Slot = 0; + + get lastVerifiedSlot(): Slot { + return this._lastVerifiedSlot; + } + + set lastVerifiedSlot(slot: Slot) { + if (slot > this._lastVerifiedSlot) { + this._lastVerifiedSlot = slot; + } + } + + setPreGloasResult(builderDeposit: electra.PendingDeposit, isValid: boolean): void { + const results = this.preGloasResultsBySlot.getOrDefault(builderDeposit.slot); + // Hash via PendingDepositNoSlot: slot is already the bucket key, so re-hashing it would + // be redundant work. PendingDeposit is structurally assignable to PendingDepositNoSlot. + results.set(toRootHex(ssz.electra.PendingDepositNoSlot.hashTreeRoot(builderDeposit)), isValid); + } + + setPayloadResult(payloadBlockHash: RootHex, builderDeposit: electra.PendingDepositNoSlot, isValid: boolean): void { + let results = this.payloadResultsByBlockHash.get(payloadBlockHash); + if (!results) { + results = new Map(); + this.payloadResultsByBlockHash.set(payloadBlockHash, results); + } + results.set(toRootHex(ssz.electra.PendingDepositNoSlot.hashTreeRoot(builderDeposit)), isValid); + // Always-prune as the final step. No-op when size ≤ cap (O(1) branch in pruneSetToMax). + pruneSetToMax(this.payloadResultsByBlockHash, MAX_VERIFIED_PAYLOAD_BLOCK_HASHES); + } + + getPreGloasResult(builderDeposit: electra.PendingDeposit): boolean | null { + const results = this.preGloasResultsBySlot.get(builderDeposit.slot); + if (!results) { + return null; + } + // setPreGloasResult uses PendingDepositNoSlot to hash; mirror here. + // Map.get returns undefined for missing keys — coalesce to null to honor the contract. + return results.get(toRootHex(ssz.electra.PendingDepositNoSlot.hashTreeRoot(builderDeposit))) ?? null; + } + + getPayloadResult(payloadBlockHash: RootHex, builderDeposit: electra.PendingDepositNoSlot): boolean | null { + const results = this.payloadResultsByBlockHash.get(payloadBlockHash); + if (!results) { + return null; + } + return results.get(toRootHex(ssz.electra.PendingDepositNoSlot.hashTreeRoot(builderDeposit))) ?? null; + } + + /** + * Clears only the pre-Gloas fork-transition slot cache. The payload-blockHash cache is + * self-rolling via the FIFO cap in setPayloadResult and is intentionally left in place. + */ + clearPreGloasCache(): void { + this.preGloasResultsBySlot.clear(); + this._lastVerifiedSlot = 0; + } +} diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 7be17881c753..2f6f16626507 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -53,6 +53,7 @@ import { } from "../util/shuffling.js"; import {computeBaseRewardPerIncrement, computeSyncParticipantReward} from "../util/syncCommittee.js"; import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalance.js"; +import {BuilderDepositSignatureCache} from "./builderDepositSignatureCache.ts"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js"; import {EpochTransitionCache} from "./epochTransitionCache.js"; import {PubkeyCache, createPubkeyCache, syncPubkeys} from "./pubkeyCache.js"; @@ -112,6 +113,14 @@ 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 `GLOAS_PREVERIFY_WINDOW_EPOCHS` + * 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. + */ + builderDepositSignatureCache: BuilderDepositSignatureCache; /** * Indexes of the block proposers for the current epoch. * For pre-fulu, this is computed and cached from the current shuffling. @@ -245,6 +254,7 @@ export class EpochCache { constructor(data: { config: BeaconConfig; pubkeyCache: PubkeyCache; + builderDepositSignatureCache: BuilderDepositSignatureCache; proposers: number[]; proposersPrevEpoch: number[] | null; proposersNextEpoch: ProposersDeferred; @@ -277,6 +287,7 @@ export class EpochCache { }) { this.config = data.config; this.pubkeyCache = data.pubkeyCache; + this.builderDepositSignatureCache = data.builderDepositSignatureCache; this.proposers = data.proposers; this.proposersPrevEpoch = data.proposersPrevEpoch; this.proposersNextEpoch = data.proposersNextEpoch; @@ -510,6 +521,8 @@ export class EpochCache { return new EpochCache({ config, pubkeyCache, + // Created once per application. + builderDepositSignatureCache: new BuilderDepositSignatureCache(), proposers, // On first epoch, set to null to prevent unnecessary work since this is only used for metrics proposersPrevEpoch: null, @@ -554,6 +567,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 + builderDepositSignatureCache: this.builderDepositSignatureCache, // Immutable data proposers: this.proposers, proposersPrevEpoch: this.proposersPrevEpoch, diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index 786f2b76a8e0..a776449c1679 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 {ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; -import {applyDepositForBuilder} 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 {BatchOnboardBuilder} from "../util/onboardBuilder.js"; import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js"; /** @@ -88,17 +88,17 @@ 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 + * + * New-builder deposits are verified lazily: signatures are queued and batch-verified + * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. */ -function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { - // Track pubkeys of new builders added when applying deposits - const builderPubkeys = new Set(); +export function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void { + const batcher = new BatchOnboardBuilder(state); 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); @@ -109,11 +109,17 @@ 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)) { + // 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 + 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 = batcher.getAppliedBuilderIndex(pubkeyHex); + + 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. @@ -127,23 +133,30 @@ function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void pendingDepositsLookup.add(deposit, pubkeyHex); continue; } - } - 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( - state, - deposit.pubkey, - deposit.withdrawalCredentials, - deposit.amount, - deposit.signature, - deposit.slot - ); - if (state.builders.length > buildersLenBefore) { - builderPubkeys.add(pubkeyHex); + // New builder candidate. If the prepareForNextSlot scanner already + // signature-verified this exact deposit in the `GLOAS_PREVERIFY_WINDOW_EPOCHS` + // epochs leading up to the fork, take the appropriate cached path: + // true → fast-path onboard + // false → drop silently (the slow path would also reject it) + // null → not yet verified, defer to batch verification + const cachedResult = state.epochCtx.builderDepositSignatureCache.getPreGloasResult(deposit); + if (cachedResult === true) { + batcher.onboardBuilderVerifiedSignature(deposit); + } else if (cachedResult === null) { + batcher.queueBuilderDeposit(pubkeyHex, deposit); + } + // cachedResult === false → intentionally drop the deposit. + } else { + // Top-up of an already-onboarded builder; no signature verification needed + batcher.topupBuilder(builderIndex, deposit.amount); } } + // Verify and apply any remaining queued builder deposits + batcher.onboardQueuedBuilders(); + state.pendingDeposits = pendingDeposits; + + // NOTE: we intentionally do NOT clear builderDepositSignatureCache 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..043486214b5f 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -31,6 +31,7 @@ import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {applyParentExecutionPayload} from "../block/processParentExecutionPayload.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; +import {BuilderDepositSignatureCache} from "../cache/builderDepositSignatureCache.ts"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; import {RewardCache} from "../cache/rewardCache.js"; @@ -66,6 +67,11 @@ import { } from "../util/execution.js"; import {canBuilderCoverBid} from "../util/gloas.js"; import {loadState} from "../util/loadState/loadState.js"; +import { + PreVerifyBuilderDepositsResult, + preVerifyBuilderDepositsPreGloas, + preVerifyPayloadBuilderDeposits, +} 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 +556,30 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return this.cachedState.epochCtx.effectiveBalanceIncrements; } + get builderDepositSignatureCache(): BuilderDepositSignatureCache { + return this.cachedState.epochCtx.builderDepositSignatureCache; + } + + preVerifyBuilderDepositsPreGloas(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult { + // Cast: this method is exposed on IBeaconStateViewElectra so callers narrow first; + // the underlying cached state has pendingDeposits available. + return preVerifyBuilderDepositsPreGloas( + this.cachedState as CachedBeaconStateElectra | CachedBeaconStateFulu, + maxBuilderDeposits + ); + } + + preVerifyPayloadBuilderDeposits( + payloadBlockHash: RootHex, + builderDeposits: electra.PendingDepositNoSlot[] + ): {verifiedCount: number; invalidCount: number} { + return preVerifyPayloadBuilderDeposits( + this.cachedState as CachedBeaconStateGloas, + payloadBlockHash, + builderDeposits + ); + } + 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..c944545ec721 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -42,6 +42,7 @@ import { } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {VoluntaryExitValidity} from "../block/processVoluntaryExit.js"; +import {BuilderDepositSignatureCache} from "../cache/builderDepositSignatureCache.ts"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; import {RewardCache} from "../cache/rewardCache.js"; @@ -49,6 +50,7 @@ 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; + builderDepositSignatureCache: BuilderDepositSignatureCache; + /** + * Pre-verify a slice of builder-prefix pending deposits and stash the verified + * roots on `builderDepositSignatureCache`. Driven by the prepareForNextSlot scheduler + * in the GLOAS_PREVERIFY_WINDOW_EPOCHS epochs before GLOAS_FORK_EPOCH. + * See `preVerifyBuilderDepositsPreGloas` in util/onboardBuilder.ts for full semantics. + */ + preVerifyBuilderDepositsPreGloas(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult; } /** Fulu+ state fields — use isStatePostFulu() guard */ @@ -242,6 +252,11 @@ export interface IBeaconStateViewFulu extends IBeaconStateViewElectra { /** Gloas+ state fields — use isStatePostGloas() guard */ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { forkName: ForkPostGloas; + /** Pre-verify builder-prefix deposit signatures from an imported execution payload envelope.*/ + preVerifyPayloadBuilderDeposits( + payloadBlockHash: RootHex, + builderDeposits: electra.PendingDepositNoSlot[] + ): {verifiedCount: number; invalidCount: number}; /** Removed from BeaconState in gloas. Use `latestBlockHash` instead. */ latestExecutionPayloadHeader: never; /** Removed from BeaconState in gloas. */ diff --git a/packages/state-transition/src/testUtils/util.ts b/packages/state-transition/src/testUtils/util.ts index 3be60e7d5a6f..eb08db02be15 100644 --- a/packages/state-transition/src/testUtils/util.ts +++ b/packages/state-transition/src/testUtils/util.ts @@ -1,37 +1,46 @@ 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, createCachedBeaconState, createPubkeyCache, - interopSecretKey, newFilledArray, processSlots, } from "../index.js"; import { BeaconStateAltair, BeaconStateElectra, + BeaconStateFulu, BeaconStatePhase0, CachedBeaconStateAllForks, CachedBeaconStateAltair, CachedBeaconStateElectra, + CachedBeaconStateFulu, 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"; @@ -541,3 +550,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/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/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 new file mode 100644 index 000000000000..a78ccc006e56 --- /dev/null +++ b/packages/state-transition/src/util/onboardBuilder.ts @@ -0,0 +1,385 @@ +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, + RootHex, + Slot, + UintNum64, + electra, + gloas, + ssz, +} from "@lodestar/types"; +import {toPubkeyHex} from "@lodestar/utils"; +import {ZERO_HASH} from "../constants/index.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 + * + * New-builder deposits are verified lazily: signatures are queued and batch-verified + * `BUILDER_DEPOSIT_BATCH_SIZE` at a time. + */ +export class BatchOnboardBuilder { + // Map of builder pubkey -> index in `state.builders` for builders already applied via this instance. + 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(); + // this is use to scan for reused builder index + private preExistingBuilders: gloas.Builder[]; + private nextReuseIndexCheck = 0; + + 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. */ + getAppliedBuilderIndex(pubkeyHex: PubkeyHex): number | null { + return this.builderIndexByPubkey.get(pubkeyHex) ?? null; + } + + /** + * 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, { + pubkey: deposit.pubkey, + withdrawalCredentials: deposit.withdrawalCredentials, + amount: deposit.amount, + signature: deposit.signature, + slot: deposit.slot, + }); + if ( + this.nextReuseIndexCheck < this.preExistingBuilders.length || + this.queuedBuilderDeposits.size >= BUILDER_DEPOSIT_BATCH_SIZE + ) { + 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); + builder.balance += amount; + if (builderIndex < this.preExistingBuilders.length) { + this.preExistingBuilders[builderIndex].balance += amount; + } + } + + /** Onboard queued builders if this pubkey is in the queue */ + onboardBuildersIfQueued(pubkeyHex: PubkeyHex): void { + if (this.queuedBuilderDeposits.has(pubkeyHex)) { + this.onboardQueuedBuilders(); + } + } + + /** Batch-verify the queued deposits and apply the ones with valid signatures. */ + onboardQueuedBuilders(): 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 [_, deposit] = entries[j]; + this.addBuilderToRegistry(deposit.pubkey, deposit.withdrawalCredentials, deposit.amount, deposit.slot); + } + this.queuedBuilderDeposits.clear(); + } + + /** + * 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); + + 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 (isBuilderExited(builder, currentEpoch)) { + 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; + } + } + + // don't have to scan again the next time + this.nextReuseIndexCheck = this.preExistingBuilders.length; + const newBuilderIndex = this.state.builders.length; + this.state.builders.push(newBuilder); + this.builderIndexByPubkey.set(toPubkeyHex(newBuilder.pubkey), newBuilderIndex); + } +} + +/** 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, + version: withdrawalCredentials[0], + executionAddress: withdrawalCredentials.subarray(12), + balance: amount, + depositEpoch, + withdrawableEpoch: FAR_FUTURE_EPOCH, + }); +} + +/** + * 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. + * Note that slot is not part of signature check. + */ +export function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDepositNoSlot[]): 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 { + // 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 bytes - 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; + } + + // 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, true, true) + : verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig, true, true); + } 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, + true, + true + ); + } + } + + return results; +} + +/** + * Pre-verify builder-prefix deposit signatures from an imported execution payload envelope + * and cache verified roots on `builderDepositSignatureCache`. + */ +export function preVerifyPayloadBuilderDeposits( + state: CachedBeaconStateGloas, + payloadBlockHash: RootHex, + builderDeposits: electra.PendingDepositNoSlot[] +): {verifiedCount: number; invalidCount: number} { + if (builderDeposits.length === 0) { + return {verifiedCount: 0, invalidCount: 0}; + } + + const results = verifyDepositSignatures(state.epochCtx.config, builderDeposits); + const cache = state.epochCtx.builderDepositSignatureCache; + let verifiedCount = 0; + for (let i = 0; i < builderDeposits.length; i++) { + cache.setPayloadResult(payloadBlockHash, builderDeposits[i], results[i]); + if (results[i]) verifiedCount++; + } + return {verifiedCount, invalidCount: builderDeposits.length - verifiedCount}; +} + +/** Summary of a single `preVerifyBuilderDepositsPreGloas` 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 `GLOAS_PREVERIFY_WINDOW_EPOCHS` 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.builderDepositSignatureCache` 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 preVerifyBuilderDepositsPreGloas( + state: CachedBeaconStateElectra | CachedBeaconStateFulu, + maxBuilderDeposits: number +): PreVerifyBuilderDepositsResult { + const cache = state.epochCtx.builderDepositSignatureCache; + 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++) { + cache.setPreGloasResult(chunk[j], results[j]); + if (results[j]) verifiedCount++; + } + } + cache.lastVerifiedSlot = maxSlotInQueue; + + return { + verifiedCount, + invalidCount: queue.length - verifiedCount, + fromSlot: minSlotInQueue, + toSlot: maxSlotInQueue, + }; +} 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/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}`); + } + }, + }); + } +}); diff --git a/packages/state-transition/test/unit/block/processDepositRequest.test.ts b/packages/state-transition/test/unit/block/processDepositRequest.test.ts new file mode 100644 index 000000000000..cc3a93c9c9cf --- /dev/null +++ b/packages/state-transition/test/unit/block/processDepositRequest.test.ts @@ -0,0 +1,104 @@ +import {describe, expect, it} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +import {ForkName, ForkSeq} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {processDepositRequest} from "../../../src/block/processDepositRequest.js"; +import {createCachedBeaconStateTest} from "../../../src/testUtils/state.js"; +import {generateBuilderPendingDeposits} from "../../../src/testUtils/util.js"; +import {CachedBeaconStateGloas} from "../../../src/types.js"; + +/** + * Targeted coverage of the cache fast-path inside processDepositRequest: + * when builderDepositSignatureCache (keyed by state.latestExecutionPayloadBid.blockHash) + * already contains a verified deposit, the path bypasses BLS verification entirely + * and onboards the builder directly. We prove the fast path is taken by seeding the + * cache with a deposit whose signature is *invalid* — the only way an invalid-sig + * deposit can result in a builder being added is if verification was skipped. + */ +describe("processDepositRequest — Gloas cache fast path", () => { + const chainConfig = getConfig(ForkName.gloas); + const beaconConfig = createBeaconConfig(chainConfig, Buffer.alloc(32)); + const pool = generateBuilderPendingDeposits(beaconConfig, 4, 5000); + + function buildState(payloadBlockHash: Uint8Array): CachedBeaconStateGloas { + const stateView = ssz.gloas.BeaconState.defaultViewDU(); + // Provide a non-zero apply slot so cache key-stripping (slot=0) is exercised. + stateView.slot = 32; + stateView.latestExecutionPayloadBid.blockHash = payloadBlockHash; + const state = createCachedBeaconStateTest(stateView, chainConfig, { + skipSyncCommitteeCache: true, + skipSyncPubkeys: true, + }); + state.commit(); + return state; + } + + it("onboards a builder without verifying signature when the deposit is in the cache", () => { + const payloadBlockHash = Buffer.alloc(32, 0xab); + const state = buildState(payloadBlockHash); + const payloadBlockHashHex = `0x${payloadBlockHash.toString("hex")}`; + + // Deposit with a deliberately invalid signature. The fast path skips verification, + // so the builder must still be onboarded — the proof that we took it. + const depositInput = {...pool[0], signature: Buffer.alloc(96)}; + + // Seed cache as if importExecutionPayload had pre-verified this deposit. The + // payload-keyed cache uses PendingDepositNoSlot, so no slot is supplied here; + // the consumer looks up with state.slot present and the hash matches because + // the type excludes slot from identity. + state.epochCtx.builderDepositSignatureCache.setPayloadResult(payloadBlockHashHex, depositInput, true); + + const buildersBefore = state.builders.length; + processDepositRequest(ForkSeq.gloas, state, {...depositInput, index: 0n}); + expect(state.builders.length).toBe(buildersBefore + 1); + }); + + it("drops the deposit when cache says the signature is invalid (false result)", () => { + const payloadBlockHash = Buffer.alloc(32, 0xbe); + const state = buildState(payloadBlockHash); + const payloadBlockHashHex = `0x${payloadBlockHash.toString("hex")}`; + + // Seed the cache with a `false` result — proves negative-cache fast path: the + // deposit is dropped without re-running BLS verification AND without being added + // to pendingDeposits. We use a *valid* signature so any code path that DID verify + // would have onboarded the builder; the only way builders.length stays 0 is the + // cache-says-invalid fast path returning early. + const depositInput = pool[0]; + state.epochCtx.builderDepositSignatureCache.setPayloadResult(payloadBlockHashHex, depositInput, false); + + processDepositRequest(ForkSeq.gloas, state, {...depositInput, index: 0n}); + expect(state.builders.length).toBe(0); + expect(state.pendingDeposits.length).toBe(0); + }); + + it("does NOT onboard when signature is invalid and cache miss (control)", () => { + const payloadBlockHash = Buffer.alloc(32, 0xcd); + const state = buildState(payloadBlockHash); + + // Invalid signature, no cache seed → goes through queueBuilderDeposit + batch verify, + // which drops the deposit because signature is invalid. + const depositInput = {...pool[0], signature: Buffer.alloc(96)}; + + processDepositRequest(ForkSeq.gloas, state, {...depositInput, index: 0n}); + expect(state.builders.length).toBe(0); + // Invalid + builder-prefix + not in cache → the deposit also doesn't end up + // in pendingDeposits (the spec branch routes it to the builder queue, where + // verification drops it). + expect(state.pendingDeposits.length).toBe(0); + }); + + it("does NOT take the fast path when the cache key does not match latestExecutionPayloadBid.blockHash", () => { + const payloadBlockHash = Buffer.alloc(32, 0x11); + const state = buildState(payloadBlockHash); + const wrongKey = `0x${Buffer.alloc(32, 0x99).toString("hex")}`; + + // Invalid signature seeded under the wrong payload key → cache lookup at the + // correct key (latestExecutionPayloadBid.blockHash) misses → builder not onboarded. + const depositInput = {...pool[0], signature: Buffer.alloc(96)}; + state.epochCtx.builderDepositSignatureCache.setPayloadResult(wrongKey, depositInput, true); + + processDepositRequest(ForkSeq.gloas, state, {...depositInput, index: 0n}); + expect(state.builders.length).toBe(0); + }); +}); 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..640a08eb7828 --- /dev/null +++ b/packages/state-transition/test/unit/cache/onboardBuildersCache.test.ts @@ -0,0 +1,220 @@ +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 {BuilderDepositSignatureCache} from "../../../src/cache/builderDepositSignatureCache.ts"; +import {generateBuilderPendingDeposits} from "../../../src/testUtils/util.js"; + +describe("BuilderDepositSignatureCache", () => { + 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 BuilderDepositSignatureCache(); + expect(cache.lastVerifiedSlot).toBe(0); + }); + + it("setter is monotonic — accepts strictly greater values, ignores smaller", () => { + const cache = new BuilderDepositSignatureCache(); + + 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("setPreGloasResult + getPreGloasResult", () => { + it("returns null for an unseen deposit (distinct from a recorded false)", () => { + const cache = new BuilderDepositSignatureCache(); + expect(cache.getPreGloasResult(atSlot(pool[0], 5))).toBe(null); + }); + + it("returns true after setPreGloasResult(deposit, true)", () => { + const cache = new BuilderDepositSignatureCache(); + const d = atSlot(pool[0], 5); + cache.setPreGloasResult(d, true); + expect(cache.getPreGloasResult(d)).toBe(true); + }); + + it("returns false after setPreGloasResult(deposit, false)", () => { + const cache = new BuilderDepositSignatureCache(); + const d = atSlot(pool[0], 5); + cache.setPreGloasResult(d, false); + expect(cache.getPreGloasResult(d)).toBe(false); + }); + + it("returns null when a different deposit at the same slot is queried", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPreGloasResult(atSlot(pool[0], 5), true); + // Same slot, different pubkey → root differs → not in cache + expect(cache.getPreGloasResult(atSlot(pool[1], 5))).toBe(null); + }); + + it("returns null when the same content is queried at a different slot", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPreGloasResult(atSlot(pool[0], 5), true); + // Slot is the outer Map key (not part of the inner hash); querying a different slot + // looks up a different bucket → miss. + expect(cache.getPreGloasResult(atSlot(pool[0], 6))).toBe(null); + }); + + it("distinguishes two deposits with same pubkey/slot but different signatures", () => { + const cache = new BuilderDepositSignatureCache(); + const d1 = atSlot(pool[0], 5); + const d2: electra.PendingDeposit = {...d1, signature: Buffer.alloc(96, 0xff)}; + cache.setPreGloasResult(d1, true); + expect(cache.getPreGloasResult(d1)).toBe(true); + expect(cache.getPreGloasResult(d2)).toBe(null); + }); + + it("holds entries across multiple slots independently", () => { + const cache = new BuilderDepositSignatureCache(); + const d5 = atSlot(pool[0], 5); + const d6 = atSlot(pool[1], 6); + cache.setPreGloasResult(d5, true); + cache.setPreGloasResult(d6, false); + expect(cache.getPreGloasResult(d5)).toBe(true); + expect(cache.getPreGloasResult(d6)).toBe(false); + }); + + it("a later set overwrites an earlier result for the same deposit", () => { + const cache = new BuilderDepositSignatureCache(); + const d = atSlot(pool[0], 5); + cache.setPreGloasResult(d, false); + cache.setPreGloasResult(d, true); + expect(cache.getPreGloasResult(d)).toBe(true); + }); + }); + + describe("clearPreGloasCache", () => { + it("empties the pre-Gloas results and resets lastVerifiedSlot to 0", () => { + const cache = new BuilderDepositSignatureCache(); + const d = atSlot(pool[0], 5); + cache.setPreGloasResult(d, true); + cache.lastVerifiedSlot = 42; + + cache.clearPreGloasCache(); + + expect(cache.lastVerifiedSlot).toBe(0); + expect(cache.getPreGloasResult(d)).toBe(null); + }); + + it("also clears recorded false entries", () => { + const cache = new BuilderDepositSignatureCache(); + const d = atSlot(pool[0], 5); + cache.setPreGloasResult(d, false); + + cache.clearPreGloasCache(); + + expect(cache.getPreGloasResult(d)).toBe(null); + }); + + it("monotonic setter still works after clear (counter truly reset)", () => { + const cache = new BuilderDepositSignatureCache(); + cache.lastVerifiedSlot = 100; + cache.clearPreGloasCache(); + // After clear, a smaller value than the pre-clear max should now be accepted + cache.lastVerifiedSlot = 10; + expect(cache.lastVerifiedSlot).toBe(10); + }); + + it("does not touch the payload-blockHash sub-cache (different lifecycle)", () => { + const cache = new BuilderDepositSignatureCache(); + const hashA = "0xaa".padEnd(66, "a"); + cache.setPayloadResult(hashA, pool[0], true); + + cache.clearPreGloasCache(); + + // Survives clear — payload sub-cache is self-rolling, not tied to fork transition + expect(cache.getPayloadResult(hashA, pool[0])).toBe(true); + }); + }); + + describe("setPayloadResult + getPayloadResult", () => { + const hashA = "0xaa".padEnd(66, "a"); + const hashB = "0xbb".padEnd(66, "b"); + + it("returns null for an unseen (payloadBlockHash, deposit) pair", () => { + const cache = new BuilderDepositSignatureCache(); + expect(cache.getPayloadResult(hashA, pool[0])).toBe(null); + }); + + it("returns true after setPayloadResult(..., true)", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPayloadResult(hashA, pool[0], true); + expect(cache.getPayloadResult(hashA, pool[0])).toBe(true); + }); + + it("returns false after setPayloadResult(..., false) — proves negative results are recorded", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPayloadResult(hashA, pool[0], false); + expect(cache.getPayloadResult(hashA, pool[0])).toBe(false); + }); + + it("ignores the deposit.slot field (PendingDepositNoSlot type excludes slot from identity)", () => { + const cache = new BuilderDepositSignatureCache(); + // The payload-keyed surface takes PendingDepositNoSlot. PendingDeposit (with slot) + // is structurally assignable, and the SSZ type hashes only the 4 slot-less fields, + // so a producer and consumer disagreeing on `slot` still hit. + cache.setPayloadResult(hashA, atSlot(pool[0], 0), true); + expect(cache.getPayloadResult(hashA, atSlot(pool[0], 42))).toBe(true); + }); + + it("isolates entries between different payloadBlockHash keys", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPayloadResult(hashA, pool[0], true); + expect(cache.getPayloadResult(hashB, pool[0])).toBe(null); + }); + + it("multiple deposits under the same key do not grow the underlying Map", () => { + const cache = new BuilderDepositSignatureCache(); + // 5 distinct deposits under one payload — should still occupy a single Map entry + for (let i = 0; i < 5; i++) cache.setPayloadResult(hashA, pool[i], true); + for (let i = 0; i < 5; i++) { + expect(cache.getPayloadResult(hashA, pool[i])).toBe(true); + } + }); + + it("evicts the oldest payloadBlockHash when the 32-entry cap is exceeded", () => { + const cache = new BuilderDepositSignatureCache(); + const keys: string[] = []; + // Insert 33 distinct payload blockHashes, each with one deposit (recycle pool[0]). + for (let i = 0; i < 33; i++) { + const k = `0x${i.toString(16).padStart(64, "0")}`; + keys.push(k); + cache.setPayloadResult(k, pool[0], true); + } + // The first inserted key was evicted; the lookup must return null (not false), + // so the caller re-verifies instead of silently dropping the deposit. + expect(cache.getPayloadResult(keys[0], pool[0])).toBe(null); + for (let i = 1; i < 33; i++) { + expect(cache.getPayloadResult(keys[i], pool[0])).toBe(true); + } + }); + + it("a later set overwrites an earlier result for the same (payload, deposit)", () => { + const cache = new BuilderDepositSignatureCache(); + cache.setPayloadResult(hashA, pool[0], false); + cache.setPayloadResult(hashA, pool[0], true); + expect(cache.getPayloadResult(hashA, pool[0])).toBe(true); + }); + }); +}); 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..b144234eefd7 --- /dev/null +++ b/packages/state-transition/test/unit/upgradeStateToGloas.test.ts @@ -0,0 +1,305 @@ +import {describe, expect, it} from "vitest"; +import {byteArrayEquals} from "@chainsafe/ssz"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +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 {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 {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) + * 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 + 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; + const builderIndex = naiveFindBuilderIndexByPubkey(state, deposit.pubkey); + naiveApplyDepositForBuilder( + state, + deposit.pubkey, + deposit.withdrawalCredentials, + deposit.amount, + deposit.signature, + deposit.slot, + builderIndex + ); + 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; + + 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, + }); + 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; + } + + /** 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())); + } + + // 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 = buildGloasState(mixedDeposits); + 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); + }); +}); 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..4fe943d42465 --- /dev/null +++ b/packages/state-transition/test/unit/util/onboardBuilder.test.ts @@ -0,0 +1,785 @@ +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 {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../../../src/types.js"; +import { + BatchOnboardBuilder, + MAX_BUILDER_DEPOSITS_PER_SLOT, + preVerifyBuilderDepositsPreGloas, + preVerifyPayloadBuilderDeposits, +} 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.onboardBuilderVerifiedSignature(pool[0]); + 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.onboardBuilderVerifiedSignature(pool[0]); + + 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.onboardBuilderVerifiedSignature(pool[5]); + + 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.onboardBuilderVerifiedSignature(pool[5]); + + // 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.onboardBuilderVerifiedSignature(pool[5]); + + // 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.onboardBuilderVerifiedSignature(pool[5]); + + 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.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)); + 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.onboardBuilderVerifiedSignature(pool[5]); + + // 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.onboardBuilderVerifiedSignature(pool[5]); + // second call: cursor was set to preExisting.length; should push directly + batcher.onboardBuilderVerifiedSignature(pool[6]); + + 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.onboardBuilderVerifiedSignature(pool[5]); + + 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.onboardBuilderVerifiedSignature(pool[0]); + // 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.onboardBuilderVerifiedSignature(pool[5]); + // 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.onboardQueuedBuilders(); + + 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.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.onboardQueuedBuilders(); + 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.onboardQueuedBuilders(); + + // 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.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); + 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("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.onboardQueuedBuilders(); + 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.onboardQueuedBuilders(); + + // 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.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); + }); + }); + + 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.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. + 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)); + }); + }); + + describe("preVerifyBuilderDepositsPreGloas", () => { + /** 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 — preVerifyBuilderDepositsPreGloas only touches + * pendingDeposits + epochCtx.builderDepositSignatureCache, 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 = preVerifyBuilderDepositsPreGloas(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + expect(result).toEqual({verifiedCount: 0, invalidCount: 0, fromSlot: null, toSlot: null}); + expect(state.epochCtx.builderDepositSignatureCache.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 = preVerifyBuilderDepositsPreGloas(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.builderDepositSignatureCache.lastVerifiedSlot).toBe(2); + for (const d of deposits) { + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(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 = preVerifyBuilderDepositsPreGloas(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); + // validator-prefix deposit is filtered out before reaching the verifier → not in cache. + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(validatorDeposit)).toBe(null); + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(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.builderDepositSignatureCache.lastVerifiedSlot = 2; + + const result = preVerifyBuilderDepositsPreGloas(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + expect(result.verifiedCount).toBe(1); + expect(result.fromSlot).toBe(3); + expect(result.toSlot).toBe(3); + // deposits[0] was below the cursor → never reached the verifier → not in cache. + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(deposits[0])).toBe(null); + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(deposits[2])).toBe(true); + }); + + it("counts invalid signatures separately and records them as false in 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 = preVerifyBuilderDepositsPreGloas(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + + expect(result.verifiedCount).toBe(2); + expect(result.invalidCount).toBe(1); + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(good1)).toBe(true); + // Bad signatures are now recorded as `false` (not missing) so the fork-transition + // consumer can skip them without re-verifying. + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(bad)).toBe(false); + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(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 = preVerifyBuilderDepositsPreGloas(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.builderDepositSignatureCache.lastVerifiedSlot).toBe(2); + // deposits[4] and deposits[5] sit beyond the slot-boundary cutoff → never reach the + // verifier → no cache entry (null), not a recorded `false`. + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(deposits[4])).toBe(null); + expect(state.epochCtx.builderDepositSignatureCache.getPreGloasResult(deposits[5])).toBe(null); + }); + + 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 = preVerifyBuilderDepositsPreGloas(state, 1); + expect(r1.verifiedCount).toBe(1); + expect(r1.fromSlot).toBe(1); + expect(r1.toSlot).toBe(1); + expect(state.epochCtx.builderDepositSignatureCache.lastVerifiedSlot).toBe(1); + + // Second call: resumes at slot 2 onward + const r2 = preVerifyBuilderDepositsPreGloas(state, MAX_BUILDER_DEPOSITS_PER_SLOT); + expect(r2.verifiedCount).toBe(3); + expect(r2.fromSlot).toBe(2); + expect(r2.toSlot).toBe(3); + expect(state.epochCtx.builderDepositSignatureCache.lastVerifiedSlot).toBe(3); + }); + }); + + describe("preVerifyEnvelopeBuilderDeposits", () => { + /** Pool of validly-signed builder deposits — distinct interop pubkeys [4000, 4099). */ + const envelopePool = generateBuilderPendingDeposits(beaconConfig, 100, 4000); + const payloadBlockHash = "0xabc".padEnd(66, "c"); + + function freshState(): CachedBeaconStateGloas { + return buildGloasState(); + } + + it("returns zero counts and writes nothing when given an empty list", () => { + const state = freshState(); + const result = preVerifyPayloadBuilderDeposits(state, payloadBlockHash, []); + expect(result).toEqual({verifiedCount: 0, invalidCount: 0}); + // Nothing recorded — empty input never reached the verifier. + expect(state.epochCtx.builderDepositSignatureCache.getPayloadResult(payloadBlockHash, envelopePool[0])).toBe( + null + ); + }); + + it("verifies and caches each builder-prefix deposit under the given payloadBlockHash", () => { + const state = freshState(); + const deposits = envelopePool.slice(0, 3).map((d) => ({...d, slot: 0})); + + const result = preVerifyPayloadBuilderDeposits(state, payloadBlockHash, deposits); + + expect(result.verifiedCount).toBe(3); + expect(result.invalidCount).toBe(0); + for (const d of deposits) { + expect(state.epochCtx.builderDepositSignatureCache.getPayloadResult(payloadBlockHash, d)).toBe(true); + } + }); + + it("counts invalid signatures separately and records them as false in the cache", () => { + const state = freshState(); + const good1 = {...envelopePool[0], slot: 0}; + const bad = withInvalidSignature({...envelopePool[1], slot: 0}); + const good2 = {...envelopePool[2], slot: 0}; + + const result = preVerifyPayloadBuilderDeposits(state, payloadBlockHash, [good1, bad, good2]); + + expect(result.verifiedCount).toBe(2); + expect(result.invalidCount).toBe(1); + const cache = state.epochCtx.builderDepositSignatureCache; + expect(cache.getPayloadResult(payloadBlockHash, good1)).toBe(true); + // Bad signatures are now recorded as `false` (not missing) so the next-block + // consumer can skip them without re-verifying. + expect(cache.getPayloadResult(payloadBlockHash, bad)).toBe(false); + expect(cache.getPayloadResult(payloadBlockHash, good2)).toBe(true); + }); + + it("cached entries are queryable with the consumer's apply-slot (slot is stripped)", () => { + // Producer writes with slot:0 (envelope-import doesn't know apply slot). Consumer + // (processDepositRequest) queries with state.slot of the child block. + const state = freshState(); + const producerView = {...envelopePool[0], slot: 0}; + const consumerView = {...envelopePool[0], slot: 42}; + + preVerifyPayloadBuilderDeposits(state, payloadBlockHash, [producerView]); + + expect(state.epochCtx.builderDepositSignatureCache.getPayloadResult(payloadBlockHash, consumerView)).toBe(true); + }); + }); +}); diff --git a/packages/types/src/electra/sszTypes.ts b/packages/types/src/electra/sszTypes.ts index 9176fb7c76e4..f9960e3e75b6 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, @@ -283,9 +284,25 @@ export const PendingDeposit = new ContainerType( {typeName: "PendingDeposit", jsonCase: "eth2"} ); +/** + * PendingDeposit minus the `slot` field. Used by the payload-keyed + * BuilderDepositSignatureCache: at execution-payload-envelope import time the + * deposit's eventual application slot is unknown (it becomes `state.slot` of the + * child block), and signature verification does not depend on slot. + */ +export const PendingDepositNoSlot = new ContainerNodeStructType( + { + pubkey: BLSPubkey, + withdrawalCredentials: Bytes32, + amount: UintNum64, + signature: BLSSignature, + }, + {typeName: "PendingDepositNoSlot", jsonCase: "eth2"} +); + 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 +316,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/electra/types.ts b/packages/types/src/electra/types.ts index 00be610626d7..60dbf2f4c9cd 100644 --- a/packages/types/src/electra/types.ts +++ b/packages/types/src/electra/types.ts @@ -44,6 +44,7 @@ export type LightClientOptimisticUpdate = ValueOf; export type PendingDeposit = ValueOf; +export type PendingDepositNoSlot = ValueOf; export type PendingDeposits = ValueOf; export type PendingPartialWithdrawal = ValueOf; export type PendingPartialWithdrawals = ValueOf; 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, 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(