feat: defer voluntary exits with transient validation failures#9216
feat: defer voluntary exits with transient validation failures#9216markolazic01 wants to merge 26 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a deferred voluntary exit pool to handle exits that are transiently invalid, such as those submitted too early. It includes a background process to re-validate and publish these exits every epoch. Feedback suggests integrating published exits into the local operation pool and event system, adding error handling to prevent log spam from persistent failures in the pool, and refactoring duplicated validation logic.
| 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); | ||
| 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}))) { | ||
| 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"}; | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Manual e2e succeeded, details in the description. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08725109b0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const validity = state.getVoluntaryExitValidity(entry.exit, false); | ||
| if (validity === VoluntaryExitValidity.valid) { | ||
| validExits.push(entry.exit); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
| const signatureSet = getVoluntaryExitSignatureSet(chain.config, state, voluntaryExit); | ||
| if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
lodekeeper
left a comment
There was a problem hiding this comment.
Multi-pass review (defender / wisdom / architect / bug-hunter / security / devil's-advocate). One High security finding, one functional bug, one architectural framing question that's worth a serious look before merging, and a longer list of style/DRY/durability cleanups.
Worth deciding before merge — is the deferred-pool the right shape?
The devil's-advocate pass came back RECONSIDER on the overall design: the motivating problem ("user submitted an exit at the wrong time") is a client-UX problem, and validateApiVoluntaryExit already computes the exact validity reason at rejection time. A structured 400/409 response body ({code: "shortTimeActive", validFromEpoch: N}) plus a retry loop in the validator client solves the user-facing case in ~30 LOC with no cross-fork state to maintain — versus ~440 LOC of in-memory pool, clock-epoch listener, new RegenCaller entries, and a state-transition export that has to be re-evaluated every fork.
Supporting evidence:
- No other CL (Lighthouse / Prysm / Teku / Nimbus) defers exits server-side —
POST /eth/v1/beacon/pool/voluntary_exitsis spec'd as a synchronous submit/broadcast endpoint. - Every transient validity already has a deterministic "valid-from" epoch (
exit.message.epoch,activation_epoch + SHARD_COMMITTEE_PERIOD, etc.) that's easy to ship back to the caller. - The 200 OK lies if the entry TTLs out (see inline) or the node restarts (no persistence), making the failure mode strictly worse than the pre-PR "rejected, retry tomorrow" behavior.
- The
inactivecarve-out is explicitly half-implemented — the most common "submitted too early" case (validator in activation queue) is excluded as a future follow-up, undermining the feature's own justification.
Not a hard block, but the trade is worth a conscious call. If the pool stays, the silent-TTL, no-persistence, and inactive-coverage points should be addressed.
Must-fix before merge
- 🔴 High — signature recheck bypass on promotion (
deferredVoluntaryExitPool.ts:40). Deferred exits are promoted intoopPooland gossip withgetVoluntaryExitValidity(exit, false)only. The fork-domain-aware BLS check is never re-run at the publication epoch, so an exit signed under one fork that becomes includable under the next can be gossiped as prevalidated. Re-verify the signature against the current state before publish. - 🐞 Bug — Gloas builder voluntary exits regress (
voluntaryExit.ts:29). The newvalidatorIndex >= state.validatorCountprecheck rejects every builder exit (builder indexes encode theBUILDER_INDEX_FLAG) beforegetVoluntaryExitValiditycan route them. This is a regression vs. the pre-PR shared validator path. - 🟡 Medium — duplicate-suppression gap on the deferred pool (
voluntaryExit.ts:21). The earlyhasSeenVoluntaryExit()check only consultsopPool. Repeated submissions of a transient exit each pay the full state-regen + BLS-verify cost until they hit thepool.has()check insideinsert(). Add a cheaphas()onDeferredVoluntaryExitPooland check it alongsideopPool.
Architecture / durability
Flagged inline below: drainer workflow should live in a dedicated beacon-node component (not inline in BeaconNode.init), isTransientExitValidity belongs in the beacon-node policy layer rather than @lodestar/state-transition, and the API + gossip validation paths should share a core helper instead of being parallel reimplementations.
Lower-priority cleanups (style / durability — non-blocking)
insert()returningbooleancollapses three distinct reject reasons into a misleading 400 message — a discriminated union ({status: "inserted"} | {status: "rejected"; reason: "pool_full" | "duplicate" | "not_transient"}) would let the caller produce precise responses and metrics.retrieveProcessableExitsreads as a query but mutates the pool —drainProcessableExits/takeProcessableExitswould surface the side-effect at call sites.TRANSIENT_EXIT_VALIDITYas aSetsilently returnsfalsefor any new fork-introduced variant. A switch withconst _exhaustive: never = vgives a compile-time tripwire for the next fork.- The two
publishVoluntaryExitsequences (API path + epoch drainer) are now identical 3-liners; apublishVoluntaryExit(chain, network, exit)helper keeps them from drifting. state.getVoluntaryExitValidity(exit, false)— the unexplained boolean shows up at two call sites; a named local (const checkSignature = false) or a paired helper makes intent obvious.- Test stub
as unknown as IBeaconStateViewdiscards typing — aPick<IBeaconStateView, "epoch" | "getVoluntaryExitValidity">retains compile-time safety.
Defender pass: no malicious patterns.
AI-assisted review (6-reviewer sweep, consolidated by @lodekeeper).
| this.pool.delete(validatorIndex); | ||
| continue; | ||
| } | ||
| const validity = state.getVoluntaryExitValidity(entry.exit, false); |
There was a problem hiding this comment.
🔴 High — BLS signature recheck is skipped on promotion.
getVoluntaryExitValidity(entry.exit, false) runs a validity-only check; the false here disables the BLS signature verification. Combined with the call site in nodejs.ts:343-347 (which inserts into opPool and gossips immediately on valid), this means:
- The signature was BLS-verified once, at submission time, against the submission-epoch fork domain in
validateApiVoluntaryExit. - When the exit becomes includable in a later epoch — potentially across a fork boundary (e.g.
earlyEpochexits withexit.message.epochpast a fork transition) — Lodestar gossips it and inserts it into the op pool as if it were prevalidated, without re-checking the signature under the current fork's domain.
Fix: before pushing to validExits, either re-run a full validation including getVoluntaryExitSignatureSet against the current state, or change this call to state.getVoluntaryExitValidity(entry.exit, true) (and drop entries whose signature now fails). The cost is a single BLS verify per promoted exit per epoch, which is negligible at expected volumes.
|
|
||
| const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateApiVoluntaryExit); | ||
|
|
||
| if (voluntaryExit.message.validatorIndex >= state.validatorCount) { |
There was a problem hiding this comment.
🐞 Bug — regresses Gloas builder voluntary exits.
This precheck rejects every exit with validatorIndex >= state.validatorCount as INACTIVE, but on Gloas builder exits the validatorIndex is built by convertBuilderIndexToValidatorIndex and carries BUILDER_INDEX_FLAG, so it's always far above the validator registry length even when the builder exists.
The pre-PR path delegated this decision to getVoluntaryExitValidity, which knows about isBuilderIndex and resolves builder exits through getPendingBalanceToWithdrawForBuilder / initiateBuilderExit. After this PR, valid Gloas builder exits submitted via the beacon pool API are rejected before reaching that fork-aware path.
Fix: drop the precheck and let state.getVoluntaryExitValidity handle out-of-range cases (it already returns VoluntaryExitValidity.inactive for genuinely unknown indexes), or carve out the builder-index range explicitly here.
| const prioritizeBls = true; | ||
| return validateVoluntaryExit(chain, voluntaryExit, prioritizeBls); | ||
|
|
||
| if (chain.opPool.hasSeenVoluntaryExit(voluntaryExit.message.validatorIndex)) { |
There was a problem hiding this comment.
🟡 Medium — duplicate-suppression gap creates a CPU amplification path.
This early-out only checks opPool. An exit already sitting in deferredVoluntaryExitPool is not in opPool, so a caller can resubmit the same signed payload repeatedly and each duplicate pays the full cost of getHeadStateAtCurrentEpoch regen + prioritized BLS verify before finally being rejected by deferredVoluntaryExitPool.insert()'s pool.has() check (currently pool.ts:24).
Fix: add a cheap has(validatorIndex): boolean to DeferredVoluntaryExitPool and check it here alongside hasSeenVoluntaryExit. Behaviorally mirror the existing duplicate-ignore path.
| for (const [validatorIndex, entry] of this.pool) { | ||
| try { | ||
| if (epoch - entry.insertedAtEpoch > this.maxDeferEpochs) { | ||
| this.pool.delete(validatorIndex); |
There was a problem hiding this comment.
Silent TTL drop + no persistence — the API contract is implicitly broken.
The HTTP caller got 200 OK on submit. After maxDeferEpochs (256 epochs ≈ 27h on mainnet) the entry is silently deleted here: no logger.warn, no event, no metric counter. On beacon-node restart the entire Map is gone (the pool is in-memory only; compare OpPool's db persistence). The user-facing failure mode becomes worse than the pre-PR "rejected, retry tomorrow" — they no longer know retry is needed.
Minimal fix if the pool stays:
logger.warn+ a metric counter (lodestar_deferred_voluntary_exits_dropped_total{reason="ttl"}) here.- Surface deferral as
202 Acceptedfrom the API with a body that namesvalidity+expiresAtEpoch, so callers can distinguish. - Persist to
db/keyed byValidatorIndexand rehydrate on startup — otherwise a singlesystemctl restartsilently drops user-submitted exits.
| private readonly maxDeferEpochs = 256 | ||
| ) {} | ||
|
|
||
| insert(exit: SignedVoluntaryExit, validity: VoluntaryExitValidity, currentEpoch: Epoch): boolean { |
There was a problem hiding this comment.
Boolean return collapses three distinct outcomes.
insert() returns false for !isTransientExitValidity (defense in depth — shouldn't be reachable from the API caller), pool.size === maxSize (overload), and pool.has(validatorIndex) (duplicate). The API caller translates all three into one 400 message ("Deferred voluntary exit pool is full or already contains this validator") that's wrong about the first case and silent about the third.
Suggested:
type InsertResult =
| {status: "inserted"}
| {status: "rejected"; reason: "pool_full" | "duplicate" | "not_transient"};The API layer can then return precise 4xx codes and per-reason metrics without inventing prose.
| return true; | ||
| } | ||
|
|
||
| retrieveProcessableExits(state: IBeaconStateView): SignedVoluntaryExit[] { |
There was a problem hiding this comment.
Naming: retrieve… reads as a query, but this mutates the pool.
This method deletes three categories of entries (valid, permanently-invalid, TTL-expired) as a side effect of being called. A reader of nodejs.ts:342 who sees chain.deferredVoluntaryExitPool.retrieveProcessableExits(state) has no signal that the pool shrinks.
Suggest renaming to drainProcessableExits (matches the outer catch's "Failed to drain deferred voluntary exit pool") or takeProcessableExits.
| // (transient), and cleanly classifying it requires splitting the enum variant | ||
| // upstream. Left for a future follow-up. | ||
|
|
||
| export function isTransientExitValidity(v: VoluntaryExitValidity): boolean { |
There was a problem hiding this comment.
Set-based classification silently misclassifies future fork additions.
If a future fork (gloas-followup, fulu, …) adds a new VoluntaryExitValidity variant, isTransientExitValidity returns false for it by default, and the deferred pool silently never accepts it — even when it should. Given Lodestar's fork cadence, this is exactly the kind of decision that needs a compile-time tripwire.
export function isTransientExitValidity(v: VoluntaryExitValidity): boolean {
switch (v) {
case VoluntaryExitValidity.earlyEpoch:
case VoluntaryExitValidity.shortTimeActive:
case VoluntaryExitValidity.pendingWithdrawals:
return true;
case VoluntaryExitValidity.valid:
case VoluntaryExitValidity.inactive:
case VoluntaryExitValidity.alreadyExited:
case VoluntaryExitValidity.invalidSignature:
return false;
}
const _exhaustive: never = v;
return _exhaustive;
}| // - earlyEpoch: exit.message.epoch is in the future; valid once current epoch catches up | ||
| // - shortTimeActive: validator active < SHARD_COMMITTEE_PERIOD; valid once enough time passes | ||
| // - pendingWithdrawals: Electra; valid once pending partial withdrawals drain | ||
| const TRANSIENT_EXIT_VALIDITY = new Set([ |
There was a problem hiding this comment.
Layer: deferral policy is beacon-node concern, not state-transition.
isTransientExitValidity / TRANSIENT_EXIT_VALIDITY are not consensus-spec functions — they encode Lodestar's mempool retry policy. Keep VoluntaryExitValidity + getVoluntaryExitValidity() here (those are spec-aligned), and move the transient classification into packages/beacon-node/src/chain/validation/voluntaryExit.ts or a dedicated policy helper.
Why: future fork edits to validity reasons are spec work; future tweaks to which reasons get deferred are operational. Mixing them expands the surface where state-transition has to be edited for non-spec reasons.
| VoluntaryExitValidity.shortTimeActive, | ||
| VoluntaryExitValidity.pendingWithdrawals, | ||
| ]); | ||
| // Note: VoluntaryExitValidity.inactive is intentionally excluded. It conflates |
There was a problem hiding this comment.
The inactive carve-out ships the most common UX case un-handled.
"I just deposited and tried to exit too early" — i.e. validator in activation queue, activation_epoch == FAR_FUTURE_EPOCH — is arguably the single most intuitive variant of "submitted exit at the wrong time", and is exactly what users would expect a deferral feature to cover. The other transient variants (earlyEpoch, shortTimeActive, pendingWithdrawals) are niche by comparison.
The split is structural — getVoluntaryExitValidity already has both pieces of information. Suggest splitting VoluntaryExitValidity.inactive into unknown (out-of-range — permanent) and notYetActivated (transient) inside this file, then including notYetActivated in TRANSIENT_EXIT_VALIDITY. Without that, the feature ships with inconsistent semantics relative to its own justification.
|
|
||
| void runNodeNotifier({network, chain, sync, config, logger, signal}); | ||
|
|
||
| chain.clock.addListener(ClockEvent.epoch, async () => { |
There was a problem hiding this comment.
Workflow lives in node assembly — extract to a dedicated component.
This 25-line block does op-pool insertion, event emission, gossip publish, and per-exit logging inline in BeaconNode.init, and pulls in routes, RegenCaller, and ClockEvent for the privilege. nodejs.ts is supposed to be a wiring/composition layer; this is business logic.
Suggest extracting to e.g. DeferredVoluntaryExitPublisher with explicit deps (chain, network, clock, logger, signal), called as one line from init — same pattern as runNodeNotifier. That also makes it independently testable (the current shape can only be tested through BeaconNode.init).
Bonus: the 3-line opPool.insert + emitter.emit + network.publish sequence here duplicates api/impl/beacon/pool/index.ts:230-232. A publishVoluntaryExit(chain, network, exit) helper would prevent drift.
lodekeeper
left a comment
There was a problem hiding this comment.
I think the core architectural question here should be narrowed. beacon-APIs does not define a deferred success state for POST /eth/v1/beacon/pool/voluntary_exits: 200 is described as stored and broadcasted, 400 as invalid, no 202/pending status. Lighthouse, Teku, and Nimbus reject currently gossip-invalid exits synchronously. Prysm is the notable exception — it accepts exits whose message.epoch is in the future by validating against that epoch and holding them in its normal voluntary-exit pool until inclusion — so I would not claim "no client ever holds these server-side." But I also do not see another client with a separate transient-failure pool that replays currently gossip-invalid exits each epoch.
I'm okay with Lodestar choosing the "submit once, node retries" UX, especially for one-shot CLI/staking-tool users and pendingWithdrawals. But then this needs to be a first-class deferred-operation contract, not a hidden branch of the submit API. Before merge I think we need the following (see inline comments for the critical ones):
- Re-run full validation including BLS/domain before promotion — deferred entries were signed at submission time but are published potentially epochs later, possibly under a different fork domain.
getVoluntaryExitValidity(entry.exit, false)withverifySignature=falseis not sufficient. - Cheap deferred-pool duplicate check before state regen/BLS — without it, repeated resubmission of any transient exit is a CPU amplification vector.
- Don't return
200 OKfor deferred exits — beacon-APIs says200means stored and broadcasted. For a deferred exit, neither is true yet. Prefer202 Acceptedwith a body like{status: "deferred", reason, expiresAtEpoch}, or if not extending beacon-APIs, at minimum document the Lodestar extension clearly. - Log TTL drops with at least
warn+ a metric counter — silent TTL expiry means operators find out their staker's exit was dropped by missing a validator ejection that never happened. - Either persist deferred exits across restart or document the in-memory/best-effort contract —
systemctl restartsilently drops everything queued. That turns "submit once and we retry" into a false promise. - Bug: Gloas builder exits are incorrectly rejected early — see inline comment on the
validatorIndex >= state.validatorCountpre-check.
Without (1)–(3), the feature makes the old explicit-failure behaviour strictly worse: the caller is told success even though the operation was neither broadcast nor durably retained.
| this.pool.delete(validatorIndex); | ||
| continue; | ||
| } | ||
| const validity = state.getVoluntaryExitValidity(entry.exit, false); |
There was a problem hiding this comment.
[Security — High] This calls getVoluntaryExitValidity(entry.exit, false) with verifySignature=false, meaning the BLS signature and fork-domain are never re-checked at promotion time. The initial BLS check was done against the head state at submission. If an exit was signed for earlyEpoch and submitted on fork A, by the time it becomes includable it may need to be valid under fork B's domain. Publishing it without re-verification can gossip an invalid-signature exit and poison hasSeenVoluntaryExit for that validator.
Before inserting into opPool and publishing, re-run getVoluntaryExitSignatureSet against the current state and verify the result, or call the full validateVoluntaryExit path. Drop entries that fail.
| for (const [validatorIndex, entry] of this.pool) { | ||
| try { | ||
| if (epoch - entry.insertedAtEpoch > this.maxDeferEpochs) { | ||
| this.pool.delete(validatorIndex); |
There was a problem hiding this comment.
Silent TTL drop — no logger.warn, no metric increment. The caller received 200 OK at submission time. When the entry ages out here, operators have no signal. At minimum add this.logger.warn("Deferred voluntary exit expired", {validatorIndex, lastValidity: entry.validity}) and a counter metric.
| }); | ||
| } | ||
|
|
||
| const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateApiVoluntaryExit); |
There was a problem hiding this comment.
[Security — Medium] chain.opPool.hasSeenVoluntaryExit() is checked above, but there is no check against chain.deferredVoluntaryExitPool here. A transient exit already sitting in the deferred pool is not in opPool, so the same signed exit can be resubmitted repeatedly. Each duplicate goes through state regen and BLS verification before DeferredVoluntaryExitPool.insert() rejects the duplicate on line 24 of the pool. This is a cheap CPU amplification vector for any caller with REST access.
Add chain.deferredVoluntaryExitPool.has(voluntaryExit.message.validatorIndex) immediately after the opPool check (and ideally expose a has() method on the pool if not already present).
|
|
||
| const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateApiVoluntaryExit); | ||
|
|
||
| if (voluntaryExit.message.validatorIndex >= state.validatorCount) { |
There was a problem hiding this comment.
[Bug — Gloas] On Gloas, builder voluntary exits encode the builder using convertBuilderIndexToValidatorIndex which sets BUILDER_INDEX_FLAG, making validatorIndex far above the validator registry length. This pre-check rejects all valid Gloas builder exits as INACTIVE before getVoluntaryExitValidity can perform the fork-aware builder lookup.
Either remove this pre-check and let getVoluntaryExitValidity handle the fork-aware validation, or skip it for builder-index values (i.e. >= BUILDER_INDEX_FLAG).
| validatorIndex: signedVoluntaryExit.message.validatorIndex, | ||
| reason: result.validity, | ||
| }); | ||
| return; |
There was a problem hiding this comment.
This returns the same void (HTTP 200 OK) as a successfully published exit, but the exit has been neither inserted into opPool nor gossiped. beacon-APIs specifies 200 as stored and broadcasted. The caller has no way to tell this succeeded immediately vs. was queued best-effort.
Consider returning 202 Accepted with a body like {status: "deferred", reason: result.validity, expiresAtEpoch: currentEpoch + maxDeferEpochs}. If extending beacon-APIs is out of scope for this PR, at minimum document this as a Lodestar-specific extension and track it as a follow-up beacon-APIs proposal.
Motivation
Feature that enables postponed execution of voluntary exits that are not initially processable (transiently invalid).
Description
Perception of voluntary exit validity is split into two categories: permanent and transient.
Transient (in)validity is the one that can become valid later on, and those exits we keep in the
deferredVoluntaryExitPool.They are kept until they become permanently invalid, valid or until the max defer window elapses.
Manual e2e test on local devnet suceeded with the following flow:
submitted a future-dated voluntary exit via the API, confirmed it was deferred (due to shortTimeActive), observed it publish automatically on the next eligible epoch, verified block inclusion and validator status transition to active_exiting.
Pool size metric added.
Closes #7431
AI Assistance Disclosure
Development of this feature was assisted by Claude, helped understanding the existing codebase patterns, reviewing code and consultations on ideas during the process.