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
18 changes: 13 additions & 5 deletions packages/beacon-node/src/api/impl/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1291,12 +1291,20 @@ 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.
// In v2 the dependent root is different after fulu due to deterministic proposer lookahead.
// `head.blockRoot` is used as the dep_root when the decision slot is not yet in

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.

@lodekeeper using head.blockRoot is wrong for majority of the time
suggest to fallback to get_proposer_duties() v1, which is how it worked in v1.43 #9518

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right — thanks, I'm convinced. head.blockRoot changes every slot, so for the pre-Fulu currentEpoch + 1 request from a mid-epoch head state it makes the V1 dependent_root churn every block instead of being a stable decision root. That defeats the point of dependent_root — a VC would see it change constantly and re-poll every slot.

And my Lighthouse citation doesn't actually apply here: LH's head_block_root branch is the genesis / at-decision edge of the current-epoch computation, not a future requested epoch.

#9518's approach is the correct one: when the requested epoch's decision slot is in the future, fall back to the state-epoch decision root (last block of the previous epoch). It's stable, it flips to the real decision root at the epoch boundary (correctly signaling a re-fetch), and it matches v1.43 — your test asserting V1 == V2 and a stable root captures exactly that.

Closing #9499 in favor of #9518. 👍

// `state.block_roots` (pre-Fulu next-epoch duties from a mid-epoch head state). For the
// genesis-decides-its-own-shuffling case (`state.slot === 0 && decisionSlot === 0`),
// `proposerShufflingDecisionRoot` returns `null` and we fall back to the canonical
// genesis root, because `head.blockRoot` is the current fork-choice head and may not
// equal the genesis root if the chain has advanced.
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));
proposerShufflingDecisionRoot(
opts?.v2 ? config.getForkName(startSlot) : ForkName.phase0,
state,
epoch,
fromHex(head.blockRoot)
) ?? (await getGenesisBlockRoot(state));

return {
data: duties,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ describe("get proposers api impl", () => {
);
});

// Regression for the V1 path: pre-Fulu decision-slot logic asks for the block root at
// `startSlot(nextEpoch) - 1`, which is in the future relative to a mid-epoch head state.
// The dep_root must fall back to the head block root instead of throwing.
it("should get proposers for next epoch via V1 from a mid-epoch head state", async () => {
const nextEpoch = currentEpoch + 1;
const {data: result, meta} = (await api.getProposerDuties({epoch: nextEpoch})) as {
data: routes.validator.ProposerDutyList;
meta: {dependentRoot: string};
};

expect(result.length).toBe(SLOTS_PER_EPOCH);
expect(meta.dependentRoot).toBe(zeroProtoBlock.blockRoot);
expect(result.map((p) => p.slot)).toEqual(
Array.from({length: SLOTS_PER_EPOCH}, (_, i) => nextEpoch * SLOTS_PER_EPOCH + i)
);
});

it("should get proposers for historical epoch", async () => {
const historicalEpoch = currentEpoch - 2;
initializeState(currentSlot - 2 * SLOTS_PER_EPOCH + 1);
Expand Down
22 changes: 18 additions & 4 deletions packages/state-transition/src/util/shuffling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,32 @@ 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).
* `headBlockRoot` is the canonical head block root applied to `state`. When the decision
* slot is at or after `state.slot` (e.g. serving pre-Fulu `currentEpoch + 1` duties from
* a mid-epoch head state), the head block root is returned as the dependent root. This
* matches the beacon-API V1 rule of using the latest block applied to state when the
* decision slot is not yet in `block_roots`, and aligns with Lighthouse's
* `proposer_shuffling_decision_root_at_epoch` behavior.
*
* Returns `null` for the genesis-decides-its-own-shuffling case (`state.slot === 0 &&
* decisionSlot === 0`); the caller must fall back to the canonical genesis block root
* (typically via `getGenesisBlockRoot(state)`), because `headBlockRoot` may not equal
* the genesis root when the chain has advanced and we are serving epoch-0 duties from a
* loaded genesis state.
*/
export function proposerShufflingDecisionRoot(
fork: ForkName,
state: IBeaconStateView,
proposalEpoch: Epoch
proposalEpoch: Epoch,
headBlockRoot: Root
): Root | null {
const decisionSlot = proposerShufflingDecisionSlot(fork, proposalEpoch);
if (state.slot === decisionSlot) {
if (state.slot === 0 && decisionSlot === 0) {
return null;
}
if (state.slot <= decisionSlot) {
return headBlockRoot;
}
return state.getBlockRootAtSlot(decisionSlot);
}

Expand Down
Loading