Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/beacon-node/src/chain/prepareNextSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import {
isForkPostGloas,
} from "@lodestar/params";
import {
GLOAS_PREVERIFY_WINDOW_EPOCHS,
IBeaconStateView,
IBeaconStateViewBellatrix,
MAX_BUILDER_DEPOSITS_PER_SLOT,
StateHashTreeRootSource,
computeEpochAtSlot,
computeTimeAtSlot,
isStatePostBellatrix,
isStatePostFulu,
isStatePostGloas,
} from "@lodestar/state-transition";
import {Bytes32, Slot, ValidatorIndex} from "@lodestar/types";
Expand Down Expand Up @@ -287,6 +290,58 @@ export class PrepareNextSlotScheduler {
});

precomputeEpochTransitionTimer?.();

// Nothing more to do on an epoch-transition slot: that precompute already ran a full (>1s)
// epoch transition, so we don't pile the builder-deposit pre-verify (below) on top of it.
return;
}

// Pre-verify builder-deposit signatures in the GLOAS_PREVERIFY_WINDOW_EPOCHS epochs leading
// up to the Gloas fork, off the block-import hot path, so onboardBuildersFromPendingDeposits
// at the transition is mostly cache hits. Reached only on non-epoch-transition slots (the
// epoch-transition branch returned above). The scan runs on prepareState (a spare-time,
// proposer-prep state); results are stashed on the shared builderDepositSignatureCache.
if (isStatePostFulu(prepareState)) {
const gloasEpoch = this.config.GLOAS_FORK_EPOCH;
const finalizedEpoch = this.chain.forkChoice.getFinalizedCheckpoint().epoch;
if (finalizedEpoch >= gloasEpoch) {
// Gloas is finalized — the pre-verify cache has served its purpose; release its memory.
prepareState.clearPreGloasBuilderDepositCache();
} else {
const clockEpoch = computeEpochAtSlot(clockSlot);
if (
ForkSeq[fork] < ForkSeq.gloas && // pins prepareState (at prepareSlot) to Fulu, not Gloas
clockEpoch >= gloasEpoch - GLOAS_PREVERIFY_WINDOW_EPOCHS && // Infinity when Gloas unscheduled → skip
clockEpoch < gloasEpoch &&
// avoid the risk of missing block proposal when this task may expand to the next epoch on some low resource node
this.chain.beaconProposerCache.get(prepareState.getBeaconProposer(prepareSlot)) === undefined
) {
const preVerifyTimer = this.metrics?.builderDepositPreVerify.duration.startTimer();
const result = prepareState.preVerifyBuilderDepositsPreGloas(MAX_BUILDER_DEPOSITS_PER_SLOT);
preVerifyTimer?.();

const preVerifyMetrics = this.metrics?.builderDepositPreVerify;
if (preVerifyMetrics) {
preVerifyMetrics.pendingDeposits.set(result.pendingDepositsCount);
preVerifyMetrics.cachedDeposits.set(result.totalBuildersVerified);
preVerifyMetrics.scannedDeposits.set(result.scannedPendingDeposits);
if (result.invalidBuildersCount > 0) {
preVerifyMetrics.invalidSignatures.inc(result.invalidBuildersCount);
}
}

if (result.verifiedBuildersCount > 0 || result.invalidBuildersCount > 0) {
this.logger.verbose("PrepareNextSlotScheduler pre-verified builder deposit signatures", {
clockSlot,
verifiedBuildersCount: result.verifiedBuildersCount,
invalidBuildersCount: result.invalidBuildersCount,
scannedPendingDeposits: result.scannedPendingDeposits,
totalBuildersVerified: result.totalBuildersVerified,
pendingDepositsCount: result.pendingDepositsCount,
});
}
}
}
}
} catch (e) {
if (!isErrorAborted(e) && !isQueueErrorAborted(e)) {
Expand Down
27 changes: 27 additions & 0 deletions packages/beacon-node/src/metrics/metrics/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1708,6 +1708,33 @@ export function createLodestarMetrics(
}),
},

// Pre-verify builder-deposit signatures in the epochs before the Gloas fork (prepareNextSlot).
// These are for tuning MAX_BUILDER_DEPOSITS_PER_SLOT on devnets: watch `duration` vs the spare
// slot budget and whether `cached_deposits` drains `pending_deposits` before the fork.
builderDepositPreVerify: {
duration: register.histogram({
name: "lodestar_builder_deposit_preverify_duration_seconds",
help: "Duration of one preVerifyBuilderDepositsPreGloas call (BLS batch verify + scan)",
buckets: [1, 2, 3, 4],
}),
pendingDeposits: register.gauge({
name: "lodestar_builder_deposit_preverify_pending_deposits",
help: "Current state.pendingDeposits length (backlog to drain before the fork)",
}),
cachedDeposits: register.gauge({
name: "lodestar_builder_deposit_preverify_cached_deposits",
help: "Builder deposits verified & cached so far this pre-fork window (valid + invalid)",
}),
scannedDeposits: register.gauge({
name: "lodestar_builder_deposit_preverify_scanned_deposits",
help: "Pending deposits examined by the last pre-verify call (< pending_deposits ⇒ cap hit)",
}),
invalidSignatures: register.counter({
name: "lodestar_builder_deposit_preverify_invalid_signatures_total",
help: "Cumulative builder-deposit signatures pre-verified as invalid (abuse/anomaly signal)",
}),
},

// reprocess attestations
reprocessApiAttestations: {
total: register.gauge({
Expand Down
76 changes: 75 additions & 1 deletion packages/state-transition/src/block/processDeposit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {PublicKey, Signature, verify} from "@chainsafe/blst";
import {PublicKey, Signature, verify, verifyMultipleAggregateSignatures} from "@chainsafe/blst";
import {BeaconConfig} from "@lodestar/config";
import {
DEPOSIT_CONTRACT_TREE_DEPTH,
Expand Down Expand Up @@ -138,6 +138,10 @@ export function addValidatorToRegistry(
state.balances.push(amount);
}

/**
* Verify a single deposit signature (proof of possession). See {@link verifyDepositSignatures} for
* the batch equivalent — the two MUST stay consistent (same domain, signing root, and checks).
*/
export function isValidDepositSignature(
config: BeaconConfig,
pubkey: Uint8Array,
Expand All @@ -164,3 +168,73 @@ export function isValidDepositSignature(
return false; // Catch all BLS errors: failed key validation, failed signature validation, invalid signature
}
}

/**
* Batch equivalent of {@link isValidDepositSignature} — verify many deposit signatures at once.
* MUST stay consistent with `isValidDepositSignature`: same fork-agnostic deposit domain, same
* `DepositMessage` signing root, same group + infinity checks. Tries a single aggregate verify and,
* on failure, falls back to verifying each deposit individually so the valid deposits in a batch
* containing an invalid one are still identified. Returns a boolean per input deposit; `slot` is not
* part of the signature check.
*
* Used by the pre-Gloas builder-deposit pre-verify scanner (see `preVerifyBuilderDeposits.ts`).
*/
export function verifyDepositSignatures(config: BeaconConfig, deposits: electra.PendingDeposit[]): boolean[] {
const results = new Array<boolean>(deposits.length).fill(false);
// fork-agnostic domain since deposits are valid across forks, matching 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, amortized across the batch.
let batchValid: boolean;
try {
batchValid =
signatureSets.length >= 2
? verifyMultipleAggregateSignatures(signatureSets, true, true)

@ensi321 ensi321 Aug 4, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there can be attackers that submit a bunch of deposits with invalid signature that force us to fall back to verify one by one which makes lodestar hard to keep up.

They only need 1 bad deposit per batch (32 deposits) to nullify all the optimizations here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a valid concern but also the attacker would burn the eth, so it's basically a trade-off here, we want the batch sizes to be large enough to get benefits from batching but not too large so an invalid signature can't waste too much time/compute

: verify(signatureSets[0].msg, signatureSets[0].pk, signatureSets[0].sig, true, true);
} catch (_) {
batchValid = false;
}

if (batchValid) {
for (const depositIndex of signatureSetDepositIndices) {
results[depositIndex] = true;
}
} else {
for (let s = 0; s < signatureSets.length; s++) {
try {
if (verify(signatureSets[s].msg, signatureSets[s].pk, signatureSets[s].sig, true, true)) {
results[signatureSetDepositIndices[s]] = true;
}
} catch (_) {
// invalid point / malformed → invalid signature, results[i] stays false
}
}
}

return results;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {electra} from "@lodestar/types";

/**
* Caches builder-deposit signature-verification results — passes (`true`) and failures (`false`)
* — so the Fulu → Gloas fork transition can skip the bulk verification cost AND skip re-verifying
* deposits already proven invalid.
*
* Keyed by the deposit's struct **value object** — `electra.PendingDeposit` is a
* `ContainerNodeStructType`, so its `node.value` is a stable object reused across
* prepareNextSlot-derived states and carried by reference through the gloas pendingDeposits
* migration. No merkle-root computation, no slot bucketing.
*
* Lifecycle owned by `prepareNextSlot`: filled during the pre-fork window, then `clear()`ed once the
* Gloas fork is finalized. `onboardBuildersFromPendingDeposits()` only reads it. A plain `Map` (not a
* `WeakMap`) is used so the drop is deterministic and the contents are inspectable for logging.
*
* Single instance across the application (created in `EpochCache.createFromState`, shared
* by-reference through `clone()`).
*/
export class BuilderDepositSignatureCache {
private validityByDeposit = new Map<electra.PendingDeposit, boolean>();

setSignatureValidity(deposit: electra.PendingDeposit, isValid: boolean): void {
this.validityByDeposit.set(deposit, isValid);
}

getSignatureValidity(deposit: electra.PendingDeposit): boolean | null {
return this.validityByDeposit.get(deposit) ?? null;
}

isVerified(deposit: electra.PendingDeposit): boolean {
return this.validityByDeposit.has(deposit);
}

/** Cumulative builder deposits verified & cached this window (pass + fail). */
get size(): number {
return this.validityByDeposit.size;
}

clear(): void {
this.validityByDeposit.clear();
}
}
14 changes: 14 additions & 0 deletions packages/state-transition/src/cache/epochCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.js";
import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js";
import {EpochTransitionCache} from "./epochTransitionCache.js";
import {PubkeyCache, createPubkeyCache, syncPubkeys} from "./pubkeyCache.js";
Expand Down Expand Up @@ -112,6 +113,13 @@ export class EpochCache {
* Couples both index→pubkey and pubkey→index lookups, keeping them in sync atomically.
*/
pubkeyCache: PubkeyCache;
/**
* Shared across all clones of the same chain head. Holds builder deposit signature validity
* 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.
Expand Down Expand Up @@ -245,6 +253,7 @@ export class EpochCache {
constructor(data: {
config: BeaconConfig;
pubkeyCache: PubkeyCache;
builderDepositSignatureCache: BuilderDepositSignatureCache;
proposers: number[];
proposersPrevEpoch: number[] | null;
proposersNextEpoch: ProposersDeferred;
Expand Down Expand Up @@ -277,6 +286,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;
Expand Down Expand Up @@ -514,6 +524,8 @@ export class EpochCache {
return new EpochCache({
config,
pubkeyCache,
// Created once per application (shared by-reference through clone()).
builderDepositSignatureCache: new BuilderDepositSignatureCache(),
proposers,
// On first epoch, set to null to prevent unnecessary work since this is only used for metrics
proposersPrevEpoch: null,
Expand Down Expand Up @@ -558,6 +570,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, shared by-reference across clones
builderDepositSignatureCache: this.builderDepositSignatureCache,
// Immutable data
proposers: this.proposers,
proposersPrevEpoch: this.proposersPrevEpoch,
Expand Down
13 changes: 9 additions & 4 deletions packages/state-transition/src/slot/upgradeStateToGloas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,20 @@ function onboardBuildersFromPendingDeposits(state: CachedBeaconStateGloas): void

// Verify the deposit signature (proof of possession). If invalid the deposit is silently
// dropped — stake is forfeited, matching the validator deposit contract behavior.
if (
!isValidDepositSignature(
//
// The prepareNextSlot scheduler pre-verifies these signatures in the epochs before the fork
// A cache miss falls back to verifying this one deposit — no worse than pre-cache.
const cached = state.epochCtx.builderDepositSignatureCache.getSignatureValidity(deposit.toValue());
const isValid =
cached ??
isValidDepositSignature(
state.config,
deposit.pubkey,
deposit.withdrawalCredentials,
deposit.amount,
deposit.signature
)
) {
);
if (!isValid) {
continue;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/state-transition/src/stateView/beaconStateView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from "../util/execution.js";
import {canBuilderCoverBid} from "../util/gloas.js";
import {loadState} from "../util/loadState/loadState.js";
import {PreVerifyBuilderDepositsResult, preVerifyBuilderDepositsPreGloas} from "../util/preVerifyBuilderDeposits.js";
import {getRandaoMix} from "../util/seed.js";
import {getLatestWeakSubjectivityCheckpointEpoch} from "../util/weakSubjectivity.js";
import {IBeaconStateView, IBeaconStateViewGloas, IBeaconStateViewLatestFork, isStatePostGloas} from "./interface.js";
Expand Down Expand Up @@ -341,6 +342,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork {
return this._proposerLookahead;
}

preVerifyBuilderDepositsPreGloas(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult {
return preVerifyBuilderDepositsPreGloas(this.cachedState as CachedBeaconStateFulu, maxBuilderDeposits);
}

clearPreGloasBuilderDepositCache(): void {
this.cachedState.epochCtx.builderDepositSignatureCache.clear();
}

// gloas

get latestBlockHash(): Bytes32 {
Expand Down
8 changes: 8 additions & 0 deletions packages/state-transition/src/stateView/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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/preVerifyBuilderDeposits.js";

/**
* A read-only view of the BeaconState.
Expand Down Expand Up @@ -242,6 +243,13 @@ export interface IBeaconStateViewElectra extends IBeaconStateViewDeneb {
export interface IBeaconStateViewFulu extends IBeaconStateViewElectra {
forkName: ForkPostFulu;
proposerLookahead: fulu.ProposerLookahead;
/**
* Pre-verify a slice of builder-prefix pending deposits and cache the results on the underlying
* `BuilderDepositSignatureCache` (Fulu is the fork immediately before Gloas).
*/
preVerifyBuilderDepositsPreGloas(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult;
/** Drop the pre-Gloas builder-deposit signature cache (called once the Gloas fork is finalized). */
clearPreGloasBuilderDepositCache(): void;
}

/** Gloas+ state fields — use isStatePostGloas() guard */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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/preVerifyBuilderDeposits.js";
import {
IBeaconStateView,
IBeaconStateViewGloas,
Expand Down Expand Up @@ -845,6 +846,14 @@ export class NativeBeaconStateView implements IBeaconStateViewLatestFork {
return this._proposerLookahead;
}

preVerifyBuilderDepositsPreGloas(maxBuilderDeposits: number): PreVerifyBuilderDepositsResult {
return this.binding.preVerifyBuilderDepositsPreGloas(maxBuilderDeposits);
}

clearPreGloasBuilderDepositCache(): void {
this.binding.clearPreGloasBuilderDepositCache();
}

// ─── gloas ───────────────────────────────────────────────────────────────

get latestBlockHash(): Bytes32 {
Expand Down
Loading
Loading