From fa3ce8dc1ca59bc5bb54945fe7e2cc31af2cae37 Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Tue, 5 May 2026 13:20:21 +0800 Subject: [PATCH 1/6] refactor: enhance pruning logic in SeenPayloadEnvelopeInput - Updated the `pruneBelowParent` method to improve the deletion of payload inputs. Now, it checks if the cached FULL variant is an ancestor of the current parent block before evicting entries, ensuring only relevant inputs are removed. (according to issue #9318) --- .../seenCache/seenPayloadEnvelopeInput.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index f5e20d1fbe3e..29bd0b821900 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {CheckpointWithHex, IForkChoice, ProtoBlock} from "@lodestar/fork-choice"; +import {CheckpointWithHex, IForkChoice, PayloadStatus, ProtoBlock} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; @@ -136,18 +136,30 @@ export class SeenPayloadEnvelopeInput { } pruneBelowParent(parentBlock: ProtoBlock): void { - for (const block of this.forkChoice.getAllAncestorBlocks(parentBlock.blockRoot, parentBlock.payloadStatus)) { - if (block.slot < parentBlock.slot) { - const input = this.payloadInputs.get(block.blockRoot); - if (input) { - this.evictPayloadInput(input); - this.logger?.verbose("SeenPayloadEnvelopeInput.pruneBelowParent deleted", { - slot: block.slot, - root: block.blockRoot, - }); - } + let deletedCount = 0; + for (const input of this.payloadInputs.values()) { + if ( + input.slot < parentBlock.slot && + // Check if the cached FULL cariant is an ancestor of the current parent block + this.forkChoice.isDescendant( + input.blockRootHex, + PayloadStatus.FULL, + parentBlock.blockRoot, + parentBlock.payloadStatus + ) + ) { + this.evictPayloadInput(input); + deletedCount++; } } + + if (deletedCount > 0) { + this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelowParent deleted entries", { + parentSlot: parentBlock.slot, + parentRoot: parentBlock.blockRoot, + deletedCount, + }); + } } private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { From 3068d8ddce8e52a253bfc578d0d897bbd7f7b242 Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Tue, 5 May 2026 13:20:35 +0800 Subject: [PATCH 2/6] test: update SeenPayloadEnvelopeInput tests for pruning logic - Modified tests to reflect changes in the `pruneBelowParent` method, ensuring it correctly handles ancestor checks using `isDescendant`. Added a new test case to verify that non-ancestor entries on forks remain intact during pruning. --- .../seenPayloadEnvelopeInput.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts index 3623a936ace9..64a50a5550d4 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -21,7 +21,7 @@ describe("SeenPayloadEnvelopeInput", () => { chainEvents = new ChainEventEmitter(); abortController = new AbortController(); forkChoice = { - getAllAncestorBlocks: vi.fn(), + isDescendant: vi.fn().mockReturnValue(false), } as unknown as IForkChoice; serializedCache = new SerializedCache(); @@ -79,7 +79,8 @@ describe("SeenPayloadEnvelopeInput", () => { const newRootHex = addPayloadInput(2); const parentBlock = protoBlock(newRootHex, 2); - vi.mocked(forkChoice.getAllAncestorBlocks).mockReturnValue([parentBlock, protoBlock(oldRootHex, 1)]); + // Only the older entries are ancestors + vi.mocked(forkChoice.isDescendant).mockImplementation((ancestorRoot) => ancestorRoot === oldRootHex); cache.pruneBelowParent(parentBlock); expect(cache.get(oldRootHex)).toBeUndefined(); @@ -90,12 +91,24 @@ describe("SeenPayloadEnvelopeInput", () => { const rootHex = addPayloadInput(1); const parentBlock = protoBlock(rootHex, 1); - vi.mocked(forkChoice.getAllAncestorBlocks).mockReturnValue([parentBlock]); + vi.mocked(forkChoice.isDescendant).mockReturnValue(true); cache.pruneBelowParent(parentBlock); expect(cache.get(rootHex)).toBeDefined(); }); + it("pruneBelowParent leaves non-ancestor entries on forks alone", () => { + const forkRootHex = addPayloadInput(1); + const headRootHex = addPayloadInput(2); + const parentBlock = protoBlock(headRootHex, 2); + + vi.mocked(forkChoice.isDescendant).mockReturnValue(false); + cache.pruneBelowParent(parentBlock); + + expect(cache.get(forkRootHex)).toBeDefined(); + expect(cache.get(headRootHex)).toBeDefined(); + }); + it("add returns the existing entry on duplicate root", () => { const {block, rootHex} = generateBlock({forkName: ForkName.gloas, slot: 1}); const props = { From e5c4f3a0adde0a5f86e3621cb3fdfdd67175a98a Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Tue, 5 May 2026 17:55:08 +0800 Subject: [PATCH 3/6] fix: comment typo --- .../beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index 29bd0b821900..de591e93f26d 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -140,7 +140,7 @@ export class SeenPayloadEnvelopeInput { for (const input of this.payloadInputs.values()) { if ( input.slot < parentBlock.slot && - // Check if the cached FULL cariant is an ancestor of the current parent block + // Check if the cached FULL variant is an ancestor of the current parent block this.forkChoice.isDescendant( input.blockRootHex, PayloadStatus.FULL, From 6d4fe72c83cb08aa7883d8cc15c7a68774a7624a Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Thu, 14 May 2026 18:04:42 +0800 Subject: [PATCH 4/6] fix: prune both PENDING and FULL payload envelope entries --- .../beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index de591e93f26d..b54fe79df476 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -143,7 +143,7 @@ export class SeenPayloadEnvelopeInput { // Check if the cached FULL variant is an ancestor of the current parent block this.forkChoice.isDescendant( input.blockRootHex, - PayloadStatus.FULL, + input.hasPayloadEnvelope() ? PayloadStatus.FULL : PayloadStatus.PENDING, parentBlock.blockRoot, parentBlock.payloadStatus ) From 956e829914e28bc36cac409c445b8773194fe5e2 Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Thu, 14 May 2026 18:07:53 +0800 Subject: [PATCH 5/6] fix: put the log inside the loop --- .../chain/seenCache/seenPayloadEnvelopeInput.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index b54fe79df476..5eadd63b8927 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -136,11 +136,10 @@ export class SeenPayloadEnvelopeInput { } pruneBelowParent(parentBlock: ProtoBlock): void { - let deletedCount = 0; for (const input of this.payloadInputs.values()) { if ( input.slot < parentBlock.slot && - // Check if the cached FULL variant is an ancestor of the current parent block + // Check if the cached PENDING/FULL variant is an ancestor of the current parent block this.forkChoice.isDescendant( input.blockRootHex, input.hasPayloadEnvelope() ? PayloadStatus.FULL : PayloadStatus.PENDING, @@ -149,17 +148,12 @@ export class SeenPayloadEnvelopeInput { ) ) { this.evictPayloadInput(input); - deletedCount++; + this.logger?.verbose("SeenPayloadEnvelopeInput.pruneBelowParent deleted", { + slot: input.slot, + root: input.blockRootHex, + }); } } - - if (deletedCount > 0) { - this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelowParent deleted entries", { - parentSlot: parentBlock.slot, - parentRoot: parentBlock.blockRoot, - deletedCount, - }); - } } private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { From d0bbbfa6e5e217575994834c2fe9153bc87b89a6 Mon Sep 17 00:00:00 2001 From: Jeff Chung Date: Thu, 14 May 2026 18:16:50 +0800 Subject: [PATCH 6/6] test: add two tests for PENDING and FULL ancestry --- .../seenPayloadEnvelopeInput.test.ts | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts index 64a50a5550d4..4eb2f9230943 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenPayloadEnvelopeInput.test.ts @@ -50,7 +50,7 @@ describe("SeenPayloadEnvelopeInput", () => { return rootHex; } - function protoBlock(blockRoot: RootHex, slot: number): ProtoBlock { + function protoBlock(blockRoot: RootHex, slot: number, payloadStatus: PayloadStatus = PayloadStatus.FULL): ProtoBlock { return { slot, blockRoot, @@ -69,7 +69,7 @@ describe("SeenPayloadEnvelopeInput", () => { executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge, dataAvailabilityStatus: DataAvailabilityStatus.PreData, - payloadStatus: PayloadStatus.FULL, + payloadStatus, parentBlockHash: null, }; } @@ -109,6 +109,44 @@ describe("SeenPayloadEnvelopeInput", () => { expect(cache.get(headRootHex)).toBeDefined(); }); + it("pruneBelowParent checks PENDING ancestry when payload envelope is not cached", () => { + const oldRootHex = addPayloadInput(1); + const newRootHex = addPayloadInput(2); + const parentBlock = protoBlock(newRootHex, 2); + + vi.mocked(forkChoice.isDescendant).mockReturnValue(true); + cache.pruneBelowParent(parentBlock); + + expect(forkChoice.isDescendant).toHaveBeenCalledWith( + oldRootHex, + PayloadStatus.PENDING, + newRootHex, + PayloadStatus.FULL + ); + expect(cache.get(oldRootHex)).toBeUndefined(); + expect(cache.get(newRootHex)).toBeDefined(); + }); + + it("pruneBelowParent checks FULL ancestry when payload envelope is cached", () => { + const oldRootHex = addPayloadInput(1); + // biome-ignore lint/style/noNonNullAssertion: input was just added on the line above + vi.spyOn(cache.get(oldRootHex)!, "hasPayloadEnvelope").mockReturnValue(true); + const newRootHex = addPayloadInput(2); + const parentBlock = protoBlock(newRootHex, 2); + + vi.mocked(forkChoice.isDescendant).mockReturnValue(true); + cache.pruneBelowParent(parentBlock); + + expect(forkChoice.isDescendant).toHaveBeenCalledWith( + oldRootHex, + PayloadStatus.FULL, + newRootHex, + PayloadStatus.FULL + ); + expect(cache.get(oldRootHex)).toBeUndefined(); + expect(cache.get(newRootHex)).toBeDefined(); + }); + it("add returns the existing entry on duplicate root", () => { const {block, rootHex} = generateBlock({forkName: ForkName.gloas, slot: 1}); const props = {