Skip to content
Draft
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
13 changes: 12 additions & 1 deletion packages/beacon-node/src/network/processor/gossipHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,20 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
}
throw e;
} finally {
const proposerIndex = signedBlock.message.proposerIndex;
// A block with a verified proposer signature counts as a proposal of its proposer in fork choice whether it is
// imported or not, so the equivocation view does not depend on how many repeat proposals are validated
if (chain.seenBlockProposers.hasBlockRoot(slot, proposerIndex, blockRootHex)) {
const receiveDelaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime);
chain.forkChoice.onSignedBlockHeader(
slot,
proposerIndex,
blockRootHex,
chain.forkChoice.isPtcTimely(slot, receiveDelaySec)
);
}
// The block received from the network may have established an equivocation, either by conflicting
// with a previously observed block root (REPEAT_PROPOSAL) or with a root observed during validation
const proposerIndex = signedBlock.message.proposerIndex;
if (chain.seenBlockProposers.isEquivocating(slot, proposerIndex)) {
chain.processProposerEquivocation(slot, proposerIndex);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DataAvailabilityStatus,
computeAnchorCheckpoint,
computeEpochAtSlot,
computeStartSlotAtEpoch,
getEffectiveBalanceIncrementsZeroed,
getTemporaryBlockHeader,
processSlots,
Expand Down Expand Up @@ -163,6 +164,13 @@ 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("isPtcTimely - only for the current slot before the PTC deadline", () => {
forkChoice.updateTime(16);
expect(forkChoice.isPtcTimely(16, 1)).toBe(true);
expect(forkChoice.isPtcTimely(16, config.SECONDS_PER_SLOT - 1)).toBe(false);
expect(forkChoice.isPtcTimely(15, 1)).toBe(false);
});

it("prune - should prune old blocks", () => {
const {blockHeader} = computeAnchorCheckpoint(config, anchorState);
const finalizedRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader);
Expand Down Expand Up @@ -269,7 +277,14 @@ describe("LodestarForkChoice", () => {
executionStatus,
dataAvailabilityStatus
);
const signedProposals = (forkChoice as unknown as {signedProposals: Map<Slot, unknown>}).signedProposals;
expect(signedProposals.has(8)).toBe(true);
forkChoice.prune(hashBlock(block16.message));
const finalizedSlot = computeStartSlotAtEpoch(forkChoice.getFinalizedCheckpoint().epoch);
expect([...signedProposals.keys()].filter((slot) => slot < finalizedSlot)).toEqual([]);
// signed blocks for pruned slots are not recorded anymore
forkChoice.onSignedBlockHeader(finalizedSlot - 1, 0, hashBlock(block08.message), true);
expect(signedProposals.has(finalizedSlot - 1)).toBe(false);
expect(forkChoice.getAllAncestorBlocks(hashBlock(block16.message), PayloadStatus.FULL).length).toBeWithMessage(
1,
"getAllAncestorBlocks returns the finalized block itself as the last (boundary) node"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, onSignedBlockHeader} = await runBeaconBlockRepeatProposal(denebConfig, {
recorded: true,
});
// the signed block counts as a proposal in fork choice regardless of the import
expect(onSignedBlockHeader).toHaveBeenCalledOnce();
expect(onSignedBlockHeader.mock.calls[0]).toEqual([1, 3, expect.any(String), true]);

// imported so LMD-GHOST can weigh it ...
expect(processBlock).toHaveBeenCalledOnce();
Expand All @@ -74,7 +79,10 @@ 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, onSignedBlockHeader} = await runBeaconBlockRepeatProposal(denebConfig, {
recorded: false,
});
expect(onSignedBlockHeader).not.toHaveBeenCalled();

expect(processBlock).not.toHaveBeenCalled();
expect(threw).toBe(true);
Expand Down Expand Up @@ -162,7 +170,11 @@ async function runBeaconBlockProcessingError(
async function runBeaconBlockRepeatProposal(
config: BeaconConfig,
{recorded}: {recorded: boolean}
): Promise<{processBlock: ReturnType<typeof vi.fn>; threw: boolean}> {
): Promise<{
processBlock: ReturnType<typeof vi.fn>;
threw: boolean;
onSignedBlockHeader: ReturnType<typeof vi.fn>;
}> {
const logger = testLogger();
const peerIdStr = "16Uiu2HAmTestGossipPeer" as PeerIdStr;
const signedBlock = ssz.deneb.SignedBeaconBlock.defaultValue();
Expand Down Expand Up @@ -201,10 +213,16 @@ async function runBeaconBlockRepeatProposal(
}

const processBlock = vi.fn().mockResolvedValue(undefined);
const onSignedBlockHeader = vi.fn();
const chain = {
clock: new ClockStopped(1),
custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig,
emitter: new ChainEventEmitter(),
forkChoice: {
onSignedBlockHeader,
isPtcTimely: vi.fn().mockReturnValue(true),
} as unknown as IBeaconChain["forkChoice"],
genesisTime: 0,
getBlobsTracker: {triggerGetBlobs: vi.fn()},
logger,
processBlock,
Expand Down Expand Up @@ -257,7 +275,7 @@ async function runBeaconBlockRepeatProposal(
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));

return {processBlock, threw};
return {processBlock, threw, onSignedBlockHeader};
}

function getExecutionBlockError(
Expand Down
75 changes: 72 additions & 3 deletions packages/fork-choice/src/forkChoice/forkChoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export type UpdateAndGetHeadOpt =

// the initial vote epoch for all validators
const INIT_VOTE_SLOT: Slot = 0;
/** Two signed block roots establish a proposer equivocation, the first two seen are also the most timely */
const MAX_SIGNED_PROPOSALS_PER_SLOT_PROPOSER = 2;

/**
* Provides an implementation of "Ethereum Consensus -- Beacon Chain Fork Choice":
Expand Down Expand Up @@ -154,6 +156,13 @@ export class ForkChoice implements IForkChoice {
private validatedAttestationDatas = new Set<string>();
/** Boost the entire branch with this proposer root as the leaf */
private proposerBoostRoot: RootHex | null = null;
/**
* Block roots signed by a proposer for a slot with their PTC timeliness, imported or not. Two roots establish
* an equivocation and the first two seen are the most timely, so later ones are not kept.
*/
private readonly signedProposals = new MapDef<Slot, MapDef<ValidatorIndex, Map<RootHex, boolean>>>(
() => new MapDef(() => new Map())
);
/** Score to use in proposer boost, evaluated lazily from justified balances */
private justifiedProposerBoostScore: bigint | null = null;
/** The current effective balances */
Expand Down Expand Up @@ -938,6 +947,7 @@ export class ForkChoice implements IForkChoice {
};

this.protoArray.onBlock(protoBlock, currentSlot, proposerBoostRoot);
this.recordSignedProposal(slot, block.proposerIndex, blockRootHex, protoBlock.ptcTimeliness);

if (isProposerBoostBlock) {
this.proposerBoostRoot = blockRootHex;
Expand Down Expand Up @@ -1065,6 +1075,19 @@ export class ForkChoice implements IForkChoice {
}
}

/**
* Record a block signed by `proposerIndex` for `slot` that was seen but not imported, e.g. a repeat proposal
* ignored on gossip. Imported blocks are recorded by onBlock. The spec reads proposer equivocations from
* store.blocks, ie. only valid blocks. Counting signed blocks instead keeps the view consistent across nodes
* that bound how many repeat proposals they validate.
*/
onSignedBlockHeader(slot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex, ptcTimely: boolean): void {
if (slot < computeStartSlotAtEpoch(this.fcStore.finalizedCheckpoint.epoch)) {
return;
}
this.recordSignedProposal(slot, proposerIndex, blockRoot, ptcTimely);
}

/**
* Small different from the spec:
* We already call is_slashable_attestation_data() and is_valid_indexed_attestation
Expand Down Expand Up @@ -1330,6 +1353,12 @@ export class ForkChoice implements IForkChoice {
*/
prune(finalizedRoot: RootHex): ProtoBlock[] {
const prunedNodes = this.protoArray.maybePrune(finalizedRoot);
const finalizedSlot = computeStartSlotAtEpoch(this.fcStore.finalizedCheckpoint.epoch);
for (const slot of this.signedProposals.keys()) {
if (slot < finalizedSlot) {
this.signedProposals.delete(slot);
}
}
const prunedCount = prunedNodes.length;
for (let i = 0; i < this.voteNextSlots.length; i++) {
const currentIndex = this.voteCurrentIndices[i];
Expand Down Expand Up @@ -1750,8 +1779,13 @@ export class ForkChoice implements IForkChoice {
* Child class can overwrite this for testing purpose.
*/
protected isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean {
return this.isPtcTimely(block.slot, blockDelaySec);
}

/** Return true if a block for `slot` received `receiveDelaySec` after the slot start is PTC-timely */
isPtcTimely(slot: Slot, receiveDelaySec: number): boolean {
const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS);
return this.fcStore.currentSlot === block.slot && blockDelaySec * 1000 < ptcThresholdMs;
return this.fcStore.currentSlot === slot && receiveDelaySec * 1000 < ptcThresholdMs;
}

/**
Expand Down Expand Up @@ -1802,7 +1836,7 @@ export class ForkChoice implements IForkChoice {

// 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(
return !this.hasEquivocatingBlock(
parentBlock.proposerIndex,
parentBlock.slot,
parentBlock.blockRoot,
Expand All @@ -1820,7 +1854,42 @@ export class ForkChoice implements IForkChoice {
private isProposerEquivocation(block: ProtoBlock): boolean {
// Any known sibling counts, timely or not. Timeliness only matters for withholding the boost from
// the next proposer, the reorg itself is safe to attempt whenever the equivocation is visible.
return this.protoArray.hasEquivocatingBlock(block.proposerIndex, block.slot, block.blockRoot, false);
return this.hasEquivocatingBlock(block.proposerIndex, block.slot, block.blockRoot, false);
}

private recordSignedProposal(
slot: Slot,
proposerIndex: ValidatorIndex,
blockRoot: RootHex,
ptcTimely: boolean
): void {
const ptcTimelyByRoot = this.signedProposals.getOrDefault(slot).getOrDefault(proposerIndex);
if (ptcTimelyByRoot.size < MAX_SIGNED_PROPOSALS_PER_SLOT_PROPOSER && !ptcTimelyByRoot.has(blockRoot)) {
ptcTimelyByRoot.set(blockRoot, ptcTimely);
}
}

/**
* Return true if a block other than `excludeRoot` at `slot` was signed by `proposerIndex`.
* `should_apply_proposer_boost` only counts PTC-timely blocks (`ptcTimelyOnly`), `is_proposer_equivocation`
* counts any known block.
*/
private hasEquivocatingBlock(
proposerIndex: ValidatorIndex,
slot: Slot,
excludeRoot: RootHex,
ptcTimelyOnly: boolean
): boolean {
const ptcTimelyByRoot = this.signedProposals.get(slot)?.get(proposerIndex);
if (ptcTimelyByRoot === undefined) {
return false;
}
for (const [blockRoot, ptcTimely] of ptcTimelyByRoot) {
if (blockRoot !== excludeRoot && (ptcTimely || !ptcTimelyOnly)) {
return true;
}
}
return false;
}

/**
Expand Down
18 changes: 17 additions & 1 deletion packages/fork-choice/src/forkChoice/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import {DataAvailabilityStatus, EffectiveBalanceIncrements, IBeaconStateView} from "@lodestar/state-transition";
import {AttesterSlashing, BeaconBlock, Epoch, IndexedAttestation, Root, RootHex, Slot} from "@lodestar/types";
import {
AttesterSlashing,
BeaconBlock,
Epoch,
IndexedAttestation,
Root,
RootHex,
Slot,
ValidatorIndex,
} from "@lodestar/types";
import {
BlockExecutionStatus,
LVHExecResponse,
Expand Down Expand Up @@ -188,6 +197,13 @@ export interface IForkChoice {
* https://github.com/ethereum/consensus-specs/blob/v1.2.0-rc.3/specs/phase0/fork-choice.md#on_attester_slashing
*/
onAttesterSlashing(slashing: AttesterSlashing): void;
/**
* Record a signed block that was seen but not imported, e.g. a repeat proposal ignored on gossip, so it counts
* as a proposer equivocation for `should_apply_proposer_boost` and `get_proposer_head`
*/
onSignedBlockHeader(slot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex, ptcTimely: boolean): void;
/** Return true if a block for `slot` received `receiveDelaySec` after the slot start is PTC-timely */
isPtcTimely(slot: Slot, receiveDelaySec: number): boolean;
/**
* Process PTC (Payload Timeliness Committee) messages from a block
* Updates the PTC votes for the attested beacon block
Expand Down
37 changes: 1 addition & 36 deletions 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, ValidatorIndex} from "@lodestar/types";
import {Epoch, RootHex, Slot} 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,41 +2007,6 @@ export class ProtoArray {
return this.getNodeByIndex(nodeIndex);
}

/**
* Return true if a block other than `excludeRoot` at `slot` was proposed by `proposerIndex`.
* `should_apply_proposer_boost` only counts PTC-timely blocks (`ptcTimelyOnly`), `is_proposer_equivocation`
* counts any known block.
*
* 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,
ptcTimelyOnly: boolean
): 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 || !ptcTimelyOnly)
) {
return true;
}
}
return false;
}

/**
* Return MUTABLE ProtoBlock for blockRoot with explicit payload status
*
Expand Down
Loading
Loading