From b6fda77e14e1dcc64db9dc14f8be265302be5c2f Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 12 Sep 2026 15:30:45 +0100 Subject: [PATCH] fix: preserve gossip arrival time of repeat proposals for PTC timeliness --- .../src/chain/blocks/importBlock.ts | 10 +- .../beacon-node/src/chain/blocks/index.ts | 1 + .../beacon-node/src/chain/blocks/types.ts | 4 + .../src/chain/seenCache/seenBlockProposers.ts | 32 +++++ .../src/network/processor/gossipHandlers.ts | 17 ++- packages/beacon-node/src/sync/unknownBlock.ts | 1 + .../unit/chain/forkChoice/forkChoice.test.ts | 40 ++++++ .../seenCache/seenBlockProposers.test.ts | 37 ++++++ .../network/processor/gossipHandlers.test.ts | 34 +++++- .../test/unit/sync/unknownBlock.test.ts | 115 +++++++++++++++++- .../fork-choice/src/forkChoice/forkChoice.ts | 5 +- .../fork-choice/src/forkChoice/interface.ts | 4 +- 12 files changed, 283 insertions(+), 17 deletions(-) diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 43bcab6a3b14..69ce988ae0b8 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -104,8 +104,11 @@ export async function importBlock( const currentEpoch = computeEpochAtSlot(currentSlot); const blockEpoch = computeEpochAtSlot(blockSlot); const prevFinalizedEpoch = this.forkChoice.getFinalizedCheckpoint().epoch; - const receiveDelaySec = - fullyVerifiedBlock.seenTimestampSec - computeTimeAtSlot(this.config, blockSlot, postState.genesisTime); + const slotTimeSec = computeTimeAtSlot(this.config, blockSlot, postState.genesisTime); + const receiveDelaySec = fullyVerifiedBlock.seenTimestampSec - slotTimeSec; + // A repeat proposal ignored on gossip and imported later keeps its gossip arrival for PTC timeliness + const ptcReceiveDelaySec = + (fullyVerifiedBlock.firstSeenTimestampSec ?? fullyVerifiedBlock.seenTimestampSec) - slotTimeSec; const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); const fork = this.config.getForkSeq(blockSlot); @@ -149,7 +152,8 @@ export async function importBlock( importDelaySec, currentSlot, executionStatus, - dataAvailabilityStatus + dataAvailabilityStatus, + ptcReceiveDelaySec ); // This adds the state necessary to process the next block diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index f88e1e19473e..a485d8e502ce 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -148,6 +148,7 @@ export async function processBlocks( indexedAttestations: indexedAttestationsByBlock[i], // TODO: Make this param mandatory and capture in gossip seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), + firstSeenTimestampSec: opts.firstSeenTimestampSec, }); } diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 7a2fc07b205b..25cf81773ce7 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -88,6 +88,8 @@ export type ImportBlockOpts = { validBlobSidecars?: BlobSidecarValidation; /** Seen timestamp seconds */ seenTimestampSec?: number; + /** Seconds the block was first seen on gossip, if it was ignored there as a repeat proposal. Only affects PTC timeliness */ + firstSeenTimestampSec?: number; }; /** @@ -108,6 +110,8 @@ export type FullyVerifiedBlock = { indexedAttestations: IndexedAttestation[]; /** Seen timestamp seconds */ seenTimestampSec: number; + /** Seconds the block was first seen on gossip, if it was ignored there as a repeat proposal. Only affects PTC timeliness */ + firstSeenTimestampSec?: number; /** If the execution payload couldn't be verified because of EL syncing status, used in optimistic sync */ executionStatus: BlockExecutionStatus | PayloadExecutionStatus; }; diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index a64207631352..68d9d00afe99 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -4,6 +4,8 @@ import {MapDef} from "@lodestar/utils"; /** Two distinct block roots signed by the same proposer for the same slot are sufficient to establish an equivocation */ const MIN_EQUIVOCATION_BLOCK_ROOTS_PER_PROPOSAL = 2; +/** Far above what a node can deserialize within a slot, so a flood of repeat proposals cannot crowd out a real sibling */ +const MAX_FIRST_SEEN_BLOCK_ROOTS_PER_SLOT = 4096; /** * Keeps a cache to filter block proposals from the same validator in the same slot. @@ -16,6 +18,9 @@ const MIN_EQUIVOCATION_BLOCK_ROOTS_PER_PROPOSAL = 2; * The signed block header of each observed root is kept so a proposer slashing can be produced from the two * conflicting headers once an equivocation is established. * + * The time a repeat proposal was first seen on gossip is kept even if it is not imported at that point, so a later + * import through sync records its PTC timeliness from arrival rather than from the download. + * * The cache is pruned on finalization and bounds the number of roots stored per proposer and slot */ export class SeenBlockProposers { @@ -26,6 +31,9 @@ export class SeenBlockProposers { Slot, MapDef> >(() => new MapDef>(() => new Map())); + private readonly firstSeenTimestampSecBySlot = new MapDef>( + () => new Map() + ); private finalizedSlot: Slot = 0; isKnown(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { @@ -94,6 +102,25 @@ export class SeenBlockProposers { } } + /** Record when a block root was first seen on gossip, later sightings keep the first timestamp */ + observeFirstSeen(blockSlot: Slot, blockRoot: RootHex, seenTimestampSec: number): void { + if (blockSlot < this.finalizedSlot) { + return; + } + + const firstSeenTimestampSecByRoot = this.firstSeenTimestampSecBySlot.getOrDefault(blockSlot); + if ( + firstSeenTimestampSecByRoot.size < MAX_FIRST_SEEN_BLOCK_ROOTS_PER_SLOT && + !firstSeenTimestampSecByRoot.has(blockRoot) + ) { + firstSeenTimestampSecByRoot.set(blockRoot, seenTimestampSec); + } + } + + getFirstSeenTimestampSec(blockSlot: Slot, blockRoot: RootHex): number | undefined { + return this.firstSeenTimestampSecBySlot.get(blockSlot)?.get(blockRoot); + } + /** Mark a block as known from gossip or another block import path */ add(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { if (blockSlot < this.finalizedSlot) { @@ -115,6 +142,11 @@ export class SeenBlockProposers { this.signedBlockHeadersBySlot.delete(slot); } } + for (const slot of this.firstSeenTimestampSecBySlot.keys()) { + if (slot < finalizedSlot) { + this.firstSeenTimestampSecBySlot.delete(slot); + } + } } seenAtEpoch(epoch: Epoch, index: ValidatorIndex): boolean { diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 6dfd3fda6925..6f64ba3d849a 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -244,8 +244,20 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand // IGNORE means the block is acceptable (e.g. FUTURE_SLOT, ALREADY_KNOWN), just not propagated. // Keep the optimistically-added cache entries; they are pruned on finalization. Only REJECT - // (provably invalid) and unexpected errors prune below. + // (provably invalid), unexpected errors and repeat proposals that are not imported prune. if (e.action === GossipAction.IGNORE) { + if (e.type.code === BlockErrorCode.REPEAT_PROPOSAL) { + // Keep the arrival time so a sibling imported later through sync still gets its PTC timeliness from gossip + chain.seenBlockProposers.observeFirstSeen(slot, blockRootHex, seenTimestampSec); + // Only a signature-verified sibling is imported by the beacon_block handler, any other repeat proposal + // is dropped from the caches and re-downloaded by sync if it ever becomes relevant + if (!chain.seenBlockProposers.hasBlockRoot(slot, signedBlock.message.proposerIndex, blockRootHex)) { + chain.seenBlockInputCache.prune(blockRootHex); + if (isForkPostGloas(fork)) { + chain.seenPayloadEnvelopeInputCache.prune(blockRootHex); + } + } + } throw e; } @@ -738,7 +750,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand if ( e instanceof BlockGossipError && e.type.code === BlockErrorCode.REPEAT_PROPOSAL && - // this is make sure the block's proposer signature was verified, it should be true anyway + // Only a signature-verified sibling recorded in the seen cache is imported. The cache holds at most two + // roots per proposer and slot, which bounds full imports over gossip to one alternate chain.seenBlockProposers.hasBlockRoot(signedBlock.message.slot, e.type.proposerIndex, e.type.root) ) { // blockInput was optimistically seeded in validateBeaconBlock and retained on IGNORE diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 28b677a68128..b3817b56ef5c 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -929,6 +929,7 @@ export class BlockInputSync { // see https://github.com/ChainSafe/lodestar/issues/5650 ignoreIfFinalized: true, blsVerifyOnMainThread: true, + firstSeenTimestampSec: this.chain.seenBlockProposers.getFirstSeenTimestampSec(blockSlot, blockRootHex), }) ); diff --git a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts index ad51fdabdf53..a44a7a12884c 100644 --- a/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts +++ b/packages/beacon-node/test/unit/chain/forkChoice/forkChoice.test.ts @@ -163,6 +163,46 @@ describe("LodestarForkChoice", () => { /** * finalized - slot 8 (finalized 1) - slot 12 - slot 16 (finalized 2) - slot 20 - slot 24 (finalized 3) - slot 28 - slot 32 (finalized 4) */ + it("onBlock - evaluates PTC timeliness with the first seen delay when provided", () => { + const {blockHeader} = computeAnchorCheckpoint(config, anchorState); + const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + const block16 = generateSignedBlockAtSlot(16); + block16.message.parentRoot = finalizedRoot; + const state16 = runStateTransition(anchorStateView, block16); + block16.message.stateRoot = state16.hashTreeRoot(); + const {block: block17, state: state17} = makeChild({block: block16, state: state16}, 17); + + // Imported late in its slot but first seen on gossip well before the PTC deadline + const lateDelaySec = config.SECONDS_PER_SLOT - 1; + forkChoice.updateTime(16); + const summary16 = forkChoice.onBlock( + block16.message, + state16, + lateDelaySec, + lateDelaySec, + 16, + executionStatus, + dataAvailabilityStatus, + 1 + ); + expect(summary16.timeliness).toBe(false); + expect(summary16.ptcTimeliness).toBe(true); + + // Without a first seen delay the import delay decides + forkChoice.updateTime(17); + const summary17 = forkChoice.onBlock( + block17.message, + state17, + lateDelaySec, + lateDelaySec, + 17, + executionStatus, + dataAvailabilityStatus + ); + expect(summary17.timeliness).toBe(false); + expect(summary17.ptcTimeliness).toBe(false); + }); + it("prune - should prune old blocks", () => { const {blockHeader} = computeAnchorCheckpoint(config, anchorState); const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts index bdf30c39fdcb..27739f130da8 100644 --- a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -94,6 +94,43 @@ describe("SeenBlockProposers", () => { expect(cache.getEquivocationHeaders(slot, proposerIndex)).toBe(null); }); + it("keeps the first seen timestamp of a block root", () => { + const cache = new SeenBlockProposers(); + + cache.observeFirstSeen(slot, blockRoot, 10); + cache.observeFirstSeen(slot, blockRoot, 20); + cache.observeFirstSeen(slot, conflictingBlockRoot, 30); + + expect(cache.getFirstSeenTimestampSec(slot, blockRoot)).toBe(10); + expect(cache.getFirstSeenTimestampSec(slot, conflictingBlockRoot)).toBe(30); + expect(cache.getFirstSeenTimestampSec(slot, additionalBlockRoot)).toBe(undefined); + expect(cache.getFirstSeenTimestampSec(slot + 1, blockRoot)).toBe(undefined); + }); + + it("bounds first seen block roots per slot", () => { + const cache = new SeenBlockProposers(); + + for (let i = 0; i < 4096; i++) { + cache.observeFirstSeen(slot, toRootHex(Buffer.from(i.toString(16).padStart(64, "0"), "hex")), i); + } + cache.observeFirstSeen(slot, blockRoot, 10); + cache.observeFirstSeen(slot + 1, blockRoot, 10); + + expect(cache.getFirstSeenTimestampSec(slot, blockRoot)).toBe(undefined); + expect(cache.getFirstSeenTimestampSec(slot + 1, blockRoot)).toBe(10); + }); + + it("prunes first seen block roots and ignores finalized slots", () => { + const cache = new SeenBlockProposers(); + cache.observeFirstSeen(slot, blockRoot, 10); + + cache.prune(slot + 1); + cache.observeFirstSeen(slot, conflictingBlockRoot, 20); + + expect(cache.getFirstSeenTimestampSec(slot, blockRoot)).toBe(undefined); + expect(cache.getFirstSeenTimestampSec(slot, conflictingBlockRoot)).toBe(undefined); + }); + it("rejects updates for slots before the finalized slot", () => { const cache = new SeenBlockProposers(); cache.prune(slot + 1); diff --git a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts index bbec2d65d6db..44ad2ae153f5 100644 --- a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts +++ b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts @@ -65,7 +65,12 @@ describe("getGossipHandlers", () => { }); it("imports a signature-verified REPEAT_PROPOSAL (equivocating) block into fork choice but keeps IGNORE", async () => { - const {processBlock, threw} = await runBeaconBlockRepeatProposal(denebConfig, {recorded: true}); + const {processBlock, threw, firstSeenTimestampSec, pruneBlockInput} = await runBeaconBlockRepeatProposal( + denebConfig, + {recorded: true} + ); + expect(firstSeenTimestampSec).toBe(12); + expect(pruneBlockInput).not.toHaveBeenCalled(); // imported so LMD-GHOST can weigh it ... expect(processBlock).toHaveBeenCalledOnce(); @@ -74,7 +79,13 @@ describe("getGossipHandlers", () => { }); it("does not import a REPEAT_PROPOSAL block whose root was not recorded (unverified 3rd+ proposal)", async () => { - const {processBlock, threw} = await runBeaconBlockRepeatProposal(denebConfig, {recorded: false}); + const {processBlock, threw, firstSeenTimestampSec, pruneBlockInput} = await runBeaconBlockRepeatProposal( + denebConfig, + {recorded: false} + ); + // arrival time is kept so a later import through sync gets its PTC timeliness from gossip, the block itself is dropped + expect(firstSeenTimestampSec).toBe(12); + expect(pruneBlockInput).toHaveBeenCalledOnce(); expect(processBlock).not.toHaveBeenCalled(); expect(threw).toBe(true); @@ -162,7 +173,12 @@ async function runBeaconBlockProcessingError( async function runBeaconBlockRepeatProposal( config: BeaconConfig, {recorded}: {recorded: boolean} -): Promise<{processBlock: ReturnType; threw: boolean}> { +): Promise<{ + processBlock: ReturnType; + threw: boolean; + firstSeenTimestampSec: number | undefined; + pruneBlockInput: ReturnType; +}> { const logger = testLogger(); const peerIdStr = "16Uiu2HAmTestGossipPeer" as PeerIdStr; const signedBlock = ssz.deneb.SignedBeaconBlock.defaultValue(); @@ -201,6 +217,7 @@ async function runBeaconBlockRepeatProposal( } const processBlock = vi.fn().mockResolvedValue(undefined); + const pruneBlockInput = vi.fn(); const chain = { clock: new ClockStopped(1), custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, @@ -213,7 +230,7 @@ async function runBeaconBlockRepeatProposal( seenBlockInputCache: { getByBlock: vi.fn().mockReturnValue(blockInput), get: vi.fn().mockReturnValue(blockInput), - prune: vi.fn(), + prune: pruneBlockInput, } as unknown as SeenBlockInput, seenPayloadEnvelopeInputCache: { add: vi.fn(), @@ -242,7 +259,7 @@ async function runBeaconBlockRepeatProposal( await beaconBlockHandler({ gossipData: {serializedData: ssz.deneb.SignedBeaconBlock.serialize(signedBlock)}, peerIdStr, - seenTimestampSec: 0, + seenTimestampSec: 12, topic: { boundary: {fork: ForkName.deneb, epoch: 0}, type: GossipType.beacon_block, @@ -257,7 +274,12 @@ async function runBeaconBlockRepeatProposal( await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); - return {processBlock, threw}; + return { + processBlock, + threw, + firstSeenTimestampSec: seenBlockProposers.getFirstSeenTimestampSec(signedBlock.message.slot, blockRootHex), + pruneBlockInput, + }; } function getExecutionBlockError( diff --git a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts index ca79ce3cfd21..edfe60557d7a 100644 --- a/packages/beacon-node/test/unit/sync/unknownBlock.test.ts +++ b/packages/beacon-node/test/unit/sync/unknownBlock.test.ts @@ -420,9 +420,10 @@ describe("sync by UnknownBlockSync", {timeout: 20_000}, () => { slot: finalizedSlot, }) as ProtoBlock, }; - const seenBlockProposers: Pick = { + const seenBlockProposers: Pick = { // only return seenBlock for blockC isKnown: (blockSlot) => (blockSlot === blockC.message.slot ? seenBlock : false), + getFirstSeenTimestampSec: () => undefined, }; const blockAResolver: () => void = () => {}; @@ -735,7 +736,10 @@ describe("UnknownBlockSync", () => { }), prune: vi.fn(), } as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + getFirstSeenTimestampSec: vi.fn().mockReturnValue(undefined), + } as unknown as SeenBlockProposers, seenPayloadEnvelopeInputCache: { add: vi.fn(), get: vi.fn().mockReturnValue(undefined), @@ -779,6 +783,107 @@ describe("UnknownBlockSync", () => { service.close(); }); } + + it("passes the first seen timestamp of a repeat proposal to block processing", async () => { + const peer = await getRandPeerIdStr(); + const block = ssz.phase0.SignedBeaconBlock.defaultValue(); + block.message.slot = 1; + block.message.parentRoot = Buffer.alloc(32, 0xaa); + + const blockRootHex = toRootHex(ssz.phase0.BeaconBlock.hashTreeRoot(block.message)); + const parentRootHex = toRootHex(block.message.parentRoot); + const networkEvents = new NetworkEventBus(); + + const networkForTest = { + events: networkEvents, + getConnectedPeers: () => [peer], + getConnectedPeerSyncMeta: () => ({ + peerId: peer, + client: "first-seen-test-client", + custodyColumns: [], + earliestAvailableSlot: 0, + }), + custodyConfig: {sampledColumns: [], sampleGroups: [[]]} as unknown as CustodyConfig, + sendBeaconBlocksByRoot: vi.fn().mockResolvedValue([block]), + reportPeer: vi.fn(), + } as unknown as INetwork; + + const processBlock = vi.fn().mockResolvedValue(undefined); + const chainForTest = { + emitter: new ChainEventEmitter(), + clock: new ClockStopped(0), + forkChoice: { + hasBlockHex: vi.fn().mockImplementation((root: string) => root === parentRootHex), + getBlockHexDefaultStatus: vi + .fn() + .mockImplementation((root: string) => (root === parentRootHex ? ({slot: 0} as ProtoBlock) : null)), + hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), + getFinalizedBlock: vi.fn().mockReturnValue({slot: 0} as ProtoBlock), + } as unknown as IForkChoice, + genesisTime: 0, + custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, + processBlock, + seenBlockInputCache: { + getByBlock: ({ + block, + blockRootHex, + seenTimestampSec, + source, + peerIdStr, + }: { + block: SignedBeaconBlock; + blockRootHex: string; + seenTimestampSec: number; + source: BlockInputSource; + peerIdStr?: PeerIdStr; + }) => + BlockInputPreData.createFromBlock({ + block, + blockRootHex, + forkName: ForkName.phase0, + daOutOfRange: false, + seenTimestampSec, + source, + peerIdStr, + }), + prune: vi.fn(), + } as unknown as SeenBlockInput, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + getFirstSeenTimestampSec: vi + .fn() + .mockImplementation((slot: number, root: string) => (slot === 1 && root === blockRootHex ? 42 : undefined)), + } as unknown as SeenBlockProposers, + seenPayloadEnvelopeInputCache: { + add: vi.fn(), + get: vi.fn().mockReturnValue(undefined), + getOrReload: vi.fn().mockResolvedValue(undefined), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + } as unknown as IBeaconChain; + + service = new BlockInputSync(minimalConfig, networkForTest, chainForTest, logger, null, defaultSyncOptions); + service.subscribeToNetwork(); + + networkEvents.emit(NetworkEvent.peerConnected, { + peer, + status: {} as never, + custodyColumns: [], + clientAgent: "first-seen-test-client", + }); + chainForTest.emitter.emit(ChainEvent.unknownBlockRoot, { + rootHex: blockRootHex, + peer, + source: BlockInputSource.gossip, + }); + + await sleep(20); + + expect(processBlock).toHaveBeenCalledOnce(); + expect(processBlock.mock.calls[0][1]).toMatchObject({firstSeenTimestampSec: 42}); + + service.close(); + }); }); describe("payload sync flows", () => { @@ -830,7 +935,10 @@ describe("UnknownBlockSync", () => { prune: vi.fn(), } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, - seenBlockProposers: {isKnown: vi.fn().mockReturnValue(false)} as unknown as SeenBlockProposers, + seenBlockProposers: { + isKnown: vi.fn().mockReturnValue(false), + getFirstSeenTimestampSec: vi.fn().mockReturnValue(undefined), + } as unknown as SeenBlockProposers, forkChoice: { hasPayloadHexUnsafe: vi.fn().mockReturnValue(false), hasBlockHex: vi.fn().mockReturnValue(false), @@ -2145,6 +2253,7 @@ describe("UnknownBlockSync", () => { seenBlockInputCache: {prune: vi.fn()} as unknown as SeenBlockInput, seenBlockProposers: { isKnown: vi.fn().mockReturnValue(false), + getFirstSeenTimestampSec: vi.fn().mockReturnValue(undefined), } as unknown as SeenBlockProposers, }; diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 60d8fc8d5f3a..31ad2fa1dabd 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -736,7 +736,8 @@ export class ForkChoice implements IForkChoice { importDelaySec: number, currentSlot: Slot, executionStatus: BlockExecutionStatus, - dataAvailabilityStatus: DataAvailabilityStatus + dataAvailabilityStatus: DataAvailabilityStatus, + ptcReceiveDelaySec = receiveDelaySec ): ProtoBlock { const {parentRoot, slot} = block; const parentRootHex = toRootHex(parentRoot); @@ -876,7 +877,7 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, - ptcTimeliness: this.isBlockPtcTimely(block, receiveDelaySec), + ptcTimeliness: this.isBlockPtcTimely(block, ptcReceiveDelaySec), importedTimely: this.isBlockImportedTimely(block, importDelaySec), proposerIndex: block.proposerIndex, diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index ee46b66ca803..78150f9d3b90 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -159,7 +159,9 @@ export interface IForkChoice { importDelaySec: number, currentSlot: Slot, executionStatus: BlockExecutionStatus, - dataAvailabilityStatus: DataAvailabilityStatus + dataAvailabilityStatus: DataAvailabilityStatus, + /** Delay to evaluate PTC timeliness with, when the block was first seen earlier than it is imported */ + ptcReceiveDelaySec?: number ): ProtoBlock; /** * Register `attestation` with the fork choice DAG so that it may influence future calls to `getHead`.