diff --git a/packages/beacon-node/src/api/impl/beacon/pool/index.ts b/packages/beacon-node/src/api/impl/beacon/pool/index.ts index 2e0fea1aa66e..4bcae7ac3c3c 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -276,7 +276,8 @@ export function getBeaconPoolApi({ chain.forkChoice.notifyPtcMessages( toRootHex(payloadAttestationMessage.data.beaconBlockRoot), [validatorCommitteeIndex], - payloadAttestationMessage.data.payloadPresent + payloadAttestationMessage.data.payloadPresent, + payloadAttestationMessage.data.blobDataAvailable ); await network.publishPayloadAttestationMessage(payloadAttestationMessage); diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 228ee5a484dc..057d3e6a13dc 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -291,7 +291,8 @@ export async function importBlock( this.forkChoice.notifyPtcMessages( toRootHex(payloadAttestation.data.beaconBlockRoot), ptcIndices, - payloadAttestation.data.payloadPresent + payloadAttestation.data.payloadPresent, + payloadAttestation.data.blobDataAvailable ); } } catch (e) { diff --git a/packages/beacon-node/src/chain/emitter.ts b/packages/beacon-node/src/chain/emitter.ts index 8d910a69cb41..870c8d3eafe5 100644 --- a/packages/beacon-node/src/chain/emitter.ts +++ b/packages/beacon-node/src/chain/emitter.ts @@ -37,6 +37,14 @@ export enum ChainEvent { * This event is guaranteed to be triggered whenever the fork choice justified checkpoint is updated. This is in response to a newly processed block. */ forkChoiceFinalized = "forkChoice:finalized", + /** + * This event signals that the PTC quorum for payload timeliness has been reached. + */ + forkChoicePTCQuorumPayloadTimely = "forkChoice:PTCQuorumPayloadTimely", + /** + * This event signals that the PTC quorum for data availability has been reached. + */ + forkChoicePTCQuorumDataAvailable = "forkChoice:PTCQuorumDataAvailable", /** * This event signals that dependent services (e.g. custody sampling) should update to account for the new target group count. */ @@ -113,6 +121,9 @@ export type IChainEvents = ApiEvents & { [ChainEvent.forkChoiceJustified]: (checkpoint: CheckpointWithHex) => void; [ChainEvent.forkChoiceFinalized]: (checkpoint: CheckpointWithHex) => void; + [ChainEvent.forkChoicePTCQuorumPayloadTimely]: (blockRoot: RootHex, payloadTimely: boolean) => void; + [ChainEvent.forkChoicePTCQuorumDataAvailable]: (blockRoot: RootHex, dataAvailable: boolean) => void; + [ChainEvent.updateTargetCustodyGroupCount]: (targetGroupCount: number) => void; [ChainEvent.publishDataColumns]: (sidecars: DataColumnSidecar[]) => void; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 312d7c270054..d9f6efa8744a 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -115,6 +115,10 @@ export function initializeForkChoiceFromFinalizedState( { onJustified: (cp) => emitter.emit(ChainEvent.forkChoiceJustified, cp), onFinalized: (cp) => emitter.emit(ChainEvent.forkChoiceFinalized, cp), + onPTCQuorumPayloadTimely: (blockRoot, payloadTimely) => + emitter.emit(ChainEvent.forkChoicePTCQuorumPayloadTimely, blockRoot, payloadTimely), + onPTCQuorumDataAvailable: (blockRoot, dataAvailable) => + emitter.emit(ChainEvent.forkChoicePTCQuorumDataAvailable, blockRoot, dataAvailable), } ), @@ -204,6 +208,10 @@ export function initializeForkChoiceFromUnfinalizedState( { onJustified: (cp) => emitter.emit(ChainEvent.forkChoiceJustified, cp), onFinalized: (cp) => emitter.emit(ChainEvent.forkChoiceFinalized, cp), + onPTCQuorumPayloadTimely: (blockRoot, payloadTimely) => + emitter.emit(ChainEvent.forkChoicePTCQuorumPayloadTimely, blockRoot, payloadTimely), + onPTCQuorumDataAvailable: (blockRoot, dataAvailable) => + emitter.emit(ChainEvent.forkChoicePTCQuorumDataAvailable, blockRoot, dataAvailable), } ); diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index d560d7169b11..a930c3ce7fc6 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -1149,7 +1149,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand chain.forkChoice.notifyPtcMessages( toRootHex(payloadAttestationMessage.data.beaconBlockRoot), [validationResult.validatorCommitteeIndex], - payloadAttestationMessage.data.payloadPresent + payloadAttestationMessage.data.payloadPresent, + payloadAttestationMessage.data.blobDataAvailable ); }, [GossipType.execution_payload_bid]: async ({ diff --git a/packages/beacon-node/test/e2e/sync/finalizedSync.test.ts b/packages/beacon-node/test/e2e/sync/finalizedSync.test.ts index 526fb469990c..1732e9313604 100644 --- a/packages/beacon-node/test/e2e/sync/finalizedSync.test.ts +++ b/packages/beacon-node/test/e2e/sync/finalizedSync.test.ts @@ -159,9 +159,12 @@ describe("sync / finalized sync for gloas", () => { `Node B missing FULL payload variant for gloas block slot=${block.slot} root=${block.blockRoot}` ); if (block.slot > gloasFirstSlot) { - const ptcVotes = bn2.chain.forkChoice.getPTCVotes(block.blockRoot) ?? []; + const ptcVotes = bn2.chain.forkChoice.getPTCVotes(block.blockRoot); + if (ptcVotes === null) { + expect.fail("Block not found or not a Gloas block"); + } - expect(ptcVotes.some(Boolean)).toBeWithMessage( + expect(ptcVotes.payloadTimelyYea + ptcVotes.payloadTimelyNay > 0).toBeWithMessage( true, `Node A missing PTC votes for gloas block slot=${block.slot} root=${block.blockRoot}` ); diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index febf125248db..7d996f4cb504 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -137,6 +137,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => { }, justifiedBalancesGetter: () => originalState.epochCtx.effectiveBalanceIncrements, equivocatingIndices: new Set(), + setPtcQuorumPayloadTimely: () => {}, + setPtcQuorumDataAvailable: () => {}, }; forkchoice = new ForkChoice(originalState.config, fcStore, protoArray, originalState.validators.length, null); }, diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index dd10077ac1b4..9ddbe8760da1 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -35,6 +35,7 @@ import { HEX_ZERO_HASH, LVHExecResponse, NULL_VOTE_INDEX, + PTCVotes, PayloadExecutionStatus, PayloadStatus, ProtoBlock, @@ -42,7 +43,7 @@ import { VoteIndex, isGloasBlock, } from "../protoArray/interface.js"; -import {ProtoArray} from "../protoArray/protoArray.js"; +import {DATA_AVAILABILITY_TIMELY_THRESHOLD, PAYLOAD_TIMELY_THRESHOLD, ProtoArray} from "../protoArray/protoArray.js"; import {ForkChoiceError, ForkChoiceErrorCode, InvalidAttestationCode, InvalidBlockCode} from "./errors.js"; import { AncestorResult, @@ -944,8 +945,46 @@ export class ForkChoice implements IForkChoice { * Updates the PTC votes for multiple validators attesting to a block * Spec: gloas/fork-choice.md#new-on_payload_attestation_message */ - notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void { - this.protoArray.notifyPtcMessages(blockRoot, ptcIndices, payloadPresent); + notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean, dataAvailable: boolean): void { + const votes = this.protoArray.getPTCVotes(blockRoot); + if (votes === null) { + return; + } + const {payloadTimelyYea, payloadTimelyNay, dataAvailableYea, dataAvailableNay} = votes; + + const newVotes = this.protoArray.notifyPtcMessages(blockRoot, ptcIndices, payloadPresent, dataAvailable); + if (newVotes === null) { + return; + } + + const { + payloadTimelyYea: newPayloadTimelyYea, + payloadTimelyNay: newPayloadTimelyNay, + dataAvailableYea: newDataAvailableYea, + dataAvailableNay: newDataAvailableNay, + } = newVotes; + + if (payloadTimelyYea <= PAYLOAD_TIMELY_THRESHOLD && newPayloadTimelyYea > PAYLOAD_TIMELY_THRESHOLD) { + this.fcStore.setPtcQuorumPayloadTimely(blockRoot, true); + } + + if (payloadTimelyNay <= PAYLOAD_TIMELY_THRESHOLD && newPayloadTimelyNay > PAYLOAD_TIMELY_THRESHOLD) { + this.fcStore.setPtcQuorumPayloadTimely(blockRoot, false); + } + + if ( + dataAvailableYea <= DATA_AVAILABILITY_TIMELY_THRESHOLD && + newDataAvailableYea > DATA_AVAILABILITY_TIMELY_THRESHOLD + ) { + this.fcStore.setPtcQuorumDataAvailable(blockRoot, true); + } + + if ( + dataAvailableNay <= DATA_AVAILABILITY_TIMELY_THRESHOLD && + newDataAvailableNay > DATA_AVAILABILITY_TIMELY_THRESHOLD + ) { + this.fcStore.setPtcQuorumDataAvailable(blockRoot, false); + } } /** @@ -1053,10 +1092,8 @@ export class ForkChoice implements IForkChoice { return this.protoArray.hasPayload(blockRoot); } - getPTCVotes(blockRootHex: RootHex): (boolean | null)[] | null { - const votes = this.protoArray.getPTCVotes(blockRootHex); - if (votes === null) return null; - return votes.toBoolArray().map((v) => v ?? null); + getPTCVotes(blockRootHex: RootHex): PTCVotes | null { + return this.protoArray.getPTCVotes(blockRootHex); } /** diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 534f5fa51524..5beec9c26ab6 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -3,6 +3,7 @@ import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, import { BlockExecutionStatus, LVHExecResponse, + PTCVotes, PayloadExecutionStatus, PayloadStatus, ProtoBlock, @@ -185,8 +186,9 @@ export interface IForkChoice { * @param blockRoot - The beacon block root being attested * @param ptcIndices - Array of PTC committee indices that voted * @param payloadPresent - Whether validators attest the payload is present + * @param dataAvailable - Whether validators attest the data is available */ - notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void; + notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean, dataAvailable: boolean): void; /** * Notify fork choice that an execution payload has arrived (Gloas fork) * Creates the FULL variant of a Gloas block when the payload becomes available @@ -232,7 +234,7 @@ export interface IForkChoice { hasPayloadUnsafe(blockRoot: Root): boolean; hasPayloadHexUnsafe(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; - getPTCVotes(blockRootHex: RootHex): (boolean | null)[] | null; + getPTCVotes(blockRootHex: RootHex): PTCVotes | null; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. */ diff --git a/packages/fork-choice/src/forkChoice/store.ts b/packages/fork-choice/src/forkChoice/store.ts index 8a7a1754cae0..b1f35a2cc5dc 100644 --- a/packages/fork-choice/src/forkChoice/store.ts +++ b/packages/fork-choice/src/forkChoice/store.ts @@ -44,6 +44,8 @@ export interface IForkChoiceStore { unrealizedFinalizedCheckpoint: CheckpointWithHex; justifiedBalancesGetter: JustifiedBalancesGetter; equivocatingIndices: Set; + setPtcQuorumPayloadTimely(blockRoot: RootHex, payloadTimely: boolean): void; + setPtcQuorumDataAvailable(blockRoot: RootHex, dataAvailable: boolean): void; } /** @@ -67,6 +69,8 @@ export class ForkChoiceStore implements IForkChoiceStore { private readonly events?: { onJustified: (cp: CheckpointWithHex) => void; onFinalized: (cp: CheckpointWithHex) => void; + onPTCQuorumPayloadTimely: (blockRoot: RootHex, payloadTimely: boolean) => void; + onPTCQuorumDataAvailable: (blockRoot: RootHex, dataAvailable: boolean) => void; } ) { this.justifiedBalancesGetter = justifiedBalancesGetter; @@ -98,6 +102,14 @@ export class ForkChoiceStore implements IForkChoiceStore { this._finalizedCheckpoint = cp; this.events?.onFinalized(cp); } + + setPtcQuorumPayloadTimely(blockRoot: RootHex, payloadTimely: boolean) { + this.events?.onPTCQuorumPayloadTimely(blockRoot, payloadTimely); + } + + setPtcQuorumDataAvailable(blockRoot: RootHex, dataAvailable: boolean) { + this.events?.onPTCQuorumDataAvailable(blockRoot, dataAvailable); + } } export function toCheckpointWithHex(checkpoint: phase0.Checkpoint): CheckpointWithHex { diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index f909009b7e29..75fc30f18aa0 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -29,6 +29,7 @@ export type { BlockExtraMeta, LVHInvalidResponse, LVHValidResponse, + PTCVotes, PayloadExecutionStatus, ProtoBlock, ProtoNode, diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index cae46fdacd55..4b51b12cb0ad 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -1,3 +1,4 @@ +import {BitArray} from "@chainsafe/ssz"; import {DataAvailabilityStatus} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot, UintNum64} from "@lodestar/types"; @@ -155,3 +156,19 @@ export type ProtoNode = ProtoBlock & { bestChild?: number; bestDescendant?: number; }; + +/** + * type to track PTC votes + * + * true means quorum of yea, + * false means quorum of nay, + * null means not enough votes + */ +export type PTCQuorum = boolean | null; +export type PTCVotes = { + votes: BitArray; + payloadTimelyYea: number; + payloadTimelyNay: number; + dataAvailableYea: number; + dataAvailableNay: number; +}; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index db8b6ff5da3e..455d7dc22b24 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -2,13 +2,14 @@ import {BitArray} from "@chainsafe/ssz"; import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params"; import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {Epoch, RootHex, Slot} from "@lodestar/types"; -import {bitCount, toRootHex} from "@lodestar/utils"; +import {toRootHex} from "@lodestar/utils"; import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js"; import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js"; import { ExecutionStatus, HEX_ZERO_HASH, LVHExecResponse, + PTCVotes, PayloadExecutionStatus, PayloadStatus, ProtoBlock, @@ -20,7 +21,13 @@ import { * Threshold for payload timeliness (>50% of PTC must vote) * Spec: gloas/fork-choice.md (PAYLOAD_TIMELY_THRESHOLD = PTC_SIZE // 2) */ -const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); +export const PAYLOAD_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); + +/** + * Threshold for data availability (>50% of PTC must vote) + * Spec: gloas/fork-choice.md (DATA_AVAILABILITY_TIMELY_THRESHOLD = PTC_SIZE // 2) + */ +export const DATA_AVAILABILITY_TIMELY_THRESHOLD = Math.floor(PTC_SIZE / 2); export const DEFAULT_PRUNE_THRESHOLD = 0; type ProposerBoost = {root: RootHex; score: number}; @@ -62,14 +69,15 @@ export class ProtoArray { private previousProposerBoost: ProposerBoost | null = null; /** - * PTC (Payload Timeliness Committee) votes per block as bitvectors - * Maps block root to BitArray of PTC_SIZE bits (512 mainnet, 2 minimal) + * PTC (Payload Timeliness Committee) votes per block as one observed-vote bitvector plus yea/nay counters. + * Maps block root to PTC vote tracking for PTC_SIZE members (512 mainnet, 2 minimal) * Spec: gloas/fork-choice.md#modified-store (line 148) * - * Bit i is set if PTC member i voted payload_present=true - * Used by is_payload_timely() to determine if payload is timely + * Bit i is set once an observed PTC message from member i has been counted. + * Equivocation is not resolved here; later contradictory messages from the same member are ignored. + * Used by is_payload_timely() and is_payload_data_available() */ - private ptcVotes = new Map(); + private ptcVotes = new Map(); constructor({ pruneThreshold, @@ -506,7 +514,13 @@ export class ProtoArray { // Initialize PTC votes for this block (all false initially) // Spec: gloas/fork-choice.md#modified-on_block (line 645) - this.ptcVotes.set(block.blockRoot, BitArray.fromBitLen(PTC_SIZE)); + this.ptcVotes.set(block.blockRoot, { + votes: BitArray.fromBitLen(PTC_SIZE), + payloadTimelyYea: 0, + payloadTimelyNay: 0, + dataAvailableYea: 0, + dataAvailableNay: 0, + }); } else { // Pre-Gloas: Only create FULL node (payload embedded in block) const node: ProtoNode = { @@ -623,25 +637,44 @@ export class ProtoArray { * * @param blockRoot - The beacon block root being attested * @param ptcIndices - Array of PTC committee indices that voted (0..PTC_SIZE-1) - * @param payloadPresent - Whether the validators attest the payload is present + * @param payloadTimely - Whether the validators attest the payload is timely + * @param dataAvailable - Whether the validators attest the data is available + * + * @returns The updated vote counts, or null when the block is unknown or pre-Gloas. */ - notifyPtcMessages(blockRoot: RootHex, ptcIndices: number[], payloadPresent: boolean): void { + notifyPtcMessages( + blockRoot: RootHex, + ptcIndices: number[], + payloadTimely: boolean, + dataAvailable: boolean + ): PTCVotes | null { const votes = this.ptcVotes.get(blockRoot); if (votes === undefined) { // Block not found or not a Gloas block, ignore - return; + return null; } + const payloadTimelyKey = payloadTimely ? "payloadTimelyYea" : "payloadTimelyNay"; + const dataAvailableKey = dataAvailable ? "dataAvailableYea" : "dataAvailableNay"; + for (const ptcIndex of ptcIndices) { if (ptcIndex < 0 || ptcIndex >= PTC_SIZE) { throw new Error(`Invalid PTC index: ${ptcIndex}, must be 0..${PTC_SIZE - 1}`); } - votes.set(ptcIndex, payloadPresent); + if (votes.votes.get(ptcIndex)) { + continue; + } + votes.votes.set(ptcIndex, true); + + votes[payloadTimelyKey]++; + votes[dataAvailableKey]++; } + + return votes; } - getPTCVotes(blockRootHex: RootHex): BitArray | null { + getPTCVotes(blockRootHex: RootHex): PTCVotes | null { const votes = this.ptcVotes.get(blockRootHex); if (votes === undefined) { // Block not found or not a Gloas block @@ -675,8 +708,34 @@ export class ProtoArray { } // Count votes for payload_present=true - const yesVotes = bitCount(votes.uint8Array); - return yesVotes > PAYLOAD_TIMELY_THRESHOLD; + return votes.payloadTimelyYea > PAYLOAD_TIMELY_THRESHOLD; + } + + /** + * Check if execution payload for a block has data available + * Spec: gloas/fork-choice.md#new-is_payload_data_available + * + * Returns true if: + * 1. Block has PTC votes tracked + * 2. Payload is locally available (FULL variant exists in proto array) + * 3. More than DATA_AVAILABILITY_TIMELY_THRESHOLD (>50% of PTC) members voted data_available=true + * + * @param blockRoot - The beacon block root to check + */ + isDataAvailable(blockRoot: RootHex): boolean { + const votes = this.ptcVotes.get(blockRoot); + if (votes === undefined) { + // Block not found or not a Gloas block + return false; + } + + // If payload is not locally available, the data is not available + if (!this.hasPayload(blockRoot)) { + return false; + } + + // Count votes for data_available=true + return votes.dataAvailableYea > DATA_AVAILABILITY_TIMELY_THRESHOLD; } /** @@ -694,7 +753,7 @@ export class ProtoArray { * Spec: gloas/fork-choice.md#new-should_extend_payload * * Returns true if payload is verified (FULL variant exists) AND: - * 1. Payload is timely, OR + * 1. Payload is timely and blob data is available according to PTC, OR * 2. No proposer boost root (empty/zero hash), OR * 3. Proposer boost root's parent is not this block, OR * 4. Proposer boost root extends FULL parent @@ -707,8 +766,8 @@ export class ProtoArray { return false; } - // Condition 1: Payload is timely - if (this.isPayloadTimely(blockRoot)) { + // Condition 1: Payload is timely and data is available + if (this.isPayloadTimely(blockRoot) && this.isDataAvailable(blockRoot)) { return true; } diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 7be4c170cfcd..ac2885b8f7c3 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -78,6 +78,8 @@ export function initializeForkChoice(opts: Opts): ForkChoice { }, justifiedBalancesGetter: () => balances, equivocatingIndices: new Set(Array.from({length: opts.initialEquivocatedCount}, (_, i) => i)), + setPtcQuorumPayloadTimely: () => {}, + setPtcQuorumDataAvailable: () => {}, }; const forkchoice = new ForkChoice(config, fcStore, protoArr, opts.initialValidatorCount, null); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index 25be3f86ec86..2c56d3fa9742 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -83,6 +83,8 @@ describe("Forkchoice", () => { }, justifiedBalancesGetter: () => new Uint16Array([32]), equivocatingIndices: new Set(), + setPtcQuorumPayloadTimely: () => {}, + setPtcQuorumDataAvailable: () => {}, }; const getParentBlockRoot = (slot: number, skippedSlots: number[] = []): RootHex => { diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 96ffca176710..d65508982511 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -140,6 +140,8 @@ describe("Forkchoice / GetProposerHead", () => { }, justifiedBalancesGetter: () => new Uint16Array(Array(32).fill(150)), equivocatingIndices: new Set(), + setPtcQuorumPayloadTimely: () => {}, + setPtcQuorumDataAvailable: () => {}, }; // head block's weight < 30 is considered weak. parent block's total weight > 240 is considered strong diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index c3f21d5ae42b..a6d84aaceb22 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -140,6 +140,8 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { }, justifiedBalancesGetter: () => new Uint16Array(Array(32).fill(150)), equivocatingIndices: new Set(), + setPtcQuorumPayloadTimely: () => {}, + setPtcQuorumDataAvailable: () => {}, }; const testCases: { diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index c1e37a4d818a..e15b382e9f8e 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -2,7 +2,7 @@ import {beforeEach, describe, expect, it} from "vitest"; import {PTC_SIZE} from "@lodestar/params"; import {DataAvailabilityStatus, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; -import {ExecutionStatus, PayloadStatus, ProtoArray, ProtoBlock, ProtoNode} from "../../../src/index.js"; +import {ExecutionStatus, type PTCVotes, PayloadStatus, ProtoArray, ProtoBlock, ProtoNode} from "../../../src/index.js"; describe("Gloas Fork Choice", () => { const genesisEpoch = 0; @@ -23,7 +23,7 @@ describe("Gloas Fork Choice", () => { ): ProtoNode | undefined { const index = protoArray.getNodeIndexByRootAndStatus(blockRoot, payloadStatus); if (index === undefined) return undefined; - return (protoArray as any).nodes[index]; + return protoArray.nodes[index]; } function createTestBlock( @@ -56,10 +56,46 @@ describe("Gloas Fork Choice", () => { }; } + function getQuorumIndices(): number[] { + return Array.from({length: Math.floor(PTC_SIZE / 2) + 1}, (_, i) => i); + } + + function getNonQuorumIndices(): number[] { + return Array.from({length: Math.floor(PTC_SIZE / 2)}, (_, i) => i); + } + + function expectPtcVotes( + votes: PTCVotes | null, + expected: { + payloadTimelyYea: number; + payloadTimelyNay: number; + dataAvailableYea: number; + dataAvailableNay: number; + votedCount: number; + } + ): void { + if (votes === null) { + throw new Error("Expected PTC votes"); + } + + expect(votes.payloadTimelyYea).toBe(expected.payloadTimelyYea); + expect(votes.payloadTimelyNay).toBe(expected.payloadTimelyNay); + expect(votes.dataAvailableYea).toBe(expected.dataAvailableYea); + expect(votes.dataAvailableNay).toBe(expected.dataAvailableNay); + + let votedCount = 0; + for (let i = 0; i < PTC_SIZE; i++) { + if (votes.votes.get(i)) { + votedCount++; + } + } + expect(votedCount).toBe(expected.votedCount); + } + describe("ProtoArray indices lookup", () => { it("indices map stores variants correctly for pre-Gloas blocks", () => { const protoArray = ProtoArray.initialize(createTestBlock(0, genesisRoot, "0x00"), 0); - const variants = (protoArray as any).indices.get(genesisRoot); + const variants = protoArray.indices.get(genesisRoot); expect(variants).toBeDefined(); // Pre-Gloas: variants is the FULL index expect(variants).toBe(0); @@ -80,8 +116,11 @@ describe("Gloas Fork Choice", () => { const gloasBlock = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(gloasBlock, gloasForkSlot, null); - const variants = (protoArray as any).indices.get("0x02"); + const variants = protoArray.indices.get("0x02"); expect(variants).toBeDefined(); + if (!Array.isArray(variants)) { + throw new Error("Expected Gloas variants"); + } // Gloas: variants[PENDING] and variants[EMPTY] should be defined expect(variants[PayloadStatus.PENDING]).toBeDefined(); expect(variants[PayloadStatus.EMPTY]).toBeDefined(); @@ -392,10 +431,18 @@ describe("Gloas Fork Choice", () => { // Initially not timely (no votes) expect(protoArray.isPayloadTimely("0x02")).toBe(false); - // Vote yes from validators at indices 0, 1, 2 - protoArray.notifyPtcMessages("0x02", [0, 1, 2], true); + // Vote yes from validators below the quorum threshold + const indices = Array.from({length: Math.min(3, Math.floor(PTC_SIZE / 2))}, (_, i) => i); + const votes = protoArray.notifyPtcMessages("0x02", indices, true, true); // Still not timely (need >50% of PTC_SIZE) + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); expect(protoArray.isPayloadTimely("0x02")).toBe(false); }); @@ -403,15 +450,15 @@ describe("Gloas Fork Choice", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot, null); - expect(() => protoArray.notifyPtcMessages("0x02", [-1], true)).toThrow(/Invalid PTC index/); - expect(() => protoArray.notifyPtcMessages("0x02", [PTC_SIZE], true)).toThrow(/Invalid PTC index/); - expect(() => protoArray.notifyPtcMessages("0x02", [PTC_SIZE + 1], true)).toThrow(/Invalid PTC index/); - expect(() => protoArray.notifyPtcMessages("0x02", [0, 1, PTC_SIZE], true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessages("0x02", [-1], true, true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessages("0x02", [PTC_SIZE], true, true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessages("0x02", [PTC_SIZE + 1], true, true)).toThrow(/Invalid PTC index/); + expect(() => protoArray.notifyPtcMessages("0x02", [0, 1, PTC_SIZE], true, true)).toThrow(/Invalid PTC index/); }); it("notifyPtcMessages() handles unknown block gracefully", () => { // Should not throw for unknown block - expect(() => protoArray.notifyPtcMessages("0x99", [0], true)).not.toThrow(); + expect(() => protoArray.notifyPtcMessages("0x99", [0], true, true)).not.toThrow(); }); it("isPayloadTimely() returns false when payload not locally available", () => { @@ -419,9 +466,8 @@ describe("Gloas Fork Choice", () => { protoArray.onBlock(block, gloasForkSlot, null); // Vote yes from majority of PTC - const threshold = Math.floor(PTC_SIZE / 2) + 1; - const indices = Array.from({length: threshold}, (_, i) => i); - protoArray.notifyPtcMessages("0x02", indices, true); + const indices = getQuorumIndices(); + protoArray.notifyPtcMessages("0x02", indices, true, true); // Without execution payload (no FULL variant), should return false expect(protoArray.isPayloadTimely("0x02")).toBe(false); @@ -443,12 +489,81 @@ describe("Gloas Fork Choice", () => { ); // Vote yes from majority of PTC (>50%) - const threshold = Math.floor(PTC_SIZE / 2) + 1; - const indices = Array.from({length: threshold}, (_, i) => i); - protoArray.notifyPtcMessages("0x02", indices, true); + const indices = getQuorumIndices(); + const votes = protoArray.notifyPtcMessages("0x02", indices, true, true); // Should now be timely + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); expect(protoArray.isPayloadTimely("0x02")).toBe(true); + expect(protoArray.isDataAvailable("0x02")).toBe(true); + }); + + it("shouldExtendPayload() requires data availability quorum for timely payloads", () => { + const block = createTestBlock(gloasForkSlot, "0x02Root", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot, null); + protoArray.onExecutionPayload( + "0x02Root", + gloasForkSlot, + "0x02FullHash", + gloasForkSlot, + null, + ExecutionStatus.Valid, + DataAvailabilityStatus.Available + ); + + const proposerBoostBlock = createTestBlock(gloasForkSlot + 1, "0x03Root", "0x02Root", "0x02Root"); + protoArray.onBlock(proposerBoostBlock, gloasForkSlot + 1, "0x03Root"); + + const indices = getQuorumIndices(); + const votes = protoArray.notifyPtcMessages("0x02Root", indices, true, false); + + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: 0, + dataAvailableNay: indices.length, + votedCount: indices.length, + }); + expect(protoArray.isPayloadTimely("0x02Root")).toBe(true); + expect(protoArray.isDataAvailable("0x02Root")).toBe(false); + expect(protoArray.shouldExtendPayload("0x02Root", "0x03Root")).toBe(false); + }); + + it("shouldExtendPayload() extends when payload timely and data availability quorums are reached", () => { + const block = createTestBlock(gloasForkSlot, "0x02Root", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot, null); + protoArray.onExecutionPayload( + "0x02Root", + gloasForkSlot, + "0x02FullHash", + gloasForkSlot, + null, + ExecutionStatus.Valid, + DataAvailabilityStatus.Available + ); + + const proposerBoostBlock = createTestBlock(gloasForkSlot + 1, "0x03Root", "0x02Root", "0x02Root"); + protoArray.onBlock(proposerBoostBlock, gloasForkSlot + 1, "0x03Root"); + + const indices = getQuorumIndices(); + const votes = protoArray.notifyPtcMessages("0x02Root", indices, true, true); + + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); + expect(protoArray.isPayloadTimely("0x02Root")).toBe(true); + expect(protoArray.isDataAvailable("0x02Root")).toBe(true); + expect(protoArray.shouldExtendPayload("0x02Root", "0x03Root")).toBe(true); }); it("isPayloadTimely() returns false when threshold not met", () => { @@ -467,15 +582,22 @@ describe("Gloas Fork Choice", () => { ); // Vote yes from exactly 50% (not >50%) - const threshold = Math.floor(PTC_SIZE / 2); - const indices = Array.from({length: threshold}, (_, i) => i); - protoArray.notifyPtcMessages("0x02", indices, true); + const indices = getNonQuorumIndices(); + const votes = protoArray.notifyPtcMessages("0x02", indices, true, true); // Should not be timely (need >50%, not >=50%) + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); expect(protoArray.isPayloadTimely("0x02")).toBe(false); + expect(protoArray.isDataAvailable("0x02")).toBe(false); }); - it("isPayloadTimely() counts only 'true' votes", () => { + it("tracks payload timeliness and data availability quorums independently", () => { const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); protoArray.onBlock(block, gloasForkSlot, null); @@ -490,23 +612,47 @@ describe("Gloas Fork Choice", () => { DataAvailabilityStatus.Available ); - // Vote mixed yes/no - const threshold = Math.floor(PTC_SIZE / 2) + 1; - // Vote yes from indices 0..threshold-1 - const yesIndices = Array.from({length: threshold}, (_, i) => i); - protoArray.notifyPtcMessages("0x02", yesIndices, true); - // Vote no from indices threshold..PTC_SIZE-1 - const noIndices = Array.from({length: PTC_SIZE - threshold}, (_, i) => i + threshold); - protoArray.notifyPtcMessages("0x02", noIndices, false); - - // Should be timely (threshold met) - expect(protoArray.isPayloadTimely("0x02")).toBe(true); - - // Change some yes votes to no - protoArray.notifyPtcMessages("0x02", [0, 1], false); + const indices = getQuorumIndices(); + const votes = protoArray.notifyPtcMessages("0x02", indices, false, true); - // Should no longer be timely + expectPtcVotes(votes, { + payloadTimelyYea: 0, + payloadTimelyNay: indices.length, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); expect(protoArray.isPayloadTimely("0x02")).toBe(false); + expect(protoArray.isDataAvailable("0x02")).toBe(true); + }); + + it("does not treat later opposite PTC messages as vote changes", () => { + const block = createTestBlock(gloasForkSlot, "0x02", genesisRoot, genesisRoot); + protoArray.onBlock(block, gloasForkSlot, null); + + protoArray.onExecutionPayload( + "0x02", + gloasForkSlot, + "0x02", + gloasForkSlot, + null, + ExecutionStatus.Valid, + DataAvailabilityStatus.Available + ); + + const indices = getQuorumIndices(); + protoArray.notifyPtcMessages("0x02", indices, true, true); + const votes = protoArray.notifyPtcMessages("0x02", indices, false, false); + + expectPtcVotes(votes, { + payloadTimelyYea: indices.length, + payloadTimelyNay: 0, + dataAvailableYea: indices.length, + dataAvailableNay: 0, + votedCount: indices.length, + }); + expect(protoArray.isPayloadTimely("0x02")).toBe(true); + expect(protoArray.isDataAvailable("0x02")).toBe(true); }); it("isPayloadTimely() returns false for unknown block", () => { @@ -521,7 +667,7 @@ describe("Gloas Fork Choice", () => { expect(protoArray.isPayloadTimely("0x02")).toBe(false); // notifyPtcMessages should be no-op - expect(() => protoArray.notifyPtcMessages("0x02", [0], true)).not.toThrow(); + expect(() => protoArray.notifyPtcMessages("0x02", [0], true, true)).not.toThrow(); }); }); @@ -816,7 +962,7 @@ describe("Gloas Fork Choice", () => { // Set PTC votes for block1 const threshold = Math.floor(PTC_SIZE / 2) + 1; const indices = Array.from({length: threshold}, (_, i) => i); - protoArray.notifyPtcMessages("0x02", indices, true); + protoArray.notifyPtcMessages("0x02", indices, true, true); // Verify PTC votes are set expect(protoArray.isPayloadTimely("0x02")).toBe(true);