Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
});
}
}

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/beacon-node/src/chain/prepareNextSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
120 changes: 38 additions & 82 deletions packages/state-transition/src/block/processDepositRequest.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
103 changes: 103 additions & 0 deletions packages/state-transition/src/cache/builderDepositSignatureCache.ts
Original file line number Diff line number Diff line change
@@ -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<Slot, Map<RootHex, boolean>> = new MapDef(() => new Map());
// Plain Map (not MapDef) so insertion order is usable for FIFO eviction via pruneSetToMax.
private payloadResultsByBlockHash = new Map<RootHex, Map<RootHex, boolean>>();

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;
}
}
Loading
Loading