Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions packages/beacon-node/src/chain/forkChoice/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export function initializeForkChoiceFromFinalizedState(
stateRoot: toRootHex(blockHeader.stateRoot),
blockRoot: toRootHex(checkpoint.root),
timeliness: true, // Optimistically assume is timely
ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True]
proposerIndex: blockHeader.proposerIndex,

justifiedEpoch: justifiedCheckpoint.epoch,
justifiedRoot: toRootHex(justifiedCheckpoint.root),
Expand Down Expand Up @@ -230,6 +232,8 @@ export function initializeForkChoiceFromUnfinalizedState(
blockRoot: headRoot,
targetRoot: headRoot,
timeliness: true, // Optimistically assume is timely
ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True]
proposerIndex: blockHeader.proposerIndex,

justifiedEpoch: justifiedCheckpoint.epoch,
justifiedRoot: toRootHex(justifiedCheckpoint.root),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => {
executionStatus: ExecutionStatus.PreMerge,

timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,
dataAvailabilityStatus: DataAvailabilityStatus.PreData,

parentBlockHash: null,
Expand Down Expand Up @@ -95,6 +97,8 @@ describe.skip(`getAttestationsForBlock vc=${vc}`, () => {
executionPayloadBlockHash: null,
executionStatus: ExecutionStatus.PreMerge,
timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,
dataAvailabilityStatus: DataAvailabilityStatus.PreData,

parentBlockHash: null,
Expand Down
5 changes: 0 additions & 5 deletions packages/beacon-node/test/spec/utils/specTestIterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ export const defaultSkipOpts: SkipOpts = {
// handling is not yet implemented in Lodestar.
/^gloas\/fork_choice\/on_payload_attestation_message\/.*$/,
/^heze\/fork_choice\/on_payload_attestation_message\/.*$/,
// TODO-GLOAS: re-enable after the gloas should_apply_proposer_boost rule is implemented.
// New test suite added by consensus-specs #5441; Lodestar still applies
// the pre-gloas proposer boost, so the head weight differs by the boost amount.
/^gloas\/fork_choice\/should_apply_proposer_boost\/.*$/,
/^heze\/fork_choice\/should_apply_proposer_boost\/.*$/,
// TODO GLOAS: enable this after gloas fork choice is ready
/^gloas\/fork_choice_compliance\/.*/,
/^heze\/fork_choice_compliance\/.*/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ describe("SeenPayloadEnvelopeInput", () => {
unrealizedFinalizedEpoch: 0,
unrealizedFinalizedRoot: blockRoot,
timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,
executionPayloadBlockHash: null,
executionStatus: ExecutionStatus.PreMerge,
dataAvailabilityStatus: DataAvailabilityStatus.PreData,
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/test/utils/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export const zeroProtoBlock: ProtoBlock = {
unrealizedFinalizedRoot: ZERO_HASH_HEX,

timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,

...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge},
dataAvailabilityStatus: DataAvailabilityStatus.PreData,
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/test/utils/typeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export function generateProtoBlock(overrides: Partial<ProtoBlock> = {}): ProtoBl
unrealizedFinalizedRoot: ZERO_HASH_HEX,

timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,
payloadStatus: PayloadStatus.FULL,
parentBlockHash: null,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): {
unrealizedFinalizedRoot: ZERO_HASH_HEX,

timeliness: false,
ptcTimeliness: false,
proposerIndex: 0,

executionPayloadBlockHash: null,
executionStatus: ExecutionStatus.PreMerge,
Expand Down
136 changes: 115 additions & 21 deletions packages/fork-choice/src/forkChoice/forkChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,32 +591,48 @@ export class ForkChoice implements IForkChoice {
computeDeltasMetrics?.newVoteValidators.set(newVoteValidators);

this.balances = newBalances;
/**
* The structure in line with deltas to propagate boost up the branch
* starting from the proposerIndex
*/
let proposerBoost: {root: RootHex; score: bigint} | null = null;
if (this.opts?.proposerBoost && this.proposerBoostRoot) {
const proposerBoostScore =
this.justifiedProposerBoostScore ??
getCommitteeFraction(this.fcStore.justified.totalBalance, {
slotsPerEpoch: SLOTS_PER_EPOCH,
committeePercent: this.config.PROPOSER_SCORE_BOOST,
});
proposerBoost = {root: this.proposerBoostRoot, score: proposerBoostScore};
this.justifiedProposerBoostScore = proposerBoostScore;
}

const currentSlot = this.fcStore.currentSlot;
this.protoArray.applyScoreChanges({
attestationDeltas,
proposerBoost,
const checkpoints = {
justifiedEpoch: this.fcStore.justified.checkpoint.epoch,
justifiedRoot: this.fcStore.justified.checkpoint.rootHex,
finalizedEpoch: this.fcStore.finalizedCheckpoint.epoch,
finalizedRoot: this.fcStore.finalizedCheckpoint.rootHex,
currentSlot,
});
};

const boostedBlock =
this.opts?.proposerBoost && this.proposerBoostRoot ? this.getBlockHexDefaultStatus(this.proposerBoostRoot) : null;

if (boostedBlock && isGloasBlock(boostedBlock)) {
// should_apply_proposer_boost judges the parent against the attestations known to the store,
// via is_head_weak (which also assumes equivocators' votes were already discounted before
// their balance is added back). Apply the attestation deltas first so the decision reads
// post-delta scores, then apply the boost in a second pass.
// TODO GLOAS: applyScoreChanges() updates weights and best child/descendant in one call;
// splitting them would let the two passes update weights and recompute best child/descendant
// once at the end.
// https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints});
const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : null;
// The first pass already rolled back the previous boost and left a coherent tree, so a
// withheld boost needs no second pass
if (proposerBoost !== null) {
this.protoArray.applyScoreChanges({
attestationDeltas: new Array<number>(this.protoArray.nodes.length).fill(0),
proposerBoost,
...checkpoints,
});
}
} else {
// Pre-gloas the boost is unconditional, so attestation deltas and boost deltas can propagate
// up the branch in a single pass
this.protoArray.applyScoreChanges({
attestationDeltas,
proposerBoost: boostedBlock ? this.getProposerBoost() : null,
...checkpoints,
});
}

// findHead returns the ProtoNode representing the head
const head = this.protoArray.findHead(this.fcStore.justified.checkpoint.rootHex, currentSlot);
Expand Down Expand Up @@ -826,6 +842,8 @@ export class ForkChoice implements IForkChoice {
targetRoot: toRootHex(targetRoot),
stateRoot: toRootHex(block.stateRoot),
timeliness: isTimely,
ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec),
proposerIndex: block.proposerIndex,

justifiedEpoch: stateJustifiedEpoch,
justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root),
Expand Down Expand Up @@ -1602,7 +1620,8 @@ export class ForkChoice implements IForkChoice {
// Only ever called on a block already in fork choice, so a miss is a broken invariant.
const node = this.protoArray.getNodeDefaultStatus(blockRoot);
if (node === undefined) {
// this is called for head so we should always have this in forkchoice, otherwise we have a serious error
// this is called for head or the boosted block's parent, both of which should always be
// in forkchoice, otherwise we have a serious error
throw new ForkChoiceError({code: ForkChoiceErrorCode.MISSING_PROTO_ARRAY_BLOCK, root: blockRoot});
}

Expand All @@ -1622,7 +1641,7 @@ export class ForkChoice implements IForkChoice {
// always 0. Return before fetching the state and walking the block's committees.
if (equivocatingIndices.size > 0) {
const state = this.fcStore.stateGetter({stateRoot: node.stateRoot});
// Only ever called on the head, so the state is always cached.
// Only ever called on the head or the boosted block's parent, so the state is always cached.
// A miss is a broken invariant, not a recoverable state.
if (state === null) {
throw new ForkChoiceError({
Expand Down Expand Up @@ -1685,6 +1704,81 @@ export class ForkChoice implements IForkChoice {
return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff;
}

/**
* Return true if the block arrived before the PTC (payload-timeliness committee) deadline,
* ie. block_timeliness[PTC_TIMELINESS_INDEX]. should_apply_proposer_boost uses this as the
* definition of an "early" proposer equivocation: only a sibling seen by the PTC deadline
* proves the proposer equivocated soon enough that honest nodes could have withheld the boost.
*
* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#modified-record_block_timeliness
*
* Child class can overwrite this for testing purpose.
*/
protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {

@nflaig nflaig Aug 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this should only be relevant in case of proposer equivocation to make sure the next proposer can see it and can be aware of the equivocation in time?

so using the ptc timing seems pretty arbitrary to me but the purpose if I understand this correctly is to figure out if the it was an "early equivocation"

should this be renamed?

Suggested change
protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
protected isBlockEquivocationTimely(block: BeaconBlock, blockDelaySec: number): boolean {

Edit: after thinking more about it, not sure the rename is better because it could suggest that the block is an equivocation

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I like isBlockPtcTimely

const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
}

/**
* Determine whether proposer boost should be applied to `proposerBoostRoot`.
*
* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
*
* Pre-gloas blocks always receive the boost (unconditional, backward compatible). For gloas
* blocks the boost is withheld when the parent is a weak block from the previous slot and the
* proposer of that parent equivocated (published another PTC-timely block at the same slot).
*/
private shouldApplyProposerBoost(): boolean {
if (!this.proposerBoostRoot) {
return false;
}

const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot);
// Pre-gloas blocks always get boost
if (!boostedBlock || !isGloasBlock(boostedBlock)) {
return true;
}

const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot);
if (!parentBlock) {
return true;
}

// Apply proposer boost if parent is not from the previous slot
if (parentBlock.slot + 1 < boostedBlock.slot) {
return true;
}

// Apply proposer boost if parent is not weak
if (!this.isHeadWeak(parentBlock.blockRoot)) {
return true;
}

// Parent is weak and from the previous slot: apply boost only if there are no early
// equivocations, ie. no other PTC-timely block at the parent's slot from the same proposer.
return !this.protoArray.hasEquivocatingBlock(parentBlock.proposerIndex, parentBlock.slot, parentBlock.blockRoot);
}

/**
* The proposer boost to apply to `proposerBoostRoot`, propagated up the branch by
* applyScoreChanges() together with the deltas.
*/
private getProposerBoost(): {root: RootHex; score: bigint} | null {
if (!this.proposerBoostRoot) {
return null;
}

const proposerBoostScore =
this.justifiedProposerBoostScore ??
getCommitteeFraction(this.fcStore.justified.totalBalance, {
slotsPerEpoch: SLOTS_PER_EPOCH,
committeePercent: this.config.PROPOSER_SCORE_BOOST,
});
this.justifiedProposerBoostScore = proposerBoostScore;

return {root: this.proposerBoostRoot, score: proposerBoostScore};
}

/**
* https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/fork-choice/src/protoArray/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {DataAvailabilityStatus} from "@lodestar/state-transition";
import {Epoch, RootHex, Slot, UintNum64} from "@lodestar/types";
import {Epoch, RootHex, Slot, UintNum64, ValidatorIndex} from "@lodestar/types";

// RootHex is a root as a hex string
// Used for lightweight and easy comparison
Expand Down Expand Up @@ -144,6 +144,13 @@ export type ProtoBlock = BlockExtraMeta & {
// Indicate whether block arrives in a timely manner ie. before the 4 second mark
timeliness: boolean;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want to use a single block_timeliness: number to represent these 2 flags, cc @wemeetagain to confirm

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a real benefit to this? Using bits impact readability, and we are only saving one boolean field per ProtoNode


// Indicate whether block arrives before the PTC deadline
// Spec: gloas/fork-choice.md#modified-record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX])
ptcTimeliness: boolean;

// The index of the block proposer. Used by should_apply_proposer_boost to detect proposer equivocations
proposerIndex: ValidatorIndex;

/** Payload status for this node (Gloas fork). Always FULL in pre-gloas */
payloadStatus: PayloadStatus;

Expand Down
28 changes: 27 additions & 1 deletion packages/fork-choice/src/protoArray/protoArray.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {BitArray} from "@chainsafe/ssz";
import {EFFECTIVE_BALANCE_INCREMENT, GENESIS_EPOCH, GENESIS_SLOT, PTC_SIZE} from "@lodestar/params";
import {DataAvailabilityStatus, computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition";
import {Epoch, RootHex, Slot} from "@lodestar/types";
import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types";
import {bitCount, toRootHex} from "@lodestar/utils";
import {ForkChoiceError, ForkChoiceErrorCode} from "../forkChoice/errors.js";
import {LVHExecError, LVHExecErrorCode, ProtoArrayError, ProtoArrayErrorCode} from "./errors.js";
Expand Down Expand Up @@ -2007,6 +2007,32 @@ export class ProtoArray {
return this.getNodeByIndex(nodeIndex);
}

/**
* Return true if a block other than `excludeRoot` at `slot` was proposed by `proposerIndex` and
* is PTC-timely. Used by `should_apply_proposer_boost` to detect proposer equivocations.
*
* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.14/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
*
* Iterates unique block roots (via the canonical variant) since `slot`, `proposerIndex` and
* `ptcTimeliness` are block-level properties identical across payload-status variants.
*/
hasEquivocatingBlock(proposerIndex: ValidatorIndex, slot: Slot, excludeRoot: RootHex): boolean {
for (const root of this.indices.keys()) {
if (root === excludeRoot) {
continue;
}
const nodeIndex = this.getDefaultNodeIndex(root);
if (nodeIndex === undefined) {
continue;
}
const node = this.nodes[nodeIndex];
if (node !== undefined && node.slot === slot && node.proposerIndex === proposerIndex && node.ptcTimeliness) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this can happen right now, if we receive a equivocating block over gossip, we would just ignore it

// [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot.
const proposerIndex = block.proposerIndex;
if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) {
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex});
}

this is kinda related to #9757, need to see if we wanna use seenBlockProposers or fork choice for this, but generally, the equivocation check on the api shouldn't care about timings, if there was an equivocation when submitting the payload, then it's safe to withhold as you don't need to pay the bid.value to the proposer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the IGNORE is more about the gossipsub spec, we should still process it and add to forkchoice #9805
some peers may still vote for the 2nd block, we'll use UnknownBlockInput sync to download, process and add to forkchoice soon anyway

return true;
}
}
return false;
}

/**
* Return MUTABLE ProtoBlock for blockRoot with explicit payload status
*
Expand Down
20 changes: 16 additions & 4 deletions packages/fork-choice/test/perf/forkChoice/updateHead.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,19 @@ describe("forkchoice updateHead", () => {
runUpdateHeadBenchmark({initialValidatorCount: 600_000, initialBlockCount: 64, initialEquivocatedCount});
}

// A boosted gloas block splits applyScoreChanges in two passes (attestation deltas, then the
// should_apply_proposer_boost decision, then boost deltas); measure that overhead against the
// single-pass `vc 600000 bc 64 eq 0` row above
runUpdateHeadBenchmark({
initialValidatorCount: 600_000,
initialBlockCount: 64,
initialEquivocatedCount: 0,
gloasBoosted: true,
});

function runUpdateHeadBenchmark(opts: Opts): void {
bench({
id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}`,
id: `forkChoice updateHead vc ${opts.initialValidatorCount} bc ${opts.initialBlockCount} eq ${opts.initialEquivocatedCount}${opts.gloasBoosted ? " gloas boosted" : ""}`,
before: () => {
const forkChoice = initializeForkChoice(opts);

Expand All @@ -43,7 +53,7 @@ describe("forkchoice updateHead", () => {
},
beforeEach: (data) => {
// Flip all votes every run
everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice);
everyoneVotes(data.currentVoteIs1 ? data.vote2 : data.vote1, data.forkChoice, opts);
data.currentVoteIs1 = !data.currentVoteIs1;
return data;
},
Expand All @@ -54,9 +64,11 @@ describe("forkchoice updateHead", () => {
}
});

function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice): void {
function everyoneVotes(vote: ProtoBlock, forkChoice: ForkChoice, opts: Opts): void {
const nextRoot = vote.blockRoot;
// gloas blocks only carry PENDING/EMPTY variants until the envelope is revealed
const payloadStatus = opts.gloasBoosted ? PayloadStatus.PENDING : PayloadStatus.FULL;
for (let i = 0; i < forkChoice["balances"].length; i++) {
forkChoice["addLatestMessage"](i, vote.slot, nextRoot, PayloadStatus.FULL);
forkChoice["addLatestMessage"](i, vote.slot, nextRoot, payloadStatus);
}
}
Loading
Loading