diff --git a/packages/state-transition/src/block/processDepositRequest.ts b/packages/state-transition/src/block/processDepositRequest.ts index 47f630a97ec7..f0292a093b16 100644 --- a/packages/state-transition/src/block/processDepositRequest.ts +++ b/packages/state-transition/src/block/processDepositRequest.ts @@ -2,6 +2,7 @@ import {BeaconConfig} from "@lodestar/config"; import {FAR_FUTURE_EPOCH, ForkSeq, UNSET_DEPOSIT_REQUESTS_START_INDEX} from "@lodestar/params"; import {BLSPubkey, Bytes32, PubkeyHex, UintNum64, electra, ssz} from "@lodestar/types"; import {toPubkeyHex} from "@lodestar/utils"; +import {type PendingDepositPubkeyIndex, type PendingValidatorVerifiedCount} from "../cache/epochCache.js"; import {CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import {findBuilderIndexByPubkey, isBuilderWithdrawalCredential} from "../util/gloas.js"; import {computeEpochAtSlot, isValidatorKnown} from "../util/index.js"; @@ -76,24 +77,17 @@ function addBuilderToRegistry( } } -// TODO GLOAS: pendingValidatorPubkeys cache is currently naive and has room for improvement. -// Currently the cache lives in process_block, but we should put it in epochCache or elsewhere that has longer -// lifetime to avoid duplicated deposit signature computation -// See https://github.com/ChainSafe/lodestar/issues/9181 export function processDepositRequest( fork: ForkSeq, state: CachedBeaconStateElectra | CachedBeaconStateGloas, - depositRequest: electra.DepositRequest, - pendingValidatorPubkeysCache?: Set + depositRequest: electra.DepositRequest ): void { const {pubkey, withdrawalCredentials, amount, signature} = depositRequest; + const stateGloas = fork >= ForkSeq.gloas ? (state as CachedBeaconStateGloas) : null; + const pubkeyHex = stateGloas !== null ? toPubkeyHex(pubkey) : null; // Check if this is a builder or validator deposit - if (fork >= ForkSeq.gloas) { - const stateGloas = state as CachedBeaconStateGloas; - const pendingValidatorPubkeys = - pendingValidatorPubkeysCache ?? getPendingValidatorPubkeys(state.config, stateGloas); - const pubkeyHex = toPubkeyHex(pubkey); + if (stateGloas !== null && pubkeyHex !== null) { const builderIndex = findBuilderIndexByPubkey(stateGloas, pubkey); const validatorIndex = state.epochCtx.getValidatorIndex(pubkey); @@ -101,24 +95,13 @@ export function processDepositRequest( // already exists with this pubkey, apply the deposit to their balance const isBuilder = builderIndex !== null; const isValidator = isValidatorKnown(state, validatorIndex); - const isPendingValidator = pendingValidatorPubkeys.has(pubkeyHex); + const isPendingValidator = getVerifiedPendingValidatorCountForPubkey(stateGloas, pubkeyHex) > 0; if (isBuilder || (isBuilderWithdrawalCredential(withdrawalCredentials) && !isValidator && !isPendingValidator)) { // Apply builder deposits immediately applyDepositForBuilder(stateGloas, pubkey, withdrawalCredentials, amount, signature, state.slot); return; } - - // Keep the shared cache in sync: if this deposit has a valid signature, subsequent - // deposit requests for the same pubkey in this envelope must see it as a pending validator - if ( - pendingValidatorPubkeysCache && - !isValidator && - !isPendingValidator && - isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature) - ) { - pendingValidatorPubkeys.add(pubkeyHex); - } } // Only set deposit_requests_start_index in Electra fork, not Gloas @@ -135,6 +118,120 @@ export function processDepositRequest( slot: state.slot, }); state.pendingDeposits.push(pendingDeposit); + + if (stateGloas !== null && pubkeyHex !== null) { + const pendingDepositPubkeyIndex = ensurePendingDepositPubkeyIndexWritable(stateGloas); + const newPendingDepositIndex = state.pendingDeposits.length - 1; + const bucket = pendingDepositPubkeyIndex.get(pubkeyHex); + + if (bucket) { + pendingDepositPubkeyIndex.set(pubkeyHex, [...bucket, newPendingDepositIndex]); + } else { + pendingDepositPubkeyIndex.set(pubkeyHex, [newPendingDepositIndex]); + } + + const verifiedPendingValidatorCount = getPendingValidatorVerifiedCount(stateGloas); + const cachedCount = verifiedPendingValidatorCount.get(pubkeyHex); + if ( + cachedCount !== undefined && + isValidDepositSignature(state.config, pubkey, withdrawalCredentials, amount, signature) + ) { + ensurePendingValidatorVerifiedCountWritable(stateGloas).set(pubkeyHex, cachedCount + 1); + } + } +} + +export function ensurePendingDepositPubkeyIndex(state: CachedBeaconStateGloas): PendingDepositPubkeyIndex { + if (state.epochCtx.pendingDepositPubkeyIndex !== null) { + return state.epochCtx.pendingDepositPubkeyIndex; + } + + const pendingDepositPubkeyIndex: PendingDepositPubkeyIndex = new Map(); + for (let i = 0; i < state.pendingDeposits.length; i++) { + const pubkeyHex = toPubkeyHex(state.pendingDeposits.getReadonly(i).pubkey); + const bucket = pendingDepositPubkeyIndex.get(pubkeyHex); + + if (bucket) { + bucket.push(i); + } else { + pendingDepositPubkeyIndex.set(pubkeyHex, [i]); + } + } + + state.epochCtx.pendingDepositPubkeyIndex = pendingDepositPubkeyIndex; + state.epochCtx.pendingDepositPubkeyIndexShared = false; + if (state.epochCtx.pendingValidatorVerifiedCount === null) { + state.epochCtx.pendingValidatorVerifiedCount = new Map(); + state.epochCtx.pendingValidatorVerifiedCountShared = false; + } + + return pendingDepositPubkeyIndex; +} + +function ensurePendingDepositPubkeyIndexWritable(state: CachedBeaconStateGloas): PendingDepositPubkeyIndex { + let pendingDepositPubkeyIndex = ensurePendingDepositPubkeyIndex(state); + + if (state.epochCtx.pendingDepositPubkeyIndexShared) { + pendingDepositPubkeyIndex = new Map(pendingDepositPubkeyIndex); + state.epochCtx.pendingDepositPubkeyIndex = pendingDepositPubkeyIndex; + state.epochCtx.pendingDepositPubkeyIndexShared = false; + } + + return pendingDepositPubkeyIndex; +} + +function getPendingValidatorVerifiedCount(state: CachedBeaconStateGloas): PendingValidatorVerifiedCount { + if (state.epochCtx.pendingValidatorVerifiedCount === null) { + state.epochCtx.pendingValidatorVerifiedCount = new Map(); + state.epochCtx.pendingValidatorVerifiedCountShared = false; + } + + return state.epochCtx.pendingValidatorVerifiedCount; +} + +function ensurePendingValidatorVerifiedCountWritable(state: CachedBeaconStateGloas): PendingValidatorVerifiedCount { + let verifiedPendingValidatorCount = getPendingValidatorVerifiedCount(state); + + if (state.epochCtx.pendingValidatorVerifiedCountShared) { + verifiedPendingValidatorCount = new Map(verifiedPendingValidatorCount); + state.epochCtx.pendingValidatorVerifiedCount = verifiedPendingValidatorCount; + state.epochCtx.pendingValidatorVerifiedCountShared = false; + } + + return verifiedPendingValidatorCount; +} + +function getVerifiedPendingValidatorCountForPubkey(state: CachedBeaconStateGloas, pubkeyHex: PubkeyHex): number { + const verifiedPendingValidatorCount = getPendingValidatorVerifiedCount(state); + const cachedCount = verifiedPendingValidatorCount.get(pubkeyHex); + if (cachedCount !== undefined) { + return cachedCount; + } + + const bucket = ensurePendingDepositPubkeyIndex(state).get(pubkeyHex); + if (!bucket) { + ensurePendingValidatorVerifiedCountWritable(state).set(pubkeyHex, 0); + return 0; + } + + let validCount = 0; + for (const pendingDepositIndex of bucket) { + const pendingDeposit = state.pendingDeposits.getReadonly(pendingDepositIndex); + if ( + isValidDepositSignature( + state.config, + pendingDeposit.pubkey, + pendingDeposit.withdrawalCredentials, + pendingDeposit.amount, + pendingDeposit.signature + ) + ) { + validCount++; + } + } + + ensurePendingValidatorVerifiedCountWritable(state).set(pubkeyHex, validCount); + return validCount; } /** diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index c7f858a908d0..7857ee89e498 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -7,7 +7,7 @@ import {CachedBeaconStateGloas} from "../types.js"; import {computeTimeAtSlot} from "../util/index.js"; import {verifySignatureSet} from "../util/signatureSets.js"; import {processConsolidationRequest} from "./processConsolidationRequest.js"; -import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; +import {ensurePendingDepositPubkeyIndex, processDepositRequest} from "./processDepositRequest.js"; import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; export type ProcessExecutionPayloadEnvelopeOpts = { @@ -33,19 +33,20 @@ export function processExecutionPayloadEnvelope( throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } + const requests = envelope.executionRequests; + + if (requests.deposits.length > 0) { + ensurePendingDepositPubkeyIndex(state); + } + // .clone() before mutating state, similar to stateTransition() const postState = state.clone(opts?.dontTransferCache) as CachedBeaconStateGloas; validateExecutionPayloadEnvelope(postState, envelope); - const requests = envelope.executionRequests; - if (requests.deposits.length > 0) { - // Build cache of pending validator pubkeys once, shared across all deposit requests - const pendingValidatorPubkeys = getPendingValidatorPubkeys(postState.config, postState); - for (const deposit of requests.deposits) { - processDepositRequest(fork, postState, deposit, pendingValidatorPubkeys); + processDepositRequest(fork, postState, deposit); } } diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index d87c998d1e8a..7e2843a34485 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -17,6 +17,7 @@ import { CommitteeIndex, Epoch, IndexedAttestation, + PubkeyHex, RootHex, Slot, SubnetID, @@ -79,6 +80,9 @@ export type EpochCacheOpts = { shufflingGetter?: ShufflingGetter; }; +export type PendingDepositPubkeyIndex = Map; +export type PendingValidatorVerifiedCount = Map; + /** Defers computing proposers by persisting only the seed, and dropping it once indexes are computed */ type ProposersDeferred = {computed: false; seed: Uint8Array} | {computed: true; indexes: ValidatorIndex[]}; @@ -226,6 +230,29 @@ export class EpochCache { /** TODO: Indexed SyncCommitteeCache */ nextSyncCommitteeIndexed: SyncCommitteeCache; + /** + * Maps each pending-deposit pubkey to the indices where it appears in `state.pendingDeposits`. + * Starts as `null` on states created from bytes and is built when needed. + */ + pendingDepositPubkeyIndex: PendingDepositPubkeyIndex | null; + /** + * True when `pendingDepositPubkeyIndex` is shared with another cloned state and must be detached + * before mutating the map itself. + */ + pendingDepositPubkeyIndexShared: boolean; + + /** + * Caches the number of signature-valid pending deposits for each pubkey. + * Starts as `null` on states created from bytes and is built when needed. a missing map entry means + * that pubkey has not been checked for the current state yet. + */ + pendingValidatorVerifiedCount: PendingValidatorVerifiedCount | null; + /** + * True when `pendingValidatorVerifiedCount` is shared with another cloned state and must be detached + * before mutating the map itself. + */ + pendingValidatorVerifiedCountShared: boolean; + // TODO GLOAS: See if we need to cache PTC for next epoch // PTC for previous epoch, required for slot N block validating slot N-1 attestations previousPayloadTimelinessCommittees: Uint32Array[]; @@ -268,6 +295,10 @@ export class EpochCache { previousTargetUnslashedBalanceIncrements: number; currentSyncCommitteeIndexed: SyncCommitteeCache; nextSyncCommitteeIndexed: SyncCommitteeCache; + pendingDepositPubkeyIndex: PendingDepositPubkeyIndex | null; + pendingDepositPubkeyIndexShared: boolean; + pendingValidatorVerifiedCount: PendingValidatorVerifiedCount | null; + pendingValidatorVerifiedCountShared: boolean; previousPayloadTimelinessCommittees: Uint32Array[]; payloadTimelinessCommittees: Uint32Array[]; epoch: Epoch; @@ -299,6 +330,10 @@ export class EpochCache { this.previousTargetUnslashedBalanceIncrements = data.previousTargetUnslashedBalanceIncrements; this.currentSyncCommitteeIndexed = data.currentSyncCommitteeIndexed; this.nextSyncCommitteeIndexed = data.nextSyncCommitteeIndexed; + this.pendingDepositPubkeyIndex = data.pendingDepositPubkeyIndex; + this.pendingDepositPubkeyIndexShared = data.pendingDepositPubkeyIndexShared; + this.pendingValidatorVerifiedCount = data.pendingValidatorVerifiedCount; + this.pendingValidatorVerifiedCountShared = data.pendingValidatorVerifiedCountShared; this.previousPayloadTimelinessCommittees = data.previousPayloadTimelinessCommittees; this.payloadTimelinessCommittees = data.payloadTimelinessCommittees; this.epoch = data.epoch; @@ -544,6 +579,10 @@ export class EpochCache { currentTargetUnslashedBalanceIncrements, currentSyncCommitteeIndexed, nextSyncCommitteeIndexed, + pendingDepositPubkeyIndex: null, + pendingDepositPubkeyIndexShared: false, + pendingValidatorVerifiedCount: null, + pendingValidatorVerifiedCountShared: false, previousPayloadTimelinessCommittees, payloadTimelinessCommittees, epoch: currentEpoch, @@ -558,6 +597,13 @@ export class EpochCache { // warning: pubkey cache is not copied, it is shared, as eth1 is not expected to reorder validators. // Shallow copy all data from current epoch context to the next // All data is completely replaced, or only-appended + if (this.pendingDepositPubkeyIndex !== null) { + this.pendingDepositPubkeyIndexShared = true; + } + if (this.pendingValidatorVerifiedCount !== null) { + this.pendingValidatorVerifiedCountShared = true; + } + return new EpochCache({ config: this.config, // Common append-only structures shared with all states, no need to clone @@ -590,6 +636,10 @@ export class EpochCache { currentTargetUnslashedBalanceIncrements: this.currentTargetUnslashedBalanceIncrements, currentSyncCommitteeIndexed: this.currentSyncCommitteeIndexed, nextSyncCommitteeIndexed: this.nextSyncCommitteeIndexed, + pendingDepositPubkeyIndex: this.pendingDepositPubkeyIndex, + pendingDepositPubkeyIndexShared: this.pendingDepositPubkeyIndex !== null, + pendingValidatorVerifiedCount: this.pendingValidatorVerifiedCount, + pendingValidatorVerifiedCountShared: this.pendingValidatorVerifiedCount !== null, previousPayloadTimelinessCommittees: this.previousPayloadTimelinessCommittees, payloadTimelinessCommittees: this.payloadTimelinessCommittees, epoch: this.epoch, diff --git a/packages/state-transition/src/epoch/processPendingDeposits.ts b/packages/state-transition/src/epoch/processPendingDeposits.ts index aa335f0ddd0c..a34f15414b5f 100644 --- a/packages/state-transition/src/epoch/processPendingDeposits.ts +++ b/packages/state-transition/src/epoch/processPendingDeposits.ts @@ -96,6 +96,11 @@ export function processPendingDeposits(state: CachedBeaconStateElectra, cache: E state.pendingDeposits.push(deposit); } + state.epochCtx.pendingDepositPubkeyIndex = null; + state.epochCtx.pendingDepositPubkeyIndexShared = false; + state.epochCtx.pendingValidatorVerifiedCount = null; + state.epochCtx.pendingValidatorVerifiedCountShared = false; + // Accumulate churn only if the churn limit has been hit. if (isChurnLimitReached) { state.depositBalanceToConsume = availableForProcessing - BigInt(processedAmount);