Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/api/test/unit/builder/testData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const testData: GenericServerTestCases<Endpoints> = {
},
submitBlindedBlock: {
args: {signedBlindedBlock: {data: ssz.electra.SignedBlindedBeaconBlock.defaultValue()}},
res: {data: ssz.deneb.ExecutionPayloadAndBlobsBundle.defaultValue(), meta: {version: ForkName.electra}},
res: {data: ssz.electra.ExecutionPayloadAndBlobsBundle.defaultValue(), meta: {version: ForkName.electra}},
},
submitBlindedBlockV2: {
args: {signedBlindedBlock: {data: ssz.fulu.SignedBlindedBeaconBlock.defaultValue()}},
Expand Down
28 changes: 18 additions & 10 deletions packages/beacon-node/src/api/impl/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
isForkPostBellatrix,
isForkPostDeneb,
isForkPostElectra,
isForkPostFulu,
isForkPostGloas,
} from "@lodestar/params";
import {
Expand Down Expand Up @@ -1124,26 +1125,33 @@ export function getValidatorApi(
async getProposerDuties({epoch}, _context, opts?: {v2?: boolean}) {
notWhileSyncing();

// Early check that epoch is no more than current_epoch + 1, or allow for pre-genesis
const currentEpoch = currentEpochWithDisparity();
const nextEpoch = currentEpoch + 1;
if (currentEpoch >= 0 && epoch > nextEpoch) {
const startSlot = computeStartSlotAtEpoch(epoch);
const prepareNextSlotLookAheadMs =
config.SLOT_DURATION_MS - config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS);
const toNextEpochMs = msToNextEpoch();
const nearNextEpoch = toNextEpochMs < prepareNextSlotLookAheadMs;
// Post-Fulu the proposer lookahead is deterministic and known a full epoch ahead, so
// close to the boundary `currentEpoch + 2` is serveable from the upcoming-epoch
// checkpoint state (its `nextProposers`). Pre-Fulu / mid-epoch: `currentEpoch + 1` max.
const isPostFulu = isForkPostFulu(config.getForkName(startSlot));
const maxFutureEpoch = isPostFulu && nearNextEpoch && opts?.v2 ? nextEpoch + 1 : nextEpoch;
if (currentEpoch >= 0 && epoch > maxFutureEpoch) {
Comment thread
wemeetagain marked this conversation as resolved.
throw new ApiError(400, `Requested epoch ${epoch} must not be more than one epoch in the future`);
}

const head = chain.forkChoice.getHead();
let state: IBeaconStateView | undefined = undefined;
const startSlot = computeStartSlotAtEpoch(epoch);
const prepareNextSlotLookAheadMs =
config.SLOT_DURATION_MS - config.getSlotComponentDurationMs(PREPARE_NEXT_SLOT_BPS);
const toNextEpochMs = msToNextEpoch();
// validators may request next epoch's duties when it's close to next epoch
// this is to avoid missed block proposal due to 0 epoch look ahead
if (epoch === nextEpoch && toNextEpochMs < prepareNextSlotLookAheadMs) {
// this is to avoid missed block proposal due to 0 epoch look ahead.
// Post-Fulu, `nextEpoch + 1` is served from the same upcoming-epoch (`nextEpoch`)
// checkpoint state via its `nextProposers` (deterministic proposer lookahead).
if (nearNextEpoch && (epoch === nextEpoch || (isPostFulu && epoch === nextEpoch + 1))) {
// wait for maximum 1 slot for cp state which is the timeout of validator api
const cpState = await waitForCheckpointState({
rootHex: head.blockRoot,
epoch,
epoch: nextEpoch,
});
if (cpState) {
state = cpState;
Expand Down Expand Up @@ -1218,7 +1226,7 @@ export function getValidatorApi(
// It should be set to the latest block applied to `self` or the genesis block root.
const dependentRoot =
// In v2 the dependent root is different after fulu due to deterministic proposer lookahead
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state) ||
proposerShufflingDecisionRoot(opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0, state, epoch) ||
(await getGenesisBlockRoot(state));

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import {routes} from "@lodestar/api";
import {config} from "@lodestar/config/default";
import {MAX_EFFECTIVE_BALANCE, SLOTS_PER_EPOCH} from "@lodestar/params";
import {getConfig} from "@lodestar/config/test-utils";
import {ForkName, MAX_EFFECTIVE_BALANCE, SLOTS_PER_EPOCH} from "@lodestar/params";
import {BeaconStateAllForks, BeaconStateView} from "@lodestar/state-transition";
import {Slot} from "@lodestar/types";
import {SYNC_TOLERANCE_EPOCHS, getValidatorApi} from "../../../../../../src/api/impl/validator/index.js";
Expand All @@ -14,6 +14,8 @@ import {generateState, zeroProtoBlock} from "../../../../../utils/state.js";
import {generateValidators} from "../../../../../utils/validator.js";

describe("get proposers api impl", () => {
// mainnet is post-Fulu; test the realistic deterministic-proposer-lookahead path
const config = getConfig(ForkName.fulu);
const currentEpoch = 2;
const currentSlot = SLOTS_PER_EPOCH * currentEpoch;

Expand All @@ -25,7 +27,7 @@ describe("get proposers api impl", () => {
beforeEach(() => {
vi.useFakeTimers({now: 0});
vi.advanceTimersByTime(currentSlot * config.SLOT_DURATION_MS);
modules = getApiTestModules({clock: "real"});
modules = getApiTestModules({clock: "real", config});
api = getValidatorApi(defaultApiOptions, modules);

initializeState(currentSlot);
Expand Down Expand Up @@ -66,18 +68,18 @@ describe("get proposers api impl", () => {
vi.advanceTimersByTime((SYNC_TOLERANCE_EPOCHS * SLOTS_PER_EPOCH + 1) * config.SLOT_DURATION_MS);
vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.SyncingHead);

await expect(api.getProposerDuties({epoch: 1})).rejects.toThrow("Node is syncing - headSlot 0 currentSlot 97");
await expect(api.getProposerDutiesV2({epoch: 1})).rejects.toThrow("Node is syncing - headSlot 0 currentSlot 97");
});

it("should raise error if node stalled", async () => {
vi.advanceTimersByTime((SYNC_TOLERANCE_EPOCHS * SLOTS_PER_EPOCH + 1) * config.SLOT_DURATION_MS);
vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Stalled);

await expect(api.getProposerDuties({epoch: 1})).rejects.toThrow("Node is syncing - waiting for peers");
await expect(api.getProposerDutiesV2({epoch: 1})).rejects.toThrow("Node is syncing - waiting for peers");
});

it("should get proposers for current epoch", async () => {
const {data: result} = (await api.getProposerDuties({epoch: currentEpoch})) as {
const {data: result} = (await api.getProposerDutiesV2({epoch: currentEpoch})) as {
data: routes.validator.ProposerDutyList;
};

Expand All @@ -92,7 +94,7 @@ describe("get proposers api impl", () => {

it("should get proposers for next epoch", async () => {
const nextEpoch = currentEpoch + 1;
const {data: result} = (await api.getProposerDuties({epoch: nextEpoch})) as {
const {data: result} = (await api.getProposerDutiesV2({epoch: nextEpoch})) as {
data: routes.validator.ProposerDutyList;
};

Expand All @@ -114,7 +116,7 @@ describe("get proposers api impl", () => {
finalized: true,
});

const {data: result} = (await api.getProposerDuties({epoch: historicalEpoch})) as {
const {data: result} = (await api.getProposerDutiesV2({epoch: historicalEpoch})) as {
data: routes.validator.ProposerDutyList;
};

Expand All @@ -126,16 +128,16 @@ describe("get proposers api impl", () => {
});

it("should raise error for more than one epoch in the future", async () => {
await expect(api.getProposerDuties({epoch: currentEpoch + 2})).rejects.toThrow(
await expect(api.getProposerDutiesV2({epoch: currentEpoch + 2})).rejects.toThrow(
"Requested epoch 4 must not be more than one epoch in the future"
);
});

it("should have different proposer validator public keys for current and next epoch", async () => {
const {data: currentProposers} = (await api.getProposerDuties({epoch: currentEpoch})) as {
const {data: currentProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch})) as {
data: routes.validator.ProposerDutyList;
};
const {data: nextProposers} = (await api.getProposerDuties({epoch: currentEpoch + 1})) as {
const {data: nextProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch + 1})) as {
data: routes.validator.ProposerDutyList;
};

Expand All @@ -144,21 +146,21 @@ describe("get proposers api impl", () => {
});

it("should have different proposer validator indexes for current and next epoch", async () => {
const {data: currentProposers} = (await api.getProposerDuties({epoch: currentEpoch})) as {
const {data: currentProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch})) as {
data: routes.validator.ProposerDutyList;
};
const {data: nextProposers} = (await api.getProposerDuties({epoch: currentEpoch + 1})) as {
const {data: nextProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch + 1})) as {
data: routes.validator.ProposerDutyList;
};

expect(currentProposers.map((p) => p.validatorIndex)).not.toEqual(nextProposers.map((p) => p.validatorIndex));
});

it("should have different proposer slots for current and next epoch", async () => {
const {data: currentProposers} = (await api.getProposerDuties({epoch: currentEpoch})) as {
const {data: currentProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch})) as {
data: routes.validator.ProposerDutyList;
};
const {data: nextProposers} = (await api.getProposerDuties({epoch: currentEpoch + 1})) as {
const {data: nextProposers} = (await api.getProposerDutiesV2({epoch: currentEpoch + 1})) as {
data: routes.validator.ProposerDutyList;
};

Expand Down
2 changes: 1 addition & 1 deletion packages/beacon-node/test/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function getApiTestModules(opts?: Partial<MockedBeaconChainOptions>): Api
const networkStub = getMockedNetwork();

return {
config,
config: opts?.config ?? config,
chain: chainStub,
sync: syncStub,
db: dbStub,
Expand Down
32 changes: 17 additions & 15 deletions packages/state-transition/src/util/shuffling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,31 @@ import {computeStartSlotAtEpoch} from "./epoch.js";
import {EpochShuffling} from "./epochShuffling.js";

/**
* Returns the block root which decided the proposer shuffling for the current epoch. This root
* can be used to key this proposer shuffling.
* Block root that decided the proposer shuffling for `proposalEpoch` (keys that shuffling).
* Computed for the requested epoch, not `state.epoch`, so it is correct when `state` is one
* epoch off the requested epoch (e.g. serving next-epoch duties from the current state).
*
* Returns `null` on the one-off scenario where the genesis block decides its own shuffling.
* It should be set to the latest block applied to this `state` or the genesis block root.
* Returns `null` when the genesis block decides its own shuffling (caller falls back to the
* genesis block root).
*/
export function proposerShufflingDecisionRoot(fork: ForkName, state: IBeaconStateView): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, state);
export function proposerShufflingDecisionRoot(
fork: ForkName,
state: IBeaconStateView,
proposalEpoch: Epoch
): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch);
if (state.slot === decisionSlot) {
return null;
}
return state.getBlockRootAtSlot(decisionSlot);
}

/**
* Returns the slot at which the proposer shuffling was decided. The block root at this slot
* can be used to key the proposer shuffling for the current epoch.
*/
function proposerShufflingDecisionSlot(fork: ForkName, state: IBeaconStateView): Slot {
// After fulu, the decision slot is in previous epoch due to deterministic proposer lookahead
const epoch = isForkPostFulu(fork) ? state.epoch - 1 : state.epoch;
const startSlot = computeStartSlotAtEpoch(epoch);
return Math.max(startSlot - 1, 0);
/** Slot whose block root keys the proposer shuffling for `proposalEpoch`. */
function proposerShufflingDecisionSlot(fork: ForkName, proposalEpoch: Epoch): Slot {
// Post-Fulu the shuffling is decided one epoch earlier (deterministic proposer lookahead,
// MIN_SEED_LOOKAHEAD = 1); pre-Fulu it is the last block before `proposalEpoch`.
const decisionEpoch = isForkPostFulu(fork) ? proposalEpoch - 1 : proposalEpoch;
return Math.max(computeStartSlotAtEpoch(decisionEpoch) - 1, 0);
}

/**
Expand Down
Loading
Loading