Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/beacon-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"test:sim": "vitest run test/sim/**/*.test.ts",
"test:sim:blobs": "vitest run test/sim/4844-interop.test.ts",
"download-spec-tests": "node --loader=ts-node/esm test/spec/downloadTests.ts",
"download-compliance-fc-tests": "../../scripts/download-compliance-fc-tests.sh",
"test:spec:bls": "vitest run --project spec-minimal test/spec/bls/",
"test:spec:general": "vitest run --project spec-minimal test/spec/general/",
"test:spec:minimal": "vitest run --project spec-minimal test/spec/presets/",
Expand Down
163 changes: 124 additions & 39 deletions packages/beacon-node/test/spec/presets/fork_choice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {CheckpointWithHex, ExecutionStatus, ForkChoice} from "@lodestar/fork-cho
import {testLogger} from "@lodestar/logger/test-utils";
import {
ACTIVE_PRESET,
EFFECTIVE_BALANCE_INCREMENT,
ForkPostDeneb,
ForkPostFulu,
ForkPostGloas,
Expand All @@ -22,9 +23,11 @@ import {
BeaconStateView,
DataAvailabilityStatus,
IBeaconStateViewGloas,
computeEpochAtSlot,
createCachedBeaconState,
createPubkeyCache,
createSingleSignatureSetFromComponents,
getIndexedAttestation,
getPayloadAttestationDataSigningRoot,
isExecutionStateType,
isGloasStateType,
Expand Down Expand Up @@ -57,9 +60,11 @@ import {
verifyExecutionPayloadEnvelope,
verifyExecutionPayloadEnvelopeSignature,
} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js";
import {BlockError, BlockErrorCode} from "../../../src/chain/errors/blockError.js";
import {BeaconChain, ChainEvent} from "../../../src/chain/index.js";
import {defaultChainOptions} from "../../../src/chain/options.js";
import {RegenCaller} from "../../../src/chain/regen/index.js";
import {getShufflingForAttestationVerification} from "../../../src/chain/validation/attestation.js";
import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js";
import {ZERO_HASH_HEX} from "../../../src/constants/constants.js";
import {ExecutionPayloadStatus} from "../../../src/execution/engine/interface.js";
Expand Down Expand Up @@ -187,13 +192,45 @@ const forkChoiceTest =
logger.debug(`Step ${i}/${stepsLen} attestation`, {root: step.attestation, valid: isValid});
const attestation = testcase.attestations.get(step.attestation);
if (!attestation) throw Error(`No attestation ${step.attestation}`);
const headState = chain.getHeadState() as BeaconStateView;
const attDataRootHex = toHexString(sszTypesFor(fork).AttestationData.hashTreeRoot(attestation.data));
const indexedAttestation = headState.cachedState.epochCtx.getIndexedAttestation(
ForkSeq[fork],
attestation
);

// Spec `validate_on_attestation` requires `get_current_slot(store) >= attestation.data.slot + 1`
// (an attestation may only influence fork choice from the slot AFTER it was created). Lodestar
// enforces this 1-slot delay in the gossip/attestation-pool layer, not in `forkChoice.onAttestation`,
// so replicate the precondition here since the runner calls `onAttestation` directly.
const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000));
if (currentSlot < attestation.data.slot + 1) {
if (isValid) {
throw Error(`Attestation not yet 1 slot old but marked valid at step ${i}`);
}
logger.debug(
`Step ${i}/${stepsLen} skip attestation: not yet 1 slot old (spec on_attestation rejects)`,
{
attSlot: attestation.data.slot,
currentSlot,
}
);
continue;
}

// `on_attestation` decodes aggregation_bits with the shuffling at the attestation's
// target checkpoint, not the head state — resolve it via ShufflingCache + regen so
// cross-epoch fork attestations (surfaced by the compliance suite) decode correctly.
// The resolution runs inside the try so that errors on `valid: false` steps (e.g.
// attesting to a future block) count as the expected rejection.
const attHeadBlock = chain.forkChoice.getBlockHexDefaultStatus(toHex(attestation.data.beaconBlockRoot));
if (attHeadBlock === null && isValid) {
throw Error(`Attestation beacon block root unknown to fork choice at step ${i}`);
}
try {
if (attHeadBlock === null) throw Error("Unknown attestation head block (expected rejection)");
const shuffling = await getShufflingForAttestationVerification(
chain,
computeEpochAtSlot(attestation.data.slot),
attHeadBlock,
RegenCaller.validateGossipAttestation
);
const indexedAttestation = getIndexedAttestation(shuffling, ForkSeq[fork], attestation);
chain.forkChoice.onAttestation(indexedAttestation, attDataRootHex);
if (!isValid) throw Error("Expect error since this is a negative test");
} catch (e) {
Expand Down Expand Up @@ -251,27 +288,31 @@ const forkChoiceTest =
);
}

// Signature verification, matching the `validateGossipPayloadAttestationMessage` flow
const validatorPubkey = pubkeyCache.get(payloadAttestationMessage.validatorIndex);
if (!validatorPubkey) {
throw Error(`Unknown validator index ${payloadAttestationMessage.validatorIndex}`);
}
const signatureSet = createSingleSignatureSetFromComponents(
validatorPubkey,
getPayloadAttestationDataSigningRoot(beaconConfig, payloadAttestationMessage.data),
payloadAttestationMessage.signature
);
let signatureValidity: boolean;
try {
signatureValidity = await chain.bls.verifySignatureSets([signatureSet], {
verifyOnMainThread: true,
batchable: true,
priority: true,
});
} catch {
signatureValidity = false;
// Signature verification (matching `validateGossipPayloadAttestationMessage`) — skipped for
// bls_setting !== 1: compliance fixtures use placeholder signatures (bls_setting: 2), so the
// spec reference runner does not verify. Mirror the block-import accommodation (`validSignatures`).
if (testcase.meta?.bls_setting === BigInt(1)) {
const validatorPubkey = pubkeyCache.get(payloadAttestationMessage.validatorIndex);
if (!validatorPubkey) {
throw Error(`Unknown validator index ${payloadAttestationMessage.validatorIndex}`);
}
const signatureSet = createSingleSignatureSetFromComponents(
validatorPubkey,
getPayloadAttestationDataSigningRoot(beaconConfig, payloadAttestationMessage.data),
payloadAttestationMessage.signature
);
let signatureValidity: boolean;
try {
signatureValidity = await chain.bls.verifySignatureSets([signatureSet], {
verifyOnMainThread: true,
batchable: true,
priority: true,
});
} catch {
signatureValidity = false;
}
if (!signatureValidity) throw Error("Invalid payload attestation signature");
}
if (!signatureValidity) throw Error("Invalid payload attestation signature");

chain.forkChoice.notifyPtcMessages(
blockRoot,
Expand Down Expand Up @@ -463,10 +504,19 @@ const forkChoiceTest =
seenTimestampSec: tickTime,
validBlobSidecars: BlobSidecarValidation.Full,
importAttestations: AttestationImportOpt.Force,
validSignatures: testcase.meta?.bls_setting !== BigInt(1),
});
if (!isValid) throw Error("Expect error since this is a negative test");
} catch (e) {
if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e;
// Spec `on_block` is a no-op success on a known block; lodestar's production
// import path rejects with ALREADY_KNOWN. Treat as success in the spec runner.
if (isValid && e instanceof BlockError && e.type.code === BlockErrorCode.ALREADY_KNOWN) {
logger.debug(`Step ${i}/${stepsLen} block already known — treating as no-op success`, {
id: step.block,
});
} else if (isValid || (e as Error).message === "Expect error since this is a negative test") {
throw e;
}
}
}

Expand Down Expand Up @@ -497,16 +547,20 @@ const forkChoiceTest =
);
verifyExecutionPayloadEnvelope(beaconConfig, blockState as IBeaconStateViewGloas, envelope.message);

// Verify signature
const sigValid = await verifyExecutionPayloadEnvelopeSignature(
beaconConfig,
blockState as IBeaconStateViewGloas,
pubkeyCache,
envelope,
blockState.latestBlockHeader.proposerIndex,
chain.bls
);
if (!sigValid) throw Error("Invalid execution payload envelope signature");
// Verify signature — skipped for bls_setting !== 1: compliance fixtures use placeholder
// signatures (bls_setting: 2), so the spec reference runner does not verify. Mirror the
// block-import accommodation (`validSignatures` above).
if (testcase.meta?.bls_setting === BigInt(1)) {
const sigValid = await verifyExecutionPayloadEnvelopeSignature(
beaconConfig,
blockState as IBeaconStateViewGloas,
pubkeyCache,
envelope,
blockState.latestBlockHeader.proposerIndex,
chain.bls
);
if (!sigValid) throw Error("Invalid execution payload envelope signature");
}

// Add predefined VALID status for the payload's block hash so the EL mock accepts it
executionEngineBackend.addPredefinedPayloadStatus(blockHash, {
Expand Down Expand Up @@ -597,15 +651,44 @@ const forkChoiceTest =
);
}
if (step.checks.head_payload_status !== undefined) {
// Map our PayloadStatus enum to spec's numbering:
// Spec: EMPTY=0, FULL=1, PENDING=2
// Ours: PENDING=0, EMPTY=1, FULL=2
// Spec: EMPTY=0, FULL=1, PENDING=2; Ours: PENDING=0, EMPTY=1, FULL=2
const payloadStatusToSpec: Record<number, number> = {0: 2, 1: 0, 2: 1};
expect(payloadStatusToSpec[head.payloadStatus]).toEqualWithMessage(
bnToNum(step.checks.head_payload_status),
`Invalid head payload status at step ${i}`
);
}
if (step.checks.viable_for_head_roots_and_weights !== undefined) {
const expected = step.checks.viable_for_head_roots_and_weights
.map((entry) => ({root: entry.root, weight: bnToNum(entry.weight)}))
.sort((a, b) => a.root.localeCompare(b.root));
const actual = (chain.forkChoice as ForkChoice)
.getViableHeads()
.map(({root, weight}) => ({root, weight}))
.sort((a, b) => a.root.localeCompare(b.root));

// The set of viable heads is determined by justified/finalized epochs, not weight,
// so it must match exactly. Comparing the root sets (not a subset) also rejects a
// degenerate empty result.
expect(actual.map((h) => h.root)).toEqualWithMessage(
expected.map((h) => h.root),
`Invalid viable head roots at step ${i}`
);

// Weights are stored in EFFECTIVE_BALANCE_INCREMENT units, so the proposer-boost
// score is floored to whole ETH while the spec keeps Gwei precision. Attestation
// weight is exact, so the per-head divergence is only the boost quantization, bounded
// by (100 - gcd(PROPOSER_SCORE_BOOST, 100) + PROPOSER_SCORE_BOOST) / 100 = 1.2 ETH,
// independent of SLOTS_PER_EPOCH and total balance. Compare within that bound.
const tolerance = 1.2 * EFFECTIVE_BALANCE_INCREMENT;
for (const [k, {root, weight}] of actual.entries()) {
const diff = Math.abs(weight - expected[k].weight);
expect(
diff,
`Viable head ${root} weight off by ${diff} Gwei (> 1.2 ETH) at step ${i}`
).toBeLessThanOrEqual(tolerance);
}
}
if (step.checks.should_override_forkchoice_update) {
const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000));
const result = chain.forkChoice.shouldOverrideForkChoiceUpdate(
Expand Down Expand Up @@ -856,6 +939,7 @@ type Checks = {
block_root: RootHex;
votes: (boolean | null)[];
};
viable_for_head_roots_and_weights?: {root: RootHex; weight: bigint}[];
};
};

Expand Down Expand Up @@ -911,4 +995,5 @@ function isCheck(step: Step): step is Checks {
specTestIterator(path.join(ethereumConsensusSpecsTests.outputDir, "tests", ACTIVE_PRESET), {
fork_choice: {type: RunnerType.default, fn: forkChoiceTest({onlyPredefinedResponses: false})},
sync: {type: RunnerType.default, fn: forkChoiceTest({onlyPredefinedResponses: true})},
fork_choice_compliance: {type: RunnerType.default, fn: forkChoiceTest({onlyPredefinedResponses: false})},
});
1 change: 1 addition & 0 deletions packages/beacon-node/test/spec/utils/specTestIterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const coveredTestRunners = [
"finality",
"fork",
"fork_choice",
"fork_choice_compliance",
"sync",
"fork",
"genesis",
Expand Down
4 changes: 4 additions & 0 deletions packages/fork-choice/src/forkChoice/forkChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,10 @@ export class ForkChoice implements IForkChoice {
return this.protoArray.nodes.filter((node) => node.bestChild === undefined);
}

getViableHeads(): {root: RootHex; weight: number}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}
Comment on lines +597 to +599

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the return type to bigint to match the fix in ProtoArray and avoid precision loss.

Suggested change
getViableHeads(): {root: RootHex; weight: number}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}
getViableHeads(): {root: RootHex; weight: bigint}[] {
return this.protoArray.getViableHeads(this.fcStore.currentSlot);
}


/** This is for the debug API only */
getAllNodes(): ProtoNode[] {
return this.protoArray.nodes;
Expand Down
49 changes: 48 additions & 1 deletion packages/fork-choice/src/protoArray/protoArray.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {BitArray} from "@chainsafe/ssz";
import {GENESIS_EPOCH, PTC_SIZE} from "@lodestar/params";
import {EFFECTIVE_BALANCE_INCREMENT, 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";
Expand Down Expand Up @@ -1541,6 +1541,53 @@ export class ProtoArray {
return correctJustified && correctFinalized;
}

/** Weight is converted from increment units to Gwei. */
getViableHeads(currentSlot: Slot): {root: RootHex; weight: number}[] {
// Mirror the spec's `get_filtered_block_tree`, which is rooted at the store's justified
// checkpoint: a viable head is a leaf (no viable descendant, i.e. `bestChild === undefined`)
// that descends from the justified checkpoint block AND is itself viable for head. Iterating
// all nodes without the justified-descendant filter would wrongly include FFG-viable leaves
// that hang off the finalized checkpoint on a branch not under the current justified checkpoint.
const justifiedIndex = this.getDefaultNodeIndex(this.justifiedRoot);
const justifiedSlot = justifiedIndex !== undefined ? this.getNodeByIndex(justifiedIndex)?.slot : undefined;
// Gloas payload-status variants share a blockRoot and more than one can be a leaf (EMPTY and
// FULL are both parented under PENDING); the spec's filtered tree is keyed by block root, so
// emit each root at most once. Which variant's weight the spec expects is an open gloas
// divergence — deterministically keep the heaviest qualifying variant.
const weightByRoot = new Map<RootHex, number>();
for (const node of this.nodes) {
if (node.bestChild !== undefined || !this.nodeIsViableForHead(node, currentSlot)) {
continue;
}
if (this.justifiedEpoch !== GENESIS_EPOCH && !this.descendsFromJustified(node, justifiedSlot)) {
continue;
}
const prev = weightByRoot.get(node.blockRoot);
if (prev === undefined || node.weight > prev) {
weightByRoot.set(node.blockRoot, node.weight);
}
}
return Array.from(weightByRoot.entries()).map(([root, weight]) => ({
root,
weight: weight * EFFECTIVE_BALANCE_INCREMENT,
}));
}

/**
* True if `node` is the justified checkpoint block or one of its descendants.
* Parent slots are non-increasing, so the walk stops once it passes the justified block's slot.
*/
private descendsFromJustified(node: ProtoNode, justifiedSlot: Slot | undefined): boolean {
let current: ProtoNode | undefined = node;
while (current !== undefined && (justifiedSlot === undefined || current.slot >= justifiedSlot)) {
if (current.blockRoot === this.justifiedRoot) {
return true;
}
current = current.parent !== undefined ? this.getNodeByIndex(current.parent) : undefined;
}
return false;
}
Comment on lines +1545 to +1589

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of getViableHeads uses number for weights, which leads to precision loss on Mainnet.

In Lodestar, node.weight is stored in EFFECTIVE_BALANCE_INCREMENT units (1 increment = 1 ETH = 10^9 Gwei). On Mainnet, the total active balance is currently ~33M ETH. Multiplying 33,000,000 * 1,000,000,000 results in 3.3 * 10^16, which exceeds Number.MAX_SAFE_INTEGER (9,007,199,254,740,9919 * 10^15).

To maintain accuracy for production use cases (even if compliance tests currently use smaller weights), this should return bigint.

Suggested change
getViableHeads(currentSlot: Slot): {root: RootHex; weight: number}[] {
const result: {root: RootHex; weight: number}[] = [];
for (const node of this.nodes) {
if (node.bestChild === undefined && this.nodeIsViableForHead(node, currentSlot)) {
result.push({root: node.blockRoot, weight: node.weight * EFFECTIVE_BALANCE_INCREMENT});
}
}
return result;
}
getViableHeads(currentSlot: Slot): {root: RootHex; weight: bigint}[] {
const result: {root: RootHex; weight: bigint}[] = [];
for (const node of this.nodes) {
if (node.bestChild === undefined && this.nodeIsViableForHead(node, currentSlot)) {
result.push({root: node.blockRoot, weight: BigInt(node.weight) * BigInt(EFFECTIVE_BALANCE_INCREMENT)});
}
}
return result;
}


/**
* Return `true` if `node` is equal to or a descendant of the finalized node.
* This function helps improve performance of nodeIsViableForHead a lot by avoiding
Expand Down
Loading
Loading