Skip to content

Commit 078e689

Browse files
committed
fix: ensure block in forkchoice before validate by_root payload (#9479)
1 parent dbf7c78 commit 078e689

2 files changed

Lines changed: 130 additions & 21 deletions

File tree

packages/beacon-node/src/sync/unknownBlock.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -850,25 +850,28 @@ export class BlockInputSync {
850850
}
851851

852852
const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex);
853-
if (!payloadInput) {
854-
if (!this.chain.forkChoice.hasBlockHex(rootHex)) {
855-
// Column commitments live on the block body, so an envelope-only entry has to pull the block first.
856-
if (!this.pendingBlocks.has(rootHex)) {
857-
this.addByRootHex(rootHex);
858-
}
853+
if (!this.chain.forkChoice.hasBlockHex(rootHex)) {
854+
// Block not in fork choice yet. payloadInput may be seeded from the block body during download, so a
855+
// non-null payloadInput does not imply the block is imported; defer regardless and pull the block first.
856+
// onBlockImported re-triggers the search to resume this envelope.
857+
if (!this.pendingBlocks.has(rootHex)) {
858+
this.addByRootHex(rootHex);
859+
}
859860

860-
const pendingBlock = this.pendingBlocks.get(rootHex);
861-
if (pendingBlock && this.network.getConnectedPeers().length > 0) {
862-
await this.downloadBlock(pendingBlock);
863-
}
864-
} else {
865-
this.logger.debug("Missing PayloadEnvelopeInput for known block while reconciling payload envelope", {
866-
root: rootHex,
867-
});
861+
const pendingBlock = this.pendingBlocks.get(rootHex);
862+
if (pendingBlock && this.network.getConnectedPeers().length > 0) {
863+
await this.downloadBlock(pendingBlock);
868864
}
869865
return;
870866
}
871867

868+
if (!payloadInput) {
869+
this.logger.debug("Missing PayloadEnvelopeInput for known block while reconciling payload envelope", {
870+
root: rootHex,
871+
});
872+
return;
873+
}
874+
872875
if (!payloadInput.hasPayloadEnvelope()) {
873876
const validationResult = await wrapError(
874877
validateGossipExecutionPayloadEnvelope(this.chain, pendingPayload.envelope)
@@ -1073,11 +1076,11 @@ export class BlockInputSync {
10731076
}
10741077

10751078
payloadInput ??= this.chain.seenPayloadEnvelopeInputCache.get(rootHex);
1076-
if (!payloadInput) {
1077-
if (this.chain.forkChoice.hasBlockHex(rootHex)) {
1078-
throw new Error(`Missing PayloadEnvelopeInput for known block ${rootHex}`);
1079-
}
1080-
// Keep the validated envelope around, but wait for the block body before turning it into a full payload input.
1079+
if (!this.chain.forkChoice.hasBlockHex(rootHex)) {
1080+
// Block not in fork choice yet. Validating now would throw BLOCK_ROOT_UNKNOWN, so keep the downloaded
1081+
// envelope and wait for the block body; reconcilePayloadEnvelope validates once the block lands.
1082+
// payloadInput may be seeded from the block body during download, so a non-null payloadInput does not
1083+
// imply the block is imported.
10811084
return {
10821085
status: PendingPayloadInputStatus.waitingForBlock,
10831086
envelope,
@@ -1086,6 +1089,11 @@ export class BlockInputSync {
10861089
};
10871090
}
10881091

1092+
if (!payloadInput) {
1093+
// Block is in fork choice but no PayloadEnvelopeInput exists, should have been created during block import.
1094+
throw new Error(`Missing PayloadEnvelopeInput for known block ${rootHex}`);
1095+
}
1096+
10891097
if (!payloadInput.hasPayloadEnvelope()) {
10901098
await validateGossipExecutionPayloadEnvelope(this.chain, envelope);
10911099
}

packages/beacon-node/test/unit/sync/unknownBlock.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -722,8 +722,8 @@ describe("UnknownBlockSync", () => {
722722

723723
beforeEach(() => {
724724
vi.useFakeTimers({shouldAdvanceTime: true});
725-
vi.mocked(validateGossipExecutionPayloadEnvelope).mockClear();
726-
vi.mocked(validateGloasBlockDataColumnSidecars).mockClear();
725+
vi.mocked(validateGossipExecutionPayloadEnvelope).mockReset().mockResolvedValue(undefined);
726+
vi.mocked(validateGloasBlockDataColumnSidecars).mockReset().mockResolvedValue(undefined);
727727
});
728728

729729
it("fetches and processes unknown envelope by root when payload input exists", async () => {
@@ -938,6 +938,107 @@ describe("UnknownBlockSync", () => {
938938
expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput);
939939
});
940940

941+
it("defers envelope validation until the block is in fork choice when payload input is seeded from the block body", async () => {
942+
const peer = await getRandPeerIdStr();
943+
const {block, blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({
944+
blobCount: 0,
945+
sampledColumns: [],
946+
slot: 1,
947+
});
948+
const parentRootHex = toRootHex(block.message.parentRoot);
949+
950+
// payloadInput is seeded from the block body during download, so the cache returns it before the block
951+
// is imported into fork choice. Block becomes known only once processBlock imports it.
952+
let blockImported = false;
953+
const knownRoots = new Set([parentRootHex]);
954+
955+
const sendExecutionPayloadEnvelopesByRoot = vi.fn().mockResolvedValue([envelope]);
956+
const sendBeaconBlocksByRoot = vi.fn().mockResolvedValue([block]);
957+
const processExecutionPayload = vi.fn().mockResolvedValue(undefined);
958+
959+
let emitter!: ChainEventEmitter;
960+
const processBlock = vi.fn().mockImplementation(async () => {
961+
blockImported = true;
962+
knownRoots.add(blockRootHex);
963+
emitter.emit(routes.events.EventType.block, {slot: 1, block: blockRootHex, executionOptimistic: false});
964+
});
965+
966+
// Reproduce BLOCK_ROOT_UNKNOWN: validation rejects while the block is absent from fork choice. The fix must
967+
// not call it until the block is imported.
968+
vi.mocked(validateGossipExecutionPayloadEnvelope).mockImplementation(async () => {
969+
if (!blockImported) {
970+
throw new Error("EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_ROOT_UNKNOWN");
971+
}
972+
});
973+
974+
({emitter} = setupPayloadSyncTest({
975+
chainOverrides: {
976+
processBlock,
977+
processExecutionPayload,
978+
seenPayloadEnvelopeInputCache: {
979+
add: vi.fn(),
980+
get: vi.fn().mockImplementation((root: string) => (root === blockRootHex ? payloadInput : undefined)),
981+
prune: vi.fn(),
982+
} as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"],
983+
seenBlockInputCache: {
984+
getByBlock: ({
985+
block,
986+
blockRootHex,
987+
seenTimestampSec,
988+
source,
989+
}: {
990+
block: gloas.SignedBeaconBlock;
991+
blockRootHex: string;
992+
seenTimestampSec: number;
993+
source: BlockInputSource;
994+
}) =>
995+
createGloasBlockInput({
996+
block,
997+
blockRootHex,
998+
seenTimestampSec,
999+
source,
1000+
}),
1001+
prune: vi.fn(),
1002+
} as unknown as SeenBlockInput,
1003+
forkChoice: {
1004+
hasPayloadHexUnsafe: vi.fn().mockReturnValue(false),
1005+
hasBlockHex: vi.fn().mockImplementation((root: string) => knownRoots.has(root)),
1006+
getBlockHexAndBlockHash: vi
1007+
.fn()
1008+
.mockImplementation((root: string, hash: string) =>
1009+
root === parentRootHex &&
1010+
hash === toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash)
1011+
? ({slot: 0} as ProtoBlock)
1012+
: null
1013+
),
1014+
getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock),
1015+
} as unknown as IForkChoice,
1016+
},
1017+
networkOverrides: {
1018+
sendExecutionPayloadEnvelopesByRoot,
1019+
sendBeaconBlocksByRoot,
1020+
},
1021+
peers: [{peerId: peer}],
1022+
}));
1023+
1024+
emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, {
1025+
rootHex: blockRootHex,
1026+
peer,
1027+
source: BlockInputSource.gossip,
1028+
});
1029+
1030+
await sleep(80);
1031+
1032+
// Envelope downloaded, block pulled because validation was deferred, then envelope validated and processed
1033+
// only after the block landed in fork choice.
1034+
expect(sendExecutionPayloadEnvelopesByRoot).toHaveBeenCalledWith(peer, [blockRoot]);
1035+
expect(sendBeaconBlocksByRoot).toHaveBeenCalledWith(peer, [blockRoot]);
1036+
expect(processBlock).toHaveBeenCalledTimes(1);
1037+
expect(validateGossipExecutionPayloadEnvelope).toHaveBeenCalledOnce();
1038+
expect(processExecutionPayload).toHaveBeenCalledTimes(1);
1039+
expect(processExecutionPayload).toHaveBeenCalledWith(payloadInput);
1040+
});
1041+
9411042
it("downloads the block and retries payload import when EL reports block not in fork choice", async () => {
9421043
const peer = await getRandPeerIdStr();
9431044
const {block, blockRoot, blockRootHex, payloadInput, envelope} = buildPayloadFixture({

0 commit comments

Comments
 (0)