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
37 changes: 29 additions & 8 deletions packages/beacon-node/src/api/impl/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,10 @@ export function getValidatorApi(
const isPostFulu = isForkPostFulu(config.getForkName(startSlot));
const maxFutureEpoch = isPostFulu && nearNextEpoch && opts?.v2 ? nextEpoch + 1 : nextEpoch;
if (currentEpoch >= 0 && epoch > maxFutureEpoch) {
throw new ApiError(400, `Requested epoch ${epoch} must not be more than one epoch in the future`);
throw new ApiError(
400,
`Requested epoch ${epoch} must not be more than ${maxFutureEpoch}, currentEpoch=${currentEpoch}, v2=${opts?.v2 ?? false}`
);
}

const head = chain.forkChoice.getHead();
Expand Down Expand Up @@ -1291,17 +1294,35 @@ export function getValidatorApi(
duties.push({slot: startSlot + i, validatorIndex: indexes[i], pubkey: pubkeys[i]});
}

// 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 `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, epoch) ||
(await getGenesisBlockRoot(state));
// In v2 the dependent root is different after fulu due to deterministic proposer lookahead
let dependentRoot = proposerShufflingDecisionRoot(
opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0,
state,
epoch
);
const logCtx = {
epoch,
stateSlot: state.slot,
stateEpoch: state.epoch,
v2: opts?.v2 ?? false,
};
if (dependentRoot === null) {
// fallback to get_proposer_duties() v1, also in lodestar v1.43
logger.verbose("Proposer duties decision root not in state, falling back to state epoch", logCtx);
dependentRoot = proposerShufflingDecisionRoot(ForkName.phase0, state, state.epoch);
Comment thread
twoeths marked this conversation as resolved.
}
if (dependentRoot === null) {
logger.verbose("Proposer duties decision root not in state, falling back to genesis block root", logCtx);
dependentRoot = await getGenesisBlockRoot(state);
}

const dependentRootHex = toRootHex(dependentRoot);
logger.verbose("Computed proposer duties decision root", {...logCtx, dependentRoot: dependentRootHex});

return {
data: duties,
meta: {
dependentRoot: toRootHex(dependentRoot),
dependentRoot: dependentRootHex,
executionOptimistic: isOptimisticBlock(head),
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 {toRootHex} from "@lodestar/utils";
import {SYNC_TOLERANCE_EPOCHS, getValidatorApi} from "../../../../../../src/api/impl/validator/index.js";
import {defaultApiOptions} from "../../../../../../src/api/options.js";
import {FAR_FUTURE_EPOCH} from "../../../../../../src/constants/index.js";
Expand Down Expand Up @@ -107,6 +108,31 @@ describe("get proposers api impl", () => {
);
});

// Regression #9380: v1 getProposerDuties for next epoch must not throw on the dependent root.
// The requested-epoch decision slot is the last slot of the current epoch (in the future for a
// head state), so it falls back to the state-epoch decision root (last block of previous epoch),
// which is stable and, post-Fulu, identical to v2.
it("should get v1 proposers for next epoch with stable dependent root", async () => {
const nextEpoch = currentEpoch + 1;
const v1 = (await api.getProposerDuties({epoch: nextEpoch})) as {meta: {dependentRoot: string}};
const v2 = (await api.getProposerDutiesV2({epoch: nextEpoch})) as {meta: {dependentRoot: string}};

const expected = toRootHex(new BeaconStateView(cachedState).getBlockRootAtSlot(currentSlot - 1));
expect(v1.meta.dependentRoot).toBe(expected);
expect(v2.meta.dependentRoot).toBe(expected);
});

it("should get v1 proposers for next epoch from a mid-epoch head state", async () => {
vi.advanceTimersByTime(15 * config.SLOT_DURATION_MS);
initializeState(currentSlot + 15);
modules.chain.getHeadStateAtCurrentEpoch.mockResolvedValue(new BeaconStateView(cachedState));

const v1 = (await api.getProposerDuties({epoch: currentEpoch + 1})) as {meta: {dependentRoot: string}};

const expected = toRootHex(new BeaconStateView(cachedState).getBlockRootAtSlot(currentSlot - 1));
expect(v1.meta.dependentRoot).toBe(expected);
});

it("should get proposers for historical epoch", async () => {
const historicalEpoch = currentEpoch - 2;
initializeState(currentSlot - 2 * SLOTS_PER_EPOCH + 1);
Expand All @@ -129,7 +155,7 @@ describe("get proposers api impl", () => {

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

Expand Down
7 changes: 4 additions & 3 deletions packages/state-transition/src/util/shuffling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@ import {EpochShuffling} from "./epochShuffling.js";
* 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` when the genesis block decides its own shuffling (caller falls back to the
* genesis block root).
* Returns `null` when the decision block is not in the state's past (the genesis block decides
* its own shuffling, or it has not been produced yet); caller supplies the fallback.
*/
export function proposerShufflingDecisionRoot(
fork: ForkName,
state: IBeaconStateView,
proposalEpoch: Epoch
): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch);
if (state.slot === decisionSlot) {
// Return null when the decision block is not in the state's past or the getBlockRootAtSlot() api will throw
if (decisionSlot >= state.slot) {
return null;
}
return state.getBlockRootAtSlot(decisionSlot);
Expand Down
Loading