Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions packages/beacon-node/src/chain/opPools/opPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,10 @@ export class OpPool {
const finalizedEpoch = headState.finalizedCheckpoint.epoch;

for (const [key, voluntaryExit] of this.voluntaryExits.entries()) {
// VoluntaryExit messages signed in the previous fork become invalid and can never be included in any future
// block, so just drop as the head state advances into the next fork.
if (this.config.getForkSeq(computeStartSlotAtEpoch(voluntaryExit.message.epoch)) < headStateFork) {
const voluntaryExitFork = this.config.getForkSeq(computeStartSlotAtEpoch(voluntaryExit.message.epoch));
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 to determine the fork sequence for a voluntary exit is repeated in multiple places (here and in getSlashingsAndExits). Extracting this into a private helper method would improve maintainability and ensure consistency across the pool's operations.

Suggested change
const voluntaryExitFork = this.config.getForkSeq(computeStartSlotAtEpoch(voluntaryExit.message.epoch));
const voluntaryExitFork = this.getVoluntaryExitFork(voluntaryExit);

if (!isVoluntaryExitSignatureIncludable(headStateFork, voluntaryExitFork)) {
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 Drop pre-Capella exits when entering Deneb+

Pruning now defers entirely to isVoluntaryExitSignatureIncludable(), but that helper returns true for every exit once stateFork >= deneb, so pre-Capella signatures can survive indefinitely until finalized. This conflicts with the actual signature domain rule (getDomainForVoluntaryExit in packages/config/src/genesisConfig/index.ts fixes Deneb+ voluntary-exit verification to the Capella domain), meaning exits that were valid in Bellatrix/Capella gossip can become invalid at Deneb while still being kept and later selected for block production (which skips re-verifying exit signatures). On short-gap/custom fork schedules or during prolonged non-finality, this can lead to proposing blocks with invalid voluntary exits.

Useful? React with 👍 / 👎.

this.voluntaryExits.delete(key);
continue;
}

// TODO: Improve this simplistic condition
Expand Down
71 changes: 71 additions & 0 deletions packages/beacon-node/test/unit/chain/opPools/opPool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {describe, expect, it} from "vitest";
import {createBeaconConfig, createChainForkConfig, defaultChainConfig} from "@lodestar/config";
import {BeaconStateView, computeStartSlotAtEpoch} from "@lodestar/state-transition";
import {phase0, ssz} from "@lodestar/types";
import {OpPool} from "../../../../src/chain/opPools/opPool.js";
import {createCachedBeaconStateTest} from "../../../utils/cachedBeaconState.js";
import {generateState} from "../../../utils/state.js";

const chainConfig = createChainForkConfig({
...defaultChainConfig,
ALTAIR_FORK_EPOCH: 1,
BELLATRIX_FORK_EPOCH: 2,
CAPELLA_FORK_EPOCH: 3,
DENEB_FORK_EPOCH: 4,
ELECTRA_FORK_EPOCH: 5,
});

describe("OpPool voluntary exits", () => {
it("keeps previous-fork exits that are still includable before deneb", () => {
const headSlot = computeStartSlotAtEpoch(chainConfig.BELLATRIX_FORK_EPOCH);
const {pool, headBlock, headState} = createPoolContext(headSlot);

pool.insertVoluntaryExit(createVoluntaryExit(chainConfig.ALTAIR_FORK_EPOCH));
pool.pruneAll(headBlock, headState);

expect(pool.getAllVoluntaryExits()).toHaveLength(1);
});

it("prunes exits whose signatures are no longer includable before deneb", () => {
const headSlot = computeStartSlotAtEpoch(chainConfig.BELLATRIX_FORK_EPOCH);
const {pool, headBlock, headState} = createPoolContext(headSlot);

pool.insertVoluntaryExit(createVoluntaryExit(0));
pool.pruneAll(headBlock, headState);

expect(pool.getAllVoluntaryExits()).toHaveLength(0);
});

it("keeps older-fork exits after deneb because signatures remain perpetually valid", () => {
const headSlot = computeStartSlotAtEpoch(chainConfig.ELECTRA_FORK_EPOCH);
const {pool, headBlock, headState} = createPoolContext(headSlot);

pool.insertVoluntaryExit(createVoluntaryExit(chainConfig.ALTAIR_FORK_EPOCH));
pool.pruneAll(headBlock, headState);

expect(pool.getAllVoluntaryExits()).toHaveLength(1);
});
});

function createPoolContext(headSlot: number) {
const state = generateState({slot: headSlot}, chainConfig);
const config = createBeaconConfig(chainConfig, state.genesisValidatorsRoot);
const headBlock = config.getForkTypes(headSlot).SignedBeaconBlock.defaultValue();
headBlock.message.slot = headSlot;

return {
pool: new OpPool(config),
headBlock,
headState: new BeaconStateView(createCachedBeaconStateTest(state, config)),
};
}

function createVoluntaryExit(epoch: number): phase0.SignedVoluntaryExit {
return {
...ssz.phase0.SignedVoluntaryExit.defaultValue(),
message: {
validatorIndex: 0,
epoch,
},
};
}