Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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 @@ -172,6 +172,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,

...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge},
dataAvailabilityStatus: DataAvailabilityStatus.PreData,
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
127 changes: 106 additions & 21 deletions packages/fork-choice/src/forkChoice/forkChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,32 +571,41 @@ 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: number} | 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.
// https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#new-should_apply_proposer_boost
this.protoArray.applyScoreChanges({attestationDeltas, proposerBoost: null, ...checkpoints});
const proposerBoost = this.shouldApplyProposerBoost() ? this.getProposerBoost() : 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 @@ -817,6 +826,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 @@ -1584,7 +1595,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 @@ -1604,7 +1616,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 @@ -1668,6 +1680,79 @@ 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].
*
* https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#modified-record_block_timeliness
*
* Child class can overwrite this for testing purpose.
*/
protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
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.12/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: number} | 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 {GENESIS_EPOCH, 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 @@ -1983,6 +1983,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.12/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) {
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