Skip to content
Open
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
16 changes: 15 additions & 1 deletion packages/beacon-node/src/api/impl/beacon/pool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,21 @@ export function getBeaconPoolApi({
},

async submitPoolVoluntaryExit({signedVoluntaryExit}) {
await validateApiVoluntaryExit(chain, signedVoluntaryExit);
const result = await validateApiVoluntaryExit(chain, signedVoluntaryExit);

if (result.status === "deferred") {
const currentEpoch = chain.clock.currentEpoch;
const inserted = chain.deferredVoluntaryExitPool.insert(signedVoluntaryExit, result.validity, currentEpoch);
if (!inserted) {
throw new ApiError(400, "Deferred voluntary exit pool is full or already contains this validator");
}
logger.info("Voluntary exit deferred until transient conditions are met", {
validatorIndex: signedVoluntaryExit.message.validatorIndex,
reason: result.validity,
});
return;
}

chain.opPool.insertVoluntaryExit(signedVoluntaryExit);
chain.emitter.emit(routes.events.EventType.voluntaryExit, signedVoluntaryExit);
await network.publishVoluntaryExit(signedVoluntaryExit);
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {LightClientServer} from "./lightClient/index.js";
import {
AggregatedAttestationPool,
AttestationPool,
DeferredVoluntaryExitPool,
ExecutionPayloadBidPool,
OpPool,
PayloadAttestationPool,
Expand Down Expand Up @@ -180,6 +181,7 @@ export class BeaconChain implements IBeaconChain {
readonly executionPayloadBidPool: ExecutionPayloadBidPool;
readonly payloadAttestationPool: PayloadAttestationPool;
readonly opPool: OpPool;
readonly deferredVoluntaryExitPool: DeferredVoluntaryExitPool;

// Gossip seen cache
readonly seenAttesters = new SeenAttesters();
Expand Down Expand Up @@ -306,6 +308,7 @@ export class BeaconChain implements IBeaconChain {
this.executionPayloadBidPool = new ExecutionPayloadBidPool();
this.payloadAttestationPool = new PayloadAttestationPool(config, clock, metrics);
this.opPool = new OpPool(config);
this.deferredVoluntaryExitPool = new DeferredVoluntaryExitPool(logger);

this.seenAggregatedAttestations = new SeenAggregatedAttestations(metrics);
this.seenContributionAndProof = new SeenContributionAndProof(metrics);
Expand Down Expand Up @@ -1406,6 +1409,7 @@ export class BeaconChain implements IBeaconChain {
// aggregatedAttestationPool tracks metrics on its own
metrics.opPool.attestationPool.size.set(this.attestationPool.getAttestationCount());
metrics.opPool.attesterSlashingPoolSize.set(this.opPool.attesterSlashingsSize);
metrics.opPool.deferredVoluntaryExitPool.size.set(this.deferredVoluntaryExitPool.size());
metrics.opPool.proposerSlashingPoolSize.set(this.opPool.proposerSlashingsSize);
metrics.opPool.voluntaryExitPoolSize.set(this.opPool.voluntaryExitsSize);
metrics.opPool.syncCommitteeMessagePoolSize.set(this.syncCommitteeMessagePool.size);
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {LightClientServer} from "./lightClient/index.js";
import {AggregatedAttestationPool} from "./opPools/aggregatedAttestationPool.js";
import {
AttestationPool,
DeferredVoluntaryExitPool,
ExecutionPayloadBidPool,
OpPool,
PayloadAttestationPool,
Expand Down Expand Up @@ -125,6 +126,7 @@ export interface IBeaconChain {
readonly executionPayloadBidPool: ExecutionPayloadBidPool;
readonly payloadAttestationPool: PayloadAttestationPool;
readonly opPool: OpPool;
readonly deferredVoluntaryExitPool: DeferredVoluntaryExitPool;

// Gossip seen cache
readonly seenAttesters: SeenAttesters;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Logger} from "@lodestar/logger";
import {IBeaconStateView, VoluntaryExitValidity, isTransientExitValidity} from "@lodestar/state-transition";
import {Epoch, ValidatorIndex} from "@lodestar/types";
import {SignedVoluntaryExit} from "@lodestar/types/phase0";

type DeferredEntry = {
exit: SignedVoluntaryExit;
validity: VoluntaryExitValidity;
insertedAtEpoch: Epoch;
};

export class DeferredVoluntaryExitPool {
private pool = new Map<ValidatorIndex, DeferredEntry>();

constructor(
private readonly logger: Logger,
private readonly maxSize = 1024,
private readonly maxDeferEpochs = 256
) {}

insert(exit: SignedVoluntaryExit, validity: VoluntaryExitValidity, currentEpoch: Epoch): boolean {
if (!isTransientExitValidity(validity)) return false;
if (this.pool.size === this.maxSize) return false;
if (this.pool.has(exit.message.validatorIndex)) return false;

this.pool.set(exit.message.validatorIndex, {exit, validity, insertedAtEpoch: currentEpoch});

return true;
}

retrieveProcessableExits(state: IBeaconStateView): SignedVoluntaryExit[] {
const epoch = state.epoch;
const validExits: SignedVoluntaryExit[] = [];
for (const [validatorIndex, entry] of this.pool) {
try {
if (epoch - entry.insertedAtEpoch > this.maxDeferEpochs) {
this.pool.delete(validatorIndex);
continue;
}
const validity = state.getVoluntaryExitValidity(entry.exit, false);
if (validity === VoluntaryExitValidity.valid) {
validExits.push(entry.exit);
Comment on lines +40 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Revalidate deferred exit signatures before publishing

Deferred exits are promoted with state.getVoluntaryExitValidity(entry.exit, false), which skips signature checks, even though those signatures were verified only at initial submission time. On pre-Deneb networks, a deferred exit can cross a fork boundary where the voluntary-exit signing domain changes; the signature that was valid at enqueue time can become invalid later. Because this path then inserts the exit into opPool without re-verifying against the current state, the node can propagate/include an invalid voluntary exit and risk producing an invalid block.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This does not apply for the future forks as all post-Deneb forks have a fixed Capella domain for voluntary exits which means that the exit will always remain valid.
Source: fn getDomainForVoluntaryExit

this.pool.delete(validatorIndex);
} else if (!isTransientExitValidity(validity)) {
this.pool.delete(validatorIndex);
}
// Else if still transient - keep
} catch (e) {
this.logger.debug("Processing deferred voluntary exit failed", {validatorIndex}, e as Error);
}
Comment thread
markolazic01 marked this conversation as resolved.
}
return validExits;
}

size(): number {
return this.pool.size;
}
}
1 change: 1 addition & 0 deletions packages/beacon-node/src/chain/opPools/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {AggregatedAttestationPool} from "./aggregatedAttestationPool.js";
export {AttestationPool} from "./attestationPool.js";
export {DeferredVoluntaryExitPool} from "./deferredVoluntaryExitPool.js";
export {ExecutionPayloadBidPool} from "./executionPayloadBidPool.js";
export {OpPool} from "./opPool.js";
export {PayloadAttestationPool} from "./payloadAttestationPool.js";
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/regen/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export enum RegenCaller {
validateGossipAggregateAndProof = "validateGossipAggregateAndProof",
validateGossipAttestation = "validateGossipAttestation",
validateGossipVoluntaryExit = "validateGossipVoluntaryExit",
validateApiVoluntaryExit = "validateApiVoluntaryExit",
publishDeferredVoluntaryExits = "publishDeferredVoluntaryExits",
validateGossipExecutionPayloadBid = "validateGossipExecutionPayloadBid",
validateGossipProposerPreferences = "validateGossipProposerPreferences",
onForkChoiceFinalized = "onForkChoiceFinalized",
Expand Down
44 changes: 41 additions & 3 deletions packages/beacon-node/src/chain/validation/voluntaryExit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {VoluntaryExitValidity, getVoluntaryExitSignatureSet} from "@lodestar/state-transition";
import {VoluntaryExitValidity, getVoluntaryExitSignatureSet, isTransientExitValidity} from "@lodestar/state-transition";
import {phase0} from "@lodestar/types";
import {
GossipAction,
Expand All @@ -9,12 +9,50 @@ import {
import {IBeaconChain} from "../index.js";
import {RegenCaller} from "../regen/index.js";

export type ApiVoluntaryExitResult = {status: "published"} | {status: "deferred"; validity: VoluntaryExitValidity};

// Comments for each call are present inside `validateVoluntaryExit`.
export async function validateApiVoluntaryExit(
chain: IBeaconChain,
voluntaryExit: phase0.SignedVoluntaryExit
): Promise<void> {
): Promise<ApiVoluntaryExitResult> {
const prioritizeBls = true;
return validateVoluntaryExit(chain, voluntaryExit, prioritizeBls);

if (chain.opPool.hasSeenVoluntaryExit(voluntaryExit.message.validatorIndex)) {
throw new VoluntaryExitError(GossipAction.IGNORE, {
code: VoluntaryExitErrorCode.ALREADY_EXISTS,
});
}

const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateApiVoluntaryExit);

if (voluntaryExit.message.validatorIndex >= state.validatorCount) {
throw new VoluntaryExitError(GossipAction.REJECT, {
code: VoluntaryExitErrorCode.INACTIVE,
});
}

const validity = state.getVoluntaryExitValidity(voluntaryExit, false);

if (validity !== VoluntaryExitValidity.valid && !isTransientExitValidity(validity)) {
throw new VoluntaryExitError(GossipAction.REJECT, {
code: voluntaryExitValidityToErrorCode(validity),
});
}

const signatureSet = getVoluntaryExitSignatureSet(chain.config, state, voluntaryExit);
if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) {
Comment on lines +43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject unknown-validator early exits instead of throwing 500

This path now verifies signatures for transient validity results, but earlyEpoch is computed before checking whether the validator index exists. A request with a future epoch and a non-existent validator index therefore reaches signature verification, where pubkey lookup throws (getOrThrow) rather than returning a typed validation error. That exception is not converted to an ApiError, so the REST layer returns a 500 instead of a client-facing 4xx rejection for malformed input.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added an explicit validator existence check before the signature verification for a cleaner fail, but this exception not being converted to 4xx seems like a preexisting issue. It is probably worth fixing but out of scope for this PR.

throw new VoluntaryExitError(GossipAction.REJECT, {
code: VoluntaryExitErrorCode.INVALID_SIGNATURE,
});
}

if (validity !== VoluntaryExitValidity.valid) {
// Transient failure — signature is good, defer
return {status: "deferred", validity};
}

return {status: "published"};
}
Comment on lines 15 to 56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The logic in validateApiVoluntaryExit significantly duplicates validateVoluntaryExit. Consider refactoring these into a shared helper function or making validateVoluntaryExit return a result object that includes the validity status, which would improve maintainability and ensure consistency in validation logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I would prefer to keep these 2 paths distinct as the flow differs. Even though they seem similar it is probably cleanest to keep the things as they are. I am happy to reconsider the change if reviewers require so.


export async function validateGossipVoluntaryExit(
Expand Down
6 changes: 6 additions & 0 deletions packages/beacon-node/src/metrics/metrics/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,12 @@ export function createLodestarMetrics(
name: "lodestar_oppool_attester_slashing_pool_size",
help: "Current size of the AttesterSlashingPool",
}),
deferredVoluntaryExitPool: {
size: register.gauge({
name: "lodestar_oppool_deferred_voluntary_exit_pool_size",
help: "Current size of the DeferredVoluntaryExitPool",
}),
},
proposerSlashingPoolSize: register.gauge({
name: "lodestar_oppool_proposer_slashing_pool_size",
help: "Current size of the ProposerSlashingPool",
Expand Down
29 changes: 28 additions & 1 deletion packages/beacon-node/src/node/nodejs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {setMaxListeners} from "node:events";
import {PrivateKey} from "@libp2p/interface";
import {Registry} from "prom-client";
import {hasher} from "@chainsafe/persistent-merkle-tree";
import {routes} from "@lodestar/api";
import {BeaconApiMethods} from "@lodestar/api/beacon/server";
import {BeaconConfig} from "@lodestar/config";
import type {LoggerNode} from "@lodestar/logger/node";
Expand All @@ -12,6 +13,7 @@ import {sleep, toRootHex} from "@lodestar/utils";
import {ProcessShutdownCallback} from "@lodestar/validator";
import {BeaconRestApiServer, getApi} from "../api/index.js";
import {BeaconChain, IBeaconChain, initBeaconMetrics} from "../chain/index.js";
import {RegenCaller} from "../chain/regen/index.js";
import {ValidatorMonitor, createValidatorMonitor} from "../chain/validatorMonitor.js";
import {IBeaconDb} from "../db/index.js";
import {initializeExecutionBuilder, initializeExecutionEngine} from "../execution/index.js";
Expand All @@ -20,7 +22,7 @@ import {MonitoringService} from "../monitoring/index.js";
import {Network, getReqRespHandlers} from "../network/index.js";
import {BackfillSync} from "../sync/backfill/index.js";
import {BeaconSync, IBeaconSync} from "../sync/index.js";
import {Clock} from "../util/clock.js";
import {Clock, ClockEvent} from "../util/clock.js";
import {runNodeNotifier} from "./notifier.js";
import {IBeaconNodeOptions} from "./options.js";

Expand Down Expand Up @@ -334,6 +336,31 @@ export class BeaconNode {

void runNodeNotifier({network, chain, sync, config, logger, signal});

chain.clock.addListener(ClockEvent.epoch, async () => {
try {
const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.publishDeferredVoluntaryExits);
const exits = chain.deferredVoluntaryExitPool.retrieveProcessableExits(state);
for (const exit of exits) {
try {
chain.opPool.insertVoluntaryExit(exit);
chain.emitter.emit(routes.events.EventType.voluntaryExit, exit);
await network.publishVoluntaryExit(exit);
Comment thread
markolazic01 marked this conversation as resolved.
logger.info("Voluntary exit successfully published for validator", {
validatorIndex: exit.message.validatorIndex,
});
} catch (e) {
logger.warn(
"Failed to publish deferred voluntary exit",
{validatorIndex: exit.message.validatorIndex},
e as Error
);
}
}
} catch (e) {
logger.warn("Failed to drain deferred voluntary exit pool", {}, e as Error);
}
});

return new BeaconNode({
opts,
config,
Expand Down
Loading