From 9038c98c6e260c92e678ffdca831c18c660c9000 Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 1 May 2026 12:48:22 +0800 Subject: [PATCH 01/46] refactor: make XXXDecisionRoot fns return `js.String` `IBeaconStateView` expects `RootHex` (66-char string) for these outputs see: https://github.com/ChainSafe/lodestar/blob/35940ffd61ad7e29f5de376e13587d044b27b246/packages/state-transition/src/stateView/interface.ts#L78-L82 --- bindings/napi/BeaconStateView.zig | 30 ++++++++++++++++-------------- build.zig.zon | 1 + 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 9cb30ad07..b9505b131 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -371,37 +371,39 @@ pub fn proposerLookahead(self: *const BeaconStateView) !js.Uint32Array { // pub fn BeaconStateView_getShufflingAtEpoch -pub fn previousDecisionRoot(self: *const BeaconStateView) !js.Uint8Array { +fn rootToHexString(root: *const [32]u8) !js.String { const env = js.env(); + var hex_buf: [66]u8 = undefined; + try @import("hex").rootIntoHex(&hex_buf, root); + return js_types.wrap(js.String, try env.createStringUtf8(&hex_buf)); +} + +pub fn previousDecisionRoot(self: *const BeaconStateView) !js.String { const cached_state = try self.requireState(); const root = cached_state.previousDecisionRoot(); - return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, &root)); + return rootToHexString(&root); } -pub fn currentDecisionRoot(self: *const BeaconStateView) !js.Uint8Array { - const env = js.env(); +pub fn currentDecisionRoot(self: *const BeaconStateView) !js.String { const cached_state = try self.requireState(); const root = cached_state.currentDecisionRoot(); - return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, &root)); + return rootToHexString(&root); } /// Get the next decision root for the state. -pub fn nextDecisionRoot(self: *const BeaconStateView) !js.Uint8Array { - const env = js.env(); +pub fn nextDecisionRoot(self: *const BeaconStateView) !js.String { const cached_state = try self.requireState(); const root = cached_state.nextDecisionRoot(); - return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, &root)); + return rootToHexString(&root); } /// Get the shuffling decision root for a given epoch. -pub fn getShufflingDecisionRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { - const env = js.env(); +pub fn getShufflingDecisionRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.String { const cached_state = try self.requireState(); - const epoch_value: u64 = @intCast(try epoch_arg.toI64()); - const root = st.calculateShufflingDecisionRoot(cached_state.state, epoch_value) catch { - return throwNullAs(js.Uint8Array, "STATE_ERROR", "Failed to calculate shuffling decision root"); + const root = st.calculateShufflingDecisionRoot(cached_state.state, try epoch_arg.toU32()) catch { + return throwNullAs(js.String, "STATE_ERROR", "Failed to calculate shuffling decision root"); }; - return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, &root)); + return rootToHexString(&root); } pub fn previousProposers(self: *const BeaconStateView) !?js.Array { diff --git a/build.zig.zon b/build.zig.zon index 0563bf940..6bd2fef89 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -298,6 +298,7 @@ .imports = .{ .bls, .bls_options, + .hex, .persistent_merkle_tree, .ssz, .consensus_types, From a7c88449b3f6f12ef1247a9d8be641b3defc0122 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 4 May 2026 12:29:25 +0800 Subject: [PATCH 02/46] update root related tests --- bindings/test/beaconStateView.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index 6189c07d7..aeb1f4a61 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -397,15 +397,15 @@ describe("BeaconStateView", () => { expect(proposer).toBeLessThan(state.validatorCount); }); - it("decision roots should be 32 bytes each", () => { - expect(state.previousDecisionRoot.length).toBe(32); - expect(state.currentDecisionRoot.length).toBe(32); - expect(state.nextDecisionRoot.length).toBe(32); + it("decision roots should be 66 bytes each", () => { + expect(state.previousDecisionRoot.length).toBe(66); + expect(state.currentDecisionRoot.length).toBe(66); + expect(state.nextDecisionRoot.length).toBe(66); }); - it("getShufflingDecisionRoot should return 32 bytes", () => { + it("getShufflingDecisionRoot should return 66 bytes", () => { const decisionRoot = state.getShufflingDecisionRoot(state.epoch); - expect(decisionRoot.length).toBe(32); + expect(decisionRoot.length).toBe(66); }); }); From af2072ee5a091fbedbc1ed83d831e2d0205bdb62 Mon Sep 17 00:00:00 2001 From: bing Date: Sun, 3 May 2026 20:32:44 +0800 Subject: [PATCH 03/46] fmt From 0b5c2a66e73a54537a60d72acfb062c01431e375 Mon Sep 17 00:00:00 2001 From: bing Date: Sun, 3 May 2026 20:42:53 +0800 Subject: [PATCH 04/46] unabbreviate --- bindings/napi/stateTransition.zig | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig index 8db5d6c76..410e372a5 100644 --- a/bindings/napi/stateTransition.zig +++ b/bindings/napi/stateTransition.zig @@ -15,18 +15,17 @@ const allocator = if (builtin.mode == .Debug) else std.heap.c_allocator; -/// Parse a JS options object into Zig's TransitionOpts. +/// Parse a JS options object into Zig's TransitionOpt. /// /// Recognized fields: /// - verifyStateRoot, verifyProposer, verifySignatures: bool /// - dontTransferCache: bool (negated to set transfer_cache) -/// - executionPayloadStatus: "valid" | "invalid" | "preMerge" -/// - dataAvailabilityStatus: "Available" | "PreData" | "OutOfRange" /// /// This is the double negative version to conform with production lodestar. /// TODO(bing): Eventually rename this to `transferCache` to avoid double negation because its confusing naming. -fn parseOptions(options: ?js.Value) !st.TransitionOpts { - var transition_opts: st.TransitionOpts = .{}; +/// TODO(bing): Other fields (executionPayloadStatus, ..). +fn parseOptions(options: ?js.Value) !st.TransitionOpt { + var transition_opts: st.TransitionOpt = .{}; if (options) |value| { const raw = value.toValue(); if (try raw.typeof() == .object) { @@ -94,7 +93,6 @@ pub fn stateTransition( const env = js.env(); const pre_state = pre_state_value.toValue(); const cached_state = try env.unwrap(CachedBeaconState, pre_state); - const transition_opts = try parseOptions(options); const signed_block_bytes_slice = try signed_block_bytes.toSlice(); const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); @@ -112,7 +110,7 @@ pub fn stateTransition( napi_io.get(), cached_state, signed_block, - transition_opts, + try parseOptions(options), ); errdefer { post_state.deinit(); From 1bcb36fdcdec0b95391e4c326c71d7b0e69e8f1c Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 1 May 2026 12:48:22 +0800 Subject: [PATCH 05/46] refactor: make XXXDecisionRoot fns return `js.String` `IBeaconStateView` expects `RootHex` (66-char string) for these outputs see: https://github.com/ChainSafe/lodestar/blob/35940ffd61ad7e29f5de376e13587d044b27b246/packages/state-transition/src/stateView/interface.ts#L78-L82 From aa0369f0e686c1136a9b413d8934b5fc761a8ff3 Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 1 May 2026 12:48:22 +0800 Subject: [PATCH 06/46] fix: param order in BeaconBlockBody --- src/state_transition/utils/execution.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state_transition/utils/execution.zig b/src/state_transition/utils/execution.zig index 7be154abf..bcf0d70dd 100644 --- a/src/state_transition/utils/execution.zig +++ b/src/state_transition/utils/execution.zig @@ -26,7 +26,7 @@ pub fn isMergeTransitionBlock( comptime fork: ForkSeq, state: *BeaconState(fork), comptime block_type: BlockType, - body: *const BeaconBlockBody(fork, block_type), + body: *const BeaconBlockBody(block_type, fork), ) bool { if (comptime fork != .bellatrix) { return false; From ef383d9ce150a8c3e0267cfe85c83c015e6581bb Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 1 May 2026 12:48:22 +0800 Subject: [PATCH 07/46] refactor(bindings): return js.Number from getBalance --- bindings/napi/BeaconStateView.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index b9505b131..c24cbc885 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -229,7 +229,6 @@ pub fn latestExecutionPayloadHeader(self: *const BeaconStateView) !js.Value { pub fn getBlockRoot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); - const slot_value: u64 = @intCast(try slot_arg.toI64()); const result = switch (cached_state.state.forkSeq()) { inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_value), @@ -241,6 +240,7 @@ pub fn getBlockRoot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8 else => "Failed to get block root", }; return throwNullAs(js.Uint8Array, "INVALID_SLOT", msg); + const slot_ = st.computeStartSlotAtEpoch(try epoch_arg.toU32()); }; return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); @@ -547,7 +547,7 @@ pub fn getBalance(self: *const BeaconStateView, index_arg: js.Number) !js.BigInt const index_value: u64 = @intCast(try index_arg.toI64()); var balances = try cached_state.state.balances(); const balance = try balances.get(index_value); - return js.BigInt.from(balance); + return js.Number.from(balance); } /// Get a validator by index. From 7dc7a69f7bb2c2fdae280a868669cd3fd66942ab Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 1 May 2026 12:48:22 +0800 Subject: [PATCH 08/46] feat: align BeaconStateView to IBeaconStateView --- bindings/napi/BeaconStateView.zig | 528 +++++++++++++++++++++++++----- bindings/napi/js_types.zig | 6 +- bindings/napi/stateTransition.zig | 8 +- bindings/src/index.d.ts | 156 +++++++-- build.zig.zon | 1 + src/state_transition/root.zig | 1 + 6 files changed, 586 insertions(+), 114 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index c24cbc885..967f94f83 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -25,6 +25,7 @@ const allocator = gpa.allocator(); pub const js_meta = js.class(.{ .properties = .{ .slot = js.prop(.{ .get = true, .set = false }), .fork = js.prop(.{ .get = true, .set = false }), + .forkName = js.prop(.{ .get = true, .set = false }), .epoch = js.prop(.{ .get = true, .set = false }), .genesisTime = js.prop(.{ .get = true, .set = false }), .genesisValidatorsRoot = js.prop(.{ .get = true, .set = false }), @@ -36,6 +37,7 @@ pub const js_meta = js.class(.{ .properties = .{ .previousEpochParticipation = js.prop(.{ .get = true, .set = false }), .currentEpochParticipation = js.prop(.{ .get = true, .set = false }), .latestExecutionPayloadHeader = js.prop(.{ .get = true, .set = false }), + .payloadBlockNumber = js.prop(.{ .get = true, .set = false }), .historicalSummaries = js.prop(.{ .get = true, .set = false }), .pendingDeposits = js.prop(.{ .get = true, .set = false }), .pendingDepositsCount = js.prop(.{ .get = true, .set = false }), @@ -57,12 +59,15 @@ pub const js_meta = js.class(.{ .properties = .{ .effectiveBalanceIncrements = js.prop(.{ .get = true, .set = false }), .validatorCount = js.prop(.{ .get = true, .set = false }), .activeValidatorCount = js.prop(.{ .get = true, .set = false }), - .isMergeTransitionComplete = js.prop(.{ .get = true, .set = false }), .isExecutionStateType = js.prop(.{ .get = true, .set = false }), .proposerRewards = js.prop(.{ .get = true, .set = false }), .clonedCount = js.prop(.{ .get = true, .set = false }), .clonedCountWithTransferCache = js.prop(.{ .get = true, .set = false }), .createdWithTransferCache = js.prop(.{ .get = true, .set = false }), + .latestBlockHash = js.prop(.{ .get = true, .set = false }), + .executionPayloadAvailability = js.prop(.{ .get = true, .set = false }), + .latestExecutionPayloadBid = js.prop(.{ .get = true, .set = false }), + .payloadExpectedWithdrawals = js.prop(.{ .get = true, .set = false }), } }); cached_state: ?*CachedBeaconState = null, @@ -128,6 +133,11 @@ pub fn fork(self: *const BeaconStateView) !js_types.Fork { return js_types.wrap(js_types.Fork, try sszValueToNapiValue(env, ct.phase0.Fork, &fork_value)); } +pub fn forkName(self: *const BeaconStateView) !js.String { + const cached_state = try self.requireState(); + return js.String.from(cached_state.state.forkSeq().name()); +} + pub fn epoch(self: *const BeaconStateView) !js.Number { const cached_state = try self.requireState(); const slot_value = try cached_state.state.slot(); @@ -207,6 +217,26 @@ pub fn currentEpochParticipation(self: *const BeaconStateView) !js.Uint8Array { return result; } +pub fn getPreviousEpochParticipation(self: *const BeaconStateView, index_arg: js.Number) !js.Number { + const cached_state = try self.requireState(); + const index_value = try index_arg.toU32(); + var view = try cached_state.state.previousEpochParticipation(); + const flag = view.get(index_value) catch { + return throwNullAs(js.Number, "INVALID_INDEX", "Failed to get previous epoch participation"); + }; + return js.Number.from(flag); +} + +pub fn getCurrentEpochParticipation(self: *const BeaconStateView, index_arg: js.Number) !js.Number { + const cached_state = try self.requireState(); + const index_value: u64 = try index_arg.toU32(); + var view = try cached_state.state.currentEpochParticipation(); + const flag = view.get(index_value) catch { + return throwNullAs(js.Number, "INVALID_INDEX", "Failed to get current epoch participation"); + }; + return js.Number.from(flag); +} + pub fn latestExecutionPayloadHeader(self: *const BeaconStateView) !js.Value { const env = js.env(); const cached_state = try self.requireState(); @@ -222,16 +252,51 @@ pub fn latestExecutionPayloadHeader(self: *const BeaconStateView) !js.Value { return js_types.wrap(js.Value, value); } +pub fn payloadBlockNumber(self: *const BeaconStateView) !js.Number { + const cached_state = try self.requireState(); + var header: AnyExecutionPayloadHeader = undefined; + try cached_state.state.latestExecutionPayloadHeader(allocator, &header); + defer header.deinit(allocator); + + const block_number: u64 = switch (header) { + .bellatrix => |*h| h.block_number, + .capella => |*h| h.block_number, + .deneb => |*h| h.block_number, + }; + return js.Number.from(block_number); +} + // ------------------------- // Instance Methods // ------------------------- -pub fn getBlockRoot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8Array { +pub fn getBlockRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { + const env = js.env(); + const cached_state = try self.requireState(); + + const slot_ = st.computeStartSlotAtEpoch(try epoch_arg.toU32()); + + const result = switch (cached_state.state.forkSeq()) { + inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_), + }; + const root = result catch |err| { + const msg = switch (err) { + error.SlotTooBig => "Can only get block root in the past", + error.SlotTooSmall => "Cannot get block root more than SLOTS_PER_HISTORICAL_ROOT in the past", + else => "Failed to get block root", + }; + return throwNullAs(js.Uint8Array, "INVALID_SLOT", msg); + }; + + return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); +} + +pub fn getBlockRootAtSlot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); const result = switch (cached_state.state.forkSeq()) { - inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_value), + inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), try slot_arg.toU32()), }; const root = result catch |err| { const msg = switch (err) { @@ -240,7 +305,26 @@ pub fn getBlockRoot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8 else => "Failed to get block root", }; return throwNullAs(js.Uint8Array, "INVALID_SLOT", msg); + }; + + return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); +} + +pub fn getBlockRootAtEpoch(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { + const env = js.env(); + const cached_state = try self.requireState(); const slot_ = st.computeStartSlotAtEpoch(try epoch_arg.toU32()); + + const result = switch (cached_state.state.forkSeq()) { + inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_), + }; + const root = result catch |err| { + const msg = switch (err) { + error.SlotTooBig => "Can only get block root in the past", + error.SlotTooSmall => "Cannot get block root more than SLOTS_PER_HISTORICAL_ROOT in the past", + else => "Failed to get block root", + }; + return throwNullAs(js.Uint8Array, "INVALID_EPOCH", msg); }; return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); @@ -261,6 +345,19 @@ pub fn getRandaoMix(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Bytes32, mix)); } +pub fn getStateRootAtSlot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8Array { + const env = js.env(); + const cached_state = try self.requireState(); + + var state_roots_view = cached_state.state.stateRoots() catch { + return throwNullAs(js.Uint8Array, "STATE_ERROR", "Failed to get stateRoots"); + }; + const root = state_roots_view.getFieldRoot(try slot_arg.toU32() % preset.SLOTS_PER_HISTORICAL_ROOT) catch { + return throwNullAs(js.Uint8Array, "INVALID_SLOT", "Failed to get state root at slot"); + }; + return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); +} + /// Get the historical summaries from the state (Capella+). /// Returns: array of {blockSummaryRoot: Uint8Array, stateSummaryRoot: Uint8Array} pub fn historicalSummaries(self: *const BeaconStateView) !js.Array { @@ -367,10 +464,6 @@ pub fn proposerLookahead(self: *const BeaconStateView) !js.Uint32Array { return .{ .val = try numberSliceToNapiValue(env, u64, lookahead, .{ .typed_array = .uint32 }) }; } -// pub fn BeaconStateView_executionPayloadAvailability - -// pub fn BeaconStateView_getShufflingAtEpoch - fn rootToHexString(root: *const [32]u8) !js.String { const env = js.env(); var hex_buf: [66]u8 = undefined; @@ -527,6 +620,24 @@ pub fn getIndexedSyncCommitteeAtEpoch(self: *const BeaconStateView, epoch_arg: j return .{ .val = obj }; } +/// Get the indexed sync committee for a given slot (uses slot+1 offset for duty lookups). +pub fn getIndexedSyncCommittee(self: *const BeaconStateView, slot_arg: js.Number) !js_types.IndexedSyncCommittee { + const env = js.env(); + const cached_state = try self.requireState(); + const slot_value: u64 = try slot_arg.toU32(); + + const sync_committee = cached_state.epoch_cache.getIndexedSyncCommittee(slot_value) catch { + return throwNullAs(js_types.IndexedSyncCommittee, "NO_SYNC_COMMITTEE", "Sync committee not available for requested slot"); + }; + + const obj = try env.createObject(); + try obj.setNamedProperty( + "validatorIndices", + try numberSliceToNapiValue(env, u64, sync_committee.getValidatorIndices(), .{ .typed_array = .uint32 }), + ); + return .{ .val = obj }; +} + pub fn effectiveBalanceIncrements(self: *const BeaconStateView) !js.Uint16Array { const env = js.env(); const cached_state = try self.requireState(); @@ -542,7 +653,7 @@ pub fn getEffectiveBalanceIncrementsZeroInactive(self: *const BeaconStateView) ! return .{ .val = try numberSliceToNapiValue(env, u16, result.items, .{ .typed_array = .uint16 }) }; } -pub fn getBalance(self: *const BeaconStateView, index_arg: js.Number) !js.BigInt { +pub fn getBalance(self: *const BeaconStateView, index_arg: js.Number) !js.Number { const cached_state = try self.requireState(); const index_value: u64 = @intCast(try index_arg.toI64()); var balances = try cached_state.state.balances(); @@ -580,6 +691,63 @@ pub fn getValidatorStatus(self: *const BeaconStateView, index_arg: js.Number) !j return js.String.from(status.toString()); } +/// Get all validators in the registry. +pub fn getAllValidators(self: *const BeaconStateView) !js.Array { + const env = js.env(); + const cached_state = try self.requireState(); + + const validators = try cached_state.state.validatorsSlice(allocator); + defer allocator.free(validators); + + const result = try env.createArray(); + for (validators, 0..) |*validator, i| { + const v_napi = try sszValueToNapiValue(env, ct.phase0.Validator, validator); + try result.setElement(@intCast(i), v_napi); + } + return js_types.wrap(js.Array, result); +} + +/// Get all balances in the registry. +pub fn getAllBalances(self: *const BeaconStateView) !js.Array { + const env = js.env(); + const cached_state = try self.requireState(); + + const balances = try cached_state.state.balancesSlice(allocator); + defer allocator.free(balances); + + return js_types.wrap(js.Array, try numberSliceToNapiValue(env, u64, balances, .{})); +} + +/// Get validators whose status is in the provided Set. +/// Arguments: +/// - statuses: JS Set +/// - currentEpoch: Epoch (number) +pub fn getValidatorsByStatus(self: *const BeaconStateView, statuses_set: js.Value, current_epoch_arg: js.Number) !js.Array { + const env = js.env(); + const cached_state = try self.requireState(); + const current_epoch: u64 = try current_epoch_arg.toU32(); + + const set_value = statuses_set.toValue(); + const has_fn = try set_value.getNamedProperty("has"); + + const validators = try cached_state.state.validatorsSlice(allocator); + defer allocator.free(validators); + + const result = try env.createArray(); + var out_idx: u32 = 0; + for (validators) |*validator| { + const status = st.getValidatorStatus(validator, current_epoch); + const status_str = try env.createStringUtf8(status.toString()); + const has_result = try env.callFunction(has_fn, set_value, .{status_str}); + if (try has_result.getValueBool()) { + const v_napi = try sszValueToNapiValue(env, ct.phase0.Validator, validator); + try result.setElement(out_idx, v_napi); + out_idx += 1; + } + } + return js_types.wrap(js.Array, result); +} + /// Get the total number of validators in the registry. pub fn validatorCount(self: *const BeaconStateView) !js.Number { const cached_state = try self.requireState(); @@ -600,53 +768,46 @@ pub fn isExecutionStateType(self: *const BeaconStateView) !js.Boolean { return js.Boolean.from(fork_seq.gte(.bellatrix)); } -/// Check if the merge transition is complete. -pub fn isExecutionEnabled(self: *const BeaconStateView, fork_name_value: js.String, signed_block_bytes: js.Uint8Array) !js.Boolean { +/// Check whether execution is enabled for the given block at this state. +/// +/// Check if 1) merge transition is complete, or 2) is a merge transition block +/// +/// Note that this does not call native `isExecutionEnabled` directly because we can save on deserializing +/// `signed_block` if 1) holds. We only deserialize in the event that it's a pre-merge bellatrix block +pub fn isExecutionEnabled(self: *const BeaconStateView, signed_block_bytes: js.Uint8Array) !js.Boolean { const cached_state = try self.requireState(); + const fork_seq = cached_state.state.forkSeq(); + if (fork_seq.lt(.bellatrix)) return js.Boolean.from(false); + + // Check if (1) holds + const merge_complete: bool = switch (fork_seq) { + inline .bellatrix, .capella, .deneb, .electra, .fulu => |f| st.isMergeTransitionComplete(f, cached_state.state.castToFork(f)), + else => unreachable, + }; + if (merge_complete) return js.Boolean.from(true); - var fork_name_buf: [16]u8 = undefined; - const fork_name = try fork_name_value.toSlice(&fork_name_buf); - const fork_seq = c.ForkSeq.fromName(fork_name); + if (fork_seq != .bellatrix) return js.Boolean.from(false); + // Only deserialize and check (2) if previous conditions have not been fulfilled const bytes = try signed_block_bytes.toSlice(); - const signed_block = try AnySignedBeaconBlock.deserialize( - allocator, - .full, - fork_seq, - bytes, - ); + const signed_block = try AnySignedBeaconBlock.deserialize(allocator, .full, fork_seq, bytes); defer signed_block.deinit(allocator); - if (signed_block.forkSeq() != cached_state.state.forkSeq()) { + if (signed_block.forkSeq() != fork_seq) { return throwNullAs(js.Boolean, "FORK_MISMATCH", "Fork of signed block does not match state fork"); } - const result = switch (cached_state.state.forkSeq()) { - inline else => |f| switch (signed_block.blockType()) { - inline else => |bt| if (comptime bt == .blinded and f.lt(.bellatrix)) { - return error.InvalidBlockTypeForFork; - } else st.isExecutionEnabled( - f, - cached_state.state.castToFork(f), - bt, - signed_block.beaconBlock().castToFork(bt, f), - ), - }, - }; - return js.Boolean.from(result); -} - -/// Check if the merge transition is complete. -pub fn isMergeTransitionComplete(self: *const BeaconStateView) !js.Boolean { - const cached_state = try self.requireState(); - const result = switch (cached_state.state.forkSeq()) { - inline else => |f| st.isMergeTransitionComplete(f, cached_state.state.castToFork(f)), + const is_merge_transition_block = switch (signed_block.blockType()) { + inline else => |bt| st.isMergeTransitionBlock( + .bellatrix, + cached_state.state.castToFork(.bellatrix), + bt, + signed_block.beaconBlock().castToFork(bt, .bellatrix).body(), + ), }; - return js.Boolean.from(result); + return js.Boolean.from(is_merge_transition_block); } -// pub fn BeaconStateView_getExpectedWithdrawals - /// Get the proposer rewards for the state. pub fn proposerRewards(self: *const BeaconStateView) !js_types.ProposerRewards { const env = js.env(); @@ -654,30 +815,40 @@ pub fn proposerRewards(self: *const BeaconStateView) !js_types.ProposerRewards { const rewards = cached_state.getProposerRewards(); const obj = try env.createObject(); - try obj.setNamedProperty("attestations", try env.createBigintUint64(rewards.attestations)); - try obj.setNamedProperty("syncAggregate", try env.createBigintUint64(rewards.sync_aggregate)); - try obj.setNamedProperty("slashing", try env.createBigintUint64(rewards.slashing)); + try obj.setNamedProperty("attestations", try env.createDouble(@floatFromInt(rewards.attestations))); + try obj.setNamedProperty("syncAggregate", try env.createDouble(@floatFromInt(rewards.sync_aggregate))); + try obj.setNamedProperty("slashing", try env.createDouble(@floatFromInt(rewards.slashing))); return .{ .val = obj }; } -// pub fn BeaconStateView_computeBlockRewards +/// Walk a JS `phase0.SignedVoluntaryExit` object and assemble the Zig SSZ value. +/// Field shape: `{message: {epoch, validatorIndex}, signature: Uint8Array(96)}`. +fn parseSignedVoluntaryExit(signed_exit_value: js.Value) !ct.phase0.SignedVoluntaryExit.Type { + var result: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; + const exit_obj = signed_exit_value.toValue(); -// pub fn BeaconStateView_computeAttestationRewards + const message = try exit_obj.getNamedProperty("message"); + result.message.epoch = try (try message.getNamedProperty("epoch")).getValueUint32(); + result.message.validator_index = try (try message.getNamedProperty("validatorIndex")).getValueUint32(); -// pub fn BeaconStateView_computeSyncCommitteeRewards - -// pub fn BeaconStateView_getLatestWeakSubjectivityCheckpointEpoch + const sig_val = try exit_obj.getNamedProperty("signature"); + const sig_info = try sig_val.getTypedarrayInfo(); + if (sig_info.array_type != .uint8 or sig_info.data.len != 96) { + return error.InvalidSignature; + } + @memcpy(&result.signature, sig_info.data); + return result; +} /// Get the validity status of a signed voluntary exit. -pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.String { +/// Caller passes a JS `phase0.SignedVoluntaryExit` object (matches IBeaconStateView). +pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.String { const env = js.env(); const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); - const bytes = try signed_exit_bytes.toSlice(); - var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; - ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { - return throwNullAs(js.String, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); + var signed_voluntary_exit = parseSignedVoluntaryExit(signed_exit_value) catch { + return throwNullAs(js.String, "PARSE_ERROR", "Failed to parse SignedVoluntaryExit"); }; const result = switch (cached_state.state.forkSeq()) { @@ -698,14 +869,13 @@ pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: } /// Check if a signed voluntary exit is valid. -pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.Boolean { +/// Caller passes a JS `phase0.SignedVoluntaryExit` object (matches IBeaconStateView). +pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.Boolean { const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); - const bytes = try signed_exit_bytes.toSlice(); - var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; - ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { - return throwNullAs(js.Boolean, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); + var signed_voluntary_exit = parseSignedVoluntaryExit(signed_exit_value) catch { + return throwNullAs(js.Boolean, "PARSE_ERROR", "Failed to parse SignedVoluntaryExit"); }; const result = switch (cached_state.state.forkSeq()) { @@ -739,8 +909,6 @@ pub fn getFinalizedRootProof(self: *const BeaconStateView) !js.Array { )); } -// pub fn BeaconStateView_getSyncCommitteesWitness - /// Get a single Merkle proof for a node at the given generalized index. pub fn getSingleProof(self: *const BeaconStateView, gindex_arg: js.Number) !js.Array { const env = js.env(); @@ -760,8 +928,6 @@ pub fn getSingleProof(self: *const BeaconStateView, gindex_arg: js.Number) !js.A return .{ .val = result }; } -// pub fn BeaconStateView_getSyncCommitteesWitness - /// Create a compact multi-proof from a descriptor. /// Returns: {type: string, leaves: Uint8Array[], descriptor: Uint8Array} pub fn createMultiProof(self: *const BeaconStateView, descriptor: js.Uint8Array) !js_types.MultiProof { @@ -846,10 +1012,6 @@ pub fn createdWithTransferCache(self: *const BeaconStateView) !js.Boolean { return js.Boolean.from(cached_state.created_with_transfer_cache); } -// pub fn BeaconStateView_isStateValidatorsNodesPopulated - -// pub fn BeaconStateView_loadOtherState - pub fn serialize(self: *const BeaconStateView) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); @@ -866,10 +1028,22 @@ pub fn serializedSize(self: *const BeaconStateView) !js.Number { return js.Number.from(size); } -/// arg 0: output: preallocated Uint8Array buffer +/// Extract the writable `uint8Array` slice from a `@chainsafe/ssz` ByteViews object +/// `{uint8Array: Uint8Array, dataView: DataView}`. The `dataView` is ignored — Zig's +/// SSZ serializer only needs the raw bytes. +fn byteViewsToSlice(output: js.Value) ![]u8 { + const arr_val = try output.toValue().getNamedProperty("uint8Array"); + const arr_info = try arr_val.getTypedarrayInfo(); + if (arr_info.array_type != .uint8) return error.InvalidByteViews; + return arr_info.data; +} + +/// arg 0: output: ByteViews `{uint8Array, dataView}` (matches IBeaconStateView contract) /// arg 1: offset: offset of buffer where serialization should start -pub fn serializeToBytes(self: *const BeaconStateView, output: js.Uint8Array, offset: js.Number) !js.Number { - const output_slice = try output.toSlice(); +/// +/// Returns the number of bytes written. +pub fn serializeToBytes(self: *const BeaconStateView, output: js.Value, offset: js.Number) !js.Number { + const output_slice = try byteViewsToSlice(output); const off = try offset.toU32(); if (off > output_slice.len) return error.InvalidOffset; @@ -898,8 +1072,12 @@ pub fn serializedValidatorsSize(self: *const BeaconStateView) !js.Number { return js.Number.from(size); } -pub fn serializeValidatorsToBytes(self: *const BeaconStateView, output: js.Uint8Array, offset: js.Number) !js.Number { - const output_slice = try output.toSlice(); +/// arg 0: output: ByteViews `{uint8Array, dataView}` (matches IBeaconStateView contract) +/// arg 1: offset: offset of buffer where serialization should start +/// +/// Returns the number of bytes written. +pub fn serializeValidatorsToBytes(self: *const BeaconStateView, output: js.Value, offset: js.Number) !js.Number { + const output_slice = try byteViewsToSlice(output); const off = try offset.toU32(); if (off > output_slice.len) return error.InvalidOffset; @@ -916,8 +1094,6 @@ pub fn hashTreeRoot(self: *const BeaconStateView) !js.Uint8Array { return .{ .val = try numberSliceToNapiValue(env, u8, root, .{ .typed_array = .uint8 }) }; } -// pub fn BeaconStateView_stateTransition - /// Process slots from current state slot to target slot, returning a new BeaconStateView. /// /// Arguments: @@ -937,6 +1113,206 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options: return .{ .cached_state = post_state }; } +/// Run the state transition on a SSZ-serialized SignedBeaconBlock, returning a new +/// BeaconStateView wrapping the post-state. Mirrors `IBeaconStateView.stateTransition`. +/// +/// Arguments: +/// - arg 0: signed block bytes (Uint8Array) +/// - arg 1: options (optional): { verifyStateRoot?, verifyProposer?, verifySignatures?, transferCache? } +pub fn stateTransition(self: *const BeaconStateView, signed_block_bytes: js.Uint8Array, options: ?js.Value) !BeaconStateView { + const cached_state = try self.requireState(); + + const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); + const fork_seq = cached_state.config.forkSeqAtEpoch(current_epoch); + const bytes = try signed_block_bytes.toSlice(); + const signed_block = try AnySignedBeaconBlock.deserialize(allocator, .full, fork_seq, bytes); + defer signed_block.deinit(allocator); + + var opts: st.TransitionOpts = .{}; + if (options) |opt_val| { + const raw = opt_val.toValue(); + if (try raw.typeof() == .object) { + if (try raw.hasNamedProperty("verifyStateRoot")) + opts.verify_state_root = try (try raw.getNamedProperty("verifyStateRoot")).getValueBool(); + if (try raw.hasNamedProperty("verifyProposer")) + opts.verify_proposer = try (try raw.getNamedProperty("verifyProposer")).getValueBool(); + if (try raw.hasNamedProperty("verifySignatures")) + opts.verify_signatures = try (try raw.getNamedProperty("verifySignatures")).getValueBool(); + if (try raw.hasNamedProperty("transferCache")) + opts.transfer_cache = try (try raw.getNamedProperty("transferCache")).getValueBool(); + } + } + + const post_state = try st.stateTransition(allocator, napi_io.get(), cached_state, signed_block, opts); + return .{ .cached_state = post_state }; +} + +/// Compute the anchor checkpoint and block header for the current state. +/// Returns: { checkpoint: { epoch, root }, blockHeader: BeaconBlockHeader } +pub fn computeAnchorCheckpoint(self: *const BeaconStateView) !js.Value { + const env = js.env(); + const cached_state = try self.requireState(); + var anchor = try st.AnchorCheckpoint.fromState(cached_state.state); + + const obj = try env.createObject(); + try obj.setNamedProperty( + "checkpoint", + try sszValueToNapiValue(env, ct.phase0.Checkpoint, &anchor.checkpoint), + ); + try obj.setNamedProperty( + "blockHeader", + try sszValueToNapiValue(env, ct.phase0.BeaconBlockHeader, &anchor.block_header), + ); + return js_types.wrap(js.Value, obj); +} + +// ------------------------- +// Shuffling +// ------------------------- + +fn shufflingToNapi(shuffling: anytype) !napi.Value { + const env = js.env(); + const obj = try env.createObject(); + try obj.setNamedProperty("epoch", try env.createInt64(@intCast(shuffling.epoch))); + try obj.setNamedProperty( + "activeIndices", + try numberSliceToNapiValue(env, u64, shuffling.active_indices, .{ .typed_array = .uint32 }), + ); + try obj.setNamedProperty( + "shuffling", + try numberSliceToNapiValue(env, u64, shuffling.shuffling, .{ .typed_array = .uint32 }), + ); + + const committees_outer = try env.createArray(); + for (shuffling.committees, 0..) |slot_committees, slot_idx| { + const slot_arr = try env.createArray(); + for (slot_committees, 0..) |committee, committee_idx| { + const committee_arr = try numberSliceToNapiValue(env, u64, committee, .{ .typed_array = .uint32 }); + try slot_arr.setElement(@intCast(committee_idx), committee_arr); + } + try committees_outer.setElement(@intCast(slot_idx), slot_arr); + } + try obj.setNamedProperty("committees", committees_outer); + try obj.setNamedProperty("committeesPerSlot", try env.createInt64(@intCast(shuffling.committees_per_slot))); + + return obj; +} + +pub fn getPreviousShuffling(self: *const BeaconStateView) !js.Value { + const cached_state = try self.requireState(); + const shuffling = cached_state.epoch_cache.getPreviousShuffling(); + return js_types.wrap(js.Value, try shufflingToNapi(shuffling)); +} + +pub fn getCurrentShuffling(self: *const BeaconStateView) !js.Value { + const cached_state = try self.requireState(); + const shuffling = cached_state.epoch_cache.getCurrentShuffling(); + return js_types.wrap(js.Value, try shufflingToNapi(shuffling)); +} + +pub fn getNextShuffling(self: *const BeaconStateView) !js.Value { + const cached_state = try self.requireState(); + const shuffling = cached_state.epoch_cache.getNextEpochShuffling(); + return js_types.wrap(js.Value, try shufflingToNapi(shuffling)); +} + +pub fn getShufflingAtEpoch(self: *const BeaconStateView, epoch_arg: js.Number) !js.Value { + const cached_state = try self.requireState(); + const epoch_value: u64 = try epoch_arg.toU32(); + + const shuffling = cached_state.epoch_cache.getShufflingAtEpochOrNull(epoch_value) orelse { + return throwNullAs(js.Value, "NO_SHUFFLING", "Shuffling not available for requested epoch"); + }; + return js_types.wrap(js.Value, try shufflingToNapi(shuffling)); +} + +// ------------------------- +// Throw stubs — IBeaconStateView surface not yet implemented in lodestar-z +// ------------------------- + +fn throwNotImpl(comptime T: type, name: [:0]const u8) !T { + return throwNullAs(T, "NOT_IMPLEMENTED", name); +} + +// --- Gloas-only fields/methods (no Gloas state in lodestar-z yet) --- + +pub fn latestBlockHash(_: *const BeaconStateView) !js.Uint8Array { + return throwNotImpl(js.Uint8Array, "latestBlockHash is not available before Gloas"); +} + +pub fn executionPayloadAvailability(_: *const BeaconStateView) !js.Value { + return throwNotImpl(js.Value, "executionPayloadAvailability is not available before Gloas"); +} + +pub fn latestExecutionPayloadBid(_: *const BeaconStateView) !js.Value { + return throwNotImpl(js.Value, "latestExecutionPayloadBid is not available before Gloas"); +} + +pub fn payloadExpectedWithdrawals(_: *const BeaconStateView) !js.Array { + return throwNotImpl(js.Array, "payloadExpectedWithdrawals is not available before Gloas"); +} + +pub fn getBuilder(_: *const BeaconStateView, _: js.Number) !js.Value { + return throwNotImpl(js.Value, "getBuilder is not available before Gloas"); +} + +pub fn canBuilderCoverBid(_: *const BeaconStateView, _: js.Number, _: js.Number) !js.Boolean { + return throwNotImpl(js.Boolean, "canBuilderCoverBid is not available before Gloas"); +} + +pub fn getEpochPTCs(_: *const BeaconStateView, _: js.Number) !js.Array { + return throwNotImpl(js.Array, "getEpochPTCs is not available before Gloas"); +} + +pub fn getIndexInPayloadTimelinessCommittee(_: *const BeaconStateView, _: js.Number, _: js.Number) !js.Number { + return throwNotImpl(js.Number, "getIndexInPayloadTimelinessCommittee is not available before Gloas"); +} + +pub fn getExpectedWithdrawalsForFullParent(_: *const BeaconStateView, _: js.Value) !js.Array { + return throwNotImpl(js.Array, "getExpectedWithdrawalsForFullParent is not available before Gloas"); +} + +// --- API-only methods (used by beacon-node rewards endpoints) --- + +pub fn computeBlockRewards(_: *const BeaconStateView, _: js.Value, _: ?js.Value) !js.Value { + return throwNotImpl(js.Value, "computeBlockRewards not implemented"); +} + +pub fn computeAttestationsRewards(_: *const BeaconStateView, _: ?js.Value) !js.Value { + return throwNotImpl(js.Value, "computeAttestationsRewards not implemented"); +} + +pub fn computeSyncCommitteeRewards(_: *const BeaconStateView, _: js.Value, _: js.Value) !js.Value { + return throwNotImpl(js.Value, "computeSyncCommitteeRewards not implemented"); +} + +// --- Misc not-yet-implemented --- + +pub fn getLatestWeakSubjectivityCheckpointEpoch(_: *const BeaconStateView) !js.Number { + return throwNotImpl(js.Number, "getLatestWeakSubjectivityCheckpointEpoch not implemented"); +} + +pub fn isStateValidatorsNodesPopulated(_: *const BeaconStateView) !js.Boolean { + // Native state is always fully populated — return true. + return js.Boolean.from(true); +} + +pub fn loadOtherState(_: *const BeaconStateView, _: js.Uint8Array, _: ?js.Uint8Array, _: ?js.Value) !js.Value { + return throwNotImpl(js.Value, "loadOtherState not implemented"); +} + +pub fn toValue(_: *const BeaconStateView) !js.Value { + return throwNotImpl(js.Value, "toValue not implemented"); +} + +pub fn getSyncCommitteesWitness(_: *const BeaconStateView) !js.Value { + return throwNotImpl(js.Value, "getSyncCommitteesWitness not implemented"); +} + +pub fn getExpectedWithdrawals(_: *const BeaconStateView) !js.Value { + return throwNotImpl(js.Value, "getExpectedWithdrawals not implemented"); +} + fn requireState(self: *const BeaconStateView) !*CachedBeaconState { return self.cached_state orelse error.InvalidState; } diff --git a/bindings/napi/js_types.zig b/bindings/napi/js_types.zig index cabd968aa..6dcf3d2f0 100644 --- a/bindings/napi/js_types.zig +++ b/bindings/napi/js_types.zig @@ -56,9 +56,9 @@ pub const Validator = js.Object(struct { }); pub const ProposerRewards = js.Object(struct { - attestations: js.BigInt, - syncAggregate: js.BigInt, - slashing: js.BigInt, + attestations: js.Number, + syncAggregate: js.Number, + slashing: js.Number, }); pub const MultiProof = js.Object(struct { diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig index 410e372a5..578013aa8 100644 --- a/bindings/napi/stateTransition.zig +++ b/bindings/napi/stateTransition.zig @@ -22,10 +22,10 @@ else /// - dontTransferCache: bool (negated to set transfer_cache) /// /// This is the double negative version to conform with production lodestar. -/// TODO(bing): Eventually rename this to `transferCache` to avoid double negation because its confusing naming. -/// TODO(bing): Other fields (executionPayloadStatus, ..). -fn parseOptions(options: ?js.Value) !st.TransitionOpt { - var transition_opts: st.TransitionOpt = .{}; +/// TODO(bing): Eventually rename `dontTransferCache` to `transferCache` to avoid double negation because its confusing naming. +fn parseOptions(options: ?js.Value) !st.TransitionOpts { + var transition_opts: st.TransitionOpts = .{}; + if (options) |value| { const raw = value.toValue(); if (try raw.typeof() == .object) { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 5443e2ca7..a246ee4b9 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -43,6 +43,17 @@ interface Fork { epoch: number; } +enum ForkName { + phase0 = "phase0", + altair = "altair", + bellatrix = "bellatrix", + capella = "capella", + deneb = "deneb", + electra = "electra", + fulu = "fulu", + gloas = "gloas", +} + interface SyncCommittee { pubkeys: Uint8Array; aggregatePubkey: Uint8Array; @@ -54,7 +65,8 @@ interface ProcessSlotsOpts { } interface CompactMultiProof { - type: "compactMulti"; + // biome-ignore lint/suspicious/noExplicitAny: native returns string literal "compactMulti", IBeaconStateView uses @chainsafe/persistent-merkle-tree's ProofType enum nominally + type: any; leaves: Uint8Array[]; descriptor: Uint8Array; } @@ -74,20 +86,27 @@ interface TransitionOpts { verifySignatures?: boolean; /** Default: false (cache is transferred). Set to true to opt out of cache transfer. */ dontTransferCache?: boolean; - /** Other fields (executionPayloadStatus, dataAvailabilityStatus, metrics, validatorMonitor, …) */ - [extra: string]: unknown; } interface ProposerRewards { - attestations: bigint; - syncAggregate: bigint; - slashing: bigint; + attestations: number; + syncAggregate: number; + slashing: number; } interface SyncCommitteeCache { validatorIndices: number[]; } +interface EpochShuffling { + epoch: number; + activeIndices: Uint32Array; + shuffling: Uint32Array; + /** committees[slotInEpoch][committeeIndex] -> validator indices */ + committees: Uint32Array[][]; + committeesPerSlot: number; +} + interface HistoricalSummary { blockSummaryRoot: Uint8Array; stateSummaryRoot: Uint8Array; @@ -134,6 +153,7 @@ declare class BeaconStateView { slot: number; fork: Fork; + forkName: ForkName; epoch: number; genesisTime: number; genesisValidatorsRoot: Uint8Array; @@ -142,11 +162,17 @@ declare class BeaconStateView { previousJustifiedCheckpoint: Checkpoint; currentJustifiedCheckpoint: Checkpoint; finalizedCheckpoint: Checkpoint; - getBlockRoot(slot: number): Uint8Array; + getBlockRoot(epoch: number): Uint8Array; + getBlockRootAtSlot(slot: number): Uint8Array; + getBlockRootAtEpoch(epoch: number): Uint8Array; + getStateRootAtSlot(slot: number): Uint8Array; getRandaoMix(epoch: number): Uint8Array; - previousEpochParticipation: number[]; - currentEpochParticipation: number[]; + previousEpochParticipation: Uint8Array; + currentEpochParticipation: Uint8Array; + getPreviousEpochParticipation(index: number): number; + getCurrentEpochParticipation(index: number): number; latestExecutionPayloadHeader: ExecutionPayloadHeader; + payloadBlockNumber: number; historicalSummaries: HistoricalSummary[]; pendingDeposits: Uint8Array; pendingDepositsCount: number; @@ -157,12 +183,35 @@ declare class BeaconStateView { proposerLookahead: Uint32Array; // executionPayloadAvailability: boolean[]; - // getShufflingAtEpoch(epoch: number): EpochShuffling; - previousDecisionRoot: Uint8Array; - currentDecisionRoot: Uint8Array; - nextDecisionRoot: Uint8Array; - // TODO wrong return type - getShufflingDecisionRoot(epoch: number): Uint8Array; + // Gloas-only — throw "not available before Gloas" when called pre-Gloas. + latestBlockHash: Uint8Array; + // TODO(bing): type this once we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + executionPayloadAvailability: any; + // TODO(bing): type this once we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + latestExecutionPayloadBid: any; + // TODO(bing): type this once we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + payloadExpectedWithdrawals: any[]; + // TODO(bing): type this once we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + getBuilder(index: number): any; + canBuilderCoverBid(builderIndex: number, bidAmount: number): boolean; + getEpochPTCs(epoch: number): Uint32Array[]; + getIndexInPayloadTimelinessCommittee(validatorIndex: number, slot: number): number; + // TODO(bing): type this once we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + getExpectedWithdrawalsForFullParent(executionRequests: any): any[]; + + getShufflingAtEpoch(epoch: number): EpochShuffling; + getPreviousShuffling(): EpochShuffling; + getCurrentShuffling(): EpochShuffling; + getNextShuffling(): EpochShuffling; + previousDecisionRoot: string; + currentDecisionRoot: string; + nextDecisionRoot: string; + getShufflingDecisionRoot(epoch: number): string; previousProposers: number[] | null; currentProposers: number[]; nextProposers: number[]; @@ -172,11 +221,15 @@ declare class BeaconStateView { currentSyncCommitteeIndexed: SyncCommitteeCache; syncProposerReward: number; getIndexedSyncCommitteeAtEpoch(epoch: number): SyncCommitteeCache; + getIndexedSyncCommittee(slot: number): SyncCommitteeCache; effectiveBalanceIncrements: Uint16Array; getEffectiveBalanceIncrementsZeroInactive(): Uint16Array; - getBalance(index: number): bigint; + getBalance(index: number): number; getValidator(index: number): Validator; + getAllValidators(): Validator[]; + getAllBalances(): number[]; + getValidatorsByStatus(statuses: Set, currentEpoch: number): Validator[]; // TODO wrong function getValidatorStatus(index: number): ValidatorStatus; validatorCount: number; @@ -184,46 +237,87 @@ declare class BeaconStateView { isExecutionStateType: boolean; isMergeTransitionComplete: boolean; - // TODO remove - isExecutionEnabled(fork: string, signedBlockBytes: Uint8Array): boolean; + /** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */ + isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean; + /** + * Spec: `is_execution_enabled(state, body) = is_merge_transition_complete(state) or is_merge_transition_block(state, body)`. + * Fast path for post-merge states (no SSZ work); deserializes only on the rare pre-merge bellatrix path. + */ + isExecutionEnabled(signedBlockBytes: Uint8Array): boolean; // getExpectedWithdrawals(): ExpectedWithdrawals; proposerRewards: ProposerRewards; - // computeBlockRewards(block: BeaconBlock, proposerRewards: RewardsCache): BlockRewards; - // computeAttestationRewards(validatorIds?: (number | string)[]): AttestationRewards; - // computeSyncCommitteeRewards(block: BeaconBlock, validatorIds?: (number | string)[]): SyncCommitteeRewards; - // getLatestWeakSubjectivityCheckpointEpoch(): number; + // biome-ignore lint/suspicious/noExplicitAny: stub + // TODO(bing): This is stubbed and untyped until we implement the beacon node rewards endpoints + computeBlockRewards(block: any, proposerRewards?: any): Promise; + // biome-ignore lint/suspicious/noExplicitAny: stub + // TODO(bing): This is stubbed and untyped until we implement the beacon node rewards endpoints + computeAttestationsRewards(validatorIds?: (number | string)[]): Promise; + // TODO(bing): This is stubbed and untyped until we implement the beacon node rewards endpoints + // biome-ignore lint/suspicious/noExplicitAny: stub + computeSyncCommitteeRewards(block: any, validatorIds: (number | string)[]): Promise; + getLatestWeakSubjectivityCheckpointEpoch(): number; - getVoluntaryExitValidity(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): VoluntaryExitValidity; - isValidVoluntaryExit(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): boolean; + /** + * Native walks the JS `phase0.SignedVoluntaryExit` shape directly. + * Return is the string union `VoluntaryExitValidity` at runtime, but lodestar's + * matching TS enum is nominally typed — `any` to satisfy structural conformance. + */ + // biome-ignore lint/suspicious/noExplicitAny: TS-nominal-enum mismatch (runtime values match exactly) + getVoluntaryExitValidity( + signedVoluntaryExit: {message: {epoch: number; validatorIndex: number}; signature: Uint8Array}, + verifySignature: boolean + ): any; + isValidVoluntaryExit( + signedVoluntaryExit: {message: {epoch: number; validatorIndex: number}; signature: Uint8Array}, + verifySignature: boolean + ): boolean; getFinalizedRootProof(): Uint8Array[]; - // getSyncCommitteesWitness(): SyncCommitteeWitness; - getSingleProof(gindex: number): Uint8Array[]; + // biome-ignore lint/suspicious/noExplicitAny: stub + getSyncCommitteesWitness(): any; + // biome-ignore lint/suspicious/noExplicitAny: stub + getExpectedWithdrawals(): any; + // biome-ignore lint/suspicious/noExplicitAny: native takes number, IBeaconStateView declares bigint + getSingleProof(gindex: any): Uint8Array[]; // createMultiProof(descriptor: Uint8Array): CompactMultiProof; computeUnrealizedCheckpoints(): { justifiedCheckpoint: Checkpoint; finalizedCheckpoint: Checkpoint; }; + computeAnchorCheckpoint(): { + checkpoint: Checkpoint; + blockHeader: BeaconBlockHeader; + }; clonedCount: number; clonedCountWithTransferCache: number; createdWithTransferCache: boolean; - // isStateValidatorsNodesPopulated(): boolean; + isStateValidatorsNodesPopulated(): boolean; - // loadOtherState(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; + // biome-ignore lint/suspicious/noExplicitAny: stub + loadOtherState( + stateBytes: Uint8Array, + seedValidatorsBytes?: Uint8Array, + opts?: {preloadValidatorsAndBalances?: boolean} + ): any; + // biome-ignore lint/suspicious/noExplicitAny: stub + toValue(): any; serialize(): Uint8Array; serializedSize(): number; - serializeToBytes(output: Uint8Array, offset: number): number; + /** Takes a `@chainsafe/ssz` ByteViews `{uint8Array, dataView}`; native uses `uint8Array` only. */ + serializeToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; serializeValidators(): Uint8Array; serializedValidatorsSize(): number; - serializeValidatorsToBytes(output: Uint8Array, offset: number): number; + /** Same shape as `serializeToBytes`. */ + serializeValidatorsToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; hashTreeRoot(): Uint8Array; createMultiProof(descriptor: Uint8Array): CompactMultiProof; - // stateTransition(signedBlockBytes: Uint8Array): BeaconStateView; + // biome-ignore lint/suspicious/noExplicitAny: signed block bytes are passed as Uint8Array at runtime; signature is loosened so it satisfies `IBeaconStateView.stateTransition(block, opts, modules)` structurally. + stateTransition(signedBlock: any, options?: any, modules?: any): BeaconStateView; processSlots(slot: number, options?: ProcessSlotsOpts): BeaconStateView; } diff --git a/build.zig.zon b/build.zig.zon index 6bd2fef89..ddbebffc6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -307,6 +307,7 @@ .config, .fork_types, .state_transition, + .hex, "zapi:zapi", }, }, diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index 6cf8033ea..d062ed5b0 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -87,6 +87,7 @@ pub const AnchorCheckpoint = @import("./AnchorCheckpoint.zig"); pub const deinitStateTransition = @import("./state_transition.zig").deinitStateTransition; pub const isExecutionEnabled = @import("./utils/execution.zig").isExecutionEnabled; pub const isMergeTransitionComplete = @import("./utils/execution.zig").isMergeTransitionComplete; +pub const isMergeTransitionBlock = @import("./utils/execution.zig").isMergeTransitionBlock; pub const getRandaoMix = @import("./utils/seed.zig").getRandaoMix; pub const getEffectiveBalanceIncrementsZeroInactive = @import("./utils/balance.zig").getEffectiveBalanceIncrementsZeroInactive; From fd1c0a9b46bf91ccbc6817fbdec08d2726e4e874 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 22:54:57 +0800 Subject: [PATCH 09/46] align doc comment for isExecutionEnabled --- bindings/src/index.d.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index a246ee4b9..acc59a2d3 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -243,9 +243,15 @@ declare class BeaconStateView { * Spec: `is_execution_enabled(state, body) = is_merge_transition_complete(state) or is_merge_transition_block(state, body)`. * Fast path for post-merge states (no SSZ work); deserializes only on the rare pre-merge bellatrix path. */ - isExecutionEnabled(signedBlockBytes: Uint8Array): boolean; + /** + * Check whether execution is enabled for the given block at this state. + * + * Check if 1) merge transition is complete, or 2) is a merge transition block + * Note that this does not call native `isExecutionEnabled` directly because we can save on deserializing + * `signed_block` if 1) holds. We only deserialize in the event that it's a pre-merge bellatrix block + */ - // getExpectedWithdrawals(): ExpectedWithdrawals; + isExecutionEnabled(signedBlockBytes: Uint8Array): boolean; proposerRewards: ProposerRewards; // biome-ignore lint/suspicious/noExplicitAny: stub From 58656ce4c6d7499123fca9a93c3220b6387ab5f5 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:01:13 +0800 Subject: [PATCH 10/46] revert(bindings): pass SSZ bytes for voluntary exit validation Reverts the IBeaconStateView-shape object signature for getVoluntaryExitValidity and isValidVoluntaryExit. Bytes is one FFI hop (vs ~8 for field-walking), reuses the SSZ deserializer (no manual u64->u32 truncation), and is robust to schema changes. Tests already pass Uint8Array(112) so they work as-is. --- bindings/napi/BeaconStateView.zig | 39 ++++++++----------------------- bindings/src/index.d.ts | 16 ++----------- 2 files changed, 12 insertions(+), 43 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 967f94f83..47333e2bc 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -821,34 +821,15 @@ pub fn proposerRewards(self: *const BeaconStateView) !js_types.ProposerRewards { return .{ .val = obj }; } -/// Walk a JS `phase0.SignedVoluntaryExit` object and assemble the Zig SSZ value. -/// Field shape: `{message: {epoch, validatorIndex}, signature: Uint8Array(96)}`. -fn parseSignedVoluntaryExit(signed_exit_value: js.Value) !ct.phase0.SignedVoluntaryExit.Type { - var result: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; - const exit_obj = signed_exit_value.toValue(); - - const message = try exit_obj.getNamedProperty("message"); - result.message.epoch = try (try message.getNamedProperty("epoch")).getValueUint32(); - result.message.validator_index = try (try message.getNamedProperty("validatorIndex")).getValueUint32(); - - const sig_val = try exit_obj.getNamedProperty("signature"); - const sig_info = try sig_val.getTypedarrayInfo(); - if (sig_info.array_type != .uint8 or sig_info.data.len != 96) { - return error.InvalidSignature; - } - @memcpy(&result.signature, sig_info.data); - return result; -} - -/// Get the validity status of a signed voluntary exit. -/// Caller passes a JS `phase0.SignedVoluntaryExit` object (matches IBeaconStateView). -pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.String { +pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.String { const env = js.env(); const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); + const bytes = try signed_exit_bytes.toSlice(); - var signed_voluntary_exit = parseSignedVoluntaryExit(signed_exit_value) catch { - return throwNullAs(js.String, "PARSE_ERROR", "Failed to parse SignedVoluntaryExit"); + var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; + ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { + return throwNullAs(js.String, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); }; const result = switch (cached_state.state.forkSeq()) { @@ -868,14 +849,14 @@ pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_value: return .{ .val = try env.createStringUtf8(@tagName(validity)) }; } -/// Check if a signed voluntary exit is valid. -/// Caller passes a JS `phase0.SignedVoluntaryExit` object (matches IBeaconStateView). -pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.Boolean { +pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.Boolean { const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); + const bytes = try signed_exit_bytes.toSlice(); - var signed_voluntary_exit = parseSignedVoluntaryExit(signed_exit_value) catch { - return throwNullAs(js.Boolean, "PARSE_ERROR", "Failed to parse SignedVoluntaryExit"); + var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; + ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { + return throwNullAs(js.Boolean, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); }; const result = switch (cached_state.state.forkSeq()) { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index acc59a2d3..2412a4e78 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -265,20 +265,8 @@ declare class BeaconStateView { computeSyncCommitteeRewards(block: any, validatorIds: (number | string)[]): Promise; getLatestWeakSubjectivityCheckpointEpoch(): number; - /** - * Native walks the JS `phase0.SignedVoluntaryExit` shape directly. - * Return is the string union `VoluntaryExitValidity` at runtime, but lodestar's - * matching TS enum is nominally typed — `any` to satisfy structural conformance. - */ - // biome-ignore lint/suspicious/noExplicitAny: TS-nominal-enum mismatch (runtime values match exactly) - getVoluntaryExitValidity( - signedVoluntaryExit: {message: {epoch: number; validatorIndex: number}; signature: Uint8Array}, - verifySignature: boolean - ): any; - isValidVoluntaryExit( - signedVoluntaryExit: {message: {epoch: number; validatorIndex: number}; signature: Uint8Array}, - verifySignature: boolean - ): boolean; + getVoluntaryExitValidity(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): VoluntaryExitValidity; + isValidVoluntaryExit(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): boolean; getFinalizedRootProof(): Uint8Array[]; // biome-ignore lint/suspicious/noExplicitAny: stub From 0fe22ee8877e9a31e213f4757847cea3aa20db01 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:06:49 +0800 Subject: [PATCH 11/46] Fix getSingleProof param from any -> bigint --- bindings/src/index.d.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 2412a4e78..ed5b2e4b7 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -273,8 +273,7 @@ declare class BeaconStateView { getSyncCommitteesWitness(): any; // biome-ignore lint/suspicious/noExplicitAny: stub getExpectedWithdrawals(): any; - // biome-ignore lint/suspicious/noExplicitAny: native takes number, IBeaconStateView declares bigint - getSingleProof(gindex: any): Uint8Array[]; + getSingleProof(gindex: bigint): Uint8Array[]; // createMultiProof(descriptor: Uint8Array): CompactMultiProof; computeUnrealizedCheckpoints(): { From 45c2b209bef002d29dd7d0ff6bd6b317fccfd429 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:16:15 +0800 Subject: [PATCH 12/46] revert slot value u32 --- bindings/napi/BeaconStateView.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 47333e2bc..df7228d3f 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -294,9 +294,10 @@ pub fn getBlockRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint pub fn getBlockRootAtSlot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); + const slot_value: u64 = @intCast(try slot_arg.toI64()); const result = switch (cached_state.state.forkSeq()) { - inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), try slot_arg.toU32()), + inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_value), }; const root = result catch |err| { const msg = switch (err) { From 67084e353bd064d7033a0ba3cda9844149052547 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:19:03 +0800 Subject: [PATCH 13/46] revert: transitionOpts changes --- bindings/napi/stateTransition.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig index 578013aa8..7b86e4e12 100644 --- a/bindings/napi/stateTransition.zig +++ b/bindings/napi/stateTransition.zig @@ -15,11 +15,13 @@ const allocator = if (builtin.mode == .Debug) else std.heap.c_allocator; -/// Parse a JS options object into Zig's TransitionOpt. +/// Parse a JS options object into Zig's TransitionOpts. /// /// Recognized fields: /// - verifyStateRoot, verifyProposer, verifySignatures: bool /// - dontTransferCache: bool (negated to set transfer_cache) +/// - executionPayloadStatus: "valid" | "invalid" | "preMerge" +/// - dataAvailabilityStatus: "Available" | "PreData" | "OutOfRange" /// /// This is the double negative version to conform with production lodestar. /// TODO(bing): Eventually rename `dontTransferCache` to `transferCache` to avoid double negation because its confusing naming. @@ -93,6 +95,7 @@ pub fn stateTransition( const env = js.env(); const pre_state = pre_state_value.toValue(); const cached_state = try env.unwrap(CachedBeaconState, pre_state); + const transition_opts = try parseOptions(options); const signed_block_bytes_slice = try signed_block_bytes.toSlice(); const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); @@ -110,7 +113,7 @@ pub fn stateTransition( napi_io.get(), cached_state, signed_block, - try parseOptions(options), + transition_opts, ); errdefer { post_state.deinit(); From 43b2244b277e4fcea866b0cd159f378f83c2a9c1 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:22:02 +0800 Subject: [PATCH 14/46] fix doc comment for isExecutionEnabled --- bindings/src/index.d.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index ed5b2e4b7..cbcd27371 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -239,10 +239,6 @@ declare class BeaconStateView { isMergeTransitionComplete: boolean; /** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */ isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean; - /** - * Spec: `is_execution_enabled(state, body) = is_merge_transition_complete(state) or is_merge_transition_block(state, body)`. - * Fast path for post-merge states (no SSZ work); deserializes only on the rare pre-merge bellatrix path. - */ /** * Check whether execution is enabled for the given block at this state. * @@ -250,7 +246,6 @@ declare class BeaconStateView { * Note that this does not call native `isExecutionEnabled` directly because we can save on deserializing * `signed_block` if 1) holds. We only deserialize in the event that it's a pre-merge bellatrix block */ - isExecutionEnabled(signedBlockBytes: Uint8Array): boolean; proposerRewards: ProposerRewards; From 0fe7152ccc02c34d7ecbb73acbb677da04f5a0dd Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:25:59 +0800 Subject: [PATCH 15/46] doc: doc comment toValue stub --- bindings/src/index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index cbcd27371..7ead08b15 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -292,6 +292,7 @@ declare class BeaconStateView { opts?: {preloadValidatorsAndBalances?: boolean} ): any; // biome-ignore lint/suspicious/noExplicitAny: stub + // TODO(bing): Only one real use case in lodestar and it's in debugging; impl when useful toValue(): any; serialize(): Uint8Array; serializedSize(): number; From b8e18e4b52649202211d934935d357a01ebf2e16 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:25:59 +0800 Subject: [PATCH 16/46] doc: doc comment CompactMultiProof type --- bindings/src/index.d.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 7ead08b15..86f8d4355 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -65,7 +65,9 @@ interface ProcessSlotsOpts { } interface CompactMultiProof { - // biome-ignore lint/suspicious/noExplicitAny: native returns string literal "compactMulti", IBeaconStateView uses @chainsafe/persistent-merkle-tree's ProofType enum nominally + // biome-ignore lint/suspicious/noExplicitAny: + // native returns string literal "compactMulti", IBeaconStateView uses @chainsafe/persistent-merkle-tree's ProofType + // TODO(bing): align types? type: any; leaves: Uint8Array[]; descriptor: Uint8Array; From e2007c54302b95ad1ff610446e304f5edcbef183 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 5 May 2026 23:34:02 +0800 Subject: [PATCH 17/46] refactor: put `parseOptions` into `transition_opts.zig` --- bindings/napi/BeaconStateView.zig | 19 ++------ bindings/napi/stateTransition.zig | 61 +------------------------- bindings/napi/transition_opts.zig | 73 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 76 deletions(-) create mode 100644 bindings/napi/transition_opts.zig diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index df7228d3f..54edc3517 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1100,31 +1100,18 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options: /// /// Arguments: /// - arg 0: signed block bytes (Uint8Array) -/// - arg 1: options (optional): { verifyStateRoot?, verifyProposer?, verifySignatures?, transferCache? } +/// - arg 1: options (optional): parse `TransitionOpts` pub fn stateTransition(self: *const BeaconStateView, signed_block_bytes: js.Uint8Array, options: ?js.Value) !BeaconStateView { const cached_state = try self.requireState(); + const opts = try @import("./transition_opts.zig").parseOptions(options); + const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); const fork_seq = cached_state.config.forkSeqAtEpoch(current_epoch); const bytes = try signed_block_bytes.toSlice(); const signed_block = try AnySignedBeaconBlock.deserialize(allocator, .full, fork_seq, bytes); defer signed_block.deinit(allocator); - var opts: st.TransitionOpts = .{}; - if (options) |opt_val| { - const raw = opt_val.toValue(); - if (try raw.typeof() == .object) { - if (try raw.hasNamedProperty("verifyStateRoot")) - opts.verify_state_root = try (try raw.getNamedProperty("verifyStateRoot")).getValueBool(); - if (try raw.hasNamedProperty("verifyProposer")) - opts.verify_proposer = try (try raw.getNamedProperty("verifyProposer")).getValueBool(); - if (try raw.hasNamedProperty("verifySignatures")) - opts.verify_signatures = try (try raw.getNamedProperty("verifySignatures")).getValueBool(); - if (try raw.hasNamedProperty("transferCache")) - opts.transfer_cache = try (try raw.getNamedProperty("transferCache")).getValueBool(); - } - } - const post_state = try st.stateTransition(allocator, napi_io.get(), cached_state, signed_block, opts); return .{ .cached_state = post_state }; } diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig index 7b86e4e12..e84cf33ae 100644 --- a/bindings/napi/stateTransition.zig +++ b/bindings/napi/stateTransition.zig @@ -15,66 +15,7 @@ const allocator = if (builtin.mode == .Debug) else std.heap.c_allocator; -/// Parse a JS options object into Zig's TransitionOpts. -/// -/// Recognized fields: -/// - verifyStateRoot, verifyProposer, verifySignatures: bool -/// - dontTransferCache: bool (negated to set transfer_cache) -/// - executionPayloadStatus: "valid" | "invalid" | "preMerge" -/// - dataAvailabilityStatus: "Available" | "PreData" | "OutOfRange" -/// -/// This is the double negative version to conform with production lodestar. -/// TODO(bing): Eventually rename `dontTransferCache` to `transferCache` to avoid double negation because its confusing naming. -fn parseOptions(options: ?js.Value) !st.TransitionOpts { - var transition_opts: st.TransitionOpts = .{}; - - if (options) |value| { - const raw = value.toValue(); - if (try raw.typeof() == .object) { - if (try raw.hasNamedProperty("verifyStateRoot")) { - transition_opts.verify_state_root = try (try raw.getNamedProperty("verifyStateRoot")).getValueBool(); - } - if (try raw.hasNamedProperty("verifyProposer")) { - transition_opts.verify_proposer = try (try raw.getNamedProperty("verifyProposer")).getValueBool(); - } - if (try raw.hasNamedProperty("verifySignatures")) { - transition_opts.verify_signatures = try (try raw.getNamedProperty("verifySignatures")).getValueBool(); - } - if (try raw.hasNamedProperty("dontTransferCache")) { - transition_opts.transfer_cache = !(try (try raw.getNamedProperty("dontTransferCache")).getValueBool()); - } - if (try raw.hasNamedProperty("executionPayloadStatus")) { - var buf: [16]u8 = undefined; - const execution_payload_status = try (try raw.getNamedProperty("executionPayloadStatus")).getValueStringUtf8(&buf); - transition_opts.block_external_data.execution_payload_status = - if (std.mem.eql(u8, execution_payload_status, "valid")) - .valid - else if (std.mem.eql(u8, execution_payload_status, "invalid")) - .invalid - else if (std.mem.eql(u8, execution_payload_status, "preMerge")) - .pre_merge - else - return error.InvalidExecutionPayloadStatus; - } - if (try raw.hasNamedProperty("dataAvailabilityStatus")) { - var buf: [16]u8 = undefined; - const da_status = try (try raw.getNamedProperty("dataAvailabilityStatus")).getValueStringUtf8(&buf); - transition_opts.block_external_data.data_availability_status = - if (std.mem.eql(u8, da_status, "Available")) - .available - else if (std.mem.eql(u8, da_status, "PreData")) - .pre_data - else if (std.mem.eql(u8, da_status, "OutOfRange")) - .out_of_range - // TODO(bing): uncomment once gloas support is in - // else if (std.mem.eql(u8, da_status, "NotRequired")) .not_required; - else - return error.InvalidDataAvailabilityStatus; - } - } - } - return transition_opts; -} +const parseOptions = @import("./transition_opts.zig").parseOptions; /// Perform a state transition given a signed beacon block. /// diff --git a/bindings/napi/transition_opts.zig b/bindings/napi/transition_opts.zig new file mode 100644 index 000000000..873fce4b6 --- /dev/null +++ b/bindings/napi/transition_opts.zig @@ -0,0 +1,73 @@ +//! Shared parser for `TransitionOpts` from a JS options object. +//! +//! This file is intentionally not registered with `root.zig` as a module export — +//! its `pub fn` should be visible to other napi files but never auto-exposed to JS. +//! (zapi tries to export every pub fn in modules listed in `root.zig`, which would +//! fail here because `TransitionOpts` isn't a JS-convertible return type.) + +const std = @import("std"); +const js = @import("zapi:zapi").js; +const st = @import("state_transition"); + +/// Parse a JS options object into Zig's `TransitionOpts`. +/// +/// Recognized fields: +/// - `verifyStateRoot`, `verifyProposer`, `verifySignatures`: bool +/// - `dontTransferCache`: bool (negated to set `transfer_cache`) +/// - `executionPayloadStatus`: "valid" | "invalid" | "preMerge" +/// - `dataAvailabilityStatus`: "Available" | "PreData" | "OutOfRange" +/// +/// Throws `error.InvalidExecutionPayloadStatus` / `error.InvalidDataAvailabilityStatus` +/// for unknown enum strings. +/// +/// TODO(bing): rename `dontTransferCache` → `transferCache` to drop the double negation. +pub fn parseOptions(options: ?js.Value) !st.TransitionOpts { + var transition_opts: st.TransitionOpts = .{}; + + if (options) |value| { + const raw = value.toValue(); + if (try raw.typeof() == .object) { + if (try raw.hasNamedProperty("verifyStateRoot")) { + transition_opts.verify_state_root = try (try raw.getNamedProperty("verifyStateRoot")).getValueBool(); + } + if (try raw.hasNamedProperty("verifyProposer")) { + transition_opts.verify_proposer = try (try raw.getNamedProperty("verifyProposer")).getValueBool(); + } + if (try raw.hasNamedProperty("verifySignatures")) { + transition_opts.verify_signatures = try (try raw.getNamedProperty("verifySignatures")).getValueBool(); + } + if (try raw.hasNamedProperty("dontTransferCache")) { + transition_opts.transfer_cache = !(try (try raw.getNamedProperty("dontTransferCache")).getValueBool()); + } + if (try raw.hasNamedProperty("executionPayloadStatus")) { + var buf: [16]u8 = undefined; + const status_str = try (try raw.getNamedProperty("executionPayloadStatus")).getValueStringUtf8(&buf); + transition_opts.block_external_data.execution_payload_status = + if (std.mem.eql(u8, status_str, "valid")) + .valid + else if (std.mem.eql(u8, status_str, "invalid")) + .invalid + else if (std.mem.eql(u8, status_str, "preMerge")) + .pre_merge + else + return error.InvalidExecutionPayloadStatus; + } + if (try raw.hasNamedProperty("dataAvailabilityStatus")) { + var buf: [16]u8 = undefined; + const da_str = try (try raw.getNamedProperty("dataAvailabilityStatus")).getValueStringUtf8(&buf); + transition_opts.block_external_data.data_availability_status = + if (std.mem.eql(u8, da_str, "Available")) + .available + else if (std.mem.eql(u8, da_str, "PreData")) + .pre_data + else if (std.mem.eql(u8, da_str, "OutOfRange")) + .out_of_range + // TODO(bing): uncomment once gloas support is in + // else if (std.mem.eql(u8, da_str, "NotRequired")) .not_required; + else + return error.InvalidDataAvailabilityStatus; + } + } + } + return transition_opts; +} From 37ca3b8f03b49637e682a823d7ce6cf5934fdbe0 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 6 May 2026 01:29:27 +0800 Subject: [PATCH 18/46] feat: naive loadOtherState --- bindings/napi/BeaconStateView.zig | 6 ++++-- bindings/src/index.d.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 54edc3517..ff1e29f0e 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1266,8 +1266,10 @@ pub fn isStateValidatorsNodesPopulated(_: *const BeaconStateView) !js.Boolean { return js.Boolean.from(true); } -pub fn loadOtherState(_: *const BeaconStateView, _: js.Uint8Array, _: ?js.Uint8Array, _: ?js.Value) !js.Value { - return throwNotImpl(js.Value, "loadOtherState not implemented"); +/// TODO(bing): This is the naive version; port real `loadState` (deserializeContainerIgnoreFields + loadValidators +/// byte-diff/tree-reuse) from TS to Zig SSZ to get the ~500ms-per-reload optimization. +pub fn loadOtherState(_: *const BeaconStateView, state_bytes: js.Uint8Array, _: ?js.Uint8Array, _: ?js.Value) !BeaconStateView { + return createFromBytes(state_bytes); } pub fn toValue(_: *const BeaconStateView) !js.Value { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 86f8d4355..ba4998d5f 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -287,12 +287,17 @@ declare class BeaconStateView { createdWithTransferCache: boolean; isStateValidatorsNodesPopulated(): boolean; - // biome-ignore lint/suspicious/noExplicitAny: stub + /** + * Simple impl: ignores `seedValidatorsBytes` and `preloadValidatorsAndBalances` — + * just deserializes `stateBytes` into a fresh BeaconStateView. Loses the + * validators-tree-reuse optimization the TS impl provides for checkpoint reload. + * TODO(bing): port `loadState` tree-reuse logic from TS to recover the perf win. + */ loadOtherState( stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array, opts?: {preloadValidatorsAndBalances?: boolean} - ): any; + ): BeaconStateView; // biome-ignore lint/suspicious/noExplicitAny: stub // TODO(bing): Only one real use case in lodestar and it's in debugging; impl when useful toValue(): any; From 342c1dcd290e21fc8f4d8a60f551e239fb49b8e5 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 6 May 2026 01:45:57 +0800 Subject: [PATCH 19/46] fix rest of toU32 to toI64 --- bindings/napi/BeaconStateView.zig | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index ff1e29f0e..6fb40bbd0 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -219,7 +219,7 @@ pub fn currentEpochParticipation(self: *const BeaconStateView) !js.Uint8Array { pub fn getPreviousEpochParticipation(self: *const BeaconStateView, index_arg: js.Number) !js.Number { const cached_state = try self.requireState(); - const index_value = try index_arg.toU32(); + const index_value: usize = @intCast(try index_arg.toI64()); var view = try cached_state.state.previousEpochParticipation(); const flag = view.get(index_value) catch { return throwNullAs(js.Number, "INVALID_INDEX", "Failed to get previous epoch participation"); @@ -229,7 +229,7 @@ pub fn getPreviousEpochParticipation(self: *const BeaconStateView, index_arg: js pub fn getCurrentEpochParticipation(self: *const BeaconStateView, index_arg: js.Number) !js.Number { const cached_state = try self.requireState(); - const index_value: u64 = try index_arg.toU32(); + const index_value: usize = @intCast(try index_arg.toI64()); var view = try cached_state.state.currentEpochParticipation(); const flag = view.get(index_value) catch { return throwNullAs(js.Number, "INVALID_INDEX", "Failed to get current epoch participation"); @@ -273,8 +273,9 @@ pub fn payloadBlockNumber(self: *const BeaconStateView) !js.Number { pub fn getBlockRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); + const epoch_value: u64 = @intCast(try epoch_arg.toI64()); - const slot_ = st.computeStartSlotAtEpoch(try epoch_arg.toU32()); + const slot_ = st.computeStartSlotAtEpoch(epoch_value); const result = switch (cached_state.state.forkSeq()) { inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_), @@ -314,7 +315,8 @@ pub fn getBlockRootAtSlot(self: *const BeaconStateView, slot_arg: js.Number) !js pub fn getBlockRootAtEpoch(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); - const slot_ = st.computeStartSlotAtEpoch(try epoch_arg.toU32()); + const epoch_value: u64 = @intCast(try epoch_arg.toI64()); + const slot_ = st.computeStartSlotAtEpoch(epoch_value); const result = switch (cached_state.state.forkSeq()) { inline else => |f| st.getBlockRootAtSlot(f, cached_state.state.castToFork(f), slot_), @@ -353,7 +355,8 @@ pub fn getStateRootAtSlot(self: *const BeaconStateView, slot_arg: js.Number) !js var state_roots_view = cached_state.state.stateRoots() catch { return throwNullAs(js.Uint8Array, "STATE_ERROR", "Failed to get stateRoots"); }; - const root = state_roots_view.getFieldRoot(try slot_arg.toU32() % preset.SLOTS_PER_HISTORICAL_ROOT) catch { + const slot_: usize = @intCast(try slot_arg.toI64()); + const root = state_roots_view.getFieldRoot(slot_ % preset.SLOTS_PER_HISTORICAL_ROOT) catch { return throwNullAs(js.Uint8Array, "INVALID_SLOT", "Failed to get state root at slot"); }; return js_types.wrap(js.Uint8Array, try sszValueToNapiValue(env, ct.primitive.Root, root)); @@ -494,7 +497,8 @@ pub fn nextDecisionRoot(self: *const BeaconStateView) !js.String { /// Get the shuffling decision root for a given epoch. pub fn getShufflingDecisionRoot(self: *const BeaconStateView, epoch_arg: js.Number) !js.String { const cached_state = try self.requireState(); - const root = st.calculateShufflingDecisionRoot(cached_state.state, try epoch_arg.toU32()) catch { + const epoch_value: u64 = @intCast(try epoch_arg.toI64()); + const root = st.calculateShufflingDecisionRoot(cached_state.state, epoch_value) catch { return throwNullAs(js.String, "STATE_ERROR", "Failed to calculate shuffling decision root"); }; return rootToHexString(&root); @@ -625,7 +629,7 @@ pub fn getIndexedSyncCommitteeAtEpoch(self: *const BeaconStateView, epoch_arg: j pub fn getIndexedSyncCommittee(self: *const BeaconStateView, slot_arg: js.Number) !js_types.IndexedSyncCommittee { const env = js.env(); const cached_state = try self.requireState(); - const slot_value: u64 = try slot_arg.toU32(); + const slot_value: u64 = @intCast(try slot_arg.toI64()); const sync_committee = cached_state.epoch_cache.getIndexedSyncCommittee(slot_value) catch { return throwNullAs(js_types.IndexedSyncCommittee, "NO_SYNC_COMMITTEE", "Sync committee not available for requested slot"); @@ -726,7 +730,7 @@ pub fn getAllBalances(self: *const BeaconStateView) !js.Array { pub fn getValidatorsByStatus(self: *const BeaconStateView, statuses_set: js.Value, current_epoch_arg: js.Number) !js.Array { const env = js.env(); const cached_state = try self.requireState(); - const current_epoch: u64 = try current_epoch_arg.toU32(); + const current_epoch: u64 = @intCast(try current_epoch_arg.toI64()); const set_value = statuses_set.toValue(); const has_fn = try set_value.getNamedProperty("has"); @@ -1026,7 +1030,7 @@ fn byteViewsToSlice(output: js.Value) ![]u8 { /// Returns the number of bytes written. pub fn serializeToBytes(self: *const BeaconStateView, output: js.Value, offset: js.Number) !js.Number { const output_slice = try byteViewsToSlice(output); - const off = try offset.toU32(); + const off: usize = @intCast(try offset.toI64()); if (off > output_slice.len) return error.InvalidOffset; const cached_state = try self.requireState(); @@ -1060,7 +1064,7 @@ pub fn serializedValidatorsSize(self: *const BeaconStateView) !js.Number { /// Returns the number of bytes written. pub fn serializeValidatorsToBytes(self: *const BeaconStateView, output: js.Value, offset: js.Number) !js.Number { const output_slice = try byteViewsToSlice(output); - const off = try offset.toU32(); + const off: usize = @intCast(try offset.toI64()); if (off > output_slice.len) return error.InvalidOffset; const cached_state = try self.requireState(); @@ -1187,7 +1191,7 @@ pub fn getNextShuffling(self: *const BeaconStateView) !js.Value { pub fn getShufflingAtEpoch(self: *const BeaconStateView, epoch_arg: js.Number) !js.Value { const cached_state = try self.requireState(); - const epoch_value: u64 = try epoch_arg.toU32(); + const epoch_value: u64 = @intCast(try epoch_arg.toI64()); const shuffling = cached_state.epoch_cache.getShufflingAtEpochOrNull(epoch_value) orelse { return throwNullAs(js.Value, "NO_SHUFFLING", "Shuffling not available for requested epoch"); From 4909369c3c485f5d008186729e796041f8a11bb1 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 6 May 2026 01:50:21 +0800 Subject: [PATCH 20/46] comments --- bindings/src/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index ba4998d5f..980c15d43 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -307,12 +307,12 @@ declare class BeaconStateView { serializeToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; serializeValidators(): Uint8Array; serializedValidatorsSize(): number; - /** Same shape as `serializeToBytes`. */ serializeValidatorsToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; hashTreeRoot(): Uint8Array; createMultiProof(descriptor: Uint8Array): CompactMultiProof; - // biome-ignore lint/suspicious/noExplicitAny: signed block bytes are passed as Uint8Array at runtime; signature is loosened so it satisfies `IBeaconStateView.stateTransition(block, opts, modules)` structurally. + // biome-ignore lint/suspicious/noExplicitAny: Note that signed block bytes are passed as Uint8Array at runtime; signature is loosened so it satisfies `IBeaconStateView.stateTransition(block, opts, modules)` structurally. + // TODO(bing): fix types stateTransition(signedBlock: any, options?: any, modules?: any): BeaconStateView; processSlots(slot: number, options?: ProcessSlotsOpts): BeaconStateView; } From 284e2bc39bab0a999c6c0333b5e4b2e07540be40 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 6 May 2026 01:50:21 +0800 Subject: [PATCH 21/46] feat: bindings to `getExpectedWithdrawals` and native tweaks - add bindings to it - don't heap allocate for `withdrawals_results` since it is capped at `preset.MAX_WITHDRAWALS_PER_PAYLOAD == 16`. This is about max ~800 bytes on mainnet preset - add assertions to assert the above --- bench/state_transition/process_block.zig | 11 ++-- bindings/napi/BeaconStateView.zig | 52 ++++++++++++++++++- bindings/src/index.d.ts | 19 +++++++ src/state_transition/block/process_block.zig | 9 +--- .../block/process_withdrawals.zig | 26 +++++----- test/spec/runner/operations.zig | 9 ++-- 6 files changed, 91 insertions(+), 35 deletions(-) diff --git a/bench/state_transition/process_block.zig b/bench/state_transition/process_block.zig index 26343eeaf..30e013953 100644 --- a/bench/state_transition/process_block.zig +++ b/bench/state_transition/process_block.zig @@ -68,10 +68,10 @@ fn ProcessWithdrawalsBench(comptime fork: ForkSeq) type { allocator.destroy(cloned); } + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]types.capella.Withdrawal.Type = undefined; var withdrawals_result = WithdrawalsResult{ - .withdrawals = Withdrawals.initCapacity(allocator, preset.MAX_WITHDRAWALS_PER_PAYLOAD) catch unreachable, + .withdrawals = Withdrawals.initBuffer(&withdrawals_buf), }; - defer withdrawals_result.withdrawals.deinit(allocator); var withdrawal_balances = std.AutoHashMap(ValidatorIndex, usize).init(allocator); defer withdrawal_balances.deinit(); @@ -79,7 +79,6 @@ fn ProcessWithdrawalsBench(comptime fork: ForkSeq) type { const state = cloned.state.castToFork(fork); state_transition.getExpectedWithdrawals( fork, - allocator, cloned.epoch_cache, state, &withdrawals_result, @@ -339,15 +338,15 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { if (comptime fork.gte(.capella)) { const withdrawals_start = time.timestampNow(io); + + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]types.capella.Withdrawal.Type = undefined; var withdrawals_result = WithdrawalsResult{ - .withdrawals = Withdrawals.initCapacity(allocator, preset.MAX_WITHDRAWALS_PER_PAYLOAD) catch unreachable, + .withdrawals = Withdrawals.initBuffer(&withdrawals_buf), }; - defer withdrawals_result.withdrawals.deinit(allocator); var withdrawal_balances = std.AutoHashMap(ValidatorIndex, usize).init(allocator); defer withdrawal_balances.deinit(); state_transition.getExpectedWithdrawals( fork, - allocator, epoch_cache, state, &withdrawals_result, diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 6fb40bbd0..b3cab3e29 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1284,8 +1284,56 @@ pub fn getSyncCommitteesWitness(_: *const BeaconStateView) !js.Value { return throwNotImpl(js.Value, "getSyncCommitteesWitness not implemented"); } -pub fn getExpectedWithdrawals(_: *const BeaconStateView) !js.Value { - return throwNotImpl(js.Value, "getExpectedWithdrawals not implemented"); +/// Compute expected withdrawals for the next payload (capella+). +/// Returns: { expectedWithdrawals: Withdrawal[], processedPartialWithdrawalsCount, processedValidatorSweepCount, +/// processedBuilderWithdrawalsCount, processedBuildersSweepCount } +/// The latter two are Gloas-only — always 0 here since Zig STF doesn't process Gloas yet. +pub fn getExpectedWithdrawals(self: *const BeaconStateView) !js.Value { + const env = js.env(); + const cached_state = try self.requireState(); + const fork_seq = cached_state.state.forkSeq(); + + // We also check this within the native fn itself but this lets us avoid allocating an `AutoHashMap` early. + if (fork_seq.lt(.capella)) { + return throwNullAs(js.Value, "INVALID_FORK", "getExpectedWithdrawals only supported capella+"); + } + + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]ct.capella.Withdrawal.Type = undefined; + var withdrawals_result = st.WithdrawalsResult{ + .withdrawals = ct.capella.Withdrawals.Type.initBuffer(&withdrawals_buf), + }; + + var withdrawal_balances = std.AutoHashMap(ct.primitive.ValidatorIndex.Type, usize).init(allocator); + defer withdrawal_balances.deinit(); + + switch (fork_seq) { + inline .capella, .deneb, .electra, .fulu => |f| { + try st.getExpectedWithdrawals( + f, + cached_state.epoch_cache, + cached_state.state.castToFork(f), + &withdrawals_result, + &withdrawal_balances, + ); + }, + else => unreachable, + } + + const obj = try env.createObject(); + + const withdrawals_arr = try env.createArray(); + for (withdrawals_result.withdrawals.items, 0..) |*w, i| { + const w_value = try sszValueToNapiValue(env, ct.capella.Withdrawal, w); + try withdrawals_arr.setElement(@intCast(i), w_value); + } + try obj.setNamedProperty("expectedWithdrawals", withdrawals_arr); + try obj.setNamedProperty("processedPartialWithdrawalsCount", try env.createUint32(@intCast(withdrawals_result.processed_partial_withdrawals_count))); + try obj.setNamedProperty("processedValidatorSweepCount", try env.createUint32(@intCast(withdrawals_result.sampled_validators))); + // TODO(bing): Implement when we support Gloas. + try obj.setNamedProperty("processedBuilderWithdrawalsCount", try env.createUint32(0)); + try obj.setNamedProperty("processedBuildersSweepCount", try env.createUint32(0)); + + return js_types.wrap(js.Value, obj); } fn requireState(self: *const BeaconStateView) !*CachedBeaconState { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 980c15d43..aeb287e3d 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -271,6 +271,25 @@ declare class BeaconStateView { // biome-ignore lint/suspicious/noExplicitAny: stub getExpectedWithdrawals(): any; getSingleProof(gindex: bigint): Uint8Array[]; + // getSyncCommitteesWitness(): any; + /** + * Compute expected withdrawals for the next payload (capella+). + * + * processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) + * processedPartialWithdrawalsCount is withdrawals coming from EL since electra (EIP-7002) + * processedBuildersSweepCount is withdrawals from builder sweep since gloas (EIP-7732) + * processedValidatorSweepCount is withdrawals coming from validator sweep + + * TODO(bing): `processedBuilderWithdrawalsCount` and `processedBuildersSweepCount` are Gloas-only + * and always 0 here since Zig STF doesn't process Gloas yet. + */ + getExpectedWithdrawals(): { + expectedWithdrawals: {index: number; validatorIndex: number; address: Uint8Array; amount: number}[]; + processedBuilderWithdrawalsCount: number; + processedPartialWithdrawalsCount: number; + processedBuildersSweepCount: number; + processedValidatorSweepCount: number; + }; // createMultiProof(descriptor: Uint8Array): CompactMultiProof; computeUnrealizedCheckpoints(): { diff --git a/src/state_transition/block/process_block.zig b/src/state_transition/block/process_block.zig index 342c61df3..5ad9d8e57 100644 --- a/src/state_transition/block/process_block.zig +++ b/src/state_transition/block/process_block.zig @@ -61,23 +61,18 @@ pub fn processBlock( // TODO Deneb: Allow to disable withdrawals for interop testing // https://github.com/ethereum/consensus-specs/blob/b62c9e877990242d63aa17a2a59a49bc649a2f2e/specs/eip4844/beacon-chain.md#disabling-withdrawals if (comptime fork.gte(.capella)) { - // TODO: given max withdrawals of MAX_WITHDRAWALS_PER_PAYLOAD, can use fixed size array instead of heap alloc - var withdrawals_result = WithdrawalsResult{ .withdrawals = try Withdrawals.initCapacity( - allocator, - preset.MAX_WITHDRAWALS_PER_PAYLOAD, - ) }; + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]types.capella.Withdrawal.Type = undefined; + var withdrawals_result = WithdrawalsResult{ .withdrawals = Withdrawals.initBuffer(&withdrawals_buf) }; var withdrawal_balances = std.AutoHashMap(ValidatorIndex, usize).init(allocator); defer withdrawal_balances.deinit(); try getExpectedWithdrawals( fork, - allocator, epoch_cache, state, &withdrawals_result, &withdrawal_balances, ); - defer withdrawals_result.withdrawals.deinit(allocator); const payload_withdrawals_root = switch (block_type) { .full => blk: { diff --git a/src/state_transition/block/process_withdrawals.zig b/src/state_transition/block/process_withdrawals.zig index 1030bbe51..c3f9f5553 100644 --- a/src/state_transition/block/process_withdrawals.zig +++ b/src/state_transition/block/process_withdrawals.zig @@ -82,18 +82,20 @@ pub fn processWithdrawals( } } -// Consumer should deinit WithdrawalsResult with .deinit() after use +/// Called by the block proposer to find a list of withdrawals to include in the block. +/// +/// This list is assumed to be bounded by `preset.MAX_WITHDRAWALS_PER_PAYLOAD`. +/// +/// Caller should deinit `withdrawal_balances` with .deinit() after use. pub fn getExpectedWithdrawals( comptime fork: ForkSeq, - allocator: Allocator, epoch_cache: *const EpochCache, state: *BeaconState(fork), withdrawals_result: *WithdrawalsResult, withdrawal_balances: *std.AutoHashMap(ValidatorIndex, usize), ) !void { - if (comptime fork.lt(.capella)) { - return error.InvalidForkSequence; - } + std.debug.assert(withdrawals_result.withdrawals.capacity == preset.MAX_WITHDRAWALS_PER_PAYLOAD); + if (comptime fork.lt(.capella)) return error.InvalidForkSequence; const epoch = epoch_cache.epoch; var withdrawal_index = try state.nextWithdrawalIndex(); @@ -134,7 +136,7 @@ pub fn getExpectedWithdrawals( const withdrawable_balance = if (balance_over_min_activation_balance < withdrawal.amount) balance_over_min_activation_balance else withdrawal.amount; var execution_address: ExecutionAddress = undefined; @memcpy(&execution_address, validator.withdrawal_credentials[12..]); - try withdrawals_result.withdrawals.append(allocator, .{ + withdrawals_result.withdrawals.appendAssumeCapacity(.{ .index = withdrawal_index, .validator_index = withdrawal.validator_index, .address = execution_address, @@ -179,7 +181,7 @@ pub fn getExpectedWithdrawals( if (withdrawable_epoch <= epoch) { var execution_address: ExecutionAddress = undefined; @memcpy(&execution_address, withdrawal_credentials[12..]); - try withdrawals_result.withdrawals.append(allocator, .{ + withdrawals_result.withdrawals.appendAssumeCapacity(.{ .index = withdrawal_index, .validator_index = validator_index, .address = execution_address, @@ -195,7 +197,7 @@ pub fn getExpectedWithdrawals( const partial_amount = balance - effective_balance; var execution_address: ExecutionAddress = undefined; @memcpy(&execution_address, withdrawal_credentials[12..]); - try withdrawals_result.withdrawals.append(allocator, .{ + withdrawals_result.withdrawals.appendAssumeCapacity(.{ .index = withdrawal_index, .validator_index = validator_index, .address = execution_address, @@ -227,13 +229,10 @@ test "process withdrawals - sanity" { var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); defer test_state.deinit(); + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]types.capella.Withdrawal.Type = undefined; var withdrawals_result = WithdrawalsResult{ - .withdrawals = try Withdrawals.initCapacity( - allocator, - preset.MAX_WITHDRAWALS_PER_PAYLOAD, - ), + .withdrawals = Withdrawals.initBuffer(&withdrawals_buf), }; - defer withdrawals_result.withdrawals.deinit(allocator); var withdrawal_balances = std.AutoHashMap(ValidatorIndex, usize).init(allocator); defer withdrawal_balances.deinit(); @@ -242,7 +241,6 @@ test "process withdrawals - sanity" { try getExpectedWithdrawals( .electra, - allocator, test_state.cached_state.epoch_cache, test_state.cached_state.state.castToFork(.electra), &withdrawals_result, diff --git a/test/spec/runner/operations.zig b/test/spec/runner/operations.zig index 80ee7c131..bbb91b4d7 100644 --- a/test/spec/runner/operations.zig +++ b/test/spec/runner/operations.zig @@ -267,11 +267,10 @@ pub fn TestCase(comptime fork: ForkSeq, comptime operation: Operation) type { }, .withdrawals => { const epoch_cache = cached_state.epoch_cache; + + var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]ssz.capella.Withdrawal.Type = undefined; var withdrawals_result = WithdrawalsResult{ - .withdrawals = try Withdrawals.initCapacity( - allocator, - preset.MAX_WITHDRAWALS_PER_PAYLOAD, - ), + .withdrawals = Withdrawals.initBuffer(&withdrawals_buf), }; var withdrawal_balances = std.AutoHashMap(u64, usize).init(allocator); @@ -279,13 +278,11 @@ pub fn TestCase(comptime fork: ForkSeq, comptime operation: Operation) type { try state_transition.getExpectedWithdrawals( fork, - allocator, epoch_cache, state, &withdrawals_result, &withdrawal_balances, ); - defer withdrawals_result.withdrawals.deinit(allocator); var payload_withdrawals_root: Root = undefined; // self.op is ExecutionPayload in this case From 496fd10af78f53b03f5e73a8445e543629d72b81 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 22:25:41 +0800 Subject: [PATCH 22/46] fixes --- bindings/napi/BeaconStateView.zig | 133 +++++++++++++++--------------- bindings/src/index.d.ts | 1 - 2 files changed, 68 insertions(+), 66 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 65f3988f4..d4c38ea22 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -266,12 +266,7 @@ pub fn payloadBlockNumber(self: *const BeaconStateView) !js.Number { try cached_state.state.latestExecutionPayloadHeader(allocator, &header); defer header.deinit(allocator); - const block_number: u64 = switch (header) { - .bellatrix => |*h| h.block_number, - .capella => |*h| h.block_number, - .deneb => |*h| h.block_number, - }; - return js.Number.from(block_number); + return js.Number.from(header.blockNumber()); } // ------------------------- @@ -1039,6 +1034,69 @@ pub fn loadOtherStateBench( result.state.deinit(); } +pub fn loadOtherState( + self: *const BeaconStateView, + state_bytes: js.Uint8Array, + seed_validators_bytes: ?js.Uint8Array, + opts: ?js.Value, +) !BeaconStateView { + const old_cached_state = try self.requireState(); + const state_bytes_slice = try state_bytes.toSlice(); + const seed_validators_bytes_slice: ?[]const u8 = + if (seed_validators_bytes) |b| try b.toSlice() else null; + + var loaded = try st.loadState( + allocator, + old_cached_state.config, + old_cached_state.state, + state_bytes_slice, + seed_validators_bytes_slice, + ); + errdefer loaded.state.deinit(); + defer allocator.free(loaded.modified_validators); + + const new_cached_state = try allocator.create(CachedBeaconState); + errdefer allocator.destroy(new_cached_state); + + try new_cached_state.init( + allocator, + &loaded.state, + .{ + .config = &config.state.config, + .index_to_pubkey = &pubkey.state.index2pubkey, + .pubkey_to_index = &pubkey.state.pubkey2index, + }, + null, + ); + + if (opts) |value| { + const raw = value.toValue(); + if (try raw.hasNamedProperty("preloadValidatorsAndBalances") and + (try (try raw.getNamedProperty("preloadValidatorsAndBalances")).getValueBool())) + { + //TODO(bing): These unnecessarily allocate and return memory that we throw away. + // This doesn't matter for typescript lodestar because GC clears it anyway, + // but we're losing some savings here. Consider implementating something like + // a `prefetchAll` that only does `populateAllNodes` that returns void + var validators_view = try new_cached_state.state.validators(); + _ = validators_view.getAllReadonlyValues(allocator) catch |err| { + try js.env().throwError("STATE_ERROR", "Failed to preload validators"); + return err; + }; + var balances_view = try new_cached_state.state.balances(); + _ = balances_view.getAll(allocator) catch |err| { + try js.env().throwError("STATE_ERROR", "Failed to preload balances"); + return err; + }; + } + } + + return .{ + .cached_state = new_cached_state, + .pool_rc = pool.state.poolRc().ref(), + }; +} + pub fn serialize(self: *const BeaconStateView) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); @@ -1305,8 +1363,10 @@ pub fn computeSyncCommitteeRewards(_: *const BeaconStateView, _: js.Value, _: js // --- Misc not-yet-implemented --- -pub fn getLatestWeakSubjectivityCheckpointEpoch(_: *const BeaconStateView) !js.Number { - return throwNotImpl(js.Number, "getLatestWeakSubjectivityCheckpointEpoch not implemented"); +pub fn getLatestWeakSubjectivityCheckpointEpoch(self: *const BeaconStateView) !js.Number { + const cached_state = try self.requireState(); + const ws_epoch = st.getLatestWeakSubjectivityCheckpointEpoch(cached_state.epoch_cache); + return js.Number.from(ws_epoch); } pub fn isStateValidatorsNodesPopulated(_: *const BeaconStateView) !js.Boolean { @@ -1314,63 +1374,6 @@ pub fn isStateValidatorsNodesPopulated(_: *const BeaconStateView) !js.Boolean { return js.Boolean.from(true); } -pub fn loadOtherState( - self: *const BeaconStateView, - state_bytes: js.Uint8Array, - seed_validators_bytes: ?js.Uint8Array, - opts: ?js.Value, -) !BeaconStateView { - const old_cached_state = try self.requireState(); - const state_bytes_slice = try state_bytes.toSlice(); - const seed_validators_bytes_slice: ?[]const u8 = - if (seed_validators_bytes) |b| try b.toSlice() else null; - - var loaded = try st.loadState( - allocator, - old_cached_state.config, - old_cached_state.state, - state_bytes_slice, - seed_validators_bytes_slice, - ); - errdefer loaded.state.deinit(); - defer allocator.free(loaded.modified_validators); - - const new_cached_state = try allocator.create(CachedBeaconState); - errdefer allocator.destroy(new_cached_state); - - try new_cached_state.init( - allocator, - &loaded.state, - .{ - .config = &config.state.config, - .index_to_pubkey = &pubkey.state.index2pubkey, - .pubkey_to_index = &pubkey.state.pubkey2index, - }, - null, - ); - - if (opts) |value| { - const raw = value.toValue(); - if (try raw.hasNamedProperty("preloadValidatorsAndBalances") and - (try (try raw.getNamedProperty("preloadValidatorsAndBalances")).getValueBool())) - { - //TODO(bing): These unnecessarily allocate and return memory that we throw away. - // This doesn't matter for typescript lodestar because GC clears it anyway, - // but we're losing some savings here. Consider implementating something like - // a `prefetchAll` that only does `populateAllNodes` that returns void - var validators_view = try new_cached_state.state.validators(); - _ = try validators_view.getAllReadonlyValues(allocator); - var balances_view = try new_cached_state.state.balances(); - _ = try balances_view.getAll(allocator); - } - } - - return .{ - .cached_state = new_cached_state, - .pool_rc = pool.state.poolRc().ref(), - }; -} - pub fn toValue(_: *const BeaconStateView) !js.Value { return throwNotImpl(js.Value, "toValue not implemented"); } diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 42ea0870b..fbbf778c1 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -290,7 +290,6 @@ declare class BeaconStateView { processedBuildersSweepCount: number; processedValidatorSweepCount: number; }; - // createMultiProof(descriptor: Uint8Array): CompactMultiProof; computeUnrealizedCheckpoints(): { justifiedCheckpoint: Checkpoint; From e827abb06939253f6b132aa87a20985f32a78d1e Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 22:25:41 +0800 Subject: [PATCH 23/46] feat: weakSubjectivity --- src/state_transition/root.zig | 4 + src/state_transition/weak_subjectivity.zig | 184 +++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/state_transition/weak_subjectivity.zig diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index 6d35b3820..cb398430e 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -113,10 +113,14 @@ const load_state = @import("load_state.zig"); pub const loadState = load_state.loadState; pub const MigrateStateOutput = load_state.MigrateStateOutput; +const weak_subjectivity = @import("./utils/weak_subjectivity.zig"); +pub const getLatestWeakSubjectivityCheckpointEpoch = weak_subjectivity.getLatestWeakSubjectivityCheckpointEpoch; + test { testing.refAllDecls(@This()); testing.refAllDecls(seed); testing.refAllDecls(state_transition); testing.refAllDecls(EpochShuffling); testing.refAllDecls(load_state); + testing.refAllDecls(weak_subjectivity); } diff --git a/src/state_transition/weak_subjectivity.zig b/src/state_transition/weak_subjectivity.zig new file mode 100644 index 000000000..94846489f --- /dev/null +++ b/src/state_transition/weak_subjectivity.zig @@ -0,0 +1,184 @@ +const std = @import("std"); + +const preset = @import("preset").preset; +const types = @import("consensus_types"); + +const BeaconConfig = @import("config").BeaconConfig; +const ForkSeq = @import("config").ForkSeq; +const EpochCache = @import("../cache/epoch_cache.zig").EpochCache; + +const validator = @import("validator.zig"); + +const Epoch = types.primitive.Epoch.Type; + +/// 10% safety decay. +const SAFETY_DECAY: u64 = 10; + +const ETH_TO_GWEI: u64 = preset.EFFECTIVE_BALANCE_INCREMENT; + +/// Returns the epoch of the latest weak subjectivity checkpoint for the given state. +/// Default safety decay is 10% (0.1). +pub fn getLatestWeakSubjectivityCheckpointEpoch(epoch_cache: *const EpochCache) Epoch { + return epoch_cache.epoch - computeWeakSubjectivityPeriodCachedState(epoch_cache); +} + +/// Returns the weak subjectivity period for the current state, using cached +/// values from EpochCache. Pre-Electra and Electra+ use different formulas. +pub fn computeWeakSubjectivityPeriodCachedState(epoch_cache: *const EpochCache) u64 { + const config = epoch_cache.config; + const fork = config.forkSeq(epoch_cache.epoch * preset.SLOTS_PER_EPOCH); + const active_validator_count = epoch_cache.current_shuffling.get().active_indices.len; + + if (fork.gte(.electra)) { + return computeWeakSubjectivityPeriodFromConstituentsElectra( + epoch_cache.total_active_balance_increments, + validator.getBalanceChurnLimitFromCache(epoch_cache), + config.chain.MIN_VALIDATOR_WITHDRAWABILITY_DELAY, + ); + } + + return computeWeakSubjectivityPeriodFromConstituentsPhase0( + active_validator_count, + epoch_cache.total_active_balance_increments, + validator.getChurnLimit(config, active_validator_count), + config.chain.MIN_VALIDATOR_WITHDRAWABILITY_DELAY, + ); +} + +/// Pre-Electra WS period. +/// +/// Math operates on integers; intermediates fit in u128 for mainnet to avoid overflow on +/// `N * (t * (200 + 12 * D) - T * (200 + 3 * D))`. +pub fn computeWeakSubjectivityPeriodFromConstituentsPhase0( + active_validator_count: usize, + total_balance_by_increment: u64, + churn_limit: usize, + min_withdrawability_delay: u64, +) u64 { + std.debug.assert(active_validator_count > 0); + std.debug.assert(churn_limit > 0); + + const N: u128 = @intCast(active_validator_count); + // totalBalanceByIncrement = totalBalance / MAX_EFFECTIVE_BALANCE, MAX_EFFECTIVE_BALANCE == ETH_TO_GWEI atm. + const t: u128 = @divFloor(@as(u128, total_balance_by_increment), N); + const T: u128 = preset.MAX_EFFECTIVE_BALANCE / ETH_TO_GWEI; + const delta: u128 = @intCast(churn_limit); + const Delta: u128 = @as(u128, preset.MAX_DEPOSITS) * preset.SLOTS_PER_EPOCH; + const D: u128 = SAFETY_DECAY; + + var ws_period: u64 = min_withdrawability_delay; + + const lhs = T * (200 + 3 * D); + const rhs = t * (200 + 12 * D); + if (lhs < rhs) { + const epochs_for_validator_set_churn: u64 = @intCast(@divFloor( + N * (rhs - lhs), + 600 * delta * (2 * t + T), + )); + const epochs_for_balance_top_ups: u64 = @intCast(@divFloor( + N * (200 + 3 * D), + 600 * Delta, + )); + ws_period += @max(epochs_for_validator_set_churn, epochs_for_balance_top_ups); + } else { + // Realistically, t < T will almost never happen. Napkin math: + // + // T = 32, t ∈ [0, 32] + // + // if T - t = 0, then lhs = 32 * 230 = 7360 < rhs = 32 * 320 = 10240, + // so we will never enter the else branch. + // + // Still, let's assert t < T as a sanity check. + std.debug.assert(t < T); + ws_period += @intCast(@divFloor( + 3 * N * D * t, + 200 * Delta * (T - t), + )); + } + + return ws_period; +} + +/// Electra+ WS period. +pub fn computeWeakSubjectivityPeriodFromConstituentsElectra( + total_balance_by_increment: u64, + /// Not the same as `churn_limit` above — measured in Gwei, computed via `getBalanceChurnLimitFromCache`. + balance_churn_limit: u64, + min_withdrawability_delay: u64, +) u64 { + std.debug.assert(balance_churn_limit > 0); + + const t: u128 = total_balance_by_increment; + const delta: u128 = balance_churn_limit; + const epochs_for_validator_set_churn: u64 = @intCast(@divFloor( + SAFETY_DECAY * t * preset.EFFECTIVE_BALANCE_INCREMENT, + 2 * delta * 100, + )); + + return min_withdrawability_delay + epochs_for_validator_set_churn; +} + +test "computeWeakSubjectivityPeriodFromConstituentsPhase0 - mainnet table" { + // Ported from packages/state-transition/test/unit/util/weakSubjectivity.test.ts + const config = &@import("config").mainnet.config; + const min_delay = config.chain.MIN_VALIDATOR_WITHDRAWABILITY_DELAY; + + const Case = struct { avg_balance: u64, val_count: usize, ws_period: u64 }; + const cases = [_]Case{ + .{ .avg_balance = 28, .val_count = 32768, .ws_period = 504 }, + .{ .avg_balance = 28, .val_count = 65536, .ws_period = 752 }, + .{ .avg_balance = 28, .val_count = 131072, .ws_period = 1248 }, + .{ .avg_balance = 28, .val_count = 262144, .ws_period = 2241 }, + .{ .avg_balance = 28, .val_count = 524288, .ws_period = 2241 }, + .{ .avg_balance = 28, .val_count = 1048576, .ws_period = 2241 }, + .{ .avg_balance = 32, .val_count = 32768, .ws_period = 665 }, + .{ .avg_balance = 32, .val_count = 65536, .ws_period = 1075 }, + .{ .avg_balance = 32, .val_count = 131072, .ws_period = 1894 }, + .{ .avg_balance = 32, .val_count = 262144, .ws_period = 3532 }, + .{ .avg_balance = 32, .val_count = 524288, .ws_period = 3532 }, + .{ .avg_balance = 32, .val_count = 1048576, .ws_period = 3532 }, + }; + + for (cases) |c| { + const total_balance_by_increment: u64 = c.avg_balance * @as(u64, @intCast(c.val_count)); + const churn = validator.getChurnLimit(config, c.val_count); + const got = computeWeakSubjectivityPeriodFromConstituentsPhase0( + c.val_count, + total_balance_by_increment, + churn, + min_delay, + ); + try std.testing.expectEqual(c.ws_period, got); + } +} + +test "computeWeakSubjectivityPeriodFromConstituentsElectra - mainnet table" { + // Ported from packages/state-transition/test/unit/util/weakSubjectivity.test.ts + // Values from https://github.com/ethereum/consensus-specs/blob/8ebb5e80862641287d7e8db2bbf69fa31612640b/specs/electra/weak-subjectivity.md#weak-subjectivity-period + const config = &@import("config").mainnet.config; + const min_delay = config.chain.MIN_VALIDATOR_WITHDRAWABILITY_DELAY; + + const Case = struct { total_balance_increment: u64, ws_period: u64 }; + const cases = [_]Case{ + .{ .total_balance_increment = 1_048_576, .ws_period = 665 }, + .{ .total_balance_increment = 2_097_152, .ws_period = 1075 }, + .{ .total_balance_increment = 4_194_304, .ws_period = 1894 }, + .{ .total_balance_increment = 8_388_608, .ws_period = 3532 }, + .{ .total_balance_increment = 16_777_216, .ws_period = 3532 }, + .{ .total_balance_increment = 33_554_432, .ws_period = 3532 }, + }; + + for (cases) |c| { + const balance_churn = validator.getBalanceChurnLimit( + c.total_balance_increment, + config.chain.CHURN_LIMIT_QUOTIENT, + config.chain.MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA, + ); + const got = computeWeakSubjectivityPeriodFromConstituentsElectra( + c.total_balance_increment, + balance_churn, + min_delay, + ); + try std.testing.expectEqual(c.ws_period, got); + } +} From ee60eeddb1adee1bb9067c61586022c5dab1381c Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 23:02:53 +0800 Subject: [PATCH 24/46] fix imports for weak subjectivity --- src/state_transition/root.zig | 2 +- src/state_transition/weak_subjectivity.zig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index cb398430e..0a055ba25 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -113,7 +113,7 @@ const load_state = @import("load_state.zig"); pub const loadState = load_state.loadState; pub const MigrateStateOutput = load_state.MigrateStateOutput; -const weak_subjectivity = @import("./utils/weak_subjectivity.zig"); +const weak_subjectivity = @import("./weak_subjectivity.zig"); pub const getLatestWeakSubjectivityCheckpointEpoch = weak_subjectivity.getLatestWeakSubjectivityCheckpointEpoch; test { diff --git a/src/state_transition/weak_subjectivity.zig b/src/state_transition/weak_subjectivity.zig index 94846489f..1fa712f59 100644 --- a/src/state_transition/weak_subjectivity.zig +++ b/src/state_transition/weak_subjectivity.zig @@ -5,9 +5,9 @@ const types = @import("consensus_types"); const BeaconConfig = @import("config").BeaconConfig; const ForkSeq = @import("config").ForkSeq; -const EpochCache = @import("../cache/epoch_cache.zig").EpochCache; +const EpochCache = @import("cache/epoch_cache.zig").EpochCache; -const validator = @import("validator.zig"); +const validator = @import("./utils/validator.zig"); const Epoch = types.primitive.Epoch.Type; From b45e1ce337e671d76fed287def9d120e2cded038 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 23:02:53 +0800 Subject: [PATCH 25/46] toValue --- bindings/napi/BeaconStateView.zig | 14 ++++++++++++-- bindings/napi/to_napi_value.zig | 20 ++++++++++++++++++-- bindings/src/index.d.ts | 4 ++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index d4c38ea22..cf5a85a8f 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1374,8 +1374,18 @@ pub fn isStateValidatorsNodesPopulated(_: *const BeaconStateView) !js.Boolean { return js.Boolean.from(true); } -pub fn toValue(_: *const BeaconStateView) !js.Value { - return throwNotImpl(js.Value, "toValue not implemented"); +pub fn toValue(self: *const BeaconStateView) !js.Value { + const env = js.env(); + const cached_state = try self.requireState(); + switch (cached_state.state.forkSeq()) { + inline else => |f| { + const ForkBeaconState = fork_types.ForkTypes(f).BeaconState; + var value: ForkBeaconState.Type = ForkBeaconState.default_value; + defer ForkBeaconState.deinit(allocator, &value); + try cached_state.state.castToFork(f).inner.toValue(allocator, &value); + return js_types.wrap(js.Value, try sszValueToNapiValue(env, ForkBeaconState, &value)); + }, + } } pub fn getSyncCommitteesWitness(_: *const BeaconStateView) !js.Value { diff --git a/bindings/napi/to_napi_value.zig b/bindings/napi/to_napi_value.zig index f3fea9527..59ab97445 100644 --- a/bindings/napi/to_napi_value.zig +++ b/bindings/napi/to_napi_value.zig @@ -15,7 +15,9 @@ pub fn sszValueToNapiValue(env: napi.Env, comptime ST: type, value: *const ST.Ty return try env.getBoolean(value.*); }, .vector => { - if (comptime ssz.isByteVectorType(ST)) { + if (comptime ssz.isBitVectorType(ST)) { + return try bitArrayToNapiValue(env, value.data[0..], ST.length); + } else if (comptime ssz.isByteVectorType(ST)) { var bytes: [*]u8 = undefined; const buf = try env.createArrayBuffer(ST.length, &bytes); @memcpy(bytes[0..ST.length], value); @@ -30,7 +32,9 @@ pub fn sszValueToNapiValue(env: napi.Env, comptime ST: type, value: *const ST.Ty } }, .list => { - if (comptime ssz.isByteListType(ST)) { + if (comptime ssz.isBitListType(ST)) { + return try bitArrayToNapiValue(env, value.data.items, value.bit_len); + } else if (comptime ssz.isByteListType(ST)) { var bytes: [*]u8 = undefined; const buf = try env.createArrayBuffer(value.items.len, &bytes); @memcpy(bytes[0..value.items.len], value.items); @@ -56,6 +60,18 @@ pub fn sszValueToNapiValue(env: napi.Env, comptime ST: type, value: *const ST.Ty } } +fn bitArrayToNapiValue(env: napi.Env, data: []const u8, bit_len: usize) !napi.Value { + var bytes: [*]u8 = undefined; + const buf = try env.createArrayBuffer(data.len, &bytes); + @memcpy(bytes[0..data.len], data); + const uint8_array = try env.createTypedarray(.uint8, data.len, buf, 0); + + const obj = try env.createObject(); + try obj.setNamedProperty("uint8Array", uint8_array); + try obj.setNamedProperty("bitLen", try env.createInt64(@intCast(bit_len))); + return obj; +} + const NumberSliceOpts = struct { typed_array: ?napi.value_types.TypedarrayType = null, }; diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index fbbf778c1..2f216ab3c 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -311,8 +311,8 @@ declare class BeaconStateView { opts?: {preloadValidatorsAndBalances?: boolean} ): BeaconStateView; loadOtherStateBench(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; - // biome-ignore lint/suspicious/noExplicitAny: stub - // TODO(bing): Only one real use case in lodestar and it's in debugging; impl when useful + // biome-ignore lint/suspicious/noExplicitAny: structurally a BeaconState (fork-narrowed), + // but typing the union here would duplicate types from @lodestar/types. Caller narrows by forkName. toValue(): any; serialize(): Uint8Array; From 596f538fefa37148377a22668324e5edd33ecd93 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 23:13:14 +0800 Subject: [PATCH 26/46] redundant com --- bindings/napi/BeaconStateView.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index cf5a85a8f..d016d9803 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1241,10 +1241,6 @@ pub fn computeAnchorCheckpoint(self: *const BeaconStateView) !js.Value { return js_types.wrap(js.Value, obj); } -// ------------------------- -// Shuffling -// ------------------------- - fn shufflingToNapi(shuffling: anytype) !napi.Value { const env = js.env(); const obj = try env.createObject(); From f14a0e116f55c7994b6a8bc6340ad01a027d5a0e Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 18 May 2026 23:18:47 +0800 Subject: [PATCH 27/46] feat: getSyncCommitteesWitness --- bindings/napi/BeaconStateView.zig | 41 ++++++++- bindings/napi/js_types.zig | 6 ++ bindings/src/index.d.ts | 8 +- src/state_transition/root.zig | 5 + .../sync_committees_witness.zig | 92 +++++++++++++++++++ 5 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 src/state_transition/sync_committees_witness.zig diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index d016d9803..f83a18c56 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -907,6 +907,43 @@ pub fn getFinalizedRootProof(self: *const BeaconStateView) !js.Array { )); } +pub fn getSyncCommitteesWitness(self: *const BeaconStateView) !js_types.SyncCommitteeWitness { + const env = js.env(); + const cached_state = try self.requireState(); + try cached_state.state.commit(); + + const fork_seq = cached_state.state.forkSeq(); + if (fork_seq.lt(.altair)) { + return throwNullAs( + js_types.SyncCommitteeWitness, + "INVALID_FORK", + "getSyncCommitteesWitness only supported altair+", + ); + } + + const root_node = switch (cached_state.state.*) { + inline else => |state| state.root, + }; + const witness_data = try st.getSyncCommitteesWitness(fork_seq, root_node, cached_state.state.nodePool()); + + const witness_arr = try env.createArrayWithLength(@intCast(witness_data.witness_len)); + for (witness_data.witness(), 0..) |*w, i| { + try witness_arr.setElement(@intCast(i), js.Uint8Array.from(w).toValue()); + } + + const obj = try env.createObject(); + try obj.setNamedProperty("witness", witness_arr); + try obj.setNamedProperty( + "currentSyncCommitteeRoot", + js.Uint8Array.from(&witness_data.current_sync_committee_root).toValue(), + ); + try obj.setNamedProperty( + "nextSyncCommitteeRoot", + js.Uint8Array.from(&witness_data.next_sync_committee_root).toValue(), + ); + return js_types.wrap(js_types.SyncCommitteeWitness, obj); +} + /// Get a single Merkle proof for a node at the given generalized index. pub fn getSingleProof(self: *const BeaconStateView, gindex_arg: js.Number) !js.Array { const env = js.env(); @@ -1384,10 +1421,6 @@ pub fn toValue(self: *const BeaconStateView) !js.Value { } } -pub fn getSyncCommitteesWitness(_: *const BeaconStateView) !js.Value { - return throwNotImpl(js.Value, "getSyncCommitteesWitness not implemented"); -} - /// Compute expected withdrawals for the next payload (capella+). /// Returns: { expectedWithdrawals: Withdrawal[], processedPartialWithdrawalsCount, processedValidatorSweepCount, /// processedBuilderWithdrawalsCount, processedBuildersSweepCount } diff --git a/bindings/napi/js_types.zig b/bindings/napi/js_types.zig index 6dcf3d2f0..9bd474b5d 100644 --- a/bindings/napi/js_types.zig +++ b/bindings/napi/js_types.zig @@ -71,3 +71,9 @@ pub const UnrealizedCheckpoints = js.Object(struct { justifiedCheckpoint: Checkpoint, finalizedCheckpoint: Checkpoint, }); + +pub const SyncCommitteeWitness = js.Object(struct { + witness: js.Array, + currentSyncCommitteeRoot: js.Uint8Array, + nextSyncCommitteeRoot: js.Uint8Array, +}); diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 2f216ab3c..69d7f9edc 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -266,12 +266,14 @@ declare class BeaconStateView { isValidVoluntaryExit(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): boolean; getFinalizedRootProof(): Uint8Array[]; - // biome-ignore lint/suspicious/noExplicitAny: stub - getSyncCommitteesWitness(): any; + getSyncCommitteesWitness(): { + witness: Uint8Array[]; + currentSyncCommitteeRoot: Uint8Array; + nextSyncCommitteeRoot: Uint8Array; + }; // biome-ignore lint/suspicious/noExplicitAny: stub getExpectedWithdrawals(): any; getSingleProof(gindex: bigint): Uint8Array[]; - // getSyncCommitteesWitness(): any; /** * Compute expected withdrawals for the next payload (capella+). * diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index 0a055ba25..15e4a070b 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -116,6 +116,10 @@ pub const MigrateStateOutput = load_state.MigrateStateOutput; const weak_subjectivity = @import("./weak_subjectivity.zig"); pub const getLatestWeakSubjectivityCheckpointEpoch = weak_subjectivity.getLatestWeakSubjectivityCheckpointEpoch; +const sync_committees_witness = @import("./sync_committees_witness.zig"); +pub const getSyncCommitteesWitness = sync_committees_witness.getSyncCommitteesWitness; +pub const SyncCommitteeWitness = sync_committees_witness.SyncCommitteeWitness; + test { testing.refAllDecls(@This()); testing.refAllDecls(seed); @@ -123,4 +127,5 @@ test { testing.refAllDecls(EpochShuffling); testing.refAllDecls(load_state); testing.refAllDecls(weak_subjectivity); + testing.refAllDecls(sync_committees_witness); } diff --git a/src/state_transition/sync_committees_witness.zig b/src/state_transition/sync_committees_witness.zig new file mode 100644 index 000000000..bf544babd --- /dev/null +++ b/src/state_transition/sync_committees_witness.zig @@ -0,0 +1,92 @@ +const std = @import("std"); + +const ForkSeq = @import("config").ForkSeq; +const Node = @import("persistent_merkle_tree").Node; + +/// Witness data needed to prove the current and next sync committee roots +/// against the beacon state root. Used by the light-client server. +/// +/// Witness branch is sorted by descending gindex. +/// Pre-electra: 4 witness entries. Post-electra: 5 witness entries. +pub const SyncCommitteeWitness = struct { + witness_buf: [5][32]u8, + witness_len: u8, + current_sync_committee_root: [32]u8, + next_sync_committee_root: [32]u8, + + pub fn witness(self: *const SyncCommitteeWitness) []const [32]u8 { + return self.witness_buf[0..self.witness_len]; + } +}; + +/// Compute the sync-committee witness for the beacon state rooted at `root_node`. +/// +/// The walk path depends on which fork the state was produced under because the BeaconState +/// container layout changes across forks — sync committee fields move to different gindices. +pub fn getSyncCommitteesWitness( + fork: ForkSeq, + root_node: Node.Id, + pool: *Node.Pool, +) !SyncCommitteeWitness { + const n1 = root_node; + + // Layout from electra onward: sync committees sit deeper in the tree. + if (fork.gte(.electra)) { + const n2 = try Node.Id.getLeft(n1, pool); + const n5 = try Node.Id.getRight(n2, pool); + const n10 = try Node.Id.getLeft(n5, pool); + const n21 = try Node.Id.getRight(n10, pool); + const n43 = try Node.Id.getRight(n21, pool); + + const current = try Node.Id.getLeft(n43, pool); // n86 + const next = try Node.Id.getRight(n43, pool); // n87 + + // Siblings on the path to the sync-committee subtree, descending gindex order. + const w0 = try Node.Id.getLeft(n21, pool); // gindex 42 + const w1 = try Node.Id.getLeft(n10, pool); // gindex 20 + const w2 = try Node.Id.getRight(n5, pool); // gindex 11 + const w3 = try Node.Id.getLeft(n2, pool); // gindex 4 + const w4 = try Node.Id.getRight(n1, pool); // gindex 3 + + return .{ + .witness_buf = .{ + w0.getRoot(pool).*, + w1.getRoot(pool).*, + w2.getRoot(pool).*, + w3.getRoot(pool).*, + w4.getRoot(pool).*, + }, + .witness_len = 5, + .current_sync_committee_root = current.getRoot(pool).*, + .next_sync_committee_root = next.getRoot(pool).*, + }; + } + // Pre-electra layout (altair → deneb): sync committees at gindices 54, 55. + else { + const n3 = try Node.Id.getRight(n1, pool); // [1]0110 + const n6 = try Node.Id.getLeft(n3, pool); // 1[0]110 + const n13 = try Node.Id.getRight(n6, pool); // 10[1]10 + const n27 = try Node.Id.getRight(n13, pool); // 101[1]0 + + const current = try Node.Id.getLeft(n27, pool); // n54 — 1011[0] + const next = try Node.Id.getRight(n27, pool); // n55 — 1011[1] + + const w0 = try Node.Id.getLeft(n13, pool); // gindex 26 + const w1 = try Node.Id.getLeft(n6, pool); // gindex 12 + const w2 = try Node.Id.getRight(n3, pool); // gindex 7 + const w3 = try Node.Id.getLeft(n1, pool); // gindex 2 + + return .{ + .witness_buf = .{ + w0.getRoot(pool).*, + w1.getRoot(pool).*, + w2.getRoot(pool).*, + w3.getRoot(pool).*, + std.mem.zeroes([32]u8), + }, + .witness_len = 4, + .current_sync_committee_root = current.getRoot(pool).*, + .next_sync_committee_root = next.getRoot(pool).*, + }; + } +} From 289c63437bce7c75816698f2e80b7e466d2281c2 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 19 May 2026 01:33:11 +0800 Subject: [PATCH 28/46] biome fix --- bindings/src/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 69d7f9edc..52a2bdd62 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -1,3 +1,4 @@ +// biome-ignore-all lint/style/useNamingConvention: spec-canonical fork names in `ForkName` interface BeaconBlockHeader { slot: number; proposerIndex: number; @@ -65,8 +66,7 @@ interface ProcessSlotsOpts { } interface CompactMultiProof { - // biome-ignore lint/suspicious/noExplicitAny: - // native returns string literal "compactMulti", IBeaconStateView uses @chainsafe/persistent-merkle-tree's ProofType + // biome-ignore lint/suspicious/noExplicitAny: native returns string literal "compactMulti", IBeaconStateView uses @chainsafe/persistent-merkle-tree's ProofType // TODO(bing): align types? type: any; leaves: Uint8Array[]; From 739229847f65ed2e8306e0f40712771e6669cf81 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 15:35:01 +0800 Subject: [PATCH 29/46] fix isMergeTransitionComplete to be a method --- bindings/src/index.d.ts | 2 +- bindings/test/beaconStateView.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 52a2bdd62..c3a7f1193 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -238,7 +238,7 @@ declare class BeaconStateView { activeValidatorCount: number; isExecutionStateType: boolean; - isMergeTransitionComplete: boolean; + isMergeTransitionComplete(): boolean; /** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */ isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean; /** diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index aeb1f4a61..d441c744b 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -272,7 +272,7 @@ describe("BeaconStateView", () => { }); it("isMergeTransitionComplete should be true for fulu state", () => { - expect(state.isMergeTransitionComplete).toBe(true); + expect(state.isMergeTransitionComplete()).toBe(true); }); it("isExecutionStateType should be true for fulu state", () => { From 03e3f3777f2689f2b0c8cc8ec0a515810c7448c4 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 15:35:01 +0800 Subject: [PATCH 30/46] fix: update expect bigints to be numbers --- bindings/test/beaconStateView.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index d441c744b..314488fd2 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -282,11 +282,11 @@ describe("BeaconStateView", () => { describe("validators and balances", () => { it("getBalance(0) should return first validator balance", () => { - expect(state.getBalance(0)).toBe(BigInt(expected.balance0)); + expect(state.getBalance(0)).toBe(expected.balance0); }); it("getBalance(100) should return validator 100 balance", () => { - expect(state.getBalance(100)).toBe(BigInt(expected.balance100)); + expect(state.getBalance(100)).toBe(expected.balance100); }); it("getValidator(0) should return first validator data", () => { @@ -556,9 +556,9 @@ describe("BeaconStateView", () => { it("proposerRewards should have expected structure", () => { const rewards = state.proposerRewards; - expect(typeof rewards.attestations).toBe("bigint"); - expect(typeof rewards.syncAggregate).toBe("bigint"); - expect(typeof rewards.slashing).toBe("bigint"); + expect(typeof rewards.attestations).toBe("number"); + expect(typeof rewards.syncAggregate).toBe("number"); + expect(typeof rewards.slashing).toBe("number"); }); }); From 450a4d9e1645060c339ac0bbe2d8fb0b27848739 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 15:35:01 +0800 Subject: [PATCH 31/46] update shape of serialized outputs --- bindings/test/beaconStateView.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index 314488fd2..633894a86 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -363,7 +363,7 @@ describe("BeaconStateView", () => { describe("block and state roots", () => { it("getBlockRoot should return 32 bytes", () => { - const blockRoot = state.getBlockRoot(state.slot - 1); + const blockRoot = state.getBlockRoot(state.epoch - 1); expect(blockRoot.length).toBe(32); }); @@ -442,7 +442,8 @@ describe("BeaconStateView", () => { it("serializeToBytes should write correct bytes", () => { const size = state.serializedSize(); const output = new Uint8Array(size); - const bytesWritten = state.serializeToBytes(output, 0); + const byteViews = {dataView: new DataView(output.buffer), uint8Array: output}; + const bytesWritten = state.serializeToBytes(byteViews, 0); expect(bytesWritten).toBe(size); expect(Buffer.compare(output, stateBytes)).toBe(0); @@ -462,7 +463,8 @@ describe("BeaconStateView", () => { it("serializeValidatorsToBytes should write correct bytes", () => { const size = state.serializedValidatorsSize(); const output = new Uint8Array(size); - const bytesWritten = state.serializeValidatorsToBytes(output, 0); + const byteViews = {dataView: new DataView(output.buffer), uint8Array: output}; + const bytesWritten = state.serializeValidatorsToBytes(byteViews, 0); expect(bytesWritten).toBe(size); From 7ed8b5a89bb4fa266f47757010371eb551a47283 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 17:36:47 +0800 Subject: [PATCH 32/46] dedup getExpectedWithdrawals --- bindings/src/index.d.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index c3a7f1193..651386df3 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -271,8 +271,6 @@ declare class BeaconStateView { currentSyncCommitteeRoot: Uint8Array; nextSyncCommitteeRoot: Uint8Array; }; - // biome-ignore lint/suspicious/noExplicitAny: stub - getExpectedWithdrawals(): any; getSingleProof(gindex: bigint): Uint8Array[]; /** * Compute expected withdrawals for the next payload (capella+). From 77687787fe2990c56e2c28e8dbc9d87a54e1e270 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 17:40:24 +0800 Subject: [PATCH 33/46] revert isMergeTransitionComplete to property --- bindings/napi/BeaconStateView.zig | 1 + bindings/src/index.d.ts | 2 +- bindings/test/beaconStateView.test.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index f83a18c56..981f2f266 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -60,6 +60,7 @@ pub const js_meta = js.class(.{ .properties = .{ .validatorCount = js.prop(.{ .get = true, .set = false }), .activeValidatorCount = js.prop(.{ .get = true, .set = false }), .isExecutionStateType = js.prop(.{ .get = true, .set = false }), + .isMergeTransitionComplete = js.prop(.{ .get = true, .set = false }), .proposerRewards = js.prop(.{ .get = true, .set = false }), .clonedCount = js.prop(.{ .get = true, .set = false }), .clonedCountWithTransferCache = js.prop(.{ .get = true, .set = false }), diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 651386df3..390496279 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -238,7 +238,7 @@ declare class BeaconStateView { activeValidatorCount: number; isExecutionStateType: boolean; - isMergeTransitionComplete(): boolean; + isMergeTransitionComplete: boolean; /** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */ isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean; /** diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index 633894a86..ba9f10db7 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -272,7 +272,7 @@ describe("BeaconStateView", () => { }); it("isMergeTransitionComplete should be true for fulu state", () => { - expect(state.isMergeTransitionComplete()).toBe(true); + expect(state.isMergeTransitionComplete).toBe(true); }); it("isExecutionStateType should be true for fulu state", () => { From 5530704bcfb90001256f06e9473a250064250d4d Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 20 May 2026 17:44:41 +0800 Subject: [PATCH 34/46] add stub for withParentPayloadApplied --- bindings/napi/BeaconStateView.zig | 5 +++++ bindings/src/index.d.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 981f2f266..3573af63f 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1381,6 +1381,11 @@ pub fn getExpectedWithdrawalsForFullParent(_: *const BeaconStateView, _: js.Valu return throwNotImpl(js.Array, "getExpectedWithdrawalsForFullParent is not available before Gloas"); } +pub fn withParentPayloadApplied(_: *const BeaconStateView, _: js.Value) !BeaconStateView { + try js.env().throwError("NOT_IMPLEMENTED", "withParentPayloadApplied is not available before Gloas"); + return error.NotImplemented; +} + // --- API-only methods (used by beacon-node rewards endpoints) --- pub fn computeBlockRewards(_: *const BeaconStateView, _: js.Value, _: ?js.Value) !js.Value { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 390496279..c9cf91c78 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -205,6 +205,9 @@ declare class BeaconStateView { // TODO(bing): type this once we support gloas // biome-ignore lint/suspicious/noExplicitAny: gloas stub getExpectedWithdrawalsForFullParent(executionRequests: any): any[]; + // TODO(bing): Implement when we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + withParentPayloadApplied(executionRequests: any): BeaconStateView; getShufflingAtEpoch(epoch: number): EpochShuffling; getPreviousShuffling(): EpochShuffling; From 2011f301f971c293c3b4e85b1ffad37ec7161e0b Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 25 May 2026 21:56:18 +0800 Subject: [PATCH 35/46] refactor: update APIs related to voluntary exits In lodestar, once a `SignedVoluntaryExit` is received via gossip, we deserialize and deal with the object directly, same with the functions in lodestar-z. So we deal with passing it as a js object and walking its properties to build a native struct (which should be cheap anyway since the struct is small) --- bindings/napi/BeaconStateView.zig | 31 ++++++++++++++++++++------- bindings/src/index.d.ts | 14 ++++++++++-- bindings/test/beaconStateView.test.ts | 12 +++++++---- bindings/test/demo.ts | 8 +++++-- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 3573af63f..44d032cfd 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -839,15 +839,31 @@ pub fn proposerRewards(self: *const BeaconStateView) !js_types.ProposerRewards { return .{ .val = obj }; } -pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.String { +/// Populate a `SignedVoluntaryExit.Type` from a JS object of shape +/// `{message: {epoch, validatorIndex}, signature: Uint8Array(96)}`. Matches `phase0.SignedVoluntaryExit` +/// from `@lodestar/types`. +fn signedVoluntaryExitFromJsValue(value: js.Value, out: *ct.phase0.SignedVoluntaryExit.Type) !void { + const raw = value.toValue(); + const message = try raw.getNamedProperty("message"); + out.message.epoch = @intCast(try (try message.getNamedProperty("epoch")).getValueInt64()); + out.message.validator_index = @intCast(try (try message.getNamedProperty("validatorIndex")).getValueInt64()); + + const signature = try raw.getNamedProperty("signature"); + if (!(try signature.isTypedarray())) return error.SignatureNotTypedArray; + const info = try signature.getTypedarrayInfo(); + if (info.array_type != .uint8) return error.SignatureNotUint8Array; + if (info.data.len != out.signature.len) return error.InvalidSignatureLength; + @memcpy(&out.signature, info.data); +} + +pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.String { const env = js.env(); const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); - const bytes = try signed_exit_bytes.toSlice(); var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; - ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { - return throwNullAs(js.String, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); + signedVoluntaryExitFromJsValue(signed_exit_value, &signed_voluntary_exit) catch { + return throwNullAs(js.String, "INVALID_ARG", "Failed to read SignedVoluntaryExit from JS object"); }; const result = switch (cached_state.state.forkSeq()) { @@ -867,14 +883,13 @@ pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: return .{ .val = try env.createStringUtf8(@tagName(validity)) }; } -pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_bytes: js.Uint8Array, verify_signature_value: js.Boolean) !js.Boolean { +pub fn isValidVoluntaryExit(self: *const BeaconStateView, signed_exit_value: js.Value, verify_signature_value: js.Boolean) !js.Boolean { const cached_state = try self.requireState(); const verify_signature = verify_signature_value.assertBool(); - const bytes = try signed_exit_bytes.toSlice(); var signed_voluntary_exit: ct.phase0.SignedVoluntaryExit.Type = ct.phase0.SignedVoluntaryExit.default_value; - ct.phase0.SignedVoluntaryExit.deserializeFromBytes(bytes, &signed_voluntary_exit) catch { - return throwNullAs(js.Boolean, "DESERIALIZE_ERROR", "Failed to deserialize SignedVoluntaryExit"); + signedVoluntaryExitFromJsValue(signed_exit_value, &signed_voluntary_exit) catch { + return throwNullAs(js.Boolean, "INVALID_ARG", "Failed to read SignedVoluntaryExit from JS object"); }; const result = switch (cached_state.state.forkSeq()) { diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index c9cf91c78..08b44d7c2 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -12,6 +12,16 @@ interface Checkpoint { root: Uint8Array; } +interface VoluntaryExit { + epoch: number; + validatorIndex: number; +} + +interface SignedVoluntaryExit { + message: VoluntaryExit; + signature: Uint8Array; +} + interface Eth1Data { depositRoot: Uint8Array; depositCount: number; @@ -265,8 +275,8 @@ declare class BeaconStateView { computeSyncCommitteeRewards(block: any, validatorIds: (number | string)[]): Promise; getLatestWeakSubjectivityCheckpointEpoch(): number; - getVoluntaryExitValidity(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): VoluntaryExitValidity; - isValidVoluntaryExit(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): boolean; + getVoluntaryExitValidity(signedVoluntaryExit: SignedVoluntaryExit, verifySignature: boolean): VoluntaryExitValidity; + isValidVoluntaryExit(signedVoluntaryExit: SignedVoluntaryExit, verifySignature: boolean): boolean; getFinalizedRootProof(): Uint8Array[]; getSyncCommitteesWitness(): { diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index ba9f10db7..8a556c851 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -516,15 +516,19 @@ describe("BeaconStateView", () => { describe("voluntary exit validation", () => { it("isValidVoluntaryExit should return boolean", () => { - // Invalid voluntary exit bytes (all zeros) - const invalidExit = new Uint8Array(112); + const invalidExit = { + message: {epoch: 0, validatorIndex: 0}, + signature: new Uint8Array(96), + }; const result = state.isValidVoluntaryExit(invalidExit, false); expect(typeof result).toBe("boolean"); }); it("getVoluntaryExitValidity should return validity reason", () => { - // Invalid voluntary exit bytes (all zeros) - const invalidExit = new Uint8Array(112); + const invalidExit = { + message: {epoch: 0, validatorIndex: 0}, + signature: new Uint8Array(96), + }; const result = state.getVoluntaryExitValidity(invalidExit, false); const validReasons = [ diff --git a/bindings/test/demo.ts b/bindings/test/demo.ts index 320b6d357..5b67504e1 100644 --- a/bindings/test/demo.ts +++ b/bindings/test/demo.ts @@ -115,8 +115,12 @@ printDuration("pendingPartialWithdrawals", () => state.pendingPartialWithdrawals printDuration("pendingConsolidations", () => state.pendingConsolidations); printDuration("proposerLookahead", () => state.proposerLookahead); printDuration("getSingleProof(169)", () => state.getSingleProof(169)); -printDuration("isValidVoluntaryExit", () => state.isValidVoluntaryExit(new Uint8Array(112), false)); -printDuration("getVoluntaryExitValidity", () => state.getVoluntaryExitValidity(new Uint8Array(112), false)); +const invalidVoluntaryExit = { + message: {epoch: 0, validatorIndex: 0}, + signature: new Uint8Array(96), +}; +printDuration("isValidVoluntaryExit", () => state.isValidVoluntaryExit(invalidVoluntaryExit, false)); +printDuration("getVoluntaryExitValidity", () => state.getVoluntaryExitValidity(invalidVoluntaryExit, false)); printDuration("createMultiProof(descriptor for gindex 42)", () => state.createMultiProof(Uint8Array.from([0x25, 0xe0])) ); From c5d5fb6d082670a62b8feb9f929059e1b3417d40 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 25 May 2026 22:13:53 +0800 Subject: [PATCH 36/46] move stf exports into dedicated files --- bindings/src/index.d.ts | 14 +++++++------- bindings/src/state-transition.d.ts | 16 ++++++++++++++++ bindings/src/state-transition.js | 6 ++++++ package.json | 4 ++++ 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 bindings/src/state-transition.d.ts create mode 100644 bindings/src/state-transition.js diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 08b44d7c2..fc65e456d 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -12,12 +12,12 @@ interface Checkpoint { root: Uint8Array; } -interface VoluntaryExit { +export interface VoluntaryExit { epoch: number; validatorIndex: number; } -interface SignedVoluntaryExit { +export interface SignedVoluntaryExit { message: VoluntaryExit; signature: Uint8Array; } @@ -54,7 +54,7 @@ interface Fork { epoch: number; } -enum ForkName { +export enum ForkName { phase0 = "phase0", altair = "altair", bellatrix = "bellatrix", @@ -70,7 +70,7 @@ interface SyncCommittee { aggregatePubkey: Uint8Array; } -interface ProcessSlotsOpts { +export interface ProcessSlotsOpts { /** Default: false (cache is transferred). Set to true to opt out of cache transfer. */ dontTransferCache?: boolean; } @@ -89,7 +89,7 @@ interface CompactMultiProof { * Note: Fields used by TS `StateTransitionOpts` but ignored by the Zig binding (e.g. * `executionPayloadStatus`) are silently dropped - they are declared here to pass type checks. */ -interface TransitionOpts { +export interface TransitionOpts { /** Verify the post-state root matches the block's state root. Default: true. */ verifyStateRoot?: boolean; /** Verify the proposer signature on the signed block. Default: true. */ @@ -151,7 +151,7 @@ type ValidatorStatus = | "withdrawal_possible" | "withdrawal_done"; -type VoluntaryExitValidity = +export type VoluntaryExitValidity = | "valid" | "inactive" | "already_exited" @@ -160,7 +160,7 @@ type VoluntaryExitValidity = | "pending_withdrawals" | "invalid_signature"; -declare class BeaconStateView { +export declare class BeaconStateView { static createFromBytes(bytes: Uint8Array): BeaconStateView; slot: number; diff --git a/bindings/src/state-transition.d.ts b/bindings/src/state-transition.d.ts new file mode 100644 index 000000000..894ef1efc --- /dev/null +++ b/bindings/src/state-transition.d.ts @@ -0,0 +1,16 @@ +export {BeaconStateView} from "./index.js"; +export type { + ProcessSlotsOpts, + SignedVoluntaryExit, + TransitionOpts, + VoluntaryExit, + VoluntaryExitValidity, +} from "./index.js"; + +import type {BeaconStateView, TransitionOpts} from "./index.js"; + +export declare function stateTransition( + preState: BeaconStateView, + signedBlockBytes: Uint8Array, + options?: TransitionOpts +): BeaconStateView; diff --git a/bindings/src/state-transition.js b/bindings/src/state-transition.js new file mode 100644 index 000000000..6a5c0cab7 --- /dev/null +++ b/bindings/src/state-transition.js @@ -0,0 +1,6 @@ +import bindings from "./bindings.js"; + +const native = bindings.stateTransition; + +export const BeaconStateView = bindings.BeaconStateView; +export const stateTransition = native.stateTransition; diff --git a/package.json b/package.json index 7d6406e66..7fcf658cd 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "./pubkeys": { "import": "./bindings/src/pubkeys.js", "types": "./bindings/src/pubkeys.d.ts" + }, + "./state-transition": { + "import": "./bindings/src/state-transition.js", + "types": "./bindings/src/state-transition.d.ts" } }, "scripts": { From b6706a9f04fa7548ae40d3c469caed23369df3d5 Mon Sep 17 00:00:00 2001 From: bing Date: Mon, 25 May 2026 22:13:53 +0800 Subject: [PATCH 37/46] fix: isExecutionEnabled accepts block object --- bindings/napi/BeaconStateView.zig | 115 ++++++++++++++++++++------ bindings/src/index.d.ts | 36 +++++++- bindings/test/beaconStateView.test.ts | 89 ++++++++++++++++++++ bindings/test/index.test.ts | 1 + 4 files changed, 214 insertions(+), 27 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 44d032cfd..d5d82c5f1 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -777,18 +777,17 @@ pub fn isExecutionStateType(self: *const BeaconStateView) !js.Boolean { return js.Boolean.from(fork_seq.gte(.bellatrix)); } -/// Check whether execution is enabled for the given block at this state. +/// Check whether execution is enabled for the given Lodestar-shaped block object. /// -/// Check if 1) merge transition is complete, or 2) is a merge transition block -/// -/// Note that this does not call native `isExecutionEnabled` directly because we can save on deserializing -/// `signed_block` if 1) holds. We only deserialize in the event that it's a pre-merge bellatrix block -pub fn isExecutionEnabled(self: *const BeaconStateView, signed_block_bytes: js.Uint8Array) !js.Boolean { +/// For normal post-merge operation this short-circuits from state alone and does +/// not inspect `block`. The block object is only read for the historical pre-merge +/// Bellatrix case, where execution is enabled iff the block carries the first +/// non-default execution payload. +pub fn isExecutionEnabled(self: *const BeaconStateView, block: js.Value) !js.Boolean { const cached_state = try self.requireState(); const fork_seq = cached_state.state.forkSeq(); if (fork_seq.lt(.bellatrix)) return js.Boolean.from(false); - // Check if (1) holds const merge_complete: bool = switch (fork_seq) { inline .bellatrix, .capella, .deneb, .electra, .fulu => |f| st.isMergeTransitionComplete(f, cached_state.state.castToFork(f)), else => unreachable, @@ -797,24 +796,24 @@ pub fn isExecutionEnabled(self: *const BeaconStateView, signed_block_bytes: js.U if (fork_seq != .bellatrix) return js.Boolean.from(false); - // Only deserialize and check (2) if previous conditions have not been fulfilled - const bytes = try signed_block_bytes.toSlice(); - const signed_block = try AnySignedBeaconBlock.deserialize(allocator, .full, fork_seq, bytes); - defer signed_block.deinit(allocator); + // After the above check, we reach the slow path: pre-merge Bellatrix. + // Walk the JS block into a native ExecutionPayload to compare against `default_value`. + const block_raw = block.toValue(); + if (try block_raw.typeof() != .object) return error.InvalidBlockObject; - if (signed_block.forkSeq() != fork_seq) { - return throwNullAs(js.Boolean, "FORK_MISMATCH", "Fork of signed block does not match state fork"); - } + const body = try (try block_raw.getNamedProperty("body")).coerceToObject(); - const is_merge_transition_block = switch (signed_block.blockType()) { - inline else => |bt| st.isMergeTransitionBlock( - .bellatrix, - cached_state.state.castToFork(.bellatrix), - bt, - signed_block.beaconBlock().castToFork(bt, .bellatrix).body(), - ), - }; - return js.Boolean.from(is_merge_transition_block); + // Lodestar treats blinded pre-merge Bellatrix blocks as not-yet-merged: the state's + // execution payload header is still default, so the block doesn't kick off the transition. + if (try body.hasNamedProperty("executionPayloadHeader")) return js.Boolean.from(false); + + const payload_js = try (try body.getNamedProperty("executionPayload")).coerceToObject(); + var payload: ct.bellatrix.ExecutionPayload.Type = ct.bellatrix.ExecutionPayload.default_value; + defer ct.bellatrix.ExecutionPayload.deinit(allocator, &payload); + try executionPayloadFromJs(payload_js, &payload); + + const is_default = ct.bellatrix.ExecutionPayload.equals(&payload, &ct.bellatrix.ExecutionPayload.default_value); + return js.Boolean.from(!is_default); } /// Check if the merge transition is complete. @@ -1529,3 +1528,73 @@ fn optionalBool(options: ?js.Value, name: [:0]const u8, default_value: bool) !bo } return default_value; } + +/// Populate a native Bellatrix `ExecutionPayload.Type` from a JS object with the Lodestar +/// shape. Caller must `ct.bellatrix.ExecutionPayload.deinit(allocator, out)` to free +/// `extra_data` and `transactions`. +fn executionPayloadFromJs(payload: napi.Value, out: *ct.bellatrix.ExecutionPayload.Type) !void { + try readByteArrayInto(payload, "parentHash", &out.parent_hash); + try readByteArrayInto(payload, "feeRecipient", &out.fee_recipient); + try readByteArrayInto(payload, "stateRoot", &out.state_root); + try readByteArrayInto(payload, "receiptsRoot", &out.receipts_root); + try readByteArrayInto(payload, "logsBloom", &out.logs_bloom); + try readByteArrayInto(payload, "prevRandao", &out.prev_randao); + try readByteArrayInto(payload, "blockHash", &out.block_hash); + + out.block_number = @intCast(try (try payload.getNamedProperty("blockNumber")).getValueInt64()); + out.gas_limit = @intCast(try (try payload.getNamedProperty("gasLimit")).getValueInt64()); + out.gas_used = @intCast(try (try payload.getNamedProperty("gasUsed")).getValueInt64()); + out.timestamp = @intCast(try (try payload.getNamedProperty("timestamp")).getValueInt64()); + + out.base_fee_per_gas = try readBigintU256(try payload.getNamedProperty("baseFeePerGas")); + + const extra_data = try payload.getNamedProperty("extraData"); + const extra_data_info = try extra_data.getTypedarrayInfo(); + if (extra_data_info.array_type != .uint8) return error.InvalidExtraData; + try out.extra_data.appendSlice(allocator, extra_data_info.data); + + const transactions = try payload.getNamedProperty("transactions"); + const tx_count = try transactions.getArrayLength(); + try out.transactions.ensureTotalCapacity(allocator, tx_count); + var i: u32 = 0; + while (i < tx_count) : (i += 1) { + const tx_value = try transactions.getElement(i); + const tx_info = try tx_value.getTypedarrayInfo(); + if (tx_info.array_type != .uint8) return error.InvalidTransaction; + var tx: std.ArrayListUnmanaged(u8) = .empty; + errdefer tx.deinit(allocator); + try tx.appendSlice(allocator, tx_info.data); + out.transactions.appendAssumeCapacity(tx); + } +} + +fn readByteArrayInto(parent: napi.Value, comptime field: [:0]const u8, out: []u8) !void { + const value = try parent.getNamedProperty(field); + const info = try value.getTypedarrayInfo(); + if (info.array_type != .uint8) return error.InvalidByteArrayField; + if (info.data.len != out.len) return error.InvalidByteArrayLength; + @memcpy(out, info.data); +} + +/// Read a JS bigint into u256. +/// +/// Throws on negative values; we never store signed u256 in consensus types. +fn readBigintU256(value: napi.Value) !u256 { + var sign_bit: c_int = 0; + var word_count: usize = 4; + var words: [4]u64 = .{ 0, 0, 0, 0 }; + try napi.status.check(napi.c.napi_get_value_bigint_words( + value.env, + value.value, + &sign_bit, + &word_count, + &words, + )); + if (sign_bit != 0) return error.NegativeBigint; + var result: u256 = 0; + var i: usize = 0; + while (i < @min(word_count, 4)) : (i += 1) { + result |= @as(u256, words[i]) << @intCast(i * 64); + } + return result; +} diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index fc65e456d..9fdf34024 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -48,6 +48,33 @@ interface ExecutionPayloadHeader { excessBlobGas?: number; // deneb+ } +/* + * We don't need *all* the fields to check if a block + * is a pre-merge or a merge transition block, so we just + * have a minimum interface that is like a `BeaconBlock`. + */ +interface BeaconBlockLike { + body: { + executionPayload?: { + parentHash: Uint8Array; + feeRecipient: Uint8Array; + stateRoot: Uint8Array; + receiptsRoot: Uint8Array; + logsBloom: Uint8Array; + prevRandao: Uint8Array; + blockNumber: number; + gasLimit: number; + gasUsed: number; + timestamp: number; + extraData: Uint8Array; + baseFeePerGas: bigint; + blockHash: Uint8Array; + transactions: Uint8Array[]; + }; + executionPayloadHeader?: ExecutionPayloadHeader; + }; +} + interface Fork { previousVersion: Uint8Array; currentVersion: Uint8Array; @@ -257,11 +284,12 @@ export declare class BeaconStateView { /** * Check whether execution is enabled for the given block at this state. * - * Check if 1) merge transition is complete, or 2) is a merge transition block - * Note that this does not call native `isExecutionEnabled` directly because we can save on deserializing - * `signed_block` if 1) holds. We only deserialize in the event that it's a pre-merge bellatrix block + * For normal post-merge operation this short-circuits from state alone and does + * not inspect `block`. The block object is only read for the historical pre-merge + * Bellatrix case, where execution is enabled iff the block carries the first + * non-default execution payload. */ - isExecutionEnabled(signedBlockBytes: Uint8Array): boolean; + isExecutionEnabled(block: BeaconBlockLike): boolean; proposerRewards: ProposerRewards; // biome-ignore lint/suspicious/noExplicitAny: stub diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index 8a556c851..df6a06798 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -5,6 +5,46 @@ import {ssz} from "@lodestar/types"; import {beforeAll, describe, expect, it} from "vitest"; import bindings from "../src/index.js"; import {getFirstEraFilePath} from "./eraFiles.ts"; +import {SecretKey} from "@chainsafe/blst"; + +// TODO(bing): it's kinda annoying to have to do this, i guess we +// expose the config somehow maybe? +/* Mainnet preset constants the binding is compiled against. */ +const SLOTS_PER_EPOCH = 32; +const SYNC_COMMITTEE_SIZE = 512; +const BELLATRIX_FORK_EPOCH = 144896; +const FAR_FUTURE_EPOCH = Number.MAX_SAFE_INTEGER; +const MAX_EFFECTIVE_BALANCE = 32_000_000_000; + +const VALIDATOR_COUNT = 16; + +interface Validator { + pubkey: Uint8Array; + withdrawalCredentials: Uint8Array; + effectiveBalance: number; + slashed: boolean; + activationEligibilityEpoch: number; + activationEpoch: number; + exitEpoch: number; + withdrawableEpoch: number; +} + +function makeValidators(count: number): Validator[] { + return Array.from({length: count}, (_, i) => { + const seed = new Uint8Array(32); + new DataView(seed.buffer).setUint32(0, i + 1); + return { + pubkey: SecretKey.fromKeygen(seed).toPublicKey().toBytes(), + withdrawalCredentials: new Uint8Array(32), + effectiveBalance: MAX_EFFECTIVE_BALANCE, + slashed: false, + activationEligibilityEpoch: 0, + activationEpoch: 0, + exitEpoch: FAR_FUTURE_EPOCH, + withdrawableEpoch: FAR_FUTURE_EPOCH, + }; + }); +} describe("BeaconStateView", () => { let state: InstanceType; @@ -280,6 +320,55 @@ describe("BeaconStateView", () => { }); }); + describe("isExecutionEnabled", () => { + const validators = makeValidators(VALIDATOR_COUNT); + + // Each sync-committee pubkey must be in the global pubkey_to_index map or + // EpochCache.createFromState throws PubkeyNotFound. Round-robin our 16 validators + // across the 512 slots — repeated pubkeys are fine for the lookup. + const syncCommitteePubkeys = Array.from( + {length: SYNC_COMMITTEE_SIZE}, + (_, i) => validators[i % VALIDATOR_COUNT].pubkey + ); + const syncCommittee = { + pubkeys: syncCommitteePubkeys, + aggregatePubkey: validators[0].pubkey, + }; + + const phase0State = ssz.phase0.BeaconState.defaultValue(); + const bellatrixState = ssz.bellatrix.BeaconState.defaultValue(); + bellatrixState.slot = 144896 * 32; // BELLATRIX_FORK_EPOCH * SLOTS_PER_EPOCH (mainnet) + bellatrixState.validators = validators; + bellatrixState.currentSyncCommittee = syncCommittee; + bellatrixState.nextSyncCommittee = syncCommittee; + + const phase0View = bindings.BeaconStateView.createFromBytes(ssz.phase0.BeaconState.serialize(phase0State)); + const bellatrixView = bindings.BeaconStateView.createFromBytes(ssz.bellatrix.BeaconState.serialize(bellatrixState)); + + it("should true on post-merge state without reading the block", () => { + // body is empty — binding short-circuits before touching it. + expect(state.isExecutionEnabled({body: {}})).toBe(true); + }); + + it("returns false even when block carries a non-default executionPayload", () => { + const payload = ssz.bellatrix.ExecutionPayload.defaultValue(); + payload.blockNumber = 1; + expect(phase0View.isExecutionEnabled({body: {executionPayload: payload}})).toBe(false); + }); + + it("returns true after walking block for non-default payload", () => { + const payload = ssz.bellatrix.ExecutionPayload.defaultValue(); + payload.blockNumber = 1; + expect(bellatrixView.isExecutionEnabled({body: {executionPayload: payload}})).toBe(true); + }); + + it("returns false when block is blinded (body has executionPayloadHeader)", () => { + // Lodestar treats blinded pre-merge Bellatrix blocks as not-yet-merged because the + // state header is still default. The Zig short-circuits on the presence of the field. + expect(bellatrixView.isExecutionEnabled({body: {executionPayloadHeader: {}}})).toBe(false); + }); + }); + describe("validators and balances", () => { it("getBalance(0) should return first validator balance", () => { expect(state.getBalance(0)).toBe(expected.balance0); diff --git a/bindings/test/index.test.ts b/bindings/test/index.test.ts index 85a0247f3..acc100124 100644 --- a/bindings/test/index.test.ts +++ b/bindings/test/index.test.ts @@ -1,3 +1,4 @@ +import {ssz} from "@lodestar/types"; import {describe, expect, it} from "vitest"; describe("sanity", () => { From aac8aeae047872f82f33c63b4a52090b05523808 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 2 Jun 2026 22:47:14 +0800 Subject: [PATCH 38/46] getBeaconProposerOrNull --- bindings/napi/BeaconStateView.zig | 7 +++++++ bindings/src/index.d.ts | 9 +++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index d5d82c5f1..1a8859198 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -543,6 +543,13 @@ pub fn getBeaconProposer(self: *const BeaconStateView, slot_arg: js.Number) !js. return js.Number.from(proposer); } +pub fn getBeaconProposerOrNull(self: *const BeaconStateView, slot_arg: js.Number) !js.Value { + const cached_state = try self.requireState(); + const slot_value: u64 = @intCast(try slot_arg.toI64()); + const proposer = cached_state.getBeaconProposer(slot_value) catch return jsNull(); + return js_types.wrap(js.Value, js.Number.from(proposer).toValue()); +} + pub fn currentSyncCommittee(self: *const BeaconStateView) !js_types.SyncCommittee { const env = js.env(); const cached_state = try self.requireState(); diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 9fdf34024..60d66646f 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -258,6 +258,7 @@ export declare class BeaconStateView { currentProposers: number[]; nextProposers: number[]; getBeaconProposer(slot: number): number; + getBeaconProposerOrNull(slot: number): number | null; currentSyncCommittee: SyncCommittee; nextSyncCommittee: SyncCommittee; currentSyncCommitteeIndexed: SyncCommitteeCache; @@ -325,7 +326,7 @@ export declare class BeaconStateView { * and always 0 here since Zig STF doesn't process Gloas yet. */ getExpectedWithdrawals(): { - expectedWithdrawals: {index: number; validatorIndex: number; address: Uint8Array; amount: number}[]; + expectedWithdrawals: { index: number; validatorIndex: number; address: Uint8Array; amount: number }[]; processedBuilderWithdrawalsCount: number; processedPartialWithdrawalsCount: number; processedBuildersSweepCount: number; @@ -349,7 +350,7 @@ export declare class BeaconStateView { loadOtherState( stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array, - opts?: {preloadValidatorsAndBalances?: boolean} + opts?: { preloadValidatorsAndBalances?: boolean } ): BeaconStateView; loadOtherStateBench(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; // biome-ignore lint/suspicious/noExplicitAny: structurally a BeaconState (fork-narrowed), @@ -359,10 +360,10 @@ export declare class BeaconStateView { serialize(): Uint8Array; serializedSize(): number; /** Takes a `@chainsafe/ssz` ByteViews `{uint8Array, dataView}`; native uses `uint8Array` only. */ - serializeToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; + serializeToBytes(output: { uint8Array: Uint8Array; dataView: DataView }, offset: number): number; serializeValidators(): Uint8Array; serializedValidatorsSize(): number; - serializeValidatorsToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; + serializeValidatorsToBytes(output: { uint8Array: Uint8Array; dataView: DataView }, offset: number): number; hashTreeRoot(): Uint8Array; createMultiProof(descriptor: Uint8Array): CompactMultiProof; From 0d259868d3b38f141f558151cef8159ce7b3cc0b Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 11 Jun 2026 20:14:35 +0200 Subject: [PATCH 39/46] bindings(pubkeys): add reset api --- bindings/napi/pubkeys.zig | 12 ++++++++++++ bindings/src/pubkeys.d.ts | 2 ++ bindings/src/pubkeys.js | 5 +++++ bindings/test/pubkeys.test.ts | 12 ++++++++++++ 4 files changed, 31 insertions(+) diff --git a/bindings/napi/pubkeys.zig b/bindings/napi/pubkeys.zig index c85344947..f770165d4 100644 --- a/bindings/napi/pubkeys.zig +++ b/bindings/napi/pubkeys.zig @@ -31,6 +31,13 @@ pub const State = struct { self.index2pubkey.deinit(allocator); self.initialized = false; } + + pub fn reset(self: *State) !void { + if (!self.initialized) return; + + self.pubkey2index.clearRetainingCapacity(); + self.index2pubkey.shrinkRetainingCapacity(0); + } }; pub var state: State = .{}; @@ -151,6 +158,11 @@ pub fn load(file_path: js.String) !void { state.initialized = true; } +/// JS: pubkeys.reset() +pub fn reset() !void { + try state.reset(); +} + /// JS: pubkeys.getIndex(pubkeyBytes) → number | null pub fn getIndex(pubkey: js.Uint8Array) !js.Value { if (!state.initialized) return error.PubkeyIndexNotInitialized; diff --git a/bindings/src/pubkeys.d.ts b/bindings/src/pubkeys.d.ts index 27069a370..47fe65339 100644 --- a/bindings/src/pubkeys.d.ts +++ b/bindings/src/pubkeys.d.ts @@ -13,6 +13,8 @@ export interface PubkeyCache { readonly size: number; /** Load cache from a PKIX file (clears JS-level cache) */ load(filepath: string): void; + /** Clear native and JS-level cache contents */ + reset(): void; /** Save cache to a PKIX file */ save(filepath: string): void; /** Pre-allocate native capacity */ diff --git a/bindings/src/pubkeys.js b/bindings/src/pubkeys.js index 572ae55e0..140969fd9 100644 --- a/bindings/src/pubkeys.js +++ b/bindings/src/pubkeys.js @@ -44,6 +44,11 @@ export const pubkeyCache = { native.load(filepath); }, + reset() { + pkCache.clear(); + native.reset(); + }, + save(filepath) { native.save(filepath); }, diff --git a/bindings/test/pubkeys.test.ts b/bindings/test/pubkeys.test.ts index c285921b9..ae55e794a 100644 --- a/bindings/test/pubkeys.test.ts +++ b/bindings/test/pubkeys.test.ts @@ -80,4 +80,16 @@ describe("pubkeys", () => { const after = pubkeyCache.get(0); expect(before).not.toBe(after); }); + + it("reset clears native and JS-level cache", () => { + const before = pubkeyCache.get(0); + expect(before).toBeDefined(); + expect(pubkeyCache.getIndex(keypairs[0].pubkeyBytes)).toBeDefined(); + + pubkeyCache.reset(); + + expect(pubkeyCache.size).toBe(0); + expect(pubkeyCache.get(0)).toBeUndefined(); + expect(pubkeyCache.getIndex(keypairs[0].pubkeyBytes)).toBeNull(); + }); }); From 414714b3a21ea427683216e55608cba49559ffe2 Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 11 Jun 2026 20:14:35 +0200 Subject: [PATCH 40/46] remove stateTransition.zig we have stateTransition bound to BeaconStateView --- bindings/napi/stateTransition.zig | 71 ------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 bindings/napi/stateTransition.zig diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig deleted file mode 100644 index e84cf33ae..000000000 --- a/bindings/napi/stateTransition.zig +++ /dev/null @@ -1,71 +0,0 @@ -const std = @import("std"); -const zapi = @import("zapi:zapi"); -const js = zapi.js; -const napi = zapi.napi; -const builtin = @import("builtin"); -const fork_types = @import("fork_types"); -const st = @import("state_transition"); -const CachedBeaconState = st.CachedBeaconState; -const napi_io = @import("./io.zig"); -const AnySignedBeaconBlock = fork_types.AnySignedBeaconBlock; - -var gpa: std.heap.DebugAllocator(.{}) = .init; -const allocator = if (builtin.mode == .Debug) - gpa.allocator() -else - std.heap.c_allocator; - -const parseOptions = @import("./transition_opts.zig").parseOptions; - -/// Perform a state transition given a signed beacon block. -/// -/// Arguments: -/// - arg 0: BeaconStateView instance (the pre-state) -/// - arg 1: signed block bytes (Uint8Array) -/// - arg 2: options object (optional) with: -/// - verifyStateRoot: bool (default true) -/// - verifyProposer: bool (default true) -/// - verifySignatures: bool (default false) -/// - transferCache: bool (default true) -/// Returns: BeaconStateView (the post-state) -pub fn stateTransition( - pre_state_value: js.Value, - signed_block_bytes: js.Uint8Array, - options: ?js.Value, -) !js.Value { - const env = js.env(); - const pre_state = pre_state_value.toValue(); - const cached_state = try env.unwrap(CachedBeaconState, pre_state); - const transition_opts = try parseOptions(options); - const signed_block_bytes_slice = try signed_block_bytes.toSlice(); - - const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); - const fork = cached_state.config.forkSeqAtEpoch(current_epoch); - const signed_block = try AnySignedBeaconBlock.deserialize( - allocator, - .full, - fork, - signed_block_bytes_slice, - ); - defer signed_block.deinit(allocator); - - const post_state = try st.stateTransition( - allocator, - napi_io.get(), - cached_state, - signed_block, - transition_opts, - ); - errdefer { - post_state.deinit(); - allocator.destroy(post_state); - } - - const ctor = try pre_state.getNamedProperty("constructor"); - const new_state_value = try env.newInstance(ctor, .{}); - const dummy_state = try env.unwrap(CachedBeaconState, new_state_value); - dummy_state.* = post_state.*; - allocator.destroy(post_state); - - return .{ .val = new_state_value }; -} From e6e2b141f84515aa4a7e7d78aa1780d2b29b9f3b Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 11 Jun 2026 20:14:35 +0200 Subject: [PATCH 41/46] fix: run stf with block fork seq instead of cached --- bindings/napi/BeaconStateView.zig | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index eb61dcf5f..76001cec4 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -1265,17 +1265,25 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options: /// - arg 1: options (optional): parse `TransitionOpts` pub fn stateTransition(self: *const BeaconStateView, signed_block_bytes: js.Uint8Array, options: ?js.Value) !BeaconStateView { const cached_state = try self.requireState(); - const opts = try @import("./transition_opts.zig").parseOptions(options); - const current_epoch = st.computeEpochAtSlot(try cached_state.state.slot()); - const fork_seq = cached_state.config.forkSeqAtEpoch(current_epoch); const bytes = try signed_block_bytes.toSlice(); + + std.debug.assert(bytes.len >= 12); + const offset = std.mem.readInt(u32, bytes[0..4], .little); + const block_slot = std.mem.readInt(u64, bytes[offset..][0..8], .little); + const block_epoch = st.computeEpochAtSlot(block_slot); + + const fork_seq = cached_state.config.forkSeqAtEpoch(block_epoch); + const signed_block = try AnySignedBeaconBlock.deserialize(allocator, .full, fork_seq, bytes); defer signed_block.deinit(allocator); const post_state = try st.stateTransition(allocator, napi_io.get(), cached_state, signed_block, opts); - return .{ .cached_state = post_state }; + return .{ + .cached_state = post_state, + .pool_rc = pool.state.poolRc().ref(), + }; } /// Compute the anchor checkpoint and block header for the current state. From 038cf6305e1a4f55fdfb5dea33d6a0547f3a699d Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 11 Jun 2026 20:14:35 +0200 Subject: [PATCH 42/46] remove export stateTransition --- bindings/napi/root.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/bindings/napi/root.zig b/bindings/napi/root.zig index 0e5f14294..5cd6a8d38 100644 --- a/bindings/napi/root.zig +++ b/bindings/napi/root.zig @@ -5,7 +5,6 @@ pub const pool = @import("./pool.zig"); pub const shuffle = @import("./shuffle.zig"); pub const config = @import("./config.zig"); pub const metrics = @import("./metrics.zig"); -pub const stateTransition = @import("./stateTransition.zig"); pub const BeaconStateView = @import("./BeaconStateView.zig"); pub const blst = @import("./blst.zig"); pub const pubkeys = @import("./pubkeys.zig"); From 4753bb1132c1d47d6341c8011d528d49952bd2b7 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 16:37:20 +0200 Subject: [PATCH 43/46] fix: various fixes in config `config.zig` was broken in various places: - `getValueUint64()` is a non-existent API, this shouldn't even have been merged (i was the reviewer so it's my bad) - we were unnecessarily using an allocator when we could've just had fixed sized buffers for `config_name` and `blob_schedule`, both of which probably won't change that frequently anyway --- bindings/napi/config.zig | 185 +++++++++++++++++++---------------- bindings/test/config.test.ts | 16 +++ 2 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 bindings/test/config.test.ts diff --git a/bindings/napi/config.zig b/bindings/napi/config.zig index e22e13b78..bd34f7810 100644 --- a/bindings/napi/config.zig +++ b/bindings/napi/config.zig @@ -5,14 +5,19 @@ const active_preset = @import("preset").active_preset; const c = @import("config"); const BeaconConfig = @import("config").BeaconConfig; const ChainConfig = @import("config").ChainConfig; +const Preset = @import("preset").Preset; -/// Allocator for internal allocations. -/// Creating ChainConfigs allocate memory for certain fields. -const allocator = std.heap.page_allocator; +const max_blob_schedule_entries = 16; pub const State = struct { config: BeaconConfig = undefined, initialized: bool = false, + config_name: [64]u8 = undefined, + blob_schedule: [max_blob_schedule_entries]ChainConfig.BlobScheduleEntry = + [_]ChainConfig.BlobScheduleEntry{.{ + .EPOCH = 0, + .MAX_BLOBS_PER_BLOCK = 0, + }} ** max_blob_schedule_entries, pub fn init(self: *State) void { if (self.initialized) return; @@ -22,33 +27,35 @@ pub const State = struct { .minimal => self.config = c.minimal.config, .gnosis => self.config = c.chiado.config, } + self.initialized = true; } pub fn deinit(self: *State) void { if (!self.initialized) return; - // Free any allocated fields in config here - inline for (std.meta.fields(ChainConfig)) |field| { - switch (field.type) { - []const u8 => allocator.free(@field(self.config.chain, field.name)), - []ChainConfig.BlobScheduleEntry => allocator.free(@field(self.config.chain, field.name)), - else => {}, - } - } - self.initialized = false; } }; pub var state: State = .{}; +fn valueToU64(value: napi.Value) !u64 { + const num = try value.getValueDouble(); + if (std.math.isPositiveInf(num)) { + return std.math.maxInt(u64); + } + if (!std.math.isFinite(num) or num < 0 or num > @as(f64, @floatFromInt(std.math.maxInt(u64)))) { + return error.InvalidChainConfigFieldValue; + } + return @intFromFloat(num); +} + /// JS: config.set(chainConfigObj, genesisValidatorsRoot) pub fn set(object: js.Value, genesis_root: js.Uint8Array) !void { if (!state.initialized) { return error.ConfigNotInitialized; } - // Drop to low-level for the complex object parsing. const chain_config = try chainConfigFromObject(js.env(), try object.toValue().coerceToObject()); const root_slice = try genesis_root.toSlice(); @@ -66,80 +73,94 @@ pub fn set(object: js.Value, genesis_root: js.Uint8Array) !void { pub fn chainConfigFromObject(env: napi.Env, obj: napi.Value) !ChainConfig { var chain_config: ChainConfig = undefined; + inline for (std.meta.fields(ChainConfig)) |field| { - const field_value = obj.getNamedProperty(field.name) catch |err| { + const field_value: napi.Value = obj.getNamedProperty(field.name) catch |err| { try env.throwError(@errorName(err), "Missing field " ++ field.name); return error.PendingException; }; - switch (field.type) { - u64 => { - const num = try field_value.getValueInt64(); - // TODO check for infinity - @field(chain_config, field.name) = num; - }, - u256 => { - var sign_bit: u1 = 0; - var words_buf: [4]u64 = undefined; - const words = try field_value.getValueBigintWords(&sign_bit, &words_buf); - if (sign_bit != 0) { - return error.InvalidChainConfigFieldValue; - } - var num_u256: u256 = 0; - for (0..4) |i| { - num_u256 |= u256(words[i]) << (@as(u256, i) * 64); - } - @field(chain_config, field.name) = num_u256; - }, - [4]u8 => { - const typedarray_info = try field_value.getTypedarrayInfo(); - if (typedarray_info.data.len != 4) { - return error.InvalidVersionLength; - } - var version: [4]u8 = undefined; - @memcpy(&version, typedarray_info.data); - @field(chain_config, field.name) = version; - }, - [20]u8 => { - const typedarray_info = try field_value.getTypedarrayInfo(); - if (typedarray_info.data.len != 20) { - return error.InvalidAddressLength; - } - var address: [20]u8 = undefined; - @memcpy(&address, typedarray_info.data); - @field(chain_config, field.name) = address; - }, - [32]u8 => { - const typedarray_info = try field_value.getTypedarrayInfo(); - if (typedarray_info.data.len != 32) { - return error.InvalidRootLength; - } - var root: [32]u8 = undefined; - @memcpy(&root, typedarray_info.data); - @field(chain_config, field.name) = root; - }, - []const u8 => { - var str_buf: [64]u8 = undefined; - const str = try field_value.getValueStringUtf8(&str_buf); - @field(chain_config, field.name) = try allocator.dupe(u8, str); - }, - []ChainConfig.BlobScheduleEntry => { - const array_length = try field_value.getArrayLength(); - const blob_schedule = try allocator.alloc(c.BlobScheduleEntry, array_length); - errdefer allocator.free(blob_schedule); - - for (0..array_length) |i| { - const entry_value = try field_value.getElement(i); - const epoch_value = try entry_value.getNamedProperty("EPOCH"); - const max_blobs_value = try entry_value.getNamedProperty("MAX_BLOBS_PER_BLOCK"); - - blob_schedule[i] = c.BlobScheduleEntry{ - .EPOCH = try epoch_value.getValueUint64(), - .MAX_BLOBS_PER_BLOCK = try max_blobs_value.getValueUint64(), + + if (try field_value.typeof() == .undefined) { + std.log.warn("missing field value for: {s}, skipping\n", .{field.name}); + } else { + switch (field.type) { + Preset => { + var str_buf: [16]u8 = undefined; + const preset_str = try field_value.getValueStringUtf8(&str_buf); + @field(chain_config, field.name) = + if (std.mem.eql(u8, preset_str, "mainnet")) + .mainnet + else if (std.mem.eql(u8, preset_str, "minimal")) + .minimal + else if (std.mem.eql(u8, preset_str, "gnosis")) + .gnosis + else + return error.InvalidPreset; + }, + u64 => @field(chain_config, field.name) = try valueToU64(field_value), + u256 => { + var str_buf: [128]u8 = undefined; + const str = try (try field_value.coerceToString()).getValueStringUtf8(&str_buf); + @field(chain_config, field.name) = std.fmt.parseInt(u256, str, 10) catch { + return error.InvalidChainConfigFieldValue; }; - } - @field(chain_config, field.name) = blob_schedule; - }, - else => return error.UnsupportedChainConfigFieldType, + }, + [4]u8 => { + const typedarray_info = try field_value.getTypedarrayInfo(); + if (typedarray_info.data.len != 4) { + return error.InvalidVersionLength; + } + var version: [4]u8 = undefined; + @memcpy(&version, typedarray_info.data); + @field(chain_config, field.name) = version; + }, + [20]u8 => { + const typedarray_info = try field_value.getTypedarrayInfo(); + if (typedarray_info.data.len != 20) { + return error.InvalidAddressLength; + } + var address: [20]u8 = undefined; + @memcpy(&address, typedarray_info.data); + @field(chain_config, field.name) = address; + }, + [32]u8 => { + const typedarray_info = try field_value.getTypedarrayInfo(); + if (typedarray_info.data.len != 32) { + return error.InvalidRootLength; + } + var root: [32]u8 = undefined; + @memcpy(&root, typedarray_info.data); + @field(chain_config, field.name) = root; + }, + []const u8 => { + _ = try field_value.getValueStringUtf8(&state.config_name); + if (comptime std.mem.eql(u8, field.name, "CONFIG_NAME")) { + @field(chain_config, field.name) = &state.config_name; + } else { + @compileError("unsupported field: " ++ field.name); + } + }, + []const ChainConfig.BlobScheduleEntry => { + const array_length: usize = @intCast(try field_value.getArrayLength()); + if (array_length > max_blob_schedule_entries) { + return error.BlobScheduleTooLong; + } + + for (0..array_length) |i| { + const entry_value = try field_value.getElement(@intCast(i)); + const epoch_value = try entry_value.getNamedProperty("EPOCH"); + const max_blobs_value = try entry_value.getNamedProperty("MAX_BLOBS_PER_BLOCK"); + + const blob_schedule_entry = ChainConfig.BlobScheduleEntry{ + .EPOCH = try valueToU64(epoch_value), + .MAX_BLOBS_PER_BLOCK = try valueToU64(max_blobs_value), + }; + state.blob_schedule[i] = blob_schedule_entry; + } + @field(chain_config, field.name) = state.blob_schedule[0..array_length]; + }, + else => return error.UnsupportedChainConfigFieldType, + } } } return chain_config; diff --git a/bindings/test/config.test.ts b/bindings/test/config.test.ts new file mode 100644 index 000000000..cdc72314b --- /dev/null +++ b/bindings/test/config.test.ts @@ -0,0 +1,16 @@ +import {createChainForkConfig} from "@lodestar/config"; +import {mainnetChainConfig} from "@lodestar/config/configs"; +import {networksChainConfig} from "@lodestar/config/networks"; +import {describe, expect, it} from "vitest"; +import bindings from "../src/index.js"; + +describe("config parses JS object config into zig native config", () => { + for (const [name, chainConfig] of Object.entries(networksChainConfig)) { + if (chainConfig.PRESET_BASE !== mainnetChainConfig.PRESET_BASE) continue; + + it(`sets ${name}`, () => { + const config = createChainForkConfig(chainConfig); + expect(() => bindings.config.set(config, new Uint8Array(32))).not.toThrow(); + }); + } +}); From c1d7bd7d86971997a5ef86a6c745f93e74341d4a Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 17:05:05 +0200 Subject: [PATCH 44/46] fmt lint --- bindings/test/beaconStateView.test.ts | 14 ++++++-------- bindings/test/index.test.ts | 1 - 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index df6a06798..eb87e1cf5 100644 --- a/bindings/test/beaconStateView.test.ts +++ b/bindings/test/beaconStateView.test.ts @@ -1,3 +1,4 @@ +import {SecretKey} from "@chainsafe/blst"; import {config} from "@lodestar/config/default"; import * as era from "@lodestar/era"; import {computeEpochAtSlot} from "@lodestar/state-transition"; @@ -5,14 +6,11 @@ import {ssz} from "@lodestar/types"; import {beforeAll, describe, expect, it} from "vitest"; import bindings from "../src/index.js"; import {getFirstEraFilePath} from "./eraFiles.ts"; -import {SecretKey} from "@chainsafe/blst"; // TODO(bing): it's kinda annoying to have to do this, i guess we // expose the config somehow maybe? /* Mainnet preset constants the binding is compiled against. */ -const SLOTS_PER_EPOCH = 32; const SYNC_COMMITTEE_SIZE = 512; -const BELLATRIX_FORK_EPOCH = 144896; const FAR_FUTURE_EPOCH = Number.MAX_SAFE_INTEGER; const MAX_EFFECTIVE_BALANCE = 32_000_000_000; @@ -34,14 +32,14 @@ function makeValidators(count: number): Validator[] { const seed = new Uint8Array(32); new DataView(seed.buffer).setUint32(0, i + 1); return { - pubkey: SecretKey.fromKeygen(seed).toPublicKey().toBytes(), - withdrawalCredentials: new Uint8Array(32), - effectiveBalance: MAX_EFFECTIVE_BALANCE, - slashed: false, activationEligibilityEpoch: 0, activationEpoch: 0, + effectiveBalance: MAX_EFFECTIVE_BALANCE, exitEpoch: FAR_FUTURE_EPOCH, + pubkey: SecretKey.fromKeygen(seed).toPublicKey().toBytes(), + slashed: false, withdrawableEpoch: FAR_FUTURE_EPOCH, + withdrawalCredentials: new Uint8Array(32), }; }); } @@ -331,8 +329,8 @@ describe("BeaconStateView", () => { (_, i) => validators[i % VALIDATOR_COUNT].pubkey ); const syncCommittee = { - pubkeys: syncCommitteePubkeys, aggregatePubkey: validators[0].pubkey, + pubkeys: syncCommitteePubkeys, }; const phase0State = ssz.phase0.BeaconState.defaultValue(); diff --git a/bindings/test/index.test.ts b/bindings/test/index.test.ts index acc100124..85a0247f3 100644 --- a/bindings/test/index.test.ts +++ b/bindings/test/index.test.ts @@ -1,4 +1,3 @@ -import {ssz} from "@lodestar/types"; import {describe, expect, it} from "vitest"; describe("sanity", () => { From 6e417d95733dace10c90c228365774cabb4db61f Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 17:06:23 +0200 Subject: [PATCH 45/46] zig fmt --- bindings/src/index.d.ts | 3 --- src/state_transition/weak_subjectivity.zig | 1 - 2 files changed, 4 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 450ea02b2..bc45006e9 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -367,9 +367,6 @@ export declare class BeaconStateView { hashTreeRoot(): Uint8Array; createMultiProof(descriptor: Uint8Array): CompactMultiProof; - // biome-ignore lint/suspicious/noExplicitAny: Note that signed block bytes are passed as Uint8Array at runtime; signature is loosened so it satisfies `IBeaconStateView.stateTransition(block, opts, modules)` structurally. - // TODO(bing): fix types - stateTransition(signedBlock: any, options?: any, modules?: any): BeaconStateView; processSlots(slot: number, options?: ProcessSlotsOpts): BeaconStateView; } diff --git a/src/state_transition/weak_subjectivity.zig b/src/state_transition/weak_subjectivity.zig index 2285fef49..8094e97e1 100644 --- a/src/state_transition/weak_subjectivity.zig +++ b/src/state_transition/weak_subjectivity.zig @@ -188,4 +188,3 @@ test "computeWeakSubjectivityPeriodFromConstituentsElectra - mainnet table" { try std.testing.expectEqual(c.ws_period, got); } } - From 6dac2610290c9612725e06976c39e77878794e78 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 18:06:46 +0200 Subject: [PATCH 46/46] chore: no-op when syncPubkeys run on a pk cache with shrinking validator set --- src/state_transition/cache/pubkey_cache.zig | 32 +++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/state_transition/cache/pubkey_cache.zig b/src/state_transition/cache/pubkey_cache.zig index 652c4d794..08b187ae2 100644 --- a/src/state_transition/cache/pubkey_cache.zig +++ b/src/state_transition/cache/pubkey_cache.zig @@ -25,7 +25,7 @@ pub fn syncPubkeys( } const new_count = validators.len; - if (new_count == old_len) { + if (new_count <= old_len) { return; } @@ -77,7 +77,7 @@ pub fn syncPubkeysParallel( } const new_count = validators.len; - if (new_count == old_len) { + if (new_count <= old_len) { return; } @@ -209,6 +209,34 @@ test "syncPubkeys no-op when already synced" { try testing.expectEqual(@as(usize, count), index_to_pubkey.items.len); } +test "syncPubkeys no-op when validator count shrinks" { + const allocator = testing.allocator; + const initial_count = 4; + const shrunk_count = 2; + + var pubkeys: [initial_count]types.primitive.BLSPubkey.Type = undefined; + try interop.interopPubkeysCached(initial_count, &pubkeys); + + var validators: [initial_count]Validator = undefined; + var validator_ptrs: [initial_count]*const Validator = undefined; + for (0..initial_count) |i| { + validators[i] = std.mem.zeroes(Validator); + validators[i].pubkey = pubkeys[i]; + validator_ptrs[i] = &validators[i]; + } + + var pubkey_to_index = PubkeyIndexMap.init(allocator); + defer pubkey_to_index.deinit(); + var index_to_pubkey: Index2PubkeyCache = .empty; + defer index_to_pubkey.deinit(allocator); + + try syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, validator_ptrs[0..shrunk_count], &pubkey_to_index, &index_to_pubkey); + + try testing.expectEqual(@as(usize, initial_count), index_to_pubkey.items.len); + try testing.expectEqual(@as(u32, initial_count), pubkey_to_index.count()); +} + test "syncPubkeys detects inconsistent cache" { const allocator = testing.allocator;