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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
MAX_BUILDER_DEPOSIT_REQUESTS_PER_PAYLOAD,
MAX_BUILDER_EXIT_REQUESTS_PER_PAYLOAD,
MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD,
MAX_DEPOSIT_REQUESTS_PER_PAYLOAD,
MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD,
SLOTS_PER_EPOCH,
SLOTS_PER_HISTORICAL_ROOT,
Expand Down Expand Up @@ -128,7 +127,6 @@ function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: numbe
}

function assertExecutionRequestsWithinLimits(requests: gloas.ExecutionRequests): void {
assertMaxLength("deposits", requests.deposits.length, MAX_DEPOSIT_REQUESTS_PER_PAYLOAD);
assertMaxLength("withdrawals", requests.withdrawals.length, MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD);
assertMaxLength("consolidations", requests.consolidations.length, MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD);
// New in GLOAS:EIP8282
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {zeroNode} from "@chainsafe/persistent-merkle-tree";
import {ssz} from "@lodestar/types";
import type {CachedBeaconStateAltair, CachedBeaconStateGloas} from "../types.js";
import {isGloasStateType} from "../util/execution.js";
import {zeroProgressiveListBasicRootNode} from "../util/ssz.js";

/**
* Updates `state.previousEpochParticipation` with precalculated epoch participation. Creates a new empty tree for
Expand Down Expand Up @@ -34,7 +35,11 @@ export function processParticipationFlagUpdates(state: CachedBeaconStateAltair):

function processParticipationFlagUpdatesGloas(state: CachedBeaconStateGloas): void {
state.previousEpochParticipation = state.currentEpochParticipation;
state.currentEpochParticipation = ssz.gloas.EpochParticipation.toViewDU(
new Array<number>(state.currentEpochParticipation.length).fill(0)

// Same trick as the altair path above, adapted to the progressive-list tree shape: all chunks
// are zero so the chunks tree is a chain of pre-computed zeroNodes, built in O(log n) instead
// of re-merkleizing a validator-count-sized array every epoch.
state.currentEpochParticipation = ssz.gloas.EpochParticipation.getViewDU(
zeroProgressiveListBasicRootNode(ssz.gloas.EpochParticipation.itemsPerChunk, state.currentEpochParticipation.length)
);
}
73 changes: 56 additions & 17 deletions packages/state-transition/src/slot/upgradeStateToGloas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import {BranchNode, LeafNode, Node} from "@chainsafe/persistent-merkle-tree";
import {progressiveSubtreeFillToContents} from "@chainsafe/ssz";
import {getNodesAtDepth} from "@chainsafe/persistent-merkle-tree";
Comment thread
twoeths marked this conversation as resolved.
import {
BasicType,
CompositeType,
CompositeView,
CompositeViewDU,
ListBasicTreeViewDU,
ListCompositeTreeViewDU,
ProgressiveListBasicType,
ProgressiveListCompositeType,
ValueOf,
} from "@chainsafe/ssz";
import {PAYLOAD_BUILDER_VERSION, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params";
import {ssz} from "@lodestar/types";
import {toPubkeyHex} from "@lodestar/utils";
Expand All @@ -9,6 +19,7 @@ import {CachedBeaconStateFulu, CachedBeaconStateGloas} from "../types.js";
import {addBuilderToRegistry, initializePtcWindow, isBuilderWithdrawalCredential} from "../util/gloas.js";
import {isValidatorKnown} from "../util/index.js";
import {PendingDepositsLookup} from "../util/pendingDepositsLookup.js";
import {progressiveListRootNode} from "../util/ssz.js";

/**
* Upgrade a state from Fulu to Gloas.
Expand Down Expand Up @@ -37,20 +48,25 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea
stateGloasView.eth1DataVotes = stateGloasCloned.eth1DataVotes;
stateGloasView.eth1DepositIndex = stateGloasCloned.eth1DepositIndex;
stateGloasView.validators = migrateCompositeListToGloas(stateGloasCloned.validators, ssz.gloas.Validators);
stateGloasView.balances = ssz.gloas.Balances.toViewDU(stateGloasCloned.balances.getAll());
stateGloasView.balances = migrateBasicListToGloas(stateGloasCloned.balances, ssz.gloas.Balances);
stateGloasView.randaoMixes = stateGloasCloned.randaoMixes;
stateGloasView.slashings = stateGloasCloned.slashings;
stateGloasView.previousEpochParticipation = ssz.gloas.EpochParticipation.toViewDU(
stateGloasCloned.previousEpochParticipation.getAll()
stateGloasView.previousEpochParticipation = migrateBasicListToGloas(
stateGloasCloned.previousEpochParticipation,
ssz.gloas.EpochParticipation
);
stateGloasView.currentEpochParticipation = ssz.gloas.EpochParticipation.toViewDU(
stateGloasCloned.currentEpochParticipation.getAll()
stateGloasView.currentEpochParticipation = migrateBasicListToGloas(
stateGloasCloned.currentEpochParticipation,
ssz.gloas.EpochParticipation
);
stateGloasView.justificationBits = stateGloasCloned.justificationBits;
stateGloasView.previousJustifiedCheckpoint = stateGloasCloned.previousJustifiedCheckpoint;
stateGloasView.currentJustifiedCheckpoint = stateGloasCloned.currentJustifiedCheckpoint;
stateGloasView.finalizedCheckpoint = stateGloasCloned.finalizedCheckpoint;
stateGloasView.inactivityScores = ssz.gloas.InactivityScores.toViewDU(stateGloasCloned.inactivityScores.getAll());
stateGloasView.inactivityScores = migrateBasicListToGloas(
stateGloasCloned.inactivityScores,
ssz.gloas.InactivityScores
);
stateGloasView.currentSyncCommittee = stateGloasCloned.currentSyncCommittee;
stateGloasView.nextSyncCommittee = stateGloasCloned.nextSyncCommittee;
stateGloasView.latestExecutionPayloadBid.blockHash = stateFulu.latestExecutionPayloadHeader.blockHash;
Expand Down Expand Up @@ -102,23 +118,46 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea

/**
* Migrate a composite list from fulu to its gloas progressive-list equivalent by reusing the fulu
* list's cached element nodes.
* list's element nodes.
*
* Works whenever the element type is identical across the fork (e.g. validators use ValidatorNodeStruct,
* the pending* queues use the same electra element types). Each element's cached subtree root is then
* valid under gloas, so only the progressive list superstructure is rebuilt and a subsequent
* hashTreeRoot() skips re-hashing every element — the dominant cost for large lists like validators.
* Much cheaper than `gloasType.toViewDU(fuluList.getAllReadonlyValues())`, which decodes every element
* to a value and forces a full re-hash.
*
* The chunk nodes of a composite list ARE the element root nodes, so they are extracted directly
* with getNodesAtDepth instead of allocating a temporary ViewDU wrapper per element (getAllReadonly).
* Requires the fulu view to be committed (done at the top of upgradeStateToGloas).
*/
function migrateCompositeListToGloas<
ElementType extends CompositeType<ValueOf<ElementType>, CompositeView<ElementType>, CompositeViewDU<ElementType>>,
>(fuluList: ListCompositeTreeViewDU<ElementType>, gloasType: ProgressiveListCompositeType<ElementType>) {
const {length, type} = fuluList;
const elementNodes = getNodesAtDepth(fuluList.node.left, type.chunkDepth, 0, length);
return gloasType.getViewDU(progressiveListRootNode(elementNodes, length));
Comment thread
twoeths marked this conversation as resolved.
}

/**
* Migrate a basic list from fulu to its gloas progressive-list equivalent by reusing the fulu
* list's packed chunk leaf nodes.
*
* Packed leaf chunks are bit-identical between List[T, N] and ProgressiveList[T] (same 32-byte
* LE packing, zero-padded final chunk); only the superstructure above the leaves differs. Reusing
* the leaves avoids materializing the value array (getAll), re-serializing it, and allocating
* fresh LeafNodes — the gloas tree shares the leaf nodes with the fulu tree.
* Requires the fulu view to be committed (done at the top of upgradeStateToGloas).
*/
function migrateCompositeListToGloas<V>(
fuluList: {getAllReadonly(): {node: Node}[]},
gloasType: {getViewDU(node: Node): V}
): V {
const elementNodes = fuluList.getAllReadonly().map((v) => v.node);
const chunksNode = progressiveSubtreeFillToContents(elementNodes);
const rootNode = new BranchNode(chunksNode, LeafNode.fromUint32(elementNodes.length));
return gloasType.getViewDU(rootNode);
function migrateBasicListToGloas<ElementType extends BasicType<unknown>>(
fuluList: ListBasicTreeViewDU<ElementType>,
gloasType: ProgressiveListBasicType<ElementType>
) {
const {length, type} = fuluList;
const chunkCount = Math.ceil(length / type.itemsPerChunk);
// List root = BranchNode(chunksNode, lengthNode) → chunks tree is the left child
const chunkLeafNodes = getNodesAtDepth(fuluList.node.left, type.chunkDepth, 0, chunkCount);
return gloasType.getViewDU(progressiveListRootNode(chunkLeafNodes, length));
Comment thread
twoeths marked this conversation as resolved.
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/state-transition/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from "./shuffling.js";
export * from "./signatureSets.js";
export * from "./signingRoot.js";
export * from "./slot.js";
export * from "./ssz.js";
export * from "./syncCommittee.js";
export * from "./validator.js";
export * from "./weakSubjectivity.js";
42 changes: 42 additions & 0 deletions packages/state-transition/src/util/ssz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {BranchNode, LeafNode, Node, zeroNode} from "@chainsafe/persistent-merkle-tree";
import {progressiveSubtreeFillToContents} from "@chainsafe/ssz";

// TODO: move these utils to @chainsafe/ssz (progressive.ts, next to progressiveSubtreeFillToContents)

/** Root node (chunks + length mix-in) of a zero-filled ProgressiveListBasicType of `length` items */
export function zeroProgressiveListBasicRootNode(itemsPerChunk: number, length: number): Node {
const chunkCount = Math.ceil(length / itemsPerChunk);
// mirrors ssz's progressiveSubtreeCount (subtree capacities 1, 4, 16, ...)
let numSubtrees = 0;
for (let remaining = chunkCount, subtreeLength = 1; remaining > 0; subtreeLength *= 4) {
remaining -= Math.min(remaining, subtreeLength);
numSubtrees++;
}
return new BranchNode(zeroProgressiveNode(numSubtrees), LeafNode.fromUint32(length));
}

/**
* Root node of a progressive list from its chunk/element nodes + length mix-in.
* `nodes` are packed 32-byte chunk leaves for basic lists, or element root nodes for composite lists.
*/
export function progressiveListRootNode(nodes: Node[], length: number): Node {
return new BranchNode(progressiveSubtreeFillToContents(nodes), LeafNode.fromUint32(length));
}

/**
* Return the chunks node of a zero-filled progressive merkle list spanning `numSubtrees`
* balanced subtrees (chunk capacities 1, 4, 16, ... = 4^i; depths 0, 2, 4, ... = 2i).
*
* Not memoized: the zero data is already fully shared via the cached zeroNode(2i) subtrees;
* only the O(numSubtrees) spine BranchNodes (~10 at 2M validators) are allocated per call.
*/
function zeroProgressiveNode(numSubtrees: number): Node {
// chain(k) = B(zeroNode(0), B(zeroNode(2), ... B(zeroNode(2(k-1)), zeroNode(0))))
let node: Node = zeroNode(0); // terminator
for (let i = numSubtrees - 1; i >= 0; i--) {
// BranchNode(left: balanced subtree i, right: rest of chain) — chain grows to the right,
// same as ssz progressiveSubtreeFillToContents: `root = new BranchNode(subtreeRoots[i], root)`
node = new BranchNode(zeroNode(2 * i), node);
}
return node;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,23 @@ describe("processParticipationFlagUpdates", () => {
Array.from({length: validatorCount}, () => 0)
);
});

it("Gloas zeroed participation matches naive rebuild (root + bytes)", () => {
// Length crossing progressive subtree boundaries and not a multiple of itemsPerChunk (32)
const validatorCount = 673;
const state = ssz.gloas.BeaconState.defaultViewDU();
state.previousEpochParticipation = ssz.gloas.EpochParticipation.toViewDU(
Array.from({length: validatorCount}, () => 1)
);
state.currentEpochParticipation = ssz.gloas.EpochParticipation.toViewDU(
Array.from({length: validatorCount}, (_, i) => i % 8)
);

processParticipationFlagUpdates(state as unknown as CachedBeaconStateAltair);

// Differential oracle: the naive implementation this fast path replaces
const naive = ssz.gloas.EpochParticipation.toViewDU(new Array<number>(validatorCount).fill(0));
expect(state.currentEpochParticipation.hashTreeRoot()).toEqual(naive.hashTreeRoot());
expect(state.currentEpochParticipation.serialize()).toEqual(naive.serialize());
});
});
33 changes: 25 additions & 8 deletions packages/state-transition/test/unit/upgradeState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ describe("upgradeState", () => {
it("upgradeStateToGloas reuses composite-list nodes with identical merkle roots", () => {
// Enough validators to span multiple progressive subtrees (capacities 1, 4, 16, 64, ...) and to
// populate every slot's committee for the gloas PTC window computed during the upgrade.
const numValidators = 128;
// Not a multiple of itemsPerChunk (4 for uint64, 32 for uint8) so basic-list migration covers
// a zero-padded partial final chunk.
const numValidators = 130;
const fuluStateView = ssz.fulu.BeaconState.defaultViewDU();
for (let i = 0; i < numValidators; i++) {
const validator = ssz.phase0.Validator.defaultValue();
Expand All @@ -56,14 +58,17 @@ describe("upgradeState", () => {
validator.exitEpoch = FAR_FUTURE_EPOCH;
validator.withdrawableEpoch = FAR_FUTURE_EPOCH;
fuluStateView.validators.push(ssz.phase0.Validator.toViewDU(validator));
fuluStateView.balances.push(32e9);
fuluStateView.previousEpochParticipation.push(0);
fuluStateView.currentEpochParticipation.push(0);
fuluStateView.inactivityScores.push(0);
// Distinct per-index values so basic-list leaf reuse in the wrong order would be caught
fuluStateView.balances.push(32e9 + i);
fuluStateView.previousEpochParticipation.push(i % 8);
fuluStateView.currentEpochParticipation.push((i + 3) % 8);
fuluStateView.inactivityScores.push(i % 5);
}

// Populate the pending* composite queues so the node-reuse path is exercised for them too.
// Non-builder withdrawal credentials (default zeros) keep the deposits pending in-order.
// pendingConsolidations is deliberately left EMPTY to cover migrating an empty list
// (a realistic fork-boundary state) through the same node-reuse path.
for (let i = 0; i < 5; i++) {
const pendingDeposit = ssz.electra.PendingDeposit.defaultValue();
pendingDeposit.amount = 1000 + i;
Expand All @@ -73,9 +78,6 @@ describe("upgradeState", () => {
fuluStateView.pendingPartialWithdrawals.push(
ssz.electra.PendingPartialWithdrawal.toViewDU(pendingPartialWithdrawal)
);
const pendingConsolidation = ssz.electra.PendingConsolidation.defaultValue();
pendingConsolidation.sourceIndex = i;
fuluStateView.pendingConsolidations.push(ssz.electra.PendingConsolidation.toViewDU(pendingConsolidation));
}
fuluStateView.commit();

Expand All @@ -101,6 +103,14 @@ describe("upgradeState", () => {
const expectedPendingConsolidationsRoot = ssz.gloas.PendingConsolidations.hashTreeRoot(
fuluState.pendingConsolidations.getAllReadonlyValues()
);
const expectedBalancesRoot = ssz.gloas.Balances.hashTreeRoot(fuluState.balances.getAll());
const expectedPreviousEpochParticipationRoot = ssz.gloas.EpochParticipation.hashTreeRoot(
fuluState.previousEpochParticipation.getAll()
);
const expectedCurrentEpochParticipationRoot = ssz.gloas.EpochParticipation.hashTreeRoot(
fuluState.currentEpochParticipation.getAll()
);
const expectedInactivityScoresRoot = ssz.gloas.InactivityScores.hashTreeRoot(fuluState.inactivityScores.getAll());

const gloasState = upgradeStateToGloas(fuluState);

Expand All @@ -110,6 +120,13 @@ describe("upgradeState", () => {
expect(gloasState.pendingDeposits.hashTreeRoot()).toEqual(expectedPendingDepositsRoot);
expect(gloasState.pendingPartialWithdrawals.hashTreeRoot()).toEqual(expectedPendingPartialWithdrawalsRoot);
expect(gloasState.pendingConsolidations.hashTreeRoot()).toEqual(expectedPendingConsolidationsRoot);
expect(gloasState.pendingConsolidations.length).toBe(0);
// Basic-list leaf reuse must produce byte-identical merkle roots too
expect(gloasState.balances.hashTreeRoot()).toEqual(expectedBalancesRoot);
expect(gloasState.balances.getAll()).toEqual(fuluStateView.balances.getAll());
expect(gloasState.previousEpochParticipation.hashTreeRoot()).toEqual(expectedPreviousEpochParticipationRoot);
expect(gloasState.currentEpochParticipation.hashTreeRoot()).toEqual(expectedCurrentEpochParticipationRoot);
expect(gloasState.inactivityScores.hashTreeRoot()).toEqual(expectedInactivityScoresRoot);
// Full state still merkleizes and round-trips
expect(() => gloasState.hashTreeRoot()).not.toThrow();
expect(() => gloasState.toValue()).not.toThrow();
Expand Down
59 changes: 59 additions & 0 deletions packages/state-transition/test/unit/util/ssz.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {describe, expect, it} from "vitest";
import {ssz} from "@lodestar/types";
import {zeroProgressiveListBasicRootNode} from "../../../src/util/ssz.js";

describe("zeroProgressiveListBasicRootNode", () => {
// EpochParticipation is ProgressiveList[ParticipationFlags] (uint8) → 32 items per chunk.
// Progressive subtree chunk capacities are 1, 4, 16, 64, ... so cumulative chunk counts are
// 1, 5, 21, 85, ... = 32, 160, 672, 2720 items. Test lengths around every boundary.
const lengths = [0, 1, 31, 32, 33, 160, 161, 671, 672, 673, 2720];

for (const length of lengths) {
it(`equals naive zero-filled list length=${length}`, () => {
const fastView = ssz.gloas.EpochParticipation.getViewDU(
zeroProgressiveListBasicRootNode(ssz.gloas.EpochParticipation.itemsPerChunk, length)
);
const naiveView = ssz.gloas.EpochParticipation.toViewDU(new Array<number>(length).fill(0));

expect(fastView.length).toBe(length);
expect(fastView.hashTreeRoot()).toEqual(naiveView.hashTreeRoot());
expect(fastView.serialize()).toEqual(naiveView.serialize());
});
}

it("produces a mutable view backed by shared zero nodes", () => {
const length = 673;
const fastView = ssz.gloas.EpochParticipation.getViewDU(
zeroProgressiveListBasicRootNode(ssz.gloas.EpochParticipation.itemsPerChunk, length)
);
const naiveView = ssz.gloas.EpochParticipation.toViewDU(new Array<number>(length).fill(0));

for (const view of [fastView, naiveView]) {
view.set(0, 7);
view.set(Math.floor(length / 2), 3);
view.set(length - 1, 1);
view.push(5);
view.commit();
}

expect(fastView.getAll()).toEqual(naiveView.getAll());
expect(fastView.hashTreeRoot()).toEqual(naiveView.hashTreeRoot());
});

it("does not mutate shared zero nodes across views", () => {
const length = 100;
const viewA = ssz.gloas.EpochParticipation.getViewDU(
zeroProgressiveListBasicRootNode(ssz.gloas.EpochParticipation.itemsPerChunk, length)
);
const viewB = ssz.gloas.EpochParticipation.getViewDU(
zeroProgressiveListBasicRootNode(ssz.gloas.EpochParticipation.itemsPerChunk, length)
);

viewA.set(50, 7);
viewA.commit();

expect(viewA.get(50)).toBe(7);
expect(viewB.get(50)).toBe(0);
expect(viewB.getAll()).toEqual(new Array<number>(length).fill(0));
});
});
Loading