From 0f066780e45f51df09cbf418d9412f72858fb7da Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 14 May 2026 13:21:33 +0200 Subject: [PATCH 01/46] chore: remove merge transition code --- .../block/process_blob_kzg_commitments.zig | 7 ++--- .../block/process_execution_payload.zig | 4 +-- src/state_transition/state_transition.zig | 1 - src/state_transition/utils/execution.zig | 26 ------------------- 4 files changed, 3 insertions(+), 35 deletions(-) diff --git a/src/state_transition/block/process_blob_kzg_commitments.zig b/src/state_transition/block/process_blob_kzg_commitments.zig index ee1feee7d..492fab540 100644 --- a/src/state_transition/block/process_blob_kzg_commitments.zig +++ b/src/state_transition/block/process_blob_kzg_commitments.zig @@ -1,11 +1,8 @@ const BlockExternalData = @import("../state_transition.zig").BlockExternalData; pub fn processBlobKzgCommitments(external_data: BlockExternalData) !void { - switch (external_data.execution_payload_status) { - .pre_merge => return error.ExecutionPayloadStatusPreMerge, - .invalid => return error.InvalidExecutionPayload, - // ok - else => {}, + if (external_data.execution_payload_status == .invalid) { + return error.InvalidExecutionPayload; } } diff --git a/src/state_transition/block/process_execution_payload.zig b/src/state_transition/block/process_execution_payload.zig index bb4f3dd0d..cd9b7e872 100644 --- a/src/state_transition/block/process_execution_payload.zig +++ b/src/state_transition/block/process_execution_payload.zig @@ -75,9 +75,7 @@ pub fn processExecutionPayload( // the state transition sync // // Equivalent to `assert executionEngine.notifyNewPayload(payload) - if (external_data.execution_payload_status == .pre_merge) { - return error.ExecutionPayloadStatusPreMerge; - } else if (external_data.execution_payload_status == .invalid) { + if (external_data.execution_payload_status == .invalid) { return error.InvalidExecutionPayload; } diff --git a/src/state_transition/state_transition.zig b/src/state_transition/state_transition.zig index 1115c41cb..c4238a364 100644 --- a/src/state_transition/state_transition.zig +++ b/src/state_transition/state_transition.zig @@ -31,7 +31,6 @@ const upgradeStateToElectra = @import("slot/upgrade_state_to_electra.zig").upgra const upgradeStateToFulu = @import("slot/upgrade_state_to_fulu.zig").upgradeStateToFulu; pub const ExecutionPayloadStatus = enum(u8) { - pre_merge, invalid, valid, }; diff --git a/src/state_transition/utils/execution.zig b/src/state_transition/utils/execution.zig index 438b94756..9affacea2 100644 --- a/src/state_transition/utils/execution.zig +++ b/src/state_transition/utils/execution.zig @@ -23,32 +23,6 @@ pub fn isExecutionEnabled(comptime fork: ForkSeq, state: *BeaconState(fork), com } } -pub fn isMergeTransitionBlock( - comptime fork: ForkSeq, - state: *BeaconState(fork), - comptime block_type: BlockType, - body: *const BeaconBlockBody(block_type, fork), -) bool { - if (comptime fork != .bellatrix) { - return false; - } - - if (isMergeTransitionComplete(fork, state)) { - return false; - } - - return switch (block_type) { - .full => !ForkTypes(fork).ExecutionPayload.equals( - &body.executionPayload().inner, - &ForkTypes(fork).ExecutionPayload.default_value, - ), - .blinded => !ForkTypes(fork).ExecutionPayloadHeader.equals( - &body.executionPayloadHeader().inner, - &ForkTypes(fork).ExecutionPayloadHeader.default_value, - ), - }; -} - pub fn isMergeTransitionComplete(comptime fork: ForkSeq, state: *BeaconState(fork)) bool { if (comptime fork.lt(.bellatrix)) { return false; From 599dfdb95429fa611db664b1f801d0bf7c8f7145 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Thu, 14 May 2026 22:15:44 +0200 Subject: [PATCH 02/46] fix: adapt bindings --- bindings/napi/stateTransition.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig index 8db5d6c76..0e761c887 100644 --- a/bindings/napi/stateTransition.zig +++ b/bindings/napi/stateTransition.zig @@ -20,7 +20,7 @@ else /// Recognized fields: /// - verifyStateRoot, verifyProposer, verifySignatures: bool /// - dontTransferCache: bool (negated to set transfer_cache) -/// - executionPayloadStatus: "valid" | "invalid" | "preMerge" +/// - executionPayloadStatus: "valid" | "invalid" /// - dataAvailabilityStatus: "Available" | "PreData" | "OutOfRange" /// /// This is the double negative version to conform with production lodestar. @@ -50,8 +50,6 @@ fn parseOptions(options: ?js.Value) !st.TransitionOpts { .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; } From 940f16cf408ddc88ddbe1b606ccdaa012acb001c Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 16 May 2026 01:13:31 +0800 Subject: [PATCH 03/46] fix(build): remove `zig-out` from `files` (#360) In zapi, [local path takes preference](https://github.com/ChainSafe/zapi/blob/e522fa4beb5eacfb8ee0f9a964b435a88bb8f2ba/ts/lib.ts#L218) so we want to not publish with that entry under `files`, otherwise `lodestar` will attempt to load an architecture incompatible library on `dlopen`. Locally testing after pulling from a published `lodestar-z` is failing because my machine (aarch64-apple-darwin) is trying to load from an x86_64-linux binary, so we'd need to re-publish after we fix this --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index ea8695584..d573daf79 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "version": "0.1.2-rc.1", "description": "Lodestar-z NAPI bindings", "files": [ - "bindings/src/", - "zig-out/lib/" + "bindings/src/" ], "type": "module", "exports": { From 675215d238cdaa5677240cb9a720251f1cb61c26 Mon Sep 17 00:00:00 2001 From: bing Date: Sun, 17 May 2026 00:53:41 +0800 Subject: [PATCH 04/46] feat(blst): use external buffers for blst operations (#358) Depends on ChainSafe/zapi#30 (we need to release zapi and update the dep) This is one of possible likely causes for increased GC pressure on experiments to swap out blst-ts for lodestar-z/bls, as observed on feat2 and feat3 deployments in [this PR](https://github.com/ChainSafe/lodestar/pull/9342/). With external array buffers, V8 is only aware of the pointer to the backing memory, instead of having to track both the pointer and the backing memory. This means that during marking phase the GC does not have to walk the backing memory to mark it as 'live' - the frequency of the GC firing off is still the same, but each cycle does less work. This of course comes with a tradeoff, we need a **finalizer** to let V8 know how much external memory is in native heap so that the GC tells the native impl to free the useless memory. Though, regardless of the effect, we should still probably do this anyway, since [napi-rs does the same](https://github.com/napi-rs/napi-rs/blob/159395b365c583a6642ad481edc5708d9f36a24b/crates/napi/src/bindgen_runtime/js_values/arraybuffer.rs#L175), and only defaults to V8 managed array buffers if it is disallowed (like in Electron). --- bindings/napi/blst.zig | 12 ++++++------ build.zig.zon | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 8a1384f8e..5a75b7670 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -136,10 +136,10 @@ pub const PublicKey = struct { pub fn toBytes(self: *const PublicKey, compress: ?js.Boolean) !js.Uint8Array { if (try boolOrDefault(compress, true)) { const bytes = self.raw.compress(); - return js.Uint8Array.from(bytes[0..]); + return js.Uint8Array.fromExternal(bytes[0..]); } const bytes = self.raw.serialize(); - return js.Uint8Array.from(bytes[0..]); + return js.Uint8Array.fromExternal(bytes[0..]); } pub fn toHex(self: *const PublicKey, compress: ?js.Boolean) !js.String { @@ -214,10 +214,10 @@ pub const Signature = struct { pub fn toBytes(self: *const Signature, compress: ?js.Boolean) !js.Uint8Array { if (try boolOrDefault(compress, true)) { const bytes = self.raw.compress(); - return js.Uint8Array.from(bytes[0..]); + return js.Uint8Array.fromExternal(bytes[0..]); } const bytes = self.raw.serialize(); - return js.Uint8Array.from(bytes[0..]); + return js.Uint8Array.fromExternal(bytes[0..]); } pub fn toHex(self: *const Signature, compress: ?js.Boolean) !js.String { @@ -295,9 +295,9 @@ pub const SecretKey = struct { } /// Serializes the SecretKey to bytes (32 bytes). - pub fn toBytes(self: *const SecretKey) js.Uint8Array { + pub fn toBytes(self: *const SecretKey) !js.Uint8Array { const bytes = self.raw.serialize(); - return js.Uint8Array.from(bytes[0..]); + return js.Uint8Array.fromExternal(bytes[0..]); } pub fn toHex(self: *const SecretKey) !js.String { diff --git a/build.zig.zon b/build.zig.zon index 29ace6b05..d7a5737d9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "zig_yaml-0.1.0-C1161kFWAwDxjKAFmklKwWVDvz2mmq0Q__bDhGGjeyd3", }, .zapi = .{ - .url = "git+https://github.com/chainsafe/zapi?ref=zapi-v2.0.0#f9fa8b0237352326e9f970b62588b6af01e3e384", - .hash = "zapi-2.0.0-rIqzUcc3BABKRuJlzpYNtiMVCOFybuVSGOLmk0KOCeel", + .url = "git+https://github.com/chainsafe/zapi?ref=zapi-v2.1.0#c5c877af9742d9d7fd6cab2ce6fec698817d56cd", + .hash = "zapi-2.1.0-rIqzUbxNBADOW16nSYQfUOtib1TgC8PxbR4ggN6ezYfA", }, .zbench = .{ .url = "git+https://github.com/hendriknielaender/zBench#b2b89c475e3ef1bb2bd71255c80478a82d3e0ca8", From 8fbf38fa00b044f28b9c41670492ad1eb91ff598 Mon Sep 17 00:00:00 2001 From: bing Date: Sun, 17 May 2026 01:26:39 +0800 Subject: [PATCH 05/46] chore(release): 0.1.2-rc.2 (#365) for testing signature validation + new zapi externalBuffer api --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d573daf79..7d6406e66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.1", + "version": "0.1.2-rc.2", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/" From 3ce439095a5de8442f70b2b95a502418d6bb1370 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Sun, 17 May 2026 18:24:28 +0800 Subject: [PATCH 06/46] feat: implement `loadState` API and binding (#165) **Motivation** It need `loadState` API to integrate state-transition-z to lodestar **Description** - Implement `loadState()` migration semantics aligned with Lodestar: migrate a new BeaconState from SSZ bytes using a seed state, returning the migrated state plus the list of modified validator indices. - Optimize validator/inactivity_scores handling by reusing seed subtrees where bytes are unchanged, and by computing modified indices via recursive byte-level diff (avoids full SSZ decoding for comparison). Fix #159 --- bindings/napi/BeaconStateView.zig | 24 + bindings/perf/loadState.test.ts | 54 ++ bindings/src/index.d.ts | 1 + src/fork_types/any_beacon_state.zig | 7 + src/state_transition/load_state.zig | 968 +++++++++++++++++++++++++ src/state_transition/root.zig | 5 + src/state_transition/ssz_bytes.zig | 30 + src/state_transition/ssz_container.zig | 88 +++ 8 files changed, 1177 insertions(+) create mode 100644 bindings/perf/loadState.test.ts create mode 100644 src/state_transition/load_state.zig create mode 100644 src/state_transition/ssz_bytes.zig create mode 100644 src/state_transition/ssz_container.zig diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 9d250bef1..2608bd302 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -858,6 +858,30 @@ pub fn createdWithTransferCache(self: *const BeaconStateView) !js.Boolean { // pub fn BeaconStateView_loadOtherState +/// Bench-only: run loadState end-to-end and tear down. Mirrors what TS's +/// loadState measures (no CachedBeaconState wrap, no EpochCache build) so +/// native vs TS comparisons isolate the SSZ tree-rebuild cost. +pub fn loadOtherStateBench( + self: *const BeaconStateView, + state_bytes: js.Uint8Array, + seed_validators_bytes: ?js.Uint8Array, +) !void { + const 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 result = try st.loadState( + allocator, + cached_state.config, + cached_state.state, + state_bytes_slice, + seed_validators_bytes_slice, + ); + allocator.free(result.modified_validators); + result.state.deinit(); +} + pub fn serialize(self: *const BeaconStateView) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); diff --git a/bindings/perf/loadState.test.ts b/bindings/perf/loadState.test.ts new file mode 100644 index 000000000..b87b73e7c --- /dev/null +++ b/bindings/perf/loadState.test.ts @@ -0,0 +1,54 @@ +import {bench, describe} from "@chainsafe/benchmark"; +import {config} from "@lodestar/config/default"; +import * as era from "@lodestar/era"; +import {loadState as loadStateTS} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import bindings from "../src/index.js"; +import {getFirstEraFilePath} from "../test/eraFiles.ts"; + +const reader = await era.era.EraReader.open(config, getFirstEraFilePath()); +const stateBytes = await reader.readSerializedState(); +await reader.close(); + +bindings.pool.ensureCapacity(10_000_000); +bindings.pubkeys.ensureCapacity(2_000_000); +try { + bindings.pubkeys.load("./mainnet.pkix"); +} catch (_e) { + // ignore +} + +const seedState = bindings.BeaconStateView.createFromBytes(stateBytes); +const seedValidatorsBytes = seedState.serializeValidators(); + +const tsSeedState = ssz.fulu.BeaconState.deserializeToViewDU(stateBytes); + +describe("loadState: native vs TS (mainnet)", () => { + bench({ + fn: () => { + seedState.loadOtherStateBench(stateBytes); + }, + id: "native (internal serialize seed)", + }); + + bench({ + fn: () => { + loadStateTS(config, tsSeedState, stateBytes); + }, + id: "TS (internal serialize seed)", + }); + + bench({ + fn: () => { + seedState.loadOtherStateBench(stateBytes, seedValidatorsBytes); + }, + id: "native (prebuilt seedValidatorsBytes)", + }); + + bench({ + fn: () => { + loadStateTS(config, tsSeedState, stateBytes, seedValidatorsBytes); + }, + id: "TS (prebuilt seedValidatorsBytes)", + }); +}); diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index 724e8ee79..deb72cac9 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -231,6 +231,7 @@ declare class BeaconStateView { // isStateValidatorsNodesPopulated(): boolean; // loadOtherState(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; + loadOtherStateBench(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; serialize(): Uint8Array; serializedSize(): number; serializeToBytes(output: Uint8Array, offset: number): number; diff --git a/src/fork_types/any_beacon_state.zig b/src/fork_types/any_beacon_state.zig index 7ad55022e..ad8603bc7 100644 --- a/src/fork_types/any_beacon_state.zig +++ b/src/fork_types/any_beacon_state.zig @@ -216,6 +216,13 @@ pub const AnyBeaconState = union(ForkSeq) { return std.meta.activeTag(self.*); } + /// Underlying persistent merkle tree pool, regardless of fork variant. + pub fn nodePool(self: *AnyBeaconState) *Node.Pool { + return switch (self.*) { + inline else => |state| state.pool, + }; + } + // pub fn castFromFork(comptime f: ForkSeq, ) pub fn castToFork(self: *AnyBeaconState, comptime f: ForkSeq) *BeaconState(f) { diff --git a/src/state_transition/load_state.zig b/src/state_transition/load_state.zig new file mode 100644 index 000000000..b4cc1e8db --- /dev/null +++ b/src/state_transition/load_state.zig @@ -0,0 +1,968 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const types = @import("consensus_types"); +const Node = @import("persistent_merkle_tree").Node; +const Gindex = @import("persistent_merkle_tree").Gindex; +const ForkSeq = @import("config").ForkSeq; +const BeaconConfig = @import("config").BeaconConfig; +const AnyBeaconState = @import("fork_types").AnyBeaconState; +const ForkTypes = @import("fork_types").ForkTypes; + +const ssz_bytes = @import("ssz_bytes.zig"); +const ssz_container = @import("ssz_container.zig"); + +const ValidatorIndex = types.primitive.ValidatorIndex.Type; + +/// Inactivity score is `uint64` (8 bytes). +const INACTIVITY_SCORE_SIZE: usize = types.primitive.Uint64.fixed_size; + +// BeaconState field indices are stable across forks. +const BEACON_STATE_VALIDATORS_FIELD_INDEX: usize = types.phase0.BeaconState.getFieldIndex("validators"); +const BEACON_STATE_INACTIVITY_SCORES_FIELD_INDEX: usize = types.altair.BeaconState.getFieldIndex("inactivity_scores"); + +pub const MigrateStateOutput = struct { + state: AnyBeaconState, + modified_validators: []ValidatorIndex, +}; + +/// Load BeaconState from SSZ bytes using a seed state to reuse unchanged subtrees. +/// +/// This avoids full deserialization for large fields (validators/inactivity_scores) +/// by diffing bytes and reusing the seed state's TreeView nodes. +/// +/// Returns the migrated state and indices of modified validators. +/// +/// Errors are propagated from SSZ parsing and tree operations when bytes are invalid. +pub fn loadState( + allocator: Allocator, + config: *const BeaconConfig, + seed_state: *AnyBeaconState, + state_bytes: []const u8, + seed_validators_bytes: ?[]const u8, +) !MigrateStateOutput { + const fork = try ssz_bytes.getForkFromStateBytes(config, state_bytes); + const seed_fork = config.forkSeq(try seed_state.slot()); + const pool = seed_state.nodePool(); + + return switch (fork) { + inline else => |f| try loadStateForFork(allocator, pool, f, seed_fork, seed_state, ForkTypes(f).BeaconState, state_bytes, seed_validators_bytes), + }; +} + +fn deserializeBeaconStateTreeViewWithSeedOverrides( + allocator: Allocator, + pool: *Node.Pool, + comptime out_fork: ForkSeq, + seed_fork: ForkSeq, + seed_state: *AnyBeaconState, + comptime StateST: type, + state_bytes: []const u8, + ranges: *const [StateST.fields.len][2]usize, + seed_validators_node: Node.Id, +) !*StateST.TreeView { + if (comptime out_fork.gte(.altair)) { + const scores_field_index = comptime StateST.getFieldIndex("inactivity_scores"); + const scores_range = ranges[scores_field_index]; + const inactivity_scores_bytes = state_bytes[scores_range[0]..scores_range[1]]; + const ScoresType = comptime StateST.getFieldType("inactivity_scores"); + + // If the seed fork is pre-altair, there are no scores to reuse, so we fully + // deserialize inactivity_scores here (diff optimization only applies when seed_fork >= altair). + const scores_node = if (seed_fork.gte(.altair)) blk: { + break :blk try inactivityScoresNodeId(seed_state); + } else blk: { + const node_id = try ScoresType.tree.deserializeFromBytes(pool, inactivity_scores_bytes); + errdefer pool.unref(node_id); + + break :blk node_id; + }; + + return try ssz_container.deserializeContainerOverrideFieldsWithRanges( + allocator, + pool, + StateST, + state_bytes, + ranges, + .{ .validators = seed_validators_node, .inactivity_scores = scores_node }, + ); + } + + return try ssz_container.deserializeContainerOverrideFieldsWithRanges( + allocator, + pool, + StateST, + state_bytes, + ranges, + .{ .validators = seed_validators_node }, + ); +} + +fn loadStateForFork( + allocator: Allocator, + pool: *Node.Pool, + comptime out_fork: ForkSeq, + seed_fork: ForkSeq, + seed_state: *AnyBeaconState, + comptime StateST: type, + state_bytes: []const u8, + seed_validators_bytes: ?[]const u8, +) !MigrateStateOutput { + const ranges = try StateST.readFieldRanges(state_bytes); + + const validators_field_index = comptime StateST.getFieldIndex("validators"); + + const seed_validators_node = try validatorsNodeId(seed_state); + + const migrated_view = try deserializeBeaconStateTreeViewWithSeedOverrides( + allocator, + pool, + out_fork, + seed_fork, + seed_state, + StateST, + state_bytes, + &ranges, + seed_validators_node, + ); + errdefer migrated_view.deinit(); + + const validators_range = ranges[validators_field_index]; + const new_validators_bytes = state_bytes[validators_range[0]..validators_range[1]]; + const modified_validators = try loadValidators(allocator, StateST, migrated_view, pool, seed_validators_node, new_validators_bytes, seed_validators_bytes); + errdefer allocator.free(modified_validators); + + if (comptime out_fork.gte(.altair)) { + if (seed_fork.gte(.altair)) { + const scores_field_index = comptime StateST.getFieldIndex("inactivity_scores"); + const scores_range = ranges[scores_field_index]; + const inactivity_scores_bytes = state_bytes[scores_range[0]..scores_range[1]]; + const seed_scores_node = try inactivityScoresNodeId(seed_state); + try loadInactivityScores(allocator, StateST, migrated_view, pool, seed_scores_node, inactivity_scores_bytes); + } + } + + try migrated_view.commit(); + + const migrated_state = @unionInit(AnyBeaconState, @tagName(out_fork), migrated_view); + return .{ .state = migrated_state, .modified_validators = modified_validators }; +} + +/// Migrate the `inactivity_scores` list onto `migrated_view`, reusing the seed's subtree. +/// +/// Inactivity scores rarely change between two states (mostly 0 on mainnet), so reusing +/// the seed's unchanged score subtrees saves ~500ms of state hashTreeRoot time. +fn loadInactivityScores( + allocator: Allocator, + comptime StateST: type, + migrated_view: *StateST.TreeView, + pool: *Node.Pool, + seed_scores_node: Node.Id, + inactivity_scores_bytes: []const u8, +) !void { + if (inactivity_scores_bytes.len % INACTIVITY_SCORE_SIZE != 0) return error.InvalidSize; + + const seed_scores = try types.altair.InactivityScores.TreeView.init(allocator, pool, seed_scores_node); + defer seed_scores.deinit(); + + var migrated_scores = try seed_scores.clone(.{ .transfer_cache = false }); + errdefer migrated_scores.deinit(); + + const diff_ctx = try buildScoresDiffContext(allocator, migrated_scores, inactivity_scores_bytes); + defer allocator.free(diff_ctx.old_bytes); + + var modified_validators: std.ArrayList(ValidatorIndex) = .empty; + defer modified_validators.deinit(allocator); + + const old_scores_slice = if (diff_ctx.has_more_validators) + diff_ctx.old_bytes + else + diff_ctx.old_bytes[0 .. diff_ctx.min_validator_count * INACTIVITY_SCORE_SIZE]; + const new_scores_slice = if (diff_ctx.has_more_validators) + inactivity_scores_bytes[0 .. diff_ctx.min_validator_count * INACTIVITY_SCORE_SIZE] + else + inactivity_scores_bytes; + + try findModifiedInactivityScores(allocator, old_scores_slice, new_scores_slice, &modified_validators, 0); + try applyScoreDiffs(migrated_scores, inactivity_scores_bytes, modified_validators.items); + migrated_scores = try syncScoresLength(allocator, migrated_scores, inactivity_scores_bytes, diff_ctx.old_validator_count, diff_ctx.new_validator_count); + + try migrated_view.set("inactivity_scores", migrated_scores); +} + +/// Migrate the `validators` list onto `migrated_view`, reusing the seed's subtree +/// for unchanged validators and deserializing only the modified ones. +/// +/// Returns the absolute indices of validators that differ from the seed (caller owns the slice). +fn loadValidators( + allocator: Allocator, + comptime StateST: type, + migrated_view: *StateST.TreeView, + pool: *Node.Pool, + seed_validators_node: Node.Id, + new_validators_bytes: []const u8, + seed_state_validators_bytes: ?[]const u8, +) ![]ValidatorIndex { + if (new_validators_bytes.len % types.phase0.Validator.fixed_size != 0) return error.InvalidSize; + if (seed_state_validators_bytes) |bytes| { + if (bytes.len % types.phase0.Validator.fixed_size != 0) return error.InvalidSize; + } + + const seed_validators = try types.phase0.Validators.TreeView.init(allocator, pool, seed_validators_node); + defer seed_validators.deinit(); + + const seed_count = try seed_validators.length(); + const new_count = new_validators_bytes.len / types.phase0.Validator.fixed_size; + const min_count = @min(seed_count, new_count); + + var migrated_validators = try seed_validators.clone(.{ .transfer_cache = false }); + errdefer migrated_validators.deinit(); + + // Only set when we serialize the seed ourselves; the cleanup frees exactly that case. + var serialized_seed: ?[]u8 = null; + defer if (serialized_seed) |bytes| allocator.free(bytes); + + // 80% of validators serialization time comes from memory allocation. + // seed_state_validators_bytes is an optimization at the beacon-node side to avoid + // memory allocation here. + const seed_bytes: []const u8 = seed_state_validators_bytes orelse blk: { + const size = try seed_validators.serializedSize(); + const out = try allocator.alloc(u8, size); + serialized_seed = out; + _ = try seed_validators.serializeIntoBytes(out); + break :blk out; + }; + + var modified_validators: std.ArrayList(ValidatorIndex) = .empty; + errdefer modified_validators.deinit(allocator); + + const old_validators_slice = seed_bytes[0 .. min_count * types.phase0.Validator.fixed_size]; + const new_validators_slice = new_validators_bytes[0 .. min_count * types.phase0.Validator.fixed_size]; + try findModifiedValidators(allocator, old_validators_slice, new_validators_slice, &modified_validators, 0); + + try applyModifiedValidators( + allocator, + seed_validators, + migrated_validators, + seed_bytes, + new_validators_bytes, + modified_validators.items, + ); + + if (new_count >= seed_count) { + const extra_count = new_count - seed_count; + try modified_validators.ensureUnusedCapacity(allocator, extra_count); + + try appendNewValidators(allocator, migrated_validators, new_validators_bytes, seed_count, new_count, &modified_validators); + } else { + migrated_validators = try trimValidators(allocator, migrated_validators, new_count); + } + + const out_slice = try modified_validators.toOwnedSlice(allocator); + errdefer allocator.free(out_slice); + + try migrated_view.set("validators", migrated_validators); + return out_slice; +} + +const ScoresDiffContext = struct { + old_bytes: []u8, + has_more_validators: bool, + min_validator_count: usize, + old_validator_count: usize, + new_validator_count: usize, +}; + +/// Snapshot the seed scores (serialized bytes plus counts) needed to diff against the new bytes. +fn buildScoresDiffContext( + allocator: Allocator, + migrated_scores: *types.altair.InactivityScores.TreeView, + inactivity_scores_bytes: []const u8, +) !ScoresDiffContext { + const old_validator_count = try migrated_scores.length(); + const new_validator_count = inactivity_scores_bytes.len / INACTIVITY_SCORE_SIZE; + const has_more_validators = new_validator_count >= old_validator_count; + const min_validator_count = @min(old_validator_count, new_validator_count); + + const old_size = try migrated_scores.serializedSize(); + const old_bytes = try allocator.alloc(u8, old_size); + errdefer allocator.free(old_bytes); + + _ = try migrated_scores.serializeIntoBytes(old_bytes); + + return .{ + .old_bytes = old_bytes, + .has_more_validators = has_more_validators, + .min_validator_count = min_validator_count, + .old_validator_count = old_validator_count, + .new_validator_count = new_validator_count, + }; +} + +/// Write each modified inactivity score into `migrated_scores` from `inactivity_scores_bytes`. +fn applyScoreDiffs( + migrated_scores: *types.altair.InactivityScores.TreeView, + inactivity_scores_bytes: []const u8, + modified_validators: []const ValidatorIndex, +) !void { + for (modified_validators) |validator_index| { + const i: usize = @intCast(validator_index); + const start = i * INACTIVITY_SCORE_SIZE; + const chunk: *const [INACTIVITY_SCORE_SIZE]u8 = @ptrCast(inactivity_scores_bytes[start .. start + INACTIVITY_SCORE_SIZE].ptr); + const value = std.mem.readInt(u64, chunk, .little); + try migrated_scores.set(i, value); + } +} + +/// Resize `migrated_scores` to `new_validator_count`: append new scores when growing, +/// or return a trimmed (or empty) view when shrinking. Returns the resulting view. +fn syncScoresLength( + allocator: Allocator, + migrated_scores: *types.altair.InactivityScores.TreeView, + inactivity_scores_bytes: []const u8, + old_validator_count: usize, + new_validator_count: usize, +) !*types.altair.InactivityScores.TreeView { + if (new_validator_count >= old_validator_count) { + var idx: usize = old_validator_count; + while (idx < new_validator_count) : (idx += 1) { + const start = idx * INACTIVITY_SCORE_SIZE; + const chunk: *const [INACTIVITY_SCORE_SIZE]u8 = @ptrCast(inactivity_scores_bytes[start .. start + INACTIVITY_SCORE_SIZE].ptr); + const value = std.mem.readInt(u64, chunk, .little); + try migrated_scores.push(value); + } + return migrated_scores; + } + + if (new_validator_count == 0) { + const pool = migrated_scores.chunks.state.pool; + const empty_root = try types.altair.InactivityScores.tree.fromValue( + pool, + &types.altair.InactivityScores.default_value, + ); + errdefer pool.unref(empty_root); + + const empty_scores = try types.altair.InactivityScores.TreeView.init(allocator, pool, empty_root); + migrated_scores.deinit(); + return empty_scores; + } + + const trimmed = try migrated_scores.sliceTo(new_validator_count - 1); + migrated_scores.deinit(); + return trimmed; +} + +/// Overwrite each modified validator in `migrated_validators` with a view rebuilt +/// from `new_validators_bytes`, reusing the seed validator's pubkey/withdrawal subtrees. +fn applyModifiedValidators( + allocator: Allocator, + seed_validators: *types.phase0.Validators.TreeView, + migrated_validators: *types.phase0.Validators.TreeView, + seed_bytes: []const u8, + new_validators_bytes: []const u8, + modified_validators: []const ValidatorIndex, +) !void { + for (modified_validators) |validator_index| { + const i: usize = @intCast(validator_index); + const start = i * types.phase0.Validator.fixed_size; + const new_bytes = new_validators_bytes[start .. start + types.phase0.Validator.fixed_size]; + const seed_val_bytes = seed_bytes[start .. start + types.phase0.Validator.fixed_size]; + + const seed_validator = try seed_validators.get(i); + // seed_validator is borrowed from seed_validators; do not deinit. + + const new_validator = try loadValidatorWithSeedReuse( + allocator, + migrated_validators.chunks.state.pool, + seed_validator, + seed_val_bytes, + new_bytes, + ); + errdefer new_validator.deinit(); + + try migrated_validators.set(i, new_validator); + } +} + +/// Deserialize and push validators at indices [start_index, end_index) from +/// `new_validators_bytes`, recording each appended index in `modified_validators`. +fn appendNewValidators( + allocator: Allocator, + migrated_validators: *types.phase0.Validators.TreeView, + new_validators_bytes: []const u8, + start_index: usize, + end_index: usize, + modified_validators: *std.ArrayList(ValidatorIndex), +) !void { + var idx: usize = start_index; + while (idx < end_index) : (idx += 1) { + const start = idx * types.phase0.Validator.fixed_size; + const new_bytes = new_validators_bytes[start .. start + types.phase0.Validator.fixed_size]; + + const pool = migrated_validators.chunks.state.pool; + var v: ?*types.phase0.Validator.TreeView = blk: { + const root = try types.phase0.Validator.tree.deserializeFromBytes(pool, new_bytes); + errdefer pool.unref(root); + + break :blk try types.phase0.Validator.TreeView.init(allocator, pool, root); + }; + errdefer if (v) |vv| vv.deinit(); + + try migrated_validators.push(v.?); + v = null; + modified_validators.appendAssumeCapacity(@intCast(idx)); + } +} + +/// Shrink `migrated_validators` to `new_count`, returning a trimmed (or empty) view. +fn trimValidators( + allocator: Allocator, + migrated_validators: *types.phase0.Validators.TreeView, + new_count: usize, +) !*types.phase0.Validators.TreeView { + if (new_count == 0) { + const pool = migrated_validators.chunks.state.pool; + const empty_root = try types.phase0.Validators.tree.fromValue( + pool, + &types.phase0.Validators.default_value, + ); + errdefer pool.unref(empty_root); + + const empty_validators = try types.phase0.Validators.TreeView.init(allocator, pool, empty_root); + migrated_validators.deinit(); + return empty_validators; + } + + const trimmed = try migrated_validators.sliceTo(new_count - 1); + migrated_validators.deinit(); + return trimmed; +} + +fn validatorsNodeId(state: *AnyBeaconState) !Node.Id { + return switch (state.*) { + inline else => |s| s.root.getNodeAtDepth(s.pool, @TypeOf(s.*).SszType.chunk_depth, BEACON_STATE_VALIDATORS_FIELD_INDEX), + }; +} + +fn inactivityScoresNodeId(state: *AnyBeaconState) !Node.Id { + return switch (state.*) { + .phase0 => error.InvalidAtFork, + inline else => |s| s.root.getNodeAtDepth(s.pool, @TypeOf(s.*).SszType.chunk_depth, BEACON_STATE_INACTIVITY_SCORES_FIELD_INDEX), + }; +} + +/// Load a validator from bytes given a seed validator. +/// - Reuse pubkey and withdrawal credentials subtrees if they are unchanged, to save memory. +/// - Otherwise deserialize the validator fresh. +fn loadValidatorWithSeedReuse( + allocator: Allocator, + pool: *Node.Pool, + seed_validator: *types.phase0.Validator.TreeView, + seed_validator_bytes: []const u8, + new_validator_bytes: []const u8, +) !*types.phase0.Validator.TreeView { + const Validator = types.phase0.Validator; + const PUBKEY_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("pubkey")]; + const PUBKEY_END = PUBKEY_OFFSET + comptime Validator.getFieldType("pubkey").fixed_size; + const WCRED_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("withdrawal_credentials")]; + const WCRED_END = WCRED_OFFSET + comptime Validator.getFieldType("withdrawal_credentials").fixed_size; + + const pubkey_same = std.mem.eql(u8, new_validator_bytes[PUBKEY_OFFSET..PUBKEY_END], seed_validator_bytes[PUBKEY_OFFSET..PUBKEY_END]); + const withdrawal_same = std.mem.eql(u8, new_validator_bytes[WCRED_OFFSET..WCRED_END], seed_validator_bytes[WCRED_OFFSET..WCRED_END]); + + if (!pubkey_same) { + if (!withdrawal_same) { + const root = try types.phase0.Validator.tree.deserializeFromBytes(pool, new_validator_bytes); + errdefer pool.unref(root); + + return try types.phase0.Validator.TreeView.init(allocator, pool, root); + } + } + + var nodes: [types.phase0.Validator.chunk_count]Node.Id = undefined; + var owned_nodes: [types.phase0.Validator.chunk_count]Node.Id = undefined; + var owned_len: usize = 0; + errdefer { + for (owned_nodes[0..owned_len]) |node_id| { + pool.unref(node_id); + } + } + + inline for (types.phase0.Validator.fields, 0..) |field, i| { + const reuse = if (comptime std.mem.eql(u8, field.name, "pubkey")) + pubkey_same + else if (comptime std.mem.eql(u8, field.name, "withdrawal_credentials")) + withdrawal_same + else + false; + + if (reuse) { + nodes[i] = try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, i); + } else { + const start = types.phase0.Validator.field_offsets[i]; + const end = start + field.type.fixed_size; + const bytes = new_validator_bytes[start..end]; + const node_id = try field.type.tree.deserializeFromBytes(pool, bytes); + owned_nodes[owned_len] = node_id; + owned_len += 1; + nodes[i] = node_id; + } + } + + const root = try Node.fillWithContents(pool, &nodes, types.phase0.Validator.chunk_depth); + errdefer pool.unref(root); + + owned_len = 0; + return try types.phase0.Validator.TreeView.init(allocator, pool, root); +} + +/// Append the absolute indices (offset by `validator_offset`) of validators that +/// differ between the two equal-length, validator-fixed-size-aligned slices. +fn findModifiedValidators( + allocator: Allocator, + validators_bytes: []const u8, + validators_bytes2: []const u8, + modified_validators: *std.ArrayList(ValidatorIndex), + validator_offset: usize, +) !void { + std.debug.assert(validators_bytes.len == validators_bytes2.len); + std.debug.assert(validators_bytes.len % types.phase0.Validator.fixed_size == 0); + + if (std.mem.eql(u8, validators_bytes, validators_bytes2)) return; + + if (validators_bytes.len == types.phase0.Validator.fixed_size) { + try modified_validators.append(allocator, @intCast(validator_offset)); + return; + } + + const num_validator = validators_bytes.len / types.phase0.Validator.fixed_size; + const half_validator = num_validator / 2; + const split = half_validator * types.phase0.Validator.fixed_size; + + try findModifiedValidators( + allocator, + validators_bytes[0..split], + validators_bytes2[0..split], + modified_validators, + validator_offset, + ); + try findModifiedValidators( + allocator, + validators_bytes[split..], + validators_bytes2[split..], + modified_validators, + validator_offset + half_validator, + ); +} + +/// Append the absolute indices (offset by `validator_offset`) of inactivity scores +/// that differ between the two equal-length, INACTIVITY_SCORE_SIZE-aligned slices. +fn findModifiedInactivityScores( + allocator: Allocator, + inactivity_scores_bytes: []const u8, + inactivity_scores_bytes2: []const u8, + modified_validators: *std.ArrayList(ValidatorIndex), + validator_offset: usize, +) !void { + std.debug.assert(inactivity_scores_bytes.len == inactivity_scores_bytes2.len); + std.debug.assert(inactivity_scores_bytes.len % INACTIVITY_SCORE_SIZE == 0); + + if (std.mem.eql(u8, inactivity_scores_bytes, inactivity_scores_bytes2)) return; + + if (inactivity_scores_bytes.len == INACTIVITY_SCORE_SIZE) { + try modified_validators.append(allocator, @intCast(validator_offset)); + return; + } + + const num_validator = inactivity_scores_bytes.len / INACTIVITY_SCORE_SIZE; + const half_validator = num_validator / 2; + const split = half_validator * INACTIVITY_SCORE_SIZE; + + try findModifiedInactivityScores( + allocator, + inactivity_scores_bytes[0..split], + inactivity_scores_bytes2[0..split], + modified_validators, + validator_offset, + ); + try findModifiedInactivityScores( + allocator, + inactivity_scores_bytes[split..], + inactivity_scores_bytes2[split..], + modified_validators, + validator_offset + half_validator, + ); +} + +test "loadValidatorWithSeedReuse: reuse vs rebuild" { + const allocator = std.testing.allocator; + + var pool = try Node.Pool.init(allocator, 1024); + defer pool.deinit(); + + const gen = @import("test_utils/generate_state.zig"); + const chain_config = gen.getConfig(@import("config").minimal.chain_config, .electra, 0); + + const state_ptr = try gen.generateElectraState(allocator, &pool, chain_config, 64); + defer { + state_ptr.deinit(); + allocator.destroy(state_ptr); + } + + // Build a seed BeaconState TreeView in this pool, then take a validator element as the seed. + const seed_state_bytes = try state_ptr.serialize(allocator); + defer allocator.free(seed_state_bytes); + + var seed_state = try AnyBeaconState.deserialize(allocator, &pool, .electra, seed_state_bytes); + defer seed_state.deinit(); + + var seed_validators = try types.phase0.Validators.TreeView.init(allocator, seed_state.nodePool(), try validatorsNodeId(&seed_state)); + defer seed_validators.deinit(); + + const target_index: usize = 3; + var seed_validator = try seed_validators.get(target_index); + // seed_validator is borrowed from seed_validators; do not deinit. + + var seed_validator_bytes: [types.phase0.Validator.fixed_size]u8 = undefined; + _ = try seed_validator.serializeIntoBytes(&seed_validator_bytes); + + var new_validator_bytes = seed_validator_bytes; + // Modify only withdrawal_credentials so the reuse path keeps pubkey but rebuilds wcred. + const Validator = types.phase0.Validator; + const WCRED_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("withdrawal_credentials")]; + const WCRED_END = WCRED_OFFSET + comptime Validator.getFieldType("withdrawal_credentials").fixed_size; + @memset(new_validator_bytes[WCRED_OFFSET..WCRED_END], 0x11); + + const new_validator = try loadValidatorWithSeedReuse( + allocator, + &pool, + seed_validator, + seed_validator_bytes[0..], + new_validator_bytes[0..], + ); + defer new_validator.deinit(); + + const pubkey_i = comptime types.phase0.Validator.getFieldIndex("pubkey"); + const withdrawal_i = comptime types.phase0.Validator.getFieldIndex("withdrawal_credentials"); + + try std.testing.expectEqual( + try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, pubkey_i), + try new_validator.root.getNodeAtDepth(new_validator.pool, types.phase0.Validator.chunk_depth, pubkey_i), + ); + try std.testing.expect( + try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, withdrawal_i) != try new_validator.root.getNodeAtDepth(new_validator.pool, types.phase0.Validator.chunk_depth, withdrawal_i), + ); + + const fresh_root = try types.phase0.Validator.tree.deserializeFromBytes(&pool, new_validator_bytes[0..]); + var fresh_validator = try types.phase0.Validator.TreeView.init(allocator, &pool, fresh_root); + defer fresh_validator.deinit(); + + try std.testing.expectEqualSlices(u8, try fresh_validator.hashTreeRoot(), try new_validator.hashTreeRoot()); +} + +test "loadState scenarios" { + const allocator = std.testing.allocator; + const gen = @import("test_utils/generate_state.zig"); + const chain_config = gen.getConfig(@import("config").minimal.chain_config, .electra, 0); + + const Mutation = union(enum) { + none, + validator_withdrawal_bytes: struct { index: usize, fill: u8 }, + validator_pubkey_and_withdrawal_bytes: struct { index: usize, pub_fill: u8, wd_fill: u8 }, + scores_struct: struct { index: usize, value: u64 }, + append_one_validator_struct: struct { pub_fill: u8 }, + trim_struct: struct { new_len: usize }, + }; + + const Case = struct { + name: []const u8, + mutation: Mutation, + expect_modified: []const ValidatorIndex, + expect_validators_len: usize, + expect_scores_len: usize, + expect_score: ?struct { index: usize, value: u64 } = null, + expect_validator_bytes_match_state_bytes: ?struct { index: usize } = null, + }; + + const expect_none = [_]ValidatorIndex{}; + const expect_one_3 = [_]ValidatorIndex{@intCast(3)}; + const expect_one_5 = [_]ValidatorIndex{@intCast(5)}; + const expect_one_64 = [_]ValidatorIndex{@intCast(64)}; + + const cases = [_]Case{ + .{ .name = "no changes", .mutation = .none, .expect_modified = expect_none[0..], .expect_validators_len = 64, .expect_scores_len = 64 }, + .{ .name = "validator withdrawal change (bytes)", .mutation = .{ .validator_withdrawal_bytes = .{ .index = 3, .fill = 0x11 } }, .expect_modified = expect_one_3[0..], .expect_validators_len = 64, .expect_scores_len = 64, .expect_validator_bytes_match_state_bytes = .{ .index = 3 } }, + .{ .name = "validator pubkey+withdrawal change (bytes)", .mutation = .{ .validator_pubkey_and_withdrawal_bytes = .{ .index = 5, .pub_fill = 0x22, .wd_fill = 0x33 } }, .expect_modified = expect_one_5[0..], .expect_validators_len = 64, .expect_scores_len = 64, .expect_validator_bytes_match_state_bytes = .{ .index = 5 } }, + .{ .name = "scores-only change (struct)", .mutation = .{ .scores_struct = .{ .index = 7, .value = 123 } }, .expect_modified = expect_none[0..], .expect_validators_len = 64, .expect_scores_len = 64, .expect_score = .{ .index = 7, .value = 123 } }, + .{ .name = "append one validator (struct)", .mutation = .{ .append_one_validator_struct = .{ .pub_fill = 0x44 } }, .expect_modified = expect_one_64[0..], .expect_validators_len = 65, .expect_scores_len = 65 }, + .{ .name = "trim validators to 63 (struct)", .mutation = .{ .trim_struct = .{ .new_len = 63 } }, .expect_modified = expect_none[0..], .expect_validators_len = 63, .expect_scores_len = 63 }, + .{ .name = "trim validators to 0 (struct)", .mutation = .{ .trim_struct = .{ .new_len = 0 } }, .expect_modified = expect_none[0..], .expect_validators_len = 0, .expect_scores_len = 0 }, + }; + + inline for (cases) |case| { + var pool = try Node.Pool.init(allocator, 8192); + defer pool.deinit(); + + const state_ptr = try gen.generateElectraState(allocator, &pool, chain_config, 64); + defer { + state_ptr.deinit(); + allocator.destroy(state_ptr); + } + + const genesis_root = (try state_ptr.genesisValidatorsRoot()).*; + const beacon_config = @import("config").BeaconConfig.init(chain_config, genesis_root); + + const seed_bytes = try state_ptr.serialize(allocator); + defer allocator.free(seed_bytes); + + var seed_all = try AnyBeaconState.deserialize(allocator, &pool, .electra, seed_bytes); + defer seed_all.deinit(); + + const mutated_bytes = blk: { + switch (case.mutation) { + .none => break :blk seed_bytes, + .validator_withdrawal_bytes => |m| { + const validators_field_index = comptime types.electra.BeaconState.getFieldIndex("validators"); + const ranges = try types.electra.BeaconState.readFieldRanges(seed_bytes); + const validators_range = ranges[validators_field_index]; + const out = try allocator.dupe(u8, seed_bytes); + const base = validators_range[0] + m.index * types.phase0.Validator.fixed_size; + @memset(out[base + 48 .. base + 80], m.fill); + break :blk out; + }, + .validator_pubkey_and_withdrawal_bytes => |m| { + const validators_field_index = comptime types.electra.BeaconState.getFieldIndex("validators"); + const ranges = try types.electra.BeaconState.readFieldRanges(seed_bytes); + const validators_range = ranges[validators_field_index]; + const out = try allocator.dupe(u8, seed_bytes); + const base = validators_range[0] + m.index * types.phase0.Validator.fixed_size; + @memset(out[base + 0 .. base + 48], m.pub_fill); + @memset(out[base + 48 .. base + 80], m.wd_fill); + break :blk out; + }, + .scores_struct => |m| { + var scores = try state_ptr.inactivityScores(); + try scores.set(m.index, m.value); + break :blk try state_ptr.serialize(allocator); + }, + .append_one_validator_struct => |m| { + var validators = try state_ptr.validators(); + var v: types.phase0.Validator.Type = undefined; + try validators.getValue(allocator, 0, &v); + v.pubkey = @as(@TypeOf(v.pubkey), [_]u8{m.pub_fill} ** 48); + try validators.pushValue(&v); + + var balances = try state_ptr.balances(); + try balances.push(try balances.get(0)); + + var scores = try state_ptr.inactivityScores(); + try scores.push(try scores.get(0)); + + var previous_epoch_participation = try state_ptr.previousEpochParticipation(); + try previous_epoch_participation.push(try previous_epoch_participation.get(0)); + + var current_epoch_participation = try state_ptr.currentEpochParticipation(); + try current_epoch_participation.push(try current_epoch_participation.get(0)); + + var eth1_data = try state_ptr.eth1Data(); + const deposit_count = try eth1_data.get("deposit_count"); + try eth1_data.set("deposit_count", deposit_count + 1); + try state_ptr.setEth1DepositIndex(try state_ptr.eth1DepositIndex() + 1); + break :blk try state_ptr.serialize(allocator); + }, + .trim_struct => |m| { + var validators = try state_ptr.validators(); + try validators.setLength(m.new_len); + + var balances = try state_ptr.balances(); + try balances.setLength(m.new_len); + + var scores = try state_ptr.inactivityScores(); + try scores.setLength(m.new_len); + + var previous_epoch_participation = try state_ptr.previousEpochParticipation(); + try previous_epoch_participation.setLength(m.new_len); + + var current_epoch_participation = try state_ptr.currentEpochParticipation(); + try current_epoch_participation.setLength(m.new_len); + + if (m.new_len == 0) { + var eth1_data = try state_ptr.eth1Data(); + try eth1_data.set("deposit_count", 0); + try state_ptr.setEth1DepositIndex(0); + } + break :blk try state_ptr.serialize(allocator); + }, + } + }; + defer if (mutated_bytes.ptr != seed_bytes.ptr) allocator.free(mutated_bytes); + + var out = try loadState(allocator, &beacon_config, &seed_all, mutated_bytes, null); + defer { + allocator.free(out.modified_validators); + var s = out.state; + s.deinit(); + } + + try std.testing.expectEqual(case.expect_modified.len, out.modified_validators.len); + for (case.expect_modified, out.modified_validators) |e, got| { + try std.testing.expectEqual(e, got); + } + + var migrated_validators = try types.phase0.Validators.TreeView.init(allocator, out.state.nodePool(), try validatorsNodeId(&out.state)); + defer migrated_validators.deinit(); + + try std.testing.expectEqual(case.expect_validators_len, try migrated_validators.length()); + + var scores = try types.altair.InactivityScores.TreeView.init(allocator, out.state.nodePool(), try inactivityScoresNodeId(&out.state)); + defer scores.deinit(); + + try std.testing.expectEqual(case.expect_scores_len, try scores.length()); + + if (case.expect_score) |exp| { + try std.testing.expectEqual(exp.value, try scores.get(exp.index)); + } + + if (case.expect_validator_bytes_match_state_bytes) |exp| { + const validators_field_index = comptime types.electra.BeaconState.getFieldIndex("validators"); + const ranges = try types.electra.BeaconState.readFieldRanges(mutated_bytes); + const validators_range = ranges[validators_field_index]; + const base = validators_range[0] + exp.index * types.phase0.Validator.fixed_size; + var mv = try migrated_validators.get(exp.index); + // mv is borrowed from migrated_validators; do not deinit. + var mv_bytes: [types.phase0.Validator.fixed_size]u8 = undefined; + _ = try mv.serializeIntoBytes(&mv_bytes); + try std.testing.expectEqualSlices(u8, mutated_bytes[base .. base + types.phase0.Validator.fixed_size], mv_bytes[0..]); + } + + var fresh_state = try AnyBeaconState.deserialize(allocator, &pool, .electra, mutated_bytes); + defer fresh_state.deinit(); + + try std.testing.expectEqualSlices(u8, try fresh_state.hashTreeRoot(), try out.state.hashTreeRoot()); + } +} + +test "diff helpers cases" { + const allocator = std.testing.allocator; + + const Kind = enum { validators, scores }; + const Case = struct { + name: []const u8, + kind: Kind, + count: usize, + modified: []const usize, + }; + + const mod_none = [_]usize{}; + const mod_some_validators = [_]usize{ 0, 1, 63, 64, 127 }; + const mod_some_scores = [_]usize{ 0, 7, 31, 32, 63 }; + + const cases = [_]Case{ + .{ .name = "validators: no diff", .kind = .validators, .count = 128, .modified = mod_none[0..] }, + .{ .name = "validators: some diff", .kind = .validators, .count = 128, .modified = mod_some_validators[0..] }, + .{ .name = "scores: no diff", .kind = .scores, .count = 64, .modified = mod_none[0..] }, + .{ .name = "scores: some diff", .kind = .scores, .count = 64, .modified = mod_some_scores[0..] }, + }; + + for (cases) |case| { + var got: std.ArrayList(ValidatorIndex) = .empty; + defer got.deinit(allocator); + + if (case.kind == .validators) { + const total = case.count * types.phase0.Validator.fixed_size; + const old_bytes = try allocator.alloc(u8, total); + defer allocator.free(old_bytes); + + const new_bytes = try allocator.alloc(u8, total); + defer allocator.free(new_bytes); + + for (0..case.count) |i| { + const start = i * types.phase0.Validator.fixed_size; + for (0..types.phase0.Validator.fixed_size) |j| { + old_bytes[start + j] = @intCast((i + 31 * j) & 0xff); + } + } + @memcpy(new_bytes, old_bytes); + + for (case.modified) |idx| { + const start = idx * types.phase0.Validator.fixed_size; + new_bytes[start] ^= 0x5a; + } + + try findModifiedValidators(allocator, old_bytes, new_bytes, &got, 0); + } else { + const total = case.count * INACTIVITY_SCORE_SIZE; + const old_bytes = try allocator.alloc(u8, total); + defer allocator.free(old_bytes); + + const new_bytes = try allocator.alloc(u8, total); + defer allocator.free(new_bytes); + + for (0..case.count) |i| { + const start = i * INACTIVITY_SCORE_SIZE; + std.mem.writeInt(u64, @ptrCast(old_bytes[start .. start + INACTIVITY_SCORE_SIZE].ptr), @intCast(i * 3), .little); + } + @memcpy(new_bytes, old_bytes); + + for (case.modified) |idx| { + const start = idx * INACTIVITY_SCORE_SIZE; + new_bytes[start] ^= 0xa5; + } + + try findModifiedInactivityScores(allocator, old_bytes, new_bytes, &got, 0); + } + + try std.testing.expectEqual(case.modified.len, got.items.len); + for (case.modified, got.items) |e, g| { + try std.testing.expectEqual(@as(ValidatorIndex, @intCast(e)), g); + } + } +} + +test "loadValidators/loadInactivityScores: rejection scenarios" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 1024); + defer pool.deinit(); + + const gen = @import("test_utils/generate_state.zig"); + const chain_config = gen.getConfig(@import("config").minimal.chain_config, .electra, 0); + const state_ptr = try gen.generateElectraState(allocator, &pool, chain_config, 8); + defer { + state_ptr.deinit(); + allocator.destroy(state_ptr); + } + + const StateST = types.electra.BeaconState; + const migrated_view = state_ptr.castToFork(.electra).inner; + + { + // new validators bytes length is not a multiple of the validator fixed size + const seed_validators_node = try validatorsNodeId(state_ptr); + const bad_bytes = [_]u8{0} ** (types.phase0.Validator.fixed_size + 1); + try std.testing.expectError( + error.InvalidSize, + loadValidators(allocator, StateST, migrated_view, &pool, seed_validators_node, bad_bytes[0..], null), + ); + } + + { + // seed_state_validators_bytes length is not a multiple of the validator fixed size + const seed_validators_node = try validatorsNodeId(state_ptr); + const good_new_bytes = [_]u8{0} ** (types.phase0.Validator.fixed_size * 2); + const bad_seed_bytes = [_]u8{0} ** (types.phase0.Validator.fixed_size + 1); + try std.testing.expectError( + error.InvalidSize, + loadValidators(allocator, StateST, migrated_view, &pool, seed_validators_node, good_new_bytes[0..], bad_seed_bytes[0..]), + ); + } + + { + // inactivity scores bytes length is not a multiple of INACTIVITY_SCORE_SIZE + const seed_scores_node = try inactivityScoresNodeId(state_ptr); + const bad_bytes = [_]u8{0} ** (INACTIVITY_SCORE_SIZE + 1); + try std.testing.expectError( + error.InvalidSize, + loadInactivityScores(allocator, StateST, migrated_view, &pool, seed_scores_node, bad_bytes[0..]), + ); + } +} diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index a00930640..0acc79ed5 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -108,9 +108,14 @@ const EpochShuffling = @import("./utils/epoch_shuffling.zig"); pub const calculateShufflingDecisionRoot = EpochShuffling.calculateShufflingDecisionRoot; pub const processProposerLookahead = @import("./epoch/process_proposer_lookahead.zig").processProposerLookahead; +const load_state = @import("load_state.zig"); +pub const loadState = load_state.loadState; +pub const MigrateStateOutput = load_state.MigrateStateOutput; + test { testing.refAllDecls(@This()); testing.refAllDecls(seed); testing.refAllDecls(state_transition); testing.refAllDecls(EpochShuffling); + testing.refAllDecls(load_state); } diff --git a/src/state_transition/ssz_bytes.zig b/src/state_transition/ssz_bytes.zig new file mode 100644 index 000000000..575bff478 --- /dev/null +++ b/src/state_transition/ssz_bytes.zig @@ -0,0 +1,30 @@ +const std = @import("std"); + +const types = @import("consensus_types"); +const ForkSeq = @import("config").ForkSeq; +const BeaconConfig = @import("config").BeaconConfig; + +const Slot = types.primitive.Slot.Type; + +/// +/// 8 + 32 = 40 +/// +/// ``` +/// class BeaconState(Container): +/// genesis_time: uint64 [fixed - 8 bytes] +/// genesis_validators_root: Root [fixed - 32 bytes] +/// slot: Slot [fixed - 8 bytes] +/// ... +/// ``` +const SLOT_BYTES_POSITION_IN_STATE: usize = 40; + +pub fn getStateSlotFromBytes(bytes: []const u8) !Slot { + const slot_size = types.primitive.Slot.fixed_size; + if (bytes.len < SLOT_BYTES_POSITION_IN_STATE + slot_size) return error.InvalidSize; + return std.mem.readInt(u64, bytes[SLOT_BYTES_POSITION_IN_STATE .. SLOT_BYTES_POSITION_IN_STATE + slot_size], .little); +} + +pub fn getForkFromStateBytes(config: *const BeaconConfig, bytes: []const u8) !ForkSeq { + const slot = try getStateSlotFromBytes(bytes); + return config.forkSeq(slot); +} diff --git a/src/state_transition/ssz_container.zig b/src/state_transition/ssz_container.zig new file mode 100644 index 000000000..1e1117a91 --- /dev/null +++ b/src/state_transition/ssz_container.zig @@ -0,0 +1,88 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const ssz = @import("ssz"); +const Node = @import("persistent_merkle_tree").Node; + +/// Deserialize an SSZ container into its TreeView, while ignoring (not deserializing) selected +/// fields by name and overriding them with precomputed subtrees. +/// +/// `overrides` should be a struct literal where field names match container field names, +/// e.g. `. { .validators = seed_validators_node }`. +pub fn deserializeContainerOverrideFieldsWithRanges( + allocator: Allocator, + pool: *Node.Pool, + comptime ContainerST: type, + bytes: []const u8, + ranges: *const [ContainerST.fields.len][2]usize, + overrides: anytype, +) !*ContainerST.TreeView { + var nodes: [ContainerST.chunk_count]Node.Id = undefined; + var owned_nodes: [ContainerST.chunk_count]Node.Id = undefined; + var owned_len: usize = 0; + + // Important: `deserializeFromBytes` returns nodes with refcount 0. If we error out before + // they're anchored under a committed root, they must be `unref`'d to avoid leaking Pool nodes. + // Once container root is created, it becomes the sole owner: unref'ing the root is enough + // and unref'ing child nodes again would be a double-unref. + errdefer { + var i: usize = 0; + while (i < owned_len) : (i += 1) pool.unref(owned_nodes[i]); + } + + inline for (ContainerST.fields, 0..) |field, i| { + if (comptime @hasField(@TypeOf(overrides), field.name)) { + nodes[i] = @field(overrides, field.name); + continue; + } + + const start = ranges[i][0]; + const end = ranges[i][1]; + const field_bytes = bytes[start..end]; + + nodes[i] = try field.type.tree.deserializeFromBytes(pool, field_bytes); + owned_nodes[owned_len] = nodes[i]; + owned_len += 1; + } + + const root = try Node.fillWithContents(pool, &nodes, ContainerST.chunk_depth); + errdefer pool.unref(root); + owned_len = 0; + + return try ContainerST.TreeView.init(allocator, pool, root); +} + +test "deserializeContainerOverrideFields... cleans up pool nodes on error" { + const allocator = std.testing.allocator; + + var pool = try Node.Pool.init(allocator, 64); + defer pool.deinit(); + + const U64 = ssz.UintType(64); + const U64List = ssz.FixedListType(U64, 4); + const Fields = struct { + a: U64, + b: U64List, + }; + const ContainerST = ssz.VariableContainerType(Fields); + + // Valid offsets for `b`, but `b` payload length is 1 which is not divisible by 8. + var bytes: [13]u8 = undefined; + @memset(&bytes, 0); + std.mem.writeInt(u32, bytes[8..12], 12, .little); + + const baseline_in_use = pool.getNodesInUse(); + const ranges = try ContainerST.readFieldRanges(bytes[0..]); + try std.testing.expectError( + error.UnexpectedRemainder, + deserializeContainerOverrideFieldsWithRanges( + allocator, + &pool, + ContainerST, + bytes[0..], + &ranges, + .{}, + ), + ); + try std.testing.expectEqual(baseline_in_use, pool.getNodesInUse()); +} From 787ab6603707360a5f4625f81f88cfbc29c240dd Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 19 May 2026 07:46:34 -0400 Subject: [PATCH 07/46] chore: support dev workflow (#364) - Motivation: https://github.com/ChainSafe/lodestar-z/pull/360#issuecomment-4462314964 Afaik, zig-out/lib/* must be in package.json `files` in order for the library to be part of the packed package when installing lodestar-z as a git dependency. Unfortunately, that also allows it to be part of the package when _published_!! which breaks cross-platform usage of the library, short circuiting the correct library from being loaded. To that end, this PR attempts to get the intended behavior for both cases: - keep zig-out/lib in `files` so git-dependency-installed lodestar-z packs a freshly-built library - but crucially suppress the prepare script from being run during publish via `--ignore-scripts` so npm-dependency-installed lodestar-z uses a platform-specific published library --- .github/workflows/publish-bindings.yml | 3 ++- package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-bindings.yml b/.github/workflows/publish-bindings.yml index 92a255a0f..247e0193c 100644 --- a/.github/workflows/publish-bindings.yml +++ b/.github/workflows/publish-bindings.yml @@ -62,5 +62,6 @@ jobs: run: | pnpm zapi build-artifacts --optimize ReleaseSafe pnpm zapi prepublish - pnpm zapi publish --access public -- --provenance ${{ steps.detect_dist_tag.outputs.tag_flag }} + rm -rf zig-out/lib + pnpm zapi publish --access public -- --ignore-scripts --provenance ${{ steps.detect_dist_tag.outputs.tag_flag }} diff --git a/package.json b/package.json index 7d6406e66..d7b0205dd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.1.2-rc.2", "description": "Lodestar-z NAPI bindings", "files": [ - "bindings/src/" + "bindings/src/", + "zig-out/lib/" ], "type": "module", "exports": { From 228801832d697e5c500b471fd2b43d98973ba97e Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 19 May 2026 19:52:47 +0800 Subject: [PATCH 08/46] fix: re-expose sizes (#369) restores https://github.com/ChainSafe/lodestar-z/pull/328 --- bindings/napi/blst.zig | 6 ++++++ build.zig.zon | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 5a75b7670..cfb3d6b98 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -92,6 +92,9 @@ fn uint8SliceFromValue(value: js.Value) ![]u8 { pub const PublicKey = struct { pub const js_meta = js.class(.{}); + pub const COMPRESS_SIZE = NativePublicKey.COMPRESS_SIZE; + pub const SERIALIZE_SIZE = NativePublicKey.SERIALIZE_SIZE; + raw: NativePublicKey = .{}, pub fn init() PublicKey { @@ -155,6 +158,9 @@ pub const PublicKey = struct { pub const Signature = struct { pub const js_meta = js.class(.{}); + pub const COMPRESS_SIZE = NativeSignature.COMPRESS_SIZE; + pub const SERIALIZE_SIZE = NativeSignature.SERIALIZE_SIZE; + raw: NativeSignature = .{}, pub fn init() Signature { diff --git a/build.zig.zon b/build.zig.zon index d7a5737d9..747919e55 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "zig_yaml-0.1.0-C1161kFWAwDxjKAFmklKwWVDvz2mmq0Q__bDhGGjeyd3", }, .zapi = .{ - .url = "git+https://github.com/chainsafe/zapi?ref=zapi-v2.1.0#c5c877af9742d9d7fd6cab2ce6fec698817d56cd", - .hash = "zapi-2.1.0-rIqzUbxNBADOW16nSYQfUOtib1TgC8PxbR4ggN6ezYfA", + .url = "git+https://github.com/chainsafe/zapi#b6a59fb084a907232ceb1a3311fdb2b2c63169f0", + .hash = "zapi-2.1.0-rIqzUXBaBABOSYjpfxeUcYkNlQZiJIXQ_-z-IPsgmcp2", }, .zbench = .{ .url = "git+https://github.com/hendriknielaender/zBench#b2b89c475e3ef1bb2bd71255c80478a82d3e0ca8", From 6b149a788f6f2a4ae606b23bbf2790d113199de0 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 19 May 2026 21:21:24 +0800 Subject: [PATCH 09/46] chore: release v0.1.2-rc.3 (#370) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7b0205dd..0984d8a4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.2", + "version": "0.1.2-rc.3", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From fd96543683e431f3d72b6ed7e33d66e4e5530301 Mon Sep 17 00:00:00 2001 From: bing Date: Thu, 21 May 2026 02:46:38 +0800 Subject: [PATCH 10/46] feat: support getLatestWeakSubjectivityCheckpointEpoch (#366) extracted from #347 Support both native + binding --- src/state_transition/root.zig | 4 + src/state_transition/weak_subjectivity.zig | 190 +++++++++++++++++++++ 2 files changed, 194 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 0acc79ed5..b571391aa 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -112,10 +112,14 @@ const load_state = @import("load_state.zig"); pub const loadState = load_state.loadState; pub const MigrateStateOutput = load_state.MigrateStateOutput; +const weak_subjectivity = @import("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..8094e97e1 --- /dev/null +++ b/src/state_transition/weak_subjectivity.zig @@ -0,0 +1,190 @@ +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("./utils/validator.zig"); + +const Epoch = types.primitive.Epoch.Type; + +/// 10% safety decay. +const SAFETY_DECAY: u64 = 10; + +/// Gwei per ETH (10^9). +const ETH_TO_GWEI: u64 = 1_000_000_000; + +/// 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); + // NOTE: `total_balance_by_increment` is total balance measured in `EFFECTIVE_BALANCE_INCREMENT` units. + // The formula needs t = (avg effective balance per validator) in ETH. + // That equals total_balance_by_increment / N only because + // EFFECTIVE_BALANCE_INCREMENT == ETH_TO_GWEI (both 1e9 Gwei) in the spec. + // If they ever diverge, this needs scaling. + comptime std.debug.assert(preset.EFFECTIVE_BALANCE_INCREMENT == ETH_TO_GWEI); + 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, division by zero due to t < T will almost never happen. + // + // Napkin math: + // if (big if) T = 32, t ∈ [0, 32] + // if T - t = 0, then lhs = 32 * 230 = 7360 < rhs = 32 * 320 = 10240, + // so we will never enter this 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 0aaabd3ddcca73a50b30ea2e1037b2d9f701864c Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Fri, 22 May 2026 10:01:05 +0200 Subject: [PATCH 11/46] fix: export asyncAggregateWithRandomness through napi binding (#371) --- bindings/napi/blst.zig | 52 +++++++++++------------- bindings/test/blst.test.ts | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index cfb3d6b98..09be0434c 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -660,13 +660,13 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { /// All input data should be copied into this struct so the worker thread doesn't depend on /// any JS-managed memory staying alive. const AsyncAggRandData = struct { - pks: []PublicKey, - sigs: []Signature, - pk_ptrs: []*const PublicKey, - sig_ptrs: []*const Signature, + pks: []NativePublicKey, + sigs: []NativeSignature, + pk_ptrs: []*const NativePublicKey, + sig_ptrs: []*const NativeSignature, randomness: []u8, - pk_out: PublicKey, - sig_out: Signature, + pk_out: NativePublicKey, + sig_out: NativeSignature, err: ?anyerror, deferred: napi.Deferred, work: napi.c.napi_async_work, @@ -735,13 +735,8 @@ fn settle(env: napi.Env, status: napi.status.Status, data: *AsyncAggRandData) !v return rejectWithError(env, data.deferred, "asyncAggregateWithRandomness", @errorName(err)); } - const pk_value: NativePublicKey = .{}; - const pk_unwrapped = try env.unwrap(PublicKey, pk_value); - pk_unwrapped.* = data.pk_out; - - const sig_value: NativeSignature = .{}; - const sig_unwrapped = try env.unwrap(Signature, sig_value); - sig_unwrapped.* = data.sig_out; + const pk_value = napi.Value{ .env = env.env, .value = js.convertReturn(PublicKey, .{ .raw = data.pk_out }, env.env) }; + const sig_value = napi.Value{ .env = env.env, .value = js.convertReturn(Signature, .{ .raw = data.sig_out }, env.env) }; const result = try env.createObject(); try result.setNamedProperty("pk", pk_value); @@ -777,24 +772,25 @@ fn rejectWithError(env: napi.Env, deferred: napi.Deferred, where: []const u8, co /// 1) sets: Array of {pk: PublicKey, sig: Uint8Array} /// /// Returns: Promise<{pk: PublicKey, sig: Signature}> -pub fn asyncAggregateWithRandomness(env: napi.Env, cb: napi.CallbackInfo(1)) !napi.Value { - const sets = cb.arg(0); - const n = try sets.getArrayLength(); +pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { + const n = try sets.length(); if (n == 0) return error.EmptyArray; if (n > MAX_AGGREGATE_PER_JOB) return error.TooManySets; if (thread_pool == null) return error.PoolNotInitialized; + const env = js.env(); + const data = try allocator.create(AsyncAggRandData); errdefer allocator.destroy(data); - data.pks = try allocator.alloc(PublicKey, n); + data.pks = try allocator.alloc(NativePublicKey, n); errdefer allocator.free(data.pks); - data.sigs = try allocator.alloc(Signature, n); + data.sigs = try allocator.alloc(NativeSignature, n); errdefer allocator.free(data.sigs); - data.pk_ptrs = try allocator.alloc(*const PublicKey, n); + data.pk_ptrs = try allocator.alloc(*const NativePublicKey, n); errdefer allocator.free(data.pk_ptrs); - data.sig_ptrs = try allocator.alloc(*const Signature, n); + data.sig_ptrs = try allocator.alloc(*const NativeSignature, n); errdefer allocator.free(data.sig_ptrs); data.randomness = try allocator.alloc(u8, n * 32); errdefer allocator.free(data.randomness); @@ -807,16 +803,16 @@ pub fn asyncAggregateWithRandomness(env: napi.Env, cb: napi.CallbackInfo(1)) !na napi_io.get().random(data.randomness); for (0..n) |i| { - const set_value = try sets.getElement(@intCast(i)); + const set = (try sets.get(@intCast(i))).toValue(); - const pk_value = try set_value.getNamedProperty("pk"); - const unwrapped_pk = try env.unwrap(PublicKey, pk_value); - data.pks[i] = unwrapped_pk.*; + const pk_napi = try set.getNamedProperty("pk"); + const wrapped_pk = try env.unwrap(PublicKey, pk_napi); + data.pks[i] = wrapped_pk.raw; data.pk_ptrs[i] = &data.pks[i]; - const sig_value = try set_value.getNamedProperty("sig"); - const sig_bytes = try sig_value.getTypedarrayInfo(); - data.sigs[i] = Signature.deserialize(sig_bytes.data[0..]) catch return error.DeserializationFailed; + const sig_napi = try set.getNamedProperty("sig"); + const sig_bytes = try uint8SliceFromValue(.{ .val = sig_napi }); + data.sigs[i] = NativeSignature.deserialize(sig_bytes[0..]) catch return error.DeserializationFailed; data.sig_ptrs[i] = &data.sigs[i]; } @@ -835,5 +831,5 @@ pub fn asyncAggregateWithRandomness(env: napi.Env, cb: napi.CallbackInfo(1)) !na try work.queue(); - return data.deferred.getPromise(); + return .{ .val = data.deferred.getPromise() }; } diff --git a/bindings/test/blst.test.ts b/bindings/test/blst.test.ts index 48e7fc86b..9e74cd203 100644 --- a/bindings/test/blst.test.ts +++ b/bindings/test/blst.test.ts @@ -8,6 +8,7 @@ import { aggregateSerializedPublicKeys, aggregateVerify, aggregateWithRandomness, + asyncAggregateWithRandomness, fastAggregateVerify, verify, verifyMultipleAggregateSignatures, @@ -357,6 +358,87 @@ describe("blst", () => { expect(() => aggregateWithRandomness(input)).toThrow(); }); }); + + describe("asyncAggregateWithRandomness", () => { + it("should be exported as a function", () => { + expect(typeof asyncAggregateWithRandomness).toBe("function"); + }); + + it("should return a Promise", () => { + const {sets} = getTestSetsSameMessage(2); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const result = asyncAggregateWithRandomness(input); + expect(result).toBeInstanceOf(Promise); + return result; + }); + + it("should resolve with aggregated pk and sig instances", async () => { + const {sets} = getTestSetsSameMessage(8); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const result = await asyncAggregateWithRandomness(input); + expect(result).toHaveProperty("pk"); + expect(result).toHaveProperty("sig"); + expect(result.pk).toBeInstanceOf(PublicKey); + expect(result.sig).toBeInstanceOf(Signature); + }); + + it("should produce a valid aggregated signature", async () => { + const {msg, sets} = getTestSetsSameMessage(8); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const {pk, sig} = await asyncAggregateWithRandomness(input); + expect(verify(msg, pk, sig, false, false)).toBe(true); + }); + + it("should work with a single set", async () => { + const {msg, sets} = getTestSetsSameMessage(1); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const {pk, sig} = await asyncAggregateWithRandomness(input); + expect(verify(msg, pk, sig, false, false)).toBe(true); + }); + + it("should fail verification against a different message", async () => { + const {sets} = getTestSetsSameMessage(4); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const {pk, sig} = await asyncAggregateWithRandomness(input); + const wrongMessage = new Uint8Array(32).fill(0); + expect(verify(wrongMessage, pk, sig, false, false)).toBe(false); + }); + + it("should match the synchronous aggregateWithRandomness verification result", async () => { + const {msg, sets} = getTestSetsSameMessage(6); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const syncResult = aggregateWithRandomness(input); + const asyncResult = await asyncAggregateWithRandomness(input); + // Randomness differs between calls so signatures aren't byte-equal, + // but both must verify against the shared message. + expect(verify(msg, syncResult.pk, syncResult.sig, false, false)).toBe(true); + expect(verify(msg, asyncResult.pk, asyncResult.sig, false, false)).toBe(true); + }); + + it("should reject on empty input", async () => { + await expect(Promise.resolve().then(() => asyncAggregateWithRandomness([]))).rejects.toThrow(); + }); + + it("should reject on invalid signature bytes", async () => { + const {sets} = getTestSetsSameMessage(4); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + input[2].sig = new Uint8Array(96).fill(0xff); + await expect(Promise.resolve().then(() => asyncAggregateWithRandomness(input))).rejects.toThrow(); + }); + + it("should resolve concurrent invocations correctly", async () => { + const {msg, sets} = getTestSetsSameMessage(8); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const results = await Promise.all([ + asyncAggregateWithRandomness(input), + asyncAggregateWithRandomness(input), + asyncAggregateWithRandomness(input), + ]); + for (const {pk, sig} of results) { + expect(verify(msg, pk, sig, false, false)).toBe(true); + } + }); + }); }); const DEFAULT_TEST_MESSAGE = Uint8Array.from(Buffer.from("lodestarlodestarlodestarlodestar")); From a417fd917a2377c408e73ef5aa4369c36e89248f Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 22 May 2026 16:53:26 +0800 Subject: [PATCH 12/46] chore(deps): use zapi v2.1.0 (#372) --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 747919e55..d4487bbf0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "zig_yaml-0.1.0-C1161kFWAwDxjKAFmklKwWVDvz2mmq0Q__bDhGGjeyd3", }, .zapi = .{ - .url = "git+https://github.com/chainsafe/zapi#b6a59fb084a907232ceb1a3311fdb2b2c63169f0", - .hash = "zapi-2.1.0-rIqzUXBaBABOSYjpfxeUcYkNlQZiJIXQ_-z-IPsgmcp2", + .url = "https://github.com/ChainSafe/zapi/archive/refs/tags/zapi-v2.1.0.tar.gz", + .hash = "zapi-2.1.0-rIqzUbxNBADOW16nSYQfUOtib1TgC8PxbR4ggN6ezYfA", }, .zbench = .{ .url = "git+https://github.com/hendriknielaender/zBench#b2b89c475e3ef1bb2bd71255c80478a82d3e0ca8", From 4dffaebc50ae1b4cb1a539e28f9ae316389a11ee Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 22 May 2026 19:07:54 +0800 Subject: [PATCH 13/46] chore(release): v0.1.2-rc.4 (#373) will contain #371 for consumption by chainsafe/lodestar#8900 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0984d8a4a..3c60d49f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.3", + "version": "0.1.2-rc.4", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From 0c6d3adb33bb483537449099929478e6ba60cfea Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 22 May 2026 20:03:32 +0800 Subject: [PATCH 14/46] chore(release): v0.1.2-rc.5 (#374) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c60d49f2..4e5b5f46a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.4", + "version": "0.1.2-rc.5", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From 760dccc740e91de20f37250424fd9c39fa50c826 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 23 May 2026 16:07:28 +0800 Subject: [PATCH 15/46] chore(deps): bump zapi v2.1.0 -> v2.2.0 (#376) for static decls --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index d4487bbf0..8e1727541 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "zig_yaml-0.1.0-C1161kFWAwDxjKAFmklKwWVDvz2mmq0Q__bDhGGjeyd3", }, .zapi = .{ - .url = "https://github.com/ChainSafe/zapi/archive/refs/tags/zapi-v2.1.0.tar.gz", - .hash = "zapi-2.1.0-rIqzUbxNBADOW16nSYQfUOtib1TgC8PxbR4ggN6ezYfA", + .url = "https://github.com/ChainSafe/zapi/archive/refs/tags/zapi-v2.2.0.tar.gz", + .hash = "zapi-2.2.0-rIqzUXBaBADUsvvvB2N5kOBdLEO0rGPhowTqmIXe3qVh", }, .zbench = .{ .url = "git+https://github.com/hendriknielaender/zBench#b2b89c475e3ef1bb2bd71255c80478a82d3e0ca8", From 78a8427faaa0ff9fb51c66f4bfba3f96a6fecec8 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 23 May 2026 16:07:53 +0800 Subject: [PATCH 16/46] chore(release): v0.1.2-rc.6 (#375) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e5b5f46a..6c433e709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.5", + "version": "0.1.2-rc.6", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From dc66acb3ff3b9b31505f6e0021464f5eed81a4a1 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Mon, 25 May 2026 16:39:44 +0800 Subject: [PATCH 17/46] fix: improve atomic ordering in ThreadPool and NAPI init (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `ThreadPool` worker hot loops use `.acquire` on `err_flag.load()` where `.monotonic` suffices — the flag is a pure early-exit signal with no data dependency on the setter's other writes. BLS verification is CPU-intensive, so relaxing this in the inner loop avoids unnecessary memory-fence cost. Matches the pattern already used in `src/state_transition/cache/pubkey_cache.zig`. The earlier NAPI init-mutex changes from this branch have been dropped after merging main, because main moved to zapi-managed lifecycle (`js.exportModule` with `init`/`cleanup` hooks). The concurrent-register race they were guarding against has been filed against zapi upstream: ChainSafe/zapi#31. ## Description `src/bls/ThreadPool.zig`: - `err_flag.load(.acquire)` → `.monotonic` in `VerifyMultiWorkItem.exec` and `AggVerifyWorkItem.exec` worker loops. - Setter side (`err_flag.store(true, .release)` on pairing failure) is unchanged — release semantics on the producer side carry no obligation on the consumer to also be `.acquire` when the consumer doesn't depend on the producer's other writes. --- src/bls/ThreadPool.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index fecfa37e3..84cc3b828 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -203,7 +203,6 @@ const VerifyMultiWorkItem = struct { const self: *VerifyMultiWorkItem = @fieldParentPtr("base", base_item); const job = self.job; - // Each worker gets its own pairing buffer on the stack var buf: PairingBuf = .{}; var pairing = Pairing.init(&buf.data, true, job.dst); @@ -213,7 +212,7 @@ const VerifyMultiWorkItem = struct { while (true) { const i = job.counter.fetchAdd(1, .monotonic); if (i >= n_elems) break; - if (job.err_flag.load(.acquire)) break; + if (job.err_flag.load(.monotonic)) break; did_work = true; @@ -347,7 +346,7 @@ const AggVerifyWorkItem = struct { while (true) { const i = job.counter.fetchAdd(1, .monotonic); if (i >= job.n_elems) break; - if (job.err_flag.load(.acquire)) break; + if (job.err_flag.load(.monotonic)) break; did_work = true; From 67ebed45e6abb8d04deec99b013b34b828b57855 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 27 May 2026 20:22:51 +0800 Subject: [PATCH 18/46] fix(bindings): misordering of print for cpu count (#381) as title says, we were printing 0 because the print was misplaced --- bindings/napi/root.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/napi/root.zig b/bindings/napi/root.zig index 53bfe33ba..6ceceb040 100644 --- a/bindings/napi/root.zig +++ b/bindings/napi/root.zig @@ -20,8 +20,8 @@ fn init(old_ref_count: u32) !void { var cpu_count: u64 = options.thread_count; if (options.thread_count == 0) { - std.debug.print("Note: no -Dthread-count set, will use runtime CPU count minus 1: {}\n", .{cpu_count}); cpu_count = @max((try std.Thread.getCpuCount()) - 1, 1); + std.debug.print("Note: no -Dthread-count set, will use runtime CPU count minus 1: {}\n", .{cpu_count}); } const n_workers = @min(cpu_count, @import("bls").ThreadPool.MAX_WORKERS); From 596aa1dc125c3663801a7d5562c69fa4a3550509 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 27 May 2026 23:18:12 +0800 Subject: [PATCH 19/46] fix: harden memory safety across PMT, SSZ tree views, and state transition (#377) ## Summary Addresses the #357 memory-safety review. The findings are error-path bugs that the happy path never exercises: `errdefer`/`defer` cleanup running over `undefined` or already-moved memory, OOM leaving aliased refcounted pointers, orphaned pool nodes on rollback, and an out-of-bounds proposer index from untrusted input. All fixes are allocate/validate-before-commit so a failure can't leave shared state corrupt. ## Changes **persistent_merkle_tree** - Zero-fill node-id buffers at `errdefer` sites so a mid-build error's `unref`/`free` no-ops on the unbuilt tail instead of unref-ing stack garbage (C1) - `Pool.alloc` rolls back already-popped slots on a preheat OOM (C2) - `createBranch` rolls back an applied child ref on `RefCountOverflow` (M2) - `FillWithContentsIterator` reclaims both orphaned `left` and `carry` on a `createBranch` OOM, guarding the all-default aliased-node case (M10) - `setNodes*` start `unfinalized_parents` all-null so the unref loop can't read an undefined `?Id` (H1); empty-`indices` no-op guarded before the ascending assert - `View.destroy` asserts no live children + poisons the recycled slot (H2) **ssz tree views** - `commit`/`set` reserve capacity up front so the stores are infallible (H4/M5) - `setValue` no longer double-frees the child view on `set`'s OOM; documented the borrowed-pointer invalidation contract on `get`/`set`/`getReadonly`/`clone` (C5) - `sliceTo` unrefs its intermediate orphan roots (H6) - `ReadonlyIterator.nextValue` initializes `out` before `toValue` (H5) **state_transition** - Epoch-cache shuffling rotation and effective-balance/pubkey updates allocate before mutating shared `Rc`/maps, so OOM can't leave an aliased double-unref (C4) - `epoch_transition_cache` errdefers for its temporary lists (M8) - Proposer signature set bounds-checks the proposer index (`verifyProposerSignature` runs before `processBlockHeader` validates it) (C3) - `effectiveBalanceIncrementsSet` allocates the buffer with the same allocator the owning `Rc` frees it with - Signature-set `out` params changed from by-value `ArrayList` to `*ArrayList` --- build.zig.zon | 5 +- src/persistent_merkle_tree/Node.zig | 70 ++++- src/persistent_merkle_tree/View.zig | 7 + src/persistent_merkle_tree/node_test.zig | 113 +++++++ src/ssz/tree_view/array_composite.zig | 2 + src/ssz/tree_view/chunks.zig | 59 ++-- src/ssz/tree_view/container.zig | 16 +- src/ssz/tree_view/list_basic.zig | 51 +++- src/ssz/tree_view/list_composite.zig | 283 +++++++++++++++++- src/ssz/type/container.zig | 18 +- src/ssz/type/vector.zig | 12 +- src/state_transition/cache/epoch_cache.zig | 58 ++-- .../cache/epoch_transition_cache.zig | 7 + .../bls_to_execution_change.zig | 2 +- .../signature_sets/indexed_attestation.zig | 6 +- .../signature_sets/proposer.zig | 10 +- .../signature_sets/proposer_slashings.zig | 7 +- .../signature_sets/voluntary_exits.zig | 2 +- src/state_transition/state_transition.zig | 35 +++ src/testing_allocators.zig | 48 +++ 20 files changed, 718 insertions(+), 93 deletions(-) create mode 100644 src/testing_allocators.zig diff --git a/build.zig.zon b/build.zig.zon index 8e1727541..4118a0c2c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -132,6 +132,9 @@ .time = .{ .root_source_file = "src/time.zig", }, + .testing_allocators = .{ + .root_source_file = "src/testing_allocators.zig", + }, .hashing = .{ .root_source_file = "src/hashing/root.zig", .imports = .{ .build_options, .hex, .hashtree }, @@ -146,7 +149,7 @@ }, .ssz = .{ .root_source_file = "src/ssz/root.zig", - .imports = .{ .build_options, .hex, .hashing, .persistent_merkle_tree }, + .imports = .{ .build_options, .hex, .hashing, .persistent_merkle_tree, .testing_allocators }, }, .consensus_types = .{ .root_source_file = "src/consensus_types/root.zig", diff --git a/src/persistent_merkle_tree/Node.zig b/src/persistent_merkle_tree/Node.zig index 59205755c..5edef5092 100644 --- a/src/persistent_merkle_tree/Node.zig +++ b/src/persistent_merkle_tree/Node.zig @@ -241,7 +241,18 @@ pub const Pool = struct { self.nodes.items(.left)[@intFromEnum(node_id)] = left_id; self.nodes.items(.right)[@intFromEnum(node_id)] = right_id; states[@intFromEnum(node_id)] = State.branch_lazy.initRefCount(); + // If a ref below overflows, hand this half-built node's slot back to the free list. + errdefer { + states[@intFromEnum(node_id)] = State.initNextFree(self.next_free_node); + self.next_free_node = node_id; + } + try self.refUnsafe(left_id, states); + // refUnsafe skips zero nodes, so only undo the ref when left is non-zero. + errdefer if (!states[@intFromEnum(left_id)].isZero()) { + _ = states[@intFromEnum(left_id)].decRefCount(); + }; + try self.refUnsafe(right_id, states); return node_id; } @@ -258,9 +269,16 @@ pub const Pool = struct { std.debug.assert(@intFromEnum(self.next_free_node) <= self.nodes.len); if (@intFromEnum(self.next_free_node) == self.nodes.len) { const remaining = out.len - i; - try self.preheat(@intCast(remaining)); - // TODO how to handle failing to resize here - // errdefer self.free(out[0..i]); + self.preheat(@intCast(remaining)) catch |err| { + // Preheat ran out of memory: put back the slots we already took. They're + // unreferenced, so push them back onto the free list (unref would underflow). + const states_now = self.nodes.items(.state); + for (out[0..i]) |id| { + states_now[@intFromEnum(id)] = State.initNextFree(self.next_free_node); + self.next_free_node = id; + } + return err; + }; states = self.nodes.items(.state); allocated = true; @@ -611,6 +629,10 @@ pub const Id = enum(u32) { if (indices.len == 0) { return root_node; } + // Callers must pass strictly-ascending indices; unsorted or duplicate input silently + // corrupts the tree. assert is a no-op in unsafe builds, so the loop is optimized away + // there; keep it after the empty guard, since `for (1..0)` would panic. + for (1..indices.len) |k| std.debug.assert(indices[k - 1] < indices[k]); const base_gindex = Gindex.fromDepth(depth, 0); @@ -620,10 +642,14 @@ pub const Id = enum(u32) { const path_len = base_gindex.pathLen(); - var path_parents_buf: [max_depth]Id = undefined; + // Zero-filled so that if a later iteration errors, the errdefer's free skips the + // not-yet-filled slots (freeing a zero id is a no-op) instead of unref-ing garbage. + var path_parents_buf: [max_depth]Id = @splat(@as(Id, @enumFromInt(0))); // at each level, there is at most 1 unfinalized parent per traversal - // "unfinalized" means it may or may not be part of the new tree - var unfinalized_parents_buf: [max_depth]?Id = undefined; + // "unfinalized" means it may or may not be part of the new tree. + // Must start all-null: the cleanup loop reads slots for right-moves it never wrote this + // pass, and an undefined `?Id` could look non-null and unref a garbage Id. + var unfinalized_parents_buf: [max_depth]?Id = @splat(null); var path_lefts_buf: [max_depth]Id = undefined; var path_rights_buf: [max_depth]Id = undefined; // right_move means it's part of the new tree, it happens when we traverse right @@ -845,10 +871,14 @@ pub const Id = enum(u32) { const path_len = base_gindex.pathLen(); - var path_parents_buf: [max_depth]Id = undefined; + // Zero-filled so that if a later iteration errors, the errdefer's free skips the + // not-yet-filled slots (freeing a zero id is a no-op) instead of unref-ing garbage. + var path_parents_buf: [max_depth]Id = @splat(@as(Id, @enumFromInt(0))); // at each level, there is at most 1 unfinalized parent per traversal - // "unfinalized" means it may or may not be part of the new tree - var unfinalized_parents_buf: [max_depth]?Id = undefined; + // "unfinalized" means it may or may not be part of the new tree. + // Must start all-null: the cleanup loop reads slots for right-moves it never wrote this + // pass, and an undefined `?Id` could look non-null and unref a garbage Id. + var unfinalized_parents_buf: [max_depth]?Id = @splat(null); var path_lefts_buf: [max_depth]Id = undefined; var path_rights_buf: [max_depth]Id = undefined; // right_move means it's part of the new tree, it happens when we traverse right @@ -1237,8 +1267,15 @@ pub const FillWithContentsIterator = struct { var carry = node_id; for (0..self.depth) |level| { if (self.lefts[level]) |left| { + // Build the branch before clearing `left`, so a failed createBranch leaves `left` + // reclaimable by deinit. Release the orphaned `carry` too — unless it's the same + // node as `left` (the all-default path pairs a node with itself). + const branch = self.pool.createBranch(left, carry) catch |err| { + if (carry != left) self.pool.unref(carry); + return err; + }; self.lefts[level] = null; - carry = try self.pool.createBranch(left, carry); + carry = branch; } else { self.lefts[level] = carry; return; @@ -1269,10 +1306,19 @@ pub const FillWithContentsIterator = struct { // Starting from the lowest non-null, build upwards with zero-nodes. for (start_level..self.depth) |level| { if (self.lefts[level]) |left| { + // Same as above: keep `left` reclaimable and release `carry` on failure. Here + // `carry` is never the same node as `left`. + const branch = self.pool.createBranch(left, carry) catch |err| { + self.pool.unref(carry); + return err; + }; self.lefts[level] = null; - carry = try self.pool.createBranch(left, carry); + carry = branch; } else { - carry = try self.pool.createBranch(carry, @enumFromInt(@as(u32, @intCast(level)))); + carry = self.pool.createBranch(carry, @enumFromInt(@as(u32, @intCast(level)))) catch |err| { + self.pool.unref(carry); + return err; + }; } } return carry; diff --git a/src/persistent_merkle_tree/View.zig b/src/persistent_merkle_tree/View.zig index 3e064601f..a75b81b62 100644 --- a/src/persistent_merkle_tree/View.zig +++ b/src/persistent_merkle_tree/View.zig @@ -91,11 +91,15 @@ pub const Pool = struct { return n; } + /// Destroy a view and return its slot to the free list. Destroy children before parents: a + /// surviving child still points at this view's id, so once the slot is reused it would drive + /// its writes into an unrelated view. The assert catches that early in safe builds. pub fn destroy(self: *Pool, view_id: View.Id) void { const view = &self.views.items[@intFromEnum(view_id)]; // delink the view from its children and deinit the children hashmap if (self.parent_views.fetchRemove(view_id)) |kv| { var children = kv.value; + std.debug.assert(children.count() == 0); children.deinit(self.allocator); } // delink the view from its parent @@ -106,6 +110,9 @@ pub const Pool = struct { } // unref the root node self.node_pool.unref(view.root_node); + // Poison the slot so a later reuse can't mistake these leftover fields for a live view. + view.parent = null; + view.root_node = @enumFromInt(0); // push to the free list view.next_free = self.next_free; self.next_free = view_id; diff --git a/src/persistent_merkle_tree/node_test.zig b/src/persistent_merkle_tree/node_test.zig index ba619c444..7124eda60 100644 --- a/src/persistent_merkle_tree/node_test.zig +++ b/src/persistent_merkle_tree/node_test.zig @@ -6,6 +6,67 @@ const Depth = @import("hashing").Depth; const Node = @import("Node.zig"); const Gindex = @import("gindex.zig").Gindex; +// Allocate until the pool is full, so the next request has to grow (and fail). Returns the filler. +fn drainPoolToFull(pool: *Node.Pool, out: *std.ArrayList(Node.Id)) !void { + while (pool.createLeafFromUint(0)) |id| { + try out.append(std.testing.allocator, id); + } else |err| switch (err) { + // Match exhaustively so a new error variant breaks the build instead of ending the drain. + error.OutOfMemory => {}, + } +} + +// When append's createBranch fails, both `left` (still in self.lefts) and `carry` must be +// reclaimed. Here they are two different nodes. +test "FillWithContentsIterator - createBranch OOM with distinct nodes does not leak" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); + var pool = try Node.Pool.init(failing.allocator(), 4); + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + + const a = try pool.createLeafFromUint(1); + const b = try pool.createLeafFromUint(2); + + var drained: std.ArrayList(Node.Id) = .empty; + defer drained.deinit(std.testing.allocator); + failing.fail_index = failing.alloc_index; // next allocation (growth) fails + try drainPoolToFull(&pool, &drained); + + var iter = Node.FillWithContentsIterator.init(&pool, 1); + try iter.append(a); // stored in lefts[0], no allocation + try std.testing.expectError(error.OutOfMemory, iter.append(b)); // createBranch(a, b) must grow + iter.deinit(); // must reclaim a (lefts[0]) and b (unref'd in append) + + for (drained.items) |id| pool.unref(id); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); +} + +// The all-default path pairs a node with itself (createBranch(X, X)). On failure append must not +// unref `carry` — it's the same node as `left`, which deinit reclaims; doing both double-frees. +test "FillWithContentsIterator - createBranch OOM with aliased node does not double-free" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); + var pool = try Node.Pool.init(failing.allocator(), 4); + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + + const x = try pool.createLeafFromUint(7); + + var drained: std.ArrayList(Node.Id) = .empty; + defer drained.deinit(std.testing.allocator); + failing.fail_index = failing.alloc_index; // next allocation (growth) fails + try drainPoolToFull(&pool, &drained); + + var iter = Node.FillWithContentsIterator.init(&pool, 1); + try iter.append(x); // lefts[0] = x + try std.testing.expectError(error.OutOfMemory, iter.append(x)); // createBranch(x, x) must grow + iter.deinit(); // reclaims x exactly once (carry==left so append did not unref it) + + for (drained.items) |id| pool.unref(id); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); +} + test "Node.State" { const State = Node.State; @@ -214,6 +275,22 @@ test "setNodes for checkpoint tree" { try std.testing.expectEqual(new_root_node, out[1]); } +// Empty `indices` should return the root untouched, not panic on the `for (1..0)` assert. +test "setNodesAtDepth - empty indices returns root unchanged" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 64); + defer pool.deinit(); + const p = &pool; + + const root = try pool.createBranch(@enumFromInt(1), @enumFromInt(1)); + defer pool.unref(root); + + const indices = [_]usize{}; + var nodes = [_]Node.Id{}; + const result = try root.setNodesAtDepth(p, 2, &indices, &nodes); + try std.testing.expectEqual(root, result); +} + test "Depth helpers - round-trip setNodesAtDepth / getNodesAtDepth" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(allocator, 64); @@ -246,6 +323,42 @@ test "Depth helpers - round-trip setNodesAtDepth / getNodesAtDepth" { for (0..4) |i| try std.testing.expectEqual(leaves[i], out[i]); } +// If an early iteration errors, the errdefer frees path_parents while its start is still unfilled; +// the zero-fill makes those frees no-ops instead of unref-ing garbage. +test "setNodesAtDepth - early-iteration error frees cleanly without leaking or corrupting" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 64); + defer pool.deinit(); + const p = &pool; + + // A leaf root hits InvalidNode on the first index; {0,1} share a prefix, so path_parents[0] + // hasn't been filled yet when the errdefer runs. + const root = try pool.createLeafFromUint(42); + defer pool.unref(root); + + const baseline = pool.getNodesInUse(); + + var leaves = [_]Node.Id{ @enumFromInt(0), @enumFromInt(0) }; + const indices = [_]usize{ 0, 1 }; + try std.testing.expectError( + Node.Error.InvalidNode, + root.setNodesAtDepth(p, 2, &indices, &leaves), + ); + + // Nothing extra was freed: the in-use count is back where it started. + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + + // The pool still works: a normal op round-trips (it would hit a freed slot if corrupted). + const tree_root = try pool.createBranch(@enumFromInt(1), @enumFromInt(1)); + defer pool.unref(tree_root); + const new_leaf = try pool.createLeafFromUint(7); + var ok_leaves = [_]Node.Id{new_leaf}; + const ok_indices = [_]usize{0}; + const new_root = try tree_root.setNodesAtDepth(p, 1, &ok_indices, &ok_leaves); + defer pool.unref(new_root); + try std.testing.expectEqual(new_leaf, try new_root.getNode(p, Gindex.fromDepth(1, 0))); +} + const TestCase = struct { depth: u6, gindexes: []const usize, diff --git a/src/ssz/tree_view/array_composite.zig b/src/ssz/tree_view/array_composite.zig index 8d3825f71..396ea5ea4 100644 --- a/src/ssz/tree_view/array_composite.zig +++ b/src/ssz/tree_view/array_composite.zig @@ -133,6 +133,8 @@ pub fn ArrayCompositeTreeView(comptime ST: type) type { return elem.getRoot().getRoot(self.chunks.state.pool); } + /// Takes ownership of `value` on success. On error.IndexOutOfBounds it does not — the + /// caller keeps `value` and must deinit it. pub fn set(self: *Self, index: usize, value: Element) !void { if (index >= length) return error.IndexOutOfBounds; try self.chunks.set(index, value); diff --git a/src/ssz/tree_view/chunks.zig b/src/ssz/tree_view/chunks.zig index 82cd72df7..4aee7fefc 100644 --- a/src/ssz/tree_view/chunks.zig +++ b/src/ssz/tree_view/chunks.zig @@ -171,6 +171,9 @@ pub fn CompositeChunks( self.children_data = .empty; } + /// Clone, optionally moving the child-view cache to `out`. With `transfer_cache = true`, + /// any pointer from an earlier get()/getReadonly() is invalidated — cached `changed` + /// children get deinited (and get() counts as a change even on a read). pub fn clone(self: *Self, opts: CloneOpts, out: *Self) !void { if (!opts.transfer_cache) { try self.state.clone(opts, &out.state); @@ -178,20 +181,22 @@ pub fn CompositeChunks( return; } - // Transfer children_data, removing uncommitted entries. - out.children_data = self.children_data; + // Trim self's own cache first (in place): if the state clone below fails, self stays + // valid instead of pointing at a half-modified map. { const changed_keys = self.state.changed.keys(); for (changed_keys) |gindex| { - if (out.children_data.fetchRemove(gindex)) |entry| { + if (self.children_data.fetchRemove(gindex)) |entry| { entry.value.deinit(); } } } - // changed_keys borrow is now out of scope. - // Clone state (transfers children_nodes, clears self caches). + // Clone the state — the only step here that can fail. try self.state.clone(opts, &out.state); + + // Now move the cache over to the clone and empty self. + out.children_data = self.children_data; self.children_data = .empty; } @@ -209,12 +214,15 @@ pub fn CompositeChunks( return; } + // Reserve first so storing each committed root can't fail. Otherwise a getOrPut OOM + // after a child already committed would leave a stale entry pointing at its freed root. + try self.state.children_nodes.ensureUnusedCapacity(self.state.allocator, @intCast(self.state.changed.count())); + // Flush child views into children_nodes so commitNodes can handle them uniformly. for (self.state.changed.keys()) |gindex| { if (self.children_data.get(gindex)) |child_ptr| { try child_ptr.commit(); - const gop = try self.state.children_nodes.getOrPut(self.state.allocator, gindex); - gop.value_ptr.* = child_ptr.getRoot(); + self.state.children_nodes.putAssumeCapacity(gindex, child_ptr.getRoot()); } } @@ -226,6 +234,8 @@ pub fn CompositeChunks( self.clearChildrenDataCache(); } + /// Returns a borrowed child view owned by this cache. A later set() on the same index or a + /// clone(transfer_cache) invalidates it — re-get() after either, and don't deinit it. pub fn get(self: *Self, index: usize) !ElementPtr { const gindex = Gindex.fromDepth(chunk_depth, index); // Always mark as changed - the child may have been previously cached @@ -235,20 +245,31 @@ pub fn CompositeChunks( if (gop.found_existing) { return gop.value_ptr.*; } + // getOrPut's new slot holds an undefined value until we fill it below; drop it on + // failure, or a later deinit would free a garbage pointer. + errdefer _ = self.children_data.remove(gindex); const child_node = try self.state.getChildNode(gindex); const child_ptr = try Element.init(self.state.allocator, self.state.pool, child_node); gop.value_ptr.* = child_ptr; return child_ptr; } + /// Takes ownership of `value` (and deinits it if a reservation fails). Deinits whatever + /// child was cached for `index`, so any earlier get()/getReadonly() of it is now invalid. + /// Pass a view you own — never a get()/getReadonly() pointer for this same index, or a + /// failed set would deinit a view the cache still holds (double-free). pub fn set(self: *Self, index: usize, value: ElementPtr) !void { const gindex = Gindex.fromDepth(chunk_depth, index); - try self.state.changed.put(self.state.allocator, gindex, {}); - const opt_old_data = try self.children_data.fetchPut( - self.state.allocator, - gindex, - value, - ); + // Reserve before storing so neither store can fail. A failure mid-store would drop + // `value` (we own it now, the caller won't free it) or leave `changed` and + // `children_data` out of sync. + { + errdefer value.deinit(); + try self.state.changed.ensureUnusedCapacity(self.state.allocator, 1); + try self.children_data.ensureUnusedCapacity(self.state.allocator, 1); + } + self.state.changed.putAssumeCapacity(gindex, {}); + const opt_old_data = self.children_data.fetchPutAssumeCapacity(gindex, value); if (opt_old_data) |old_data_value| { var child_ptr: ElementPtr = @constCast(&old_data_value.value.*); if (child_ptr != value) { @@ -257,7 +278,8 @@ pub fn CompositeChunks( } } - /// Get a child view without tracking changes (read-only access). + /// Like get() but doesn't mark the index changed. Same borrow rules: a later set() on this + /// index or clone(transfer_cache) invalidates the pointer; don't deinit it. pub fn getReadonly(self: *Self, index: usize) !ElementPtr { const gindex = Gindex.fromDepth(chunk_depth, index); if (self.children_data.get(gindex)) |child_ptr| { @@ -295,9 +317,12 @@ pub fn CompositeChunks( /// Set a child from an SSZ value type. pub fn setValue(self: *Self, index: usize, value: *const Value) !void { const root = try ST.Element.tree.fromValue(self.state.pool, value); - errdefer self.state.pool.unref(root); - const child_view = try Element.init(self.state.allocator, self.state.pool, root); - errdefer child_view.deinit(); + // Free `root` only if init fails. Once init succeeds, `set` owns `child_view` on every + // path, so we must not deinit it here; that would double-free if set later fails. + const child_view = Element.init(self.state.allocator, self.state.pool, root) catch |err| { + self.state.pool.unref(root); + return err; + }; try self.set(index, child_view); } diff --git a/src/ssz/tree_view/container.zig b/src/ssz/tree_view/container.zig index 2bef1392b..028040e32 100644 --- a/src/ssz/tree_view/container.zig +++ b/src/ssz/tree_view/container.zig @@ -56,6 +56,10 @@ pub fn ContainerTreeView(comptime ST: type) type { return ptr; } + /// Clone this view, optionally moving its child-view cache to the clone. + /// `transfer_cache = true` invalidates any pointer from an earlier get()/getReadonly(): + /// cached `changed` children get deinited (and get() counts as a change even on a read). + /// Re-fetch from whichever view you keep. pub fn clone(self: *Self, opts: CloneOpts) !*Self { const ptr = try init(self.allocator, self.pool, self.root); if (!opts.transfer_cache) { @@ -198,6 +202,10 @@ pub fn ContainerTreeView(comptime ST: type) type { /// Get a field by name. If the field is a basic type, returns the value directly. /// Caller borrows a reference to child value so there is no need to deinit it. + /// + /// A composite field returns a borrowed *TreeView owned by this parent. A later set() on + /// the field or a clone(transfer_cache) invalidates it — re-get() instead. (This also + /// marks the field changed, even though it's a read.) pub fn get(self: *Self, comptime field_name: []const u8) !Field(field_name) { const field_index = comptime ST.getFieldIndex(field_name); const ChildST = ST.getFieldType(field_name); @@ -231,7 +239,8 @@ pub fn ContainerTreeView(comptime ST: type) type { /// Set a field by name. If the field is a basic type, pass the value directly. /// If the field is a complex type, pass a TreeView of the corresponding type. /// The caller transfers ownership of the `value` TreeView to this parent view. - /// The existing TreeView, if any, will be deinited by this function. + /// Deinits the field's existing TreeView, so any earlier get()/getReadonly() of it is now + /// invalid. Keep `value`, or re-get() the field, to use the new view. pub fn set(self: *Self, comptime field_name: []const u8, value: Field(field_name)) !void { const field_index = comptime ST.getFieldIndex(field_name); const ChildST = ST.getFieldType(field_name); @@ -339,8 +348,9 @@ pub fn ContainerTreeView(comptime ST: type) type { } } - /// Get a field by name without tracking changes (read-only access). - /// For basic types, returns the value. For composite types, returns a borrowed *TreeView. + /// Like get() but doesn't mark the field changed. A composite field returns a borrowed + /// *TreeView owned by this parent; a later set() on the field or clone(transfer_cache) + /// invalidates it. Don't deinit it. pub fn getReadonly(self: *Self, comptime field_name: []const u8) !Field(field_name) { comptime { @setEvalBranchQuota(20000); diff --git a/src/ssz/tree_view/list_basic.zig b/src/ssz/tree_view/list_basic.zig index f6e9d25a3..ad87d3ca5 100644 --- a/src/ssz/tree_view/list_basic.zig +++ b/src/ssz/tree_view/list_basic.zig @@ -49,6 +49,8 @@ pub fn ListBasicTreeView(comptime ST: type) type { errdefer allocator.destroy(ptr); try Chunks.init(&ptr.chunks, allocator, pool, root); + errdefer ptr.chunks.deinit(); + ptr.allocator = allocator; ptr._orig_len = try ptr.chunks.getLength(); ptr._len = ptr._orig_len; @@ -183,7 +185,10 @@ pub fn ListBasicTreeView(comptime ST: type) type { if (list_length >= ST.limit) { return error.LengthOverLimit; } + self._len += 1; + errdefer self._len -= 1; + try self.set(list_length, value); } @@ -214,27 +219,30 @@ pub fn ListBasicTreeView(comptime ST: type) type { var truncated_chunk_node: ?Node.Id = try self.chunks.state.pool.createLeaf(&chunk_bytes); defer if (truncated_chunk_node) |id| self.chunks.state.pool.unref(id); - var updated: ?Node.Id = try Node.Id.setNodeAtDepth( + + const updated = try Node.Id.setNodeAtDepth( self.chunks.state.root, self.chunks.state.pool, chunk_depth, chunk_index, truncated_chunk_node.?, ); - defer if (updated) |id| self.chunks.state.pool.unref(id); + // `updated` is a fresh orphan root from setNodeAtDepth; we own it, so unref it. + defer self.chunks.state.pool.unref(updated); truncated_chunk_node = null; - var new_root: ?Node.Id = try Node.Id.truncateAfterIndex(updated.?, self.chunks.state.pool, chunk_depth, chunk_index); - defer if (new_root) |id| self.chunks.state.pool.unref(id); - updated = null; + const new_root = try Node.Id.truncateAfterIndex(updated, self.chunks.state.pool, chunk_depth, chunk_index); + // Likewise `new_root` is a fresh orphan from truncateAfterIndex; unref it. + defer self.chunks.state.pool.unref(new_root); var length_node: ?Node.Id = try self.chunks.state.pool.createLeafFromUint(@intCast(new_length)); defer if (length_node) |id| self.chunks.state.pool.unref(id); - const root_with_length = try Node.Id.setNode(new_root.?, self.chunks.state.pool, @enumFromInt(3), length_node.?); - errdefer self.chunks.state.pool.unref(root_with_length); + // setNode takes `length_node` into the tree, so null it below to keep the defer from + // unref-ing what the tree now owns. + const root_with_length = try Node.Id.setNode(new_root, self.chunks.state.pool, @enumFromInt(3), length_node.?); + errdefer self.chunks.state.pool.unref(root_with_length); length_node = null; - new_root = null; return try Self.init(self.allocator, self.chunks.state.pool, root_with_length); } @@ -760,6 +768,33 @@ test "TreeView basic list sliceTo matches incremental snapshots" { } } +// std.testing.allocator can't see pool-slot leaks, so check getNodesInUse() against a baseline. +test "TreeView basic list sliceTo does not leak pool nodes" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 2048); + defer pool.deinit(); + + const Uint64 = UintType(64); + const ListType = FixedListType(Uint64, 1024); + + var empty_list: ListType.Type = .empty; + defer empty_list.deinit(allocator); + const root_node = try ListType.tree.fromValue(&pool, &empty_list); + var view = try ListType.TreeView.init(allocator, &pool, root_node); + defer view.deinit(); + + for (0..16) |i| try view.push(@intCast(i)); + try view.commit(); + + const baseline = pool.getNodesInUse(); + for (0..15) |idx| { + var sliced = try view.sliceTo(idx); + sliced.deinit(); + // Any difference means an intermediate orphan root leaked. + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + test "TreeView list sliceTo truncates tail elements" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(allocator, 256); diff --git a/src/ssz/tree_view/list_composite.zig b/src/ssz/tree_view/list_composite.zig index 2ac6ae6a6..f98a0408a 100644 --- a/src/ssz/tree_view/list_composite.zig +++ b/src/ssz/tree_view/list_composite.zig @@ -50,12 +50,17 @@ pub fn ListCompositeTreeView(comptime ST: type) type { errdefer allocator.destroy(ptr); try Chunks.init(&ptr.chunks, allocator, pool, root); + errdefer ptr.chunks.deinit(); + ptr.allocator = allocator; ptr._orig_len = try ptr.chunks.getLength(); ptr._len = ptr._orig_len; return ptr; } + /// Clone this list view, optionally moving its element-view cache to the clone. + /// `transfer_cache = true` invalidates any pointer from an earlier get()/getReadonly(): + /// cached `changed` elements get deinited (and get() counts as a change even on a read). pub fn clone(self: *Self, opts: CloneOpts) !*Self { const ptr = try self.allocator.create(Self); errdefer self.allocator.destroy(ptr); @@ -115,12 +120,15 @@ pub fn ListCompositeTreeView(comptime ST: type) type { return self._len; } + /// Returns a borrowed element view owned by this list view. A later set() on the same index + /// or a clone(transfer_cache) invalidates it; re-get() after either, and don't deinit it. pub fn get(self: *Self, index: usize) !Element { const list_length = try self.length(); if (index >= list_length) return error.IndexOutOfBounds; return self.chunks.get(index); } + /// Read-only variant of `get`; same borrow/invalidation rules apply. pub fn getReadonly(self: *Self, index: usize) !Element { const list_length = try self.length(); if (index >= list_length) return error.IndexOutOfBounds; @@ -147,6 +155,9 @@ pub fn ListCompositeTreeView(comptime ST: type) type { return elem.getRoot().getRoot(self.chunks.state.pool); } + /// On success takes ownership of `value` and deinits the element cached for `index`, so any + /// earlier get()/getReadonly() of it is now invalid. On error.IndexOutOfBounds the caller + /// keeps `value` (ownership only transfers once it reaches the backing chunks). pub fn set(self: *Self, index: usize, value: Element) !void { const list_length = try self.length(); if (index >= list_length) return error.IndexOutOfBounds; @@ -175,15 +186,22 @@ pub fn ListCompositeTreeView(comptime ST: type) type { } self._len += 1; + errdefer self._len -= 1; + try self.set(list_length, value); } /// Push an SSZ value type, creating a TreeView internally. pub fn pushValue(self: *Self, value: *const ST.Element.Type) !void { + // Check the limit first. After this, push always takes the view (set frees it on its + // own OOM), so adding a cleanup errdefer here would double-free. + if ((try self.length()) >= ST.limit) return error.LengthOverLimit; + const root = try ST.Element.tree.fromValue(self.chunks.state.pool, value); - errdefer self.chunks.state.pool.unref(root); - const child_view = try ST.Element.TreeView.init(self.allocator, self.chunks.state.pool, root); - errdefer child_view.deinit(); + const child_view = ST.Element.TreeView.init(self.allocator, self.chunks.state.pool, root) catch |err| { + self.chunks.state.pool.unref(root); + return err; + }; try self.push(child_view); } @@ -230,14 +248,22 @@ pub fn ListCompositeTreeView(comptime ST: type) type { /// Get the next element as an SSZ value type. pub fn nextValue(self: *ReadonlyIterator, allocator: Allocator) !ST.Element.Type { const node = try self.depth_iterator.next(); - self.elem_index += 1; - var value: ST.Element.Type = undefined; if (comptime isFixedType(ST.Element)) { + var value: ST.Element.Type = undefined; try ST.Element.tree.toValue(node, self.tree_view.chunks.state.pool, &value); + self.elem_index += 1; + return value; } else { + // Variable-size elements: toValue reads `out` (it resizes embedded ArrayLists), + // so initialize it before the call. + var value: ST.Element.Type = if (comptime @hasDecl(ST.Element, "default_value")) + ST.Element.default_value + else + std.mem.zeroes(ST.Element.Type); try ST.Element.tree.toValue(allocator, node, self.tree_view.chunks.state.pool, &value); + self.elem_index += 1; + return value; } - return value; } }; @@ -256,15 +282,18 @@ pub fn ListCompositeTreeView(comptime ST: type) type { return error.LengthOverLimit; } - var chunk_root: ?Node.Id = try Node.Id.truncateAfterIndex(self.chunks.state.root, self.chunks.state.pool, chunk_depth, index); - defer if (chunk_root) |id| self.chunks.state.pool.unref(id); + // `chunk_root` is a fresh orphan root from truncateAfterIndex; we own it, so unref it. + const chunk_root = try Node.Id.truncateAfterIndex(self.chunks.state.root, self.chunks.state.pool, chunk_depth, index); + defer self.chunks.state.pool.unref(chunk_root); var length_node: ?Node.Id = try self.chunks.state.pool.createLeafFromUint(@intCast(new_length)); defer if (length_node) |id| self.chunks.state.pool.unref(id); - const root_with_length = try Node.Id.setNode(chunk_root.?, self.chunks.state.pool, @enumFromInt(3), length_node.?); + + // setNode takes `length_node` into the tree, so null it to keep the defer from + // unref-ing what the tree now owns. + const root_with_length = try Node.Id.setNode(chunk_root, self.chunks.state.pool, @enumFromInt(3), length_node.?); errdefer self.chunks.state.pool.unref(root_with_length); length_node = null; - chunk_root = null; return try Self.init(self.allocator, self.chunks.state.pool, root_with_length); } @@ -383,6 +412,240 @@ test "TreeView composite list sliceTo truncates elements" { try std.testing.expectEqual(checkpoints[1].epoch, roundtrip.items[1].epoch); } +// std.testing.allocator can't see pool-slot leaks, so check getNodesInUse() against a baseline. +test "TreeView composite list sliceTo does not leak pool nodes" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 512); + defer pool.deinit(); + + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(allocator); + for (0..8) |i| try list.append(allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + const root_node = try ListType.tree.fromValue(&pool, &list); + var view = try ListType.TreeView.init(allocator, &pool, root_node); + defer view.deinit(); + + const baseline = pool.getNodesInUse(); + for (0..7) |idx| { + var sliced = try view.sliceTo(idx); + sliced.deinit(); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + +const DoubleFreeDetectAllocator = @import("testing_allocators").DoubleFreeDetectAllocator; + +// set takes ownership of the view, so setValue must not deinit it too. Sweep every OOM point. +test "TreeView composite list setValue - OOM does not double-free the element view" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..3) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + const newval: Checkpoint.Type = .{ .epoch = 99, .root = [_]u8{0xee} ** 32 }; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(alloc, 0) catch continue; + defer pool.deinit(); + + const root = ListType.tree.fromValue(&pool, &list) catch continue; + var view = ListType.TreeView.init(alloc, &pool, root) catch { + pool.unref(root); + continue; + }; + defer view.deinit(); + + // We only care that no path double-frees; the OOM itself is expected. + view.setValue(0, &newval) catch {}; + try std.testing.expect(!oom.double_free); + } +} + +test "TreeView composite list push - OOM does not double-free" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..3) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + const newval: Checkpoint.Type = .{ .epoch = 99, .root = [_]u8{0xee} ** 32 }; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(alloc, 0) catch continue; + defer pool.deinit(); + + const root = ListType.tree.fromValue(&pool, &list) catch continue; + var view = ListType.TreeView.init(alloc, &pool, root) catch { + pool.unref(root); + continue; + }; + defer view.deinit(); + + view.pushValue(&newval) catch {}; + try std.testing.expect(!oom.double_free); + } +} + +test "TreeView composite list clone(transfer_cache) - OOM does not double-free cached children" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..3) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(alloc, 0) catch continue; + defer pool.deinit(); + + const root = ListType.tree.fromValue(&pool, &list) catch continue; + var view = ListType.TreeView.init(alloc, &pool, root) catch { + pool.unref(root); + continue; + }; + defer view.deinit(); + + // Cache a child so the transfer_cache path has something to move. + _ = view.get(0) catch {}; + const cloned = view.clone(.{ .transfer_cache = true }) catch { + try std.testing.expect(!oom.double_free); + continue; + }; + cloned.deinit(); + try std.testing.expect(!oom.double_free); + } +} + +test "TreeView composite list commit - OOM does not double-free" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..3) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + const newval: Checkpoint.Type = .{ .epoch = 99, .root = [_]u8{0xee} ** 32 }; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(alloc, 0) catch continue; + defer pool.deinit(); + + const root = ListType.tree.fromValue(&pool, &list) catch continue; + var view = ListType.TreeView.init(alloc, &pool, root) catch { + pool.unref(root); + continue; + }; + defer view.deinit(); + + // Stage a change so commit has work; the sweep injects OOM inside commit too. + view.setValue(0, &newval) catch { + try std.testing.expect(!oom.double_free); + continue; + }; + view.commit() catch {}; + try std.testing.expect(!oom.double_free); + } +} + +test "TreeView composite list fromValue - OOM leaves no orphan pool nodes" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..6) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + var fail_at: usize = 0; + while (fail_at < 400) : (fail_at += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); + var pool = Node.Pool.init(failing.allocator(), 0) catch continue; + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + const root = ListType.tree.fromValue(&pool, &list) catch { + // OOM mid-build: the error path must release every partial node. + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + continue; + }; + pool.unref(root); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + +test "TreeView composite list deserializeFromBytes - OOM leaves no orphan pool nodes" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..6) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + const bytes = try std.testing.allocator.alloc(u8, ListType.serializedSize(&list)); + defer std.testing.allocator.free(bytes); + _ = ListType.serializeIntoBytes(&list, bytes); + + var fail_at: usize = 0; + while (fail_at < 400) : (fail_at += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); + var pool = Node.Pool.init(failing.allocator(), 0) catch continue; + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + const root = ListType.tree.deserializeFromBytes(&pool, bytes) catch { + // OOM mid-build: the error path must release every partial node. + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + continue; + }; + pool.unref(root); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + +test "TreeView composite list deserializeFromBytes - malformed input errors without leaking" { + const ListType = FixedListType(Checkpoint, 16); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..6) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + const bytes = try std.testing.allocator.alloc(u8, ListType.serializedSize(&list)); + defer std.testing.allocator.free(bytes); + _ = ListType.serializeIntoBytes(&list, bytes); + + var pool = try Node.Pool.init(std.testing.allocator, 512); + defer pool.deinit(); + const baseline = pool.getNodesInUse(); + + // Every truncated prefix must either parse cleanly (a shorter valid list) or error, and + // never leak pool nodes either way. + var len: usize = 0; + while (len <= bytes.len) : (len += 1) { + const root = ListType.tree.deserializeFromBytes(&pool, bytes[0..len]) catch { + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + continue; + }; + pool.unref(root); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + test "TreeView composite list sliceFrom returns suffix" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(allocator, 512); diff --git a/src/ssz/type/container.zig b/src/ssz/type/container.zig index 502c352e1..d7bce1e34 100644 --- a/src/ssz/type/container.zig +++ b/src/ssz/type/container.zig @@ -148,7 +148,8 @@ pub fn FixedContainerType(comptime ST: type) type { pub const tree = struct { pub fn default(pool: *Node.Pool) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); inline for (fields, 0..) |field, i| { if (comptime isBasicType(field.type)) { @@ -165,7 +166,8 @@ pub fn FixedContainerType(comptime ST: type) type { return error.InvalidSize; } - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); var offset: usize = 0; @@ -189,7 +191,8 @@ pub fn FixedContainerType(comptime ST: type) type { } pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); inline for (fields, 0..) |field, i| { @@ -597,7 +600,8 @@ pub fn VariableContainerType(comptime ST: type) type { pub const tree = struct { pub fn default(pool: *Node.Pool) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); inline for (fields, 0..) |field, i| { if (comptime isBasicType(field.type)) { @@ -615,7 +619,8 @@ pub fn VariableContainerType(comptime ST: type) type { } const ranges = try readFieldRanges(data); - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); inline for (fields, 0..) |field, i| { @@ -643,7 +648,8 @@ pub fn VariableContainerType(comptime ST: type) type { } pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); inline for (fields, 0..) |field, i| { diff --git a/src/ssz/type/vector.zig b/src/ssz/type/vector.zig index b5aa5ed92..11e95bed1 100644 --- a/src/ssz/type/vector.zig +++ b/src/ssz/type/vector.zig @@ -155,7 +155,8 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { return error.InvalidSize; } - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); if (comptime isBasicType(Element)) { @@ -203,7 +204,8 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { } pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); if (comptime isBasicType(Element)) { @@ -428,7 +430,8 @@ pub fn VariableVectorType(comptime ST: type, comptime _length: comptime_int) typ } const offsets = try readVariableOffsets(data); - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); for (0..length) |i| { @@ -455,7 +458,8 @@ pub fn VariableVectorType(comptime ST: type, comptime _length: comptime_int) typ } pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { - var nodes: [chunk_count]Node.Id = undefined; + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. + var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); for (0..chunk_count) |i| { diff --git a/src/state_transition/cache/epoch_cache.zig b/src/state_transition/cache/epoch_cache.zig index 784bb7140..d230aa85f 100644 --- a/src/state_transition/cache/epoch_cache.zig +++ b/src/state_transition/cache/epoch_cache.zig @@ -534,25 +534,28 @@ pub const EpochCache = struct { pub fn afterProcessEpoch(self: *EpochCache, state: *AnyBeaconState, epoch_transition_cache: *const EpochTransitionCache) !void { const upcoming_epoch = self.epoch + 1; const epoch_after_upcoming = upcoming_epoch + 1; + const slot = try state.slot(); - // move current to previous - self.previous_shuffling.unref(); - // no need to release current_shuffling and next_shuffling - self.previous_shuffling = self.current_shuffling; - self.current_shuffling = self.next_shuffling; - // allocate next_shuffling_active_indices here and transfer owner ship to EpochShuffling const next_shuffling_active_indices = try self.allocator.alloc(ValidatorIndex, epoch_transition_cache.next_shuffling_active_indices.len); std.mem.copyForwards(ValidatorIndex, next_shuffling_active_indices, epoch_transition_cache.next_shuffling_active_indices); + const next_shuffling = try computeEpochShuffling( self.allocator, state, next_shuffling_active_indices, epoch_after_upcoming, ); - self.next_shuffling = try EpochShufflingRc.init(self.allocator, next_shuffling); + errdefer next_shuffling.deinit(); + + const next_shuffling_rc = try EpochShufflingRc.init(self.allocator, next_shuffling); + + self.previous_shuffling.unref(); + self.previous_shuffling = self.current_shuffling; + self.current_shuffling = self.next_shuffling; + self.next_shuffling = next_shuffling_rc; self.churn_limit = getChurnLimit(self.config, self.current_shuffling.get().active_indices.len); - self.activation_churn_limit = getActivationChurnLimit(self.config, self.config.forkSeq(try state.slot()), self.current_shuffling.get().active_indices.len); + self.activation_churn_limit = getActivationChurnLimit(self.config, self.config.forkSeq(slot), self.current_shuffling.get().active_indices.len); const exit_queue_epoch = computeActivationExitEpoch(upcoming_epoch); if (exit_queue_epoch > self.exit_queue_epoch) { @@ -570,7 +573,7 @@ pub const EpochCache = struct { self.previous_target_unslashed_balance_increments = self.current_target_unslashed_balance_increments; self.current_target_unslashed_balance_increments = 0; - self.epoch = computeEpochAtSlot(try state.slot()); + self.epoch = computeEpochAtSlot(slot); self.sync_period = computeSyncPeriodAtEpoch(self.epoch); } @@ -614,10 +617,13 @@ pub const EpochCache = struct { } pub fn beforeEpochTransition(self: *EpochCache) !void { - // Clone (copy) before being mutated in processEffectiveBalanceUpdates - const effective_balance_increments = try self.effective_balance_increments.get().clone(self.allocator); + var effective_balance_increments = try self.effective_balance_increments.get().clone(self.allocator); + errdefer effective_balance_increments.deinit(self.allocator); + + const new_rc = try EffectiveBalanceIncrementsRc.init(self.allocator, effective_balance_increments); + self.effective_balance_increments.unref(); - self.effective_balance_increments = try EffectiveBalanceIncrementsRc.init(self.allocator, effective_balance_increments); + self.effective_balance_increments = new_rc; } /// Consumer borrows the returned slice @@ -770,14 +776,18 @@ pub const EpochCache = struct { /// Sets `index` at `PublicKey` within the index to pubkey map and allocates and puts a new `PublicKey` at `index` within the set of validators. pub fn addPubkey(self: *EpochCache, index: ValidatorIndex, pubkey: *const types.primitive.BLSPubkey.Type) !void { std.debug.assert(index <= self.index_to_pubkey.items.len); - try self.pubkey_to_index.put(pubkey.*, index); - // this is deinit() by application - const pk = try bls.PublicKey.uncompress(pubkey); - if (index == self.index_to_pubkey.items.len) { - try self.index_to_pubkey.append(self.allocator, pk); - return; + const appending = index == self.index_to_pubkey.items.len; + + const public_key = try bls.PublicKey.uncompress(pubkey); + try self.pubkey_to_index.ensureUnusedCapacity(1); + if (appending) try self.index_to_pubkey.ensureUnusedCapacity(self.allocator, 1); + + self.pubkey_to_index.putAssumeCapacity(pubkey.*, index); + if (appending) { + self.index_to_pubkey.appendAssumeCapacity(public_key); + } else { + self.index_to_pubkey.items[index] = public_key; } - self.index_to_pubkey.items[index] = pk; } // TODO: getBeaconCommittee @@ -861,15 +871,17 @@ pub const EpochCache = struct { const old = self.effective_balance_increments.get(); const new_len = index + 1; const capacity = 1024 * @divFloor(new_len + 1024, 1024); - var new_increments = try EffectiveBalanceIncrements.initCapacity(self.allocator, capacity); - errdefer new_increments.deinit(self.allocator); - try new_increments.resize(self.allocator, new_len); + var new_increments = try EffectiveBalanceIncrements.initCapacity(allocator, capacity); + errdefer new_increments.deinit(allocator); + + try new_increments.resize(allocator, new_len); @memcpy(new_increments.items[0..old.items.len], old.items); @memset(new_increments.items[old.items.len..new_len], 0); + const new_rc = try EffectiveBalanceIncrementsRc.init(allocator, new_increments); self.effective_balance_increments.unref(); - self.effective_balance_increments = try EffectiveBalanceIncrementsRc.init(allocator, new_increments); + self.effective_balance_increments = new_rc; } self.effective_balance_increments.get().items[index] = @intCast(@divFloor(effective_balance, preset.EFFECTIVE_BALANCE_INCREMENT)); } diff --git a/src/state_transition/cache/epoch_transition_cache.zig b/src/state_transition/cache/epoch_transition_cache.zig index ba73a1cc6..1c73c140f 100644 --- a/src/state_transition/cache/epoch_transition_cache.zig +++ b/src/state_transition/cache/epoch_transition_cache.zig @@ -231,11 +231,17 @@ pub const EpochTransitionCache = struct { const slashings_epoch = current_epoch + @divFloor(preset.EPOCHS_PER_SLASHINGS_VECTOR, 2); var indices_to_slash: std.ArrayList(ValidatorIndex) = .empty; + errdefer indices_to_slash.deinit(allocator); + var indices_eligible_for_activation_queue: std.ArrayList(ValidatorIndex) = .empty; + errdefer indices_eligible_for_activation_queue.deinit(allocator); + // we will extract indices_eligible_for_activation from validator_activation_list later var validator_activation_list: ValidatorActivationList = .empty; defer validator_activation_list.deinit(allocator); + var indices_to_eject: std.ArrayList(ValidatorIndex) = .empty; + errdefer indices_to_eject.deinit(allocator); var total_active_stake_by_increment: u64 = 0; const validators = try state.validatorsSlice(allocator); @@ -506,6 +512,7 @@ pub const EpochTransitionCache = struct { // zig specific map function similar to "indicesEligibleForActivation.map(({validatorIndex}) => validatorIndex)" var indices_eligible_for_activation = try std.ArrayList(ValidatorIndex).initCapacity(allocator, validator_activation_list.items.len); + errdefer indices_eligible_for_activation.deinit(allocator); for (validator_activation_list.items) |activation| { try indices_eligible_for_activation.append(allocator, activation.validator_index); } diff --git a/src/state_transition/signature_sets/bls_to_execution_change.zig b/src/state_transition/signature_sets/bls_to_execution_change.zig index 915c6cfbb..bc38c790e 100644 --- a/src/state_transition/signature_sets/bls_to_execution_change.zig +++ b/src/state_transition/signature_sets/bls_to_execution_change.zig @@ -28,7 +28,7 @@ pub fn getBlsToExecutionChangeSignatureSet(config: *const BeaconConfig, signed_b }; } -pub fn getBlsToExecutionChangeSignatureSets(allocator: std.mem.Allocator, config: *const BeaconConfig, signed_block: *const AnySignedBeaconBlock, out: std.ArrayList(SingleSignatureSet)) !void { +pub fn getBlsToExecutionChangeSignatureSets(allocator: std.mem.Allocator, config: *const BeaconConfig, signed_block: *const AnySignedBeaconBlock, out: *std.ArrayList(SingleSignatureSet)) !void { const bls_to_execution_changes = signed_block.beaconBlock().beaconBlockBody().blsToExecutionChanges().items; for (bls_to_execution_changes) |signed_bls_to_execution_change| { const signature_set = try getBlsToExecutionChangeSignatureSet(config, signed_bls_to_execution_change); diff --git a/src/state_transition/signature_sets/indexed_attestation.zig b/src/state_transition/signature_sets/indexed_attestation.zig index f71c2b924..a3b42e561 100644 --- a/src/state_transition/signature_sets/indexed_attestation.zig +++ b/src/state_transition/signature_sets/indexed_attestation.zig @@ -68,7 +68,7 @@ pub fn getIndexedAttestationSignatureSet( /// Appends to out all the AggregatedSignatureSet for each attestation in the signed_block /// Consumer need to free the pubkeys arrays in each AggregatedSignatureSet in out /// TODO: consume in https://github.com/ChainSafe/state-transition-z/issues/72 -pub fn attestationsSignatureSets(allocator: Allocator, cached_state: *const CachedBeaconState, signed_block: *const AnySignedBeaconBlock, out: std.ArrayList(AggregatedSignatureSet)) !void { +pub fn attestationsSignatureSets(allocator: Allocator, cached_state: *const CachedBeaconState, signed_block: *const AnySignedBeaconBlock, out: *std.ArrayList(AggregatedSignatureSet)) !void { const epoch_cache = cached_state.epoch_cache; const attestation_items = signed_block.beaconBlock().beaconBlockBody().attestations().items(); @@ -79,7 +79,7 @@ pub fn attestationsSignatureSets(allocator: Allocator, cached_state: *const Cach var attesting_indices = indexed_attestation.attesting_indices; defer attesting_indices.deinit(allocator); const signature_set = try getIndexedAttestationSignatureSet(allocator, cached_state, indexed_attestation); - try out.append(signature_set); + try out.append(allocator, signature_set); } }, .electra => |electra_attestations| { @@ -88,7 +88,7 @@ pub fn attestationsSignatureSets(allocator: Allocator, cached_state: *const Cach var attesting_indices = indexed_attestation.attesting_indices; defer attesting_indices.deinit(allocator); const signature_set = try getIndexedAttestationSignatureSet(allocator, cached_state, indexed_attestation); - try out.append(signature_set); + try out.append(allocator, signature_set); } }, } diff --git a/src/state_transition/signature_sets/proposer.zig b/src/state_transition/signature_sets/proposer.zig index daae6ccea..fc3124d27 100644 --- a/src/state_transition/signature_sets/proposer.zig +++ b/src/state_transition/signature_sets/proposer.zig @@ -39,8 +39,16 @@ pub fn getBlockProposerSignatureSet( try computeBlockSigningRoot(allocator, block, domain, &signing_root_buf); // Root.uncompressFromBytes(&signing_root_buf, &signing_root); + + // The proposer index isn't validated until processBlockHeader, so a malicious block could + // put an out-of-range value here. + const proposer_index = block.proposerIndex(); + if (proposer_index >= epoch_cache.index_to_pubkey.items.len) { + return error.InvalidProposerIndex; + } + return .{ - .pubkey = epoch_cache.index_to_pubkey.items[block.proposerIndex()], + .pubkey = epoch_cache.index_to_pubkey.items[proposer_index], .signing_root = signing_root_buf, .signature = signed_block.signature().*, }; diff --git a/src/state_transition/signature_sets/proposer_slashings.zig b/src/state_transition/signature_sets/proposer_slashings.zig index 6df8b2768..ab6e0b04b 100644 --- a/src/state_transition/signature_sets/proposer_slashings.zig +++ b/src/state_transition/signature_sets/proposer_slashings.zig @@ -43,16 +43,17 @@ pub fn getProposerSlashingSignatureSets( pub fn proposerSlashingsSignatureSets( comptime fork: ForkSeq, + allocator: std.mem.Allocator, config: *const BeaconConfig, epoch_cache: *const EpochCache, state: *const BeaconState(fork), signed_block: *const ForkTypes(fork).SignedBeaconBlock.Type, - out: std.ArrayList(SingleSignatureSet), + out: *std.ArrayList(SingleSignatureSet), ) !void { const proposer_slashings = signed_block.message.body.proposer_slashings.items; for (proposer_slashings) |*proposer_slashing| { const signature_sets = try getProposerSlashingSignatureSets(fork, config, epoch_cache, state, proposer_slashing); - try out.append(signature_sets[0]); - try out.append(signature_sets[1]); + try out.append(allocator, signature_sets[0]); + try out.append(allocator, signature_sets[1]); } } diff --git a/src/state_transition/signature_sets/voluntary_exits.zig b/src/state_transition/signature_sets/voluntary_exits.zig index 493134bb6..07551f845 100644 --- a/src/state_transition/signature_sets/voluntary_exits.zig +++ b/src/state_transition/signature_sets/voluntary_exits.zig @@ -46,7 +46,7 @@ pub fn voluntaryExitsSignatureSets( config: *const BeaconConfig, epoch_cache: *const EpochCache, voluntary_exits: []types.phase0.SignedVoluntaryExit.Type, - out: std.ArrayList(SingleSignatureSet), + out: *std.ArrayList(SingleSignatureSet), ) !void { for (voluntary_exits) |*signed_voluntary_exit| { const signature_set = try getVoluntaryExitSignatureSet( diff --git a/src/state_transition/state_transition.zig b/src/state_transition/state_transition.zig index c4238a364..6bf4ce10d 100644 --- a/src/state_transition/state_transition.zig +++ b/src/state_transition/state_transition.zig @@ -325,3 +325,38 @@ test "state transition - electra block" { defer deinitStateTransition(std.testing.io); } + +test "state transition - a rejected block leaves the pre-state unchanged" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 256 * 5); + defer pool.deinit(); + defer deinitStateTransition(std.testing.io); + + var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); + defer test_state.deinit(); + + var electra_block = types.electra.SignedBeaconBlock.default_value; + try generateElectraBlock(allocator, test_state.cached_state, &electra_block); + defer types.electra.SignedBeaconBlock.deinit(allocator, &electra_block); + + const signed_beacon_block = AnySignedBeaconBlock{ .full_electra = &electra_block }; + + // Snapshot the pre-state just before the transition. + const before = (try test_state.cached_state.state.hashTreeRoot()).*; + const before_slot = try test_state.cached_state.state.slot(); + + // Full verification rejects this block (it isn't validly signed). stateTransition advances + // and mutates a clone, then discards it on error — so the original state must come out + // untouched: same root, same slot. (This is the invariant behind the "mutate then reject" + // findings; the mutations only ever land on the thrown-away clone.) + const res = stateTransition(allocator, std.testing.io, test_state.cached_state, signed_beacon_block, .{}); + if (res) |post| { + post.deinit(); + allocator.destroy(post); + try testing.expect(false); // expected the block to be rejected + } else |_| {} + + const after = (try test_state.cached_state.state.hashTreeRoot()).*; + try testing.expectEqualSlices(u8, &before, &after); + try testing.expectEqual(before_slot, try test_state.cached_state.state.slot()); +} diff --git a/src/testing_allocators.zig b/src/testing_allocators.zig new file mode 100644 index 000000000..928ae63b7 --- /dev/null +++ b/src/testing_allocators.zig @@ -0,0 +1,48 @@ +//! Test-only allocators shared across module test suites. +const std = @import("std"); + +/// A failing allocator (via std.testing.FailingAllocator) that also catches double-frees. It fails +/// the Nth alloc to inject OOM, and resize_fail_index = 0 forces every growth through alloc so the +/// failure actually lands. Freeing an address that isn't live flags `double_free` instead of +/// forwarding to the GPA (which would panic), so a test can sweep OOM points in a loop without +/// crashing on the first double-free. Liveness is tracked by raw address. +pub const DoubleFreeDetectAllocator = struct { + failing: std.testing.FailingAllocator, + live: std.AutoHashMap(usize, void), + double_free: bool = false, + + pub fn init(backing: std.mem.Allocator, fail_after: usize) DoubleFreeDetectAllocator { + return .{ + .failing = std.testing.FailingAllocator.init(backing, .{ .fail_index = fail_after, .resize_fail_index = 0 }), + .live = std.AutoHashMap(usize, void).init(std.heap.page_allocator), + }; + } + pub fn deinit(self: *DoubleFreeDetectAllocator) void { + self.live.deinit(); + } + pub fn allocator(self: *DoubleFreeDetectAllocator) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = allocFn, .resize = resizeFn, .remap = remapFn, .free = freeFn } }; + } + fn allocFn(ctx: *anyopaque, len: usize, a: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *DoubleFreeDetectAllocator = @ptrCast(@alignCast(ctx)); + const p = self.failing.allocator().rawAlloc(len, a, ra) orelse return null; + self.live.put(@intFromPtr(p), {}) catch {}; + return p; + } + fn resizeFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, new_len: usize, ra: usize) bool { + const self: *DoubleFreeDetectAllocator = @ptrCast(@alignCast(ctx)); + return self.failing.allocator().rawResize(memory, a, new_len, ra); + } + fn remapFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, new_len: usize, ra: usize) ?[*]u8 { + const self: *DoubleFreeDetectAllocator = @ptrCast(@alignCast(ctx)); + return self.failing.allocator().rawRemap(memory, a, new_len, ra); + } + fn freeFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, ra: usize) void { + const self: *DoubleFreeDetectAllocator = @ptrCast(@alignCast(ctx)); + if (self.live.remove(@intFromPtr(memory.ptr))) { + self.failing.allocator().rawFree(memory, a, ra); + } else { + self.double_free = true; // freeing memory that is not currently live + } + } +}; From 5196f67df8bace3d4770708bf263c40065b5aac1 Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 27 May 2026 14:47:34 -0400 Subject: [PATCH 20/46] chore: disable gemini auto code review (#382) Resolves #380 --- .gemini/config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gemini/config.yaml b/.gemini/config.yaml index 191648c07..4ea4d072e 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -1,6 +1,6 @@ have_fun: false code_review: - disable: false + disable: true comment_severity_threshold: MEDIUM max_review_comments: -1 pull_request_opened: @@ -8,4 +8,4 @@ code_review: summary: true code_review: true include_drafts: true -ignore_patterns: [] \ No newline at end of file +ignore_patterns: [] From 07652a8cea1aa31ab044637506839a7d20af8deb Mon Sep 17 00:00:00 2001 From: Marko Lazic Date: Thu, 28 May 2026 13:36:59 +0200 Subject: [PATCH 21/46] chore: move state clones out of benchmark run functions (#324) ## bench: move state clones out of benchmark run functions Closes #164 ### Problem Benchmark `run` functions were cloning and freeing `CachedBeaconState` on every iteration. This clone/deinit cost was included in the timed measurement, skewing results for `processBlock`, `processEpoch`, and their individual sub-step benchmarks. This is visible through the `runImpl` inside `zbench`. This change should also increase readability of the affected files. ### Solution Moved state cloning into zbench's `before_each`/`after_each` lifecycle hooks using module-level variables: - `beforeEach` clones the cached state before each timed iteration - `afterEach` frees the clone after the iteration completes - `run` functions now operate directly on the pre-cloned instance Also removed the now-unused `cached_state` field from all benchmark structs and cleaned up the corresponding `addParam` calls. Applied to `process_block.zig` and `process_epoch.zig`. *AI disclosure: Claude was consulted for reviewing the approach and drafting this description. All code changes were authored manually.* --- bench/state_transition/process_block.zig | 154 ++++-------- bench/state_transition/process_epoch.zig | 298 +++++++---------------- bench/state_transition/utils.zig | 21 ++ 3 files changed, 162 insertions(+), 311 deletions(-) diff --git a/bench/state_transition/process_block.zig b/bench/state_transition/process_block.zig index 13dae5e5f..e24e6af39 100644 --- a/bench/state_transition/process_block.zig +++ b/bench/state_transition/process_block.zig @@ -27,6 +27,7 @@ const Index2PubkeyCache = state_transition.Index2PubkeyCache; const slotFromStateBytes = @import("utils.zig").slotFromStateBytes; const loadState = @import("utils.zig").loadState; const loadBlock = @import("utils.zig").loadBlock; +const BenchState = @import("utils.zig").BenchState; const BenchOpts = struct { verify_signature: bool, @@ -34,21 +35,14 @@ const BenchOpts = struct { fn ProcessBlockHeaderBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, block: *const BeaconBlock(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - state_transition.processBlockHeader( fork, allocator, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), .full, self.block, ) catch unreachable; @@ -58,16 +52,9 @@ fn ProcessBlockHeaderBench(comptime fork: ForkSeq) type { fn ProcessWithdrawalsBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - var withdrawals_buf: [preset.MAX_WITHDRAWALS_PER_PAYLOAD]types.capella.Withdrawal.Type = undefined; var withdrawals_result = WithdrawalsResult{ .withdrawals = Withdrawals.initBuffer(&withdrawals_buf), @@ -76,10 +63,10 @@ fn ProcessWithdrawalsBench(comptime fork: ForkSeq) type { var withdrawal_balances = std.AutoHashMap(ValidatorIndex, usize).init(allocator); defer withdrawal_balances.deinit(); - const state = cloned.state.castToFork(fork); + const state = BenchState.cloned_cached_state.state.castToFork(fork); state_transition.getExpectedWithdrawals( fork, - cloned.epoch_cache, + BenchState.cloned_cached_state.epoch_cache, state, &withdrawals_result, &withdrawal_balances, @@ -102,23 +89,16 @@ fn ProcessWithdrawalsBench(comptime fork: ForkSeq) type { fn ProcessExecutionPayloadBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - const external_data = BlockExternalData{ .execution_payload_status = .valid, .data_availability_status = .available }; state_transition.processExecutionPayload( fork, allocator, - cloned.config, - cloned.state.castToFork(fork), - cloned.epoch_cache.epoch, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache.epoch, .full, self.body, external_data, @@ -129,22 +109,17 @@ fn ProcessExecutionPayloadBench(comptime fork: ForkSeq) type { fn ProcessRandaoBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { return struct { - cached_state: *CachedBeaconState, block: *const BeaconBlock(.full, fork), body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; state_transition.processRandao( fork, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), .full, self.body, self.block.proposerIndex(), @@ -156,19 +131,14 @@ fn ProcessRandaoBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { fn ProcessEth1DataBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; state_transition.processEth1Data( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), self.body.eth1Data(), ) catch unreachable; } @@ -177,23 +147,16 @@ fn ProcessEth1DataBench(comptime fork: ForkSeq) type { fn ProcessOperationsBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { return struct { - cached_state: *CachedBeaconState, body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - state_transition.processOperations( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), - &cloned.slashings_cache, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), + &BenchState.cloned_cached_state.slashings_cache, .full, self.body, .{ .verify_signature = opts.verify_signature }, @@ -204,22 +167,15 @@ fn ProcessOperationsBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type fn ProcessSyncAggregateBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { return struct { - cached_state: *CachedBeaconState, body: *const BeaconBlockBody(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - state_transition.processSyncAggregate( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), self.body.syncAggregate(), opts.verify_signature, ) catch unreachable; @@ -229,24 +185,17 @@ fn ProcessSyncAggregateBench(comptime fork: ForkSeq, comptime opts: BenchOpts) t fn ProcessBlockBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { return struct { - cached_state: *CachedBeaconState, block: *const BeaconBlock(.full, fork), pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - const external_data = BlockExternalData{ .execution_payload_status = .valid, .data_availability_status = .available }; state_transition.processBlock( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), - &cloned.slashings_cache, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), + &BenchState.cloned_cached_state.slashings_cache, .full, self.block, external_data, @@ -307,22 +256,14 @@ fn printSegmentStats(stdout: *std.Io.Writer) !void { fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, block: *const BeaconBlock(.full, fork), body: *const BeaconBlockBody(.full, fork), io: std.Io, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { const io = self.io; - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - - const state = cloned.state.castToFork(fork); - const epoch_cache = cloned.epoch_cache; - + const state = BenchState.cloned_cached_state.state.castToFork(fork); + const epoch_cache = BenchState.cloned_cached_state.epoch_cache; const block_start = time.timestampNow(io); const header_start = time.timestampNow(io); @@ -371,7 +312,7 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { state_transition.processExecutionPayload( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, state, epoch_cache.epoch, .full, @@ -384,7 +325,7 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { const randao_start = time.timestampNow(io); state_transition.processRandao( fork, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, state, .full, @@ -406,10 +347,10 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { state_transition.processOperations( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, state, - &cloned.slashings_cache, + &BenchState.cloned_cached_state.slashings_cache, .full, self.body, .{ .verify_signature = true }, @@ -421,7 +362,7 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { state_transition.processSyncAggregate( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, state, self.body.syncAggregate(), @@ -444,6 +385,7 @@ pub fn main(init: std.process.Init) !void { var stdout_buf: [4096]u8 = undefined; var stdout_file_writer = std.Io.File.stdout().writer(io, &stdout_buf); var stdout = &stdout_file_writer.interface; + var pool = try Node.Pool.init(allocator, 10_000_000); defer pool.deinit(); @@ -535,6 +477,7 @@ fn runBenchmark( .index_to_pubkey = index_pubkey_cache, .pubkey_to_index = &pubkey_index_map, }, .{ .skip_sync_committee_cache = !comptime fork.gte(.altair), .skip_sync_pubkeys = false }); + BenchState.init(allocator, cached_state); beacon_state = null; defer { cached_state.deinit(); @@ -552,37 +495,40 @@ fn runBenchmark( try state_transition.buildSlashingsCacheFromStateIfNeeded(allocator, cached_state.state, &cached_state.slashings_cache); try stdout.print("State: slot={}, validators={}\n", .{ try cached_state.state.slot(), try cached_state.state.validatorsCount() }); + const hooks: zbench.Hooks = .{ .before_each = BenchState.beforeEach, .after_each = BenchState.afterEach }; + var bench = zbench.Benchmark.init(allocator, .{ .iterations = 50, }); defer bench.deinit(); - try bench.addParam("block_header", &ProcessBlockHeaderBench(fork){ .cached_state = cached_state, .block = block }, .{}); + try bench.addParam("block_header", &ProcessBlockHeaderBench(fork){ .block = block }, .{ .hooks = hooks }); if (comptime fork.gte(.capella) and fork.lt(.gloas)) { - try bench.addParam("withdrawals", &ProcessWithdrawalsBench(fork){ .cached_state = cached_state, .body = body }, .{}); + try bench.addParam("withdrawals", &ProcessWithdrawalsBench(fork){ .body = body }, .{ .hooks = hooks }); } if (comptime fork.gte(.bellatrix) and fork.lt(.gloas)) { - try bench.addParam("execution_payload", &ProcessExecutionPayloadBench(fork){ .cached_state = cached_state, .body = body }, .{}); + try bench.addParam("execution_payload", &ProcessExecutionPayloadBench(fork){ .body = body }, .{ .hooks = hooks }); } - try bench.addParam("randao", &ProcessRandaoBench(fork, .{ .verify_signature = true }){ .cached_state = cached_state, .block = block, .body = body }, .{}); - try bench.addParam("randao_no_sig", &ProcessRandaoBench(fork, .{ .verify_signature = false }){ .cached_state = cached_state, .block = block, .body = body }, .{}); - try bench.addParam("eth1_data", &ProcessEth1DataBench(fork){ .cached_state = cached_state, .body = body }, .{}); - try bench.addParam("operations", &ProcessOperationsBench(fork, .{ .verify_signature = true }){ .cached_state = cached_state, .body = body }, .{}); - try bench.addParam("operations_no_sig", &ProcessOperationsBench(fork, .{ .verify_signature = false }){ .cached_state = cached_state, .body = body }, .{}); + try bench.addParam("randao", &ProcessRandaoBench(fork, .{ .verify_signature = true }){ .block = block, .body = body }, .{ .hooks = hooks }); + try bench.addParam("randao_no_sig", &ProcessRandaoBench(fork, .{ .verify_signature = false }){ .block = block, .body = body }, .{ .hooks = hooks }); + try bench.addParam("eth1_data", &ProcessEth1DataBench(fork){ .body = body }, .{ .hooks = hooks }); + try bench.addParam("operations", &ProcessOperationsBench(fork, .{ .verify_signature = true }){ .body = body }, .{ .hooks = hooks }); + try bench.addParam("operations_no_sig", &ProcessOperationsBench(fork, .{ .verify_signature = false }){ .body = body }, .{ .hooks = hooks }); if (comptime fork.gte(.altair)) { - try bench.addParam("sync_aggregate", &ProcessSyncAggregateBench(fork, .{ .verify_signature = true }){ .cached_state = cached_state, .body = body }, .{}); - try bench.addParam("sync_aggregate_no_sig", &ProcessSyncAggregateBench(fork, .{ .verify_signature = false }){ .cached_state = cached_state, .body = body }, .{}); + try bench.addParam("sync_aggregate", &ProcessSyncAggregateBench(fork, .{ .verify_signature = true }){ .body = body }, .{ .hooks = hooks }); + try bench.addParam("sync_aggregate_no_sig", &ProcessSyncAggregateBench(fork, .{ .verify_signature = false }){ .body = body }, .{ .hooks = hooks }); } - try bench.addParam("process_block", &ProcessBlockBench(fork, .{ .verify_signature = true }){ .cached_state = cached_state, .block = block }, .{}); - try bench.addParam("process_block_no_sig", &ProcessBlockBench(fork, .{ .verify_signature = false }){ .cached_state = cached_state, .block = block }, .{}); + try bench.addParam("process_block", &ProcessBlockBench(fork, .{ .verify_signature = true }){ .block = block }, .{ .hooks = hooks }); + try bench.addParam("process_block_no_sig", &ProcessBlockBench(fork, .{ .verify_signature = false }){ .block = block }, .{ .hooks = hooks }); // // Segmented benchmark (step-by-step timing) resetSegmentStats(); - try bench.addParam("block(segments)", &ProcessBlockSegmentedBench(fork){ .cached_state = cached_state, .block = block, .body = body, .io = io }, .{}); + + try bench.addParam("block(segments)", &ProcessBlockSegmentedBench(fork){ .block = block, .body = body, .io = io }, .{ .hooks = hooks }); try bench.run(io, std.Io.File.stdout()); try printSegmentStats(stdout); diff --git a/bench/state_transition/process_epoch.zig b/bench/state_transition/process_epoch.zig index 00c4c34a3..42626cc17 100644 --- a/bench/state_transition/process_epoch.zig +++ b/bench/state_transition/process_epoch.zig @@ -20,22 +20,20 @@ const Index2PubkeyCache = state_transition.Index2PubkeyCache; const ValidatorIndex = types.primitive.ValidatorIndex.Type; const slotFromStateBytes = @import("utils.zig").slotFromStateBytes; const loadState = @import("utils.zig").loadState; +const BenchState = @import("utils.zig").BenchState; fn ProcessJustificationAndFinalizationBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; + const cache = self.epoch_transition_cache; + state_transition.processJustificationAndFinalization( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -45,23 +43,17 @@ fn ProcessJustificationAndFinalizationBench(comptime fork: ForkSeq) type { fn ProcessBeforeProcessEpochBench(comptime fork: ForkSeq) type { comptime _ = fork; return struct { - cached_state: *CachedBeaconState, io: std.Io, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } - cloned.state.commit() catch unreachable; + BenchState.cloned_cached_state.state.commit() catch unreachable; var epoch_transition_cache = EpochTransitionCache.init( allocator, self.io, - cloned.config, - cloned.epoch_cache, - cloned.state, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state, ) catch unreachable; defer epoch_transition_cache.deinit(allocator); } @@ -70,22 +62,17 @@ fn ProcessBeforeProcessEpochBench(comptime fork: ForkSeq) type { fn ProcessInactivityUpdatesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; + state_transition.processInactivityUpdates( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -94,25 +81,20 @@ fn ProcessInactivityUpdatesBench(comptime fork: ForkSeq) type { fn ProcessRewardsAndPenaltiesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, io: std.Io, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; - const validator_count = cloned.state.validatorsCount() catch unreachable; + const validator_count = BenchState.cloned_cached_state.state.validatorsCount() catch unreachable; cache.syncRewardPenaltyLengths(self.io, validator_count) catch unreachable; + state_transition.processRewardsAndPenalties( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, null, ) catch unreachable; @@ -122,21 +104,17 @@ fn ProcessRewardsAndPenaltiesBench(comptime fork: ForkSeq) type { fn ProcessRegistryUpdatesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; const cache = self.epoch_transition_cache; + state_transition.processRegistryUpdates( fork, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -145,21 +123,16 @@ fn ProcessRegistryUpdatesBench(comptime fork: ForkSeq) type { fn ProcessSlashingsBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; + _ = state_transition.processSlashings( fork, allocator, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, true, ) catch unreachable; @@ -169,20 +142,15 @@ fn ProcessSlashingsBench(comptime fork: ForkSeq) type { fn ProcessEth1DataResetBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; const cache = self.epoch_transition_cache; state_transition.processEth1DataReset( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -191,23 +159,17 @@ fn ProcessEth1DataResetBench(comptime fork: ForkSeq) type { fn ProcessPendingDepositsBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; state_transition.processPendingDeposits( fork, allocator, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -216,21 +178,16 @@ fn ProcessPendingDepositsBench(comptime fork: ForkSeq) type { fn ProcessPendingConsolidationsBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; const cache = self.epoch_transition_cache; state_transition.processPendingConsolidations( fork, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -239,22 +196,16 @@ fn ProcessPendingConsolidationsBench(comptime fork: ForkSeq) type { fn ProcessEffectiveBalanceUpdatesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; _ = state_transition.processEffectiveBalanceUpdates( fork, allocator, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -263,21 +214,16 @@ fn ProcessEffectiveBalanceUpdatesBench(comptime fork: ForkSeq) type { fn ProcessSlashingsResetBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; const cache = self.epoch_transition_cache; state_transition.processSlashingsReset( fork, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -286,20 +232,16 @@ fn ProcessSlashingsResetBench(comptime fork: ForkSeq) type { fn ProcessRandaoMixesResetBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; + const cache = self.epoch_transition_cache; state_transition.processRandaoMixesReset( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -308,20 +250,16 @@ fn ProcessRandaoMixesResetBench(comptime fork: ForkSeq) type { fn ProcessHistoricalSummariesUpdateBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = allocator; + const cache = self.epoch_transition_cache; state_transition.processHistoricalSummariesUpdate( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -330,18 +268,13 @@ fn ProcessHistoricalSummariesUpdateBench(comptime fork: ForkSeq) type { fn ProcessParticipationFlagUpdatesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, - pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = self; + _ = allocator; state_transition.processParticipationFlagUpdates( fork, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.state.castToFork(fork), ) catch unreachable; } }; @@ -349,21 +282,14 @@ fn ProcessParticipationFlagUpdatesBench(comptime fork: ForkSeq) type { fn ProcessSyncCommitteeUpdatesBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, - epoch_transition_cache: *EpochTransitionCache, - pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } + _ = self; state_transition.processSyncCommitteeUpdates( fork, allocator, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), ) catch unreachable; } }; @@ -371,22 +297,16 @@ fn ProcessSyncCommitteeUpdatesBench(comptime fork: ForkSeq) type { fn ProcessProposerLookaheadBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, epoch_transition_cache: *EpochTransitionCache, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const cache = self.epoch_transition_cache; state_transition.processProposerLookahead( fork, allocator, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), cache, ) catch unreachable; } @@ -454,34 +374,28 @@ fn printSegmentStats(stdout: *std.Io.Writer) !void { fn ProcessEpochBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, io: std.Io, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } var cache = EpochTransitionCache.init( allocator, self.io, - cloned.config, - cloned.epoch_cache, - cloned.state, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state, ) catch unreachable; defer cache.deinit(allocator); - const validator_count = cloned.state.validatorsCount() catch unreachable; + const validator_count = BenchState.cloned_cached_state.state.validatorsCount() catch unreachable; cache.syncRewardPenaltyLengths(self.io, validator_count) catch unreachable; state_transition.processEpoch( fork, allocator, self.io, - cloned.config, - cloned.epoch_cache, - cloned.state.castToFork(fork), + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), &cache, ) catch unreachable; } @@ -490,33 +404,27 @@ fn ProcessEpochBench(comptime fork: ForkSeq) type { fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { return struct { - cached_state: *CachedBeaconState, io: std.Io, pub fn run(self: *@This(), allocator: std.mem.Allocator) void { const io = self.io; - const cloned = self.cached_state.clone(allocator, .{}) catch unreachable; - defer { - cloned.deinit(); - allocator.destroy(cloned); - } const epoch_start = time.timestampNow(io); const before_start = time.timestampNow(io); - cloned.state.commit() catch unreachable; + BenchState.cloned_cached_state.state.commit() catch unreachable; var cache_val = EpochTransitionCache.init( allocator, io, - cloned.config, - cloned.epoch_cache, - cloned.state, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state, ) catch unreachable; defer cache_val.deinit(allocator); const cache = &cache_val; recordSegment(.before_process_epoch, @as(u64, @intCast(time.since(io, before_start).nanoseconds))); - const fork_state = cloned.state.castToFork(fork); - const epoch_cache = cloned.epoch_cache; + const fork_state = BenchState.cloned_cached_state.state.castToFork(fork); + const epoch_cache = BenchState.cloned_cached_state.epoch_cache; const jf_start = time.timestampNow(io); state_transition.processJustificationAndFinalization(fork, fork_state, cache) catch unreachable; @@ -527,7 +435,7 @@ fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { state_transition.processInactivityUpdates( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, fork_state, cache, @@ -538,7 +446,7 @@ fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { const registry_start = time.timestampNow(io); state_transition.processRegistryUpdates( fork, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, fork_state, cache, @@ -560,7 +468,7 @@ fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { state_transition.processRewardsAndPenalties( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, fork_state, cache, @@ -577,7 +485,7 @@ fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { state_transition.processPendingDeposits( fork, allocator, - cloned.config, + BenchState.cloned_cached_state.config, epoch_cache, fork_state, cache, @@ -777,6 +685,7 @@ fn runBenchmark( .skip_sync_committee_cache = !comptime fork.gte(.altair), .skip_sync_pubkeys = false, }); + BenchState.init(allocator, cached_state); beacon_state = null; defer { cached_state.deinit(); @@ -795,111 +704,86 @@ fn runBenchmark( try stdout.print("Cached state created at slot {}\n", .{try cached_state.state.slot()}); try stdout.print("\nStarting process_epoch benchmarks for {s} fork...\n\n", .{@tagName(fork)}); + const hooks: zbench.Hooks = .{ .before_each = BenchState.beforeEach, .after_each = BenchState.afterEach }; + var bench = zbench.Benchmark.init(allocator, .{ .iterations = 50 }); defer bench.deinit(); - try bench.addParam("before_process_epoch", &ProcessBeforeProcessEpochBench(fork){ - .cached_state = cached_state, - .io = io, - }, .{}); + try bench.addParam("before_process_epoch", &ProcessBeforeProcessEpochBench(fork){ .io = io }, .{ .hooks = hooks }); try bench.addParam("justification_finalization", &ProcessJustificationAndFinalizationBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); if (comptime fork.gte(.altair)) { try bench.addParam("inactivity_updates", &ProcessInactivityUpdatesBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); } try bench.addParam("rewards_and_penalties", &ProcessRewardsAndPenaltiesBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, .io = io, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("registry_updates", &ProcessRegistryUpdatesBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("slashings", &ProcessSlashingsBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("eth1_data_reset", &ProcessEth1DataResetBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); if (comptime fork.gte(.electra)) { try bench.addParam("pending_deposits", &ProcessPendingDepositsBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("pending_consolidations", &ProcessPendingConsolidationsBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); } try bench.addParam("effective_balance_updates", &ProcessEffectiveBalanceUpdatesBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("slashings_reset", &ProcessSlashingsResetBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); try bench.addParam("randao_mixes_reset", &ProcessRandaoMixesResetBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); if (comptime fork.gte(.capella)) { try bench.addParam("historical_summaries", &ProcessHistoricalSummariesUpdateBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); } if (comptime fork.gte(.altair)) { - try bench.addParam("participation_flags", &ProcessParticipationFlagUpdatesBench(fork){ - .cached_state = cached_state, - }, .{}); + try bench.addParam("participation_flags", &ProcessParticipationFlagUpdatesBench(fork){}, .{ .hooks = hooks }); - try bench.addParam("sync_committee_updates", &ProcessSyncCommitteeUpdatesBench(fork){ - .cached_state = cached_state, - .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + try bench.addParam("sync_committee_updates", &ProcessSyncCommitteeUpdatesBench(fork){}, .{ .hooks = hooks }); } if (comptime fork.gte(.fulu)) { try bench.addParam("proposer_lookahead", &ProcessProposerLookaheadBench(fork){ - .cached_state = cached_state, .epoch_transition_cache = &epoch_transition_cache, - }, .{}); + }, .{ .hooks = hooks }); } // Non-segmented - try bench.addParam("epoch(non-segmented)", &ProcessEpochBench(fork){ - .cached_state = cached_state, - .io = io, - }, .{}); + try bench.addParam("epoch(non-segmented)", &ProcessEpochBench(fork){ .io = io }, .{ .hooks = hooks }); // Segmented (step-by-step timing) resetSegmentStats(); - try bench.addParam("epoch(segmented)", &ProcessEpochSegmentedBench(fork){ - .cached_state = cached_state, - .io = io, - }, .{}); + try bench.addParam("epoch(segmented)", &ProcessEpochSegmentedBench(fork){ .io = io }, .{ .hooks = hooks }); try bench.run(io, std.Io.File.stdout()); try printSegmentStats(stdout); diff --git a/bench/state_transition/utils.zig b/bench/state_transition/utils.zig index f5a3e0106..c458e779a 100644 --- a/bench/state_transition/utils.zig +++ b/bench/state_transition/utils.zig @@ -5,12 +5,33 @@ const Node = @import("persistent_merkle_tree").Node; const types = @import("consensus_types"); const config = @import("config"); const fork_types = @import("fork_types"); +const CachedBeaconState = @import("state_transition").CachedBeaconState; const ForkSeq = config.ForkSeq; const AnyBeaconState = fork_types.AnyBeaconState; const AnySignedBeaconBlock = fork_types.AnySignedBeaconBlock; const Slot = types.primitive.Slot.Type; +pub const BenchState = struct { + var allocator: std.mem.Allocator = undefined; + var cached_state: *CachedBeaconState = undefined; + pub var cloned_cached_state: *CachedBeaconState = undefined; + + pub fn init(alloc: std.mem.Allocator, state: *CachedBeaconState) void { + allocator = alloc; + cached_state = state; + } + + pub fn beforeEach() void { + cloned_cached_state = cached_state.clone(allocator, .{}) catch unreachable; + } + + pub fn afterEach() void { + cloned_cached_state.deinit(); + allocator.destroy(cloned_cached_state); + } +}; + /// Read slot from raw BeaconState SSZ bytes (offset 40) pub fn slotFromStateBytes(state_bytes: []const u8) Slot { std.debug.assert(state_bytes.len >= 48); From 246aa29a18b907b324a5a358af876e79f3043a85 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 30 May 2026 04:26:42 +0800 Subject: [PATCH 22/46] refactor(bindings): wake only required number of workers (#383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit introduces - `sleeping_workers`: mark itself as asleep when no work is available, mark itself as not a `sleeping_worker` (i.e. `sleeping_workers -= 1` when it sees work - use `signal` instead of `broadcast` I suspect contention on threads causing regression on bls workload. This at least metrics wise on `feat3-sas` has some good effects (white line is redeployment): Screenshot 2026-05-28 at 9 30 20 PM Job wait time is also trending down: Screenshot 2026-05-28 at 9 32 18 PM --------- Co-authored-by: Cayman --- src/bls/ThreadPool.zig | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index 84cc3b828..98fb9f19c 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -60,6 +60,11 @@ const JobQueue = struct { cond: std.Io.Condition = std.Io.Condition.init, head: ?*WorkItem = null, tail: ?*WorkItem = null, + /// Count of workers currently blocked in `cond.wait`. Guarded by `mutex` + /// (read in `pushBatch`, maintained in `workerLoop`), so it is exact at + /// signal time. Lets `pushBatch` wake only as many workers as there is new + /// work for, instead of broadcasting to all of them. + sleeping_workers: usize = 0, /// Pushes a batch of `WorkItem`s to the `JobQueue`. /// @@ -80,7 +85,13 @@ const JobQueue = struct { } self.tail = item; } - self.cond.broadcast(io); + // Wake at most one sleeping worker per submitted item, and never more than + // are actually asleep. Running workers loop back to `pop()` after each item, + // so signals are only needed to bring sleeping workers back into the queue; + // extra signals only create scheduler churn. + for (0..@min(items.len, self.sleeping_workers)) |_| { + self.cond.signal(io); + } return true; } @@ -161,7 +172,9 @@ fn workerLoop(pool: *ThreadPool, io: std.Io) void { while (true) { if (pool.queue.pop()) |wi| break :blk wi; if (pool.shutdown.load(.acquire)) return; + pool.queue.sleeping_workers += 1; pool.queue.cond.waitUncancelable(io, &pool.queue.mutex); + pool.queue.sleeping_workers -= 1; } }; From fc4f1b8845bf0807e8ad3b601dd33e2626d90f15 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 3 Jun 2026 22:26:14 +0800 Subject: [PATCH 23/46] feat: getSyncCommitteesWitness (#367) extracted from #347 Support both native call + binding --- bindings/napi/BeaconStateView.zig | 40 ++- bindings/napi/js_types.zig | 6 + bindings/src/index.d.ts | 6 +- build.zig.zon | 1 + src/fork_types/any_beacon_state.zig | 7 + src/state_transition/root.zig | 5 + .../sync_committees_witness.zig | 307 ++++++++++++++++++ 7 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/state_transition/sync_committees_witness.zig diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 2608bd302..97d38ed33 100644 --- a/bindings/napi/BeaconStateView.zig +++ b/bindings/napi/BeaconStateView.zig @@ -747,7 +747,41 @@ pub fn getFinalizedRootProof(self: *const BeaconStateView) !js.Array { )); } -// pub fn BeaconStateView_getSyncCommitteesWitness +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 = cached_state.state.root(); + var witness_data: st.SyncCommitteeWitness = undefined; + try st.getSyncCommitteesWitness(fork_seq, root_node, cached_state.state.nodePool(), &witness_data); + + 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 { @@ -779,9 +813,7 @@ pub fn createMultiProof(self: *const BeaconStateView, descriptor: js.Uint8Array) const descriptor_bytes = try descriptor.toSlice(); try cached_state.state.commit(); - const root_node = switch (cached_state.state.*) { - inline else => |state| state.root, - }; + const root_node = cached_state.state.root(); const proof_input = persistent_merkle_tree.proof.ProofInput{ .compactMulti = .{ .descriptor = descriptor_bytes }, diff --git a/bindings/napi/js_types.zig b/bindings/napi/js_types.zig index cabd968aa..25abeb005 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 deb72cac9..a13426e09 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -199,7 +199,11 @@ declare class BeaconStateView { isValidVoluntaryExit(signedVoluntaryExitBytes: Uint8Array, verifySignature: boolean): boolean; getFinalizedRootProof(): Uint8Array[]; - // getSyncCommitteesWitness(): any; + getSyncCommitteesWitness(): { + witness: Uint8Array[]; + currentSyncCommitteeRoot: Uint8Array; + nextSyncCommitteeRoot: Uint8Array; + }; /** * Compute expected withdrawals for the next payload (capella+). * diff --git a/build.zig.zon b/build.zig.zon index 4118a0c2c..133571916 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -181,6 +181,7 @@ .hex, .persistent_merkle_tree, .metrics, + .hashing, .time, }, }, diff --git a/src/fork_types/any_beacon_state.zig b/src/fork_types/any_beacon_state.zig index ad8603bc7..f519eeaff 100644 --- a/src/fork_types/any_beacon_state.zig +++ b/src/fork_types/any_beacon_state.zig @@ -223,6 +223,13 @@ pub const AnyBeaconState = union(ForkSeq) { }; } + /// Root node of the underlying tree view, regardless of fork variant. + pub fn root(self: *AnyBeaconState) Node.Id { + return switch (self.*) { + inline else => |state| state.root, + }; + } + // pub fn castFromFork(comptime f: ForkSeq, ) pub fn castToFork(self: *AnyBeaconState, comptime f: ForkSeq) *BeaconState(f) { diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index b571391aa..6681f94e4 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -112,6 +112,10 @@ const load_state = @import("load_state.zig"); pub const loadState = load_state.loadState; pub const MigrateStateOutput = load_state.MigrateStateOutput; +const sync_committees_witness = @import("./sync_committees_witness.zig"); +pub const getSyncCommitteesWitness = sync_committees_witness.getSyncCommitteesWitness; +pub const SyncCommitteeWitness = sync_committees_witness.SyncCommitteeWitness; + const weak_subjectivity = @import("weak_subjectivity.zig"); pub const getLatestWeakSubjectivityCheckpointEpoch = weak_subjectivity.getLatestWeakSubjectivityCheckpointEpoch; @@ -121,5 +125,6 @@ test { testing.refAllDecls(state_transition); testing.refAllDecls(EpochShuffling); testing.refAllDecls(load_state); + testing.refAllDecls(sync_committees_witness); testing.refAllDecls(weak_subjectivity); } diff --git a/src/state_transition/sync_committees_witness.zig b/src/state_transition/sync_committees_witness.zig new file mode 100644 index 000000000..166a77b75 --- /dev/null +++ b/src/state_transition/sync_committees_witness.zig @@ -0,0 +1,307 @@ +//! Computes the Merkle witness that proves the current and next sync committee +//! roots are committed to by a beacon state root. Light-client servers serve +//! this witness so that clients can verify sync committee updates without +//! downloading the full beacon state. +//! +//! The witness is a sibling branch from the `sync_committees` subtree up to the +//! state root, ordered by descending gindex. The path through the BeaconState +//! tree differs across forks because the container layout changes: pre-electra +//! the sync committees live at gindices 54/55 (4 siblings), electra and later +//! at gindices 86/87 (5 siblings). +//! +//! Tests are ported from lodestar: +//! packages/beacon-node/test/unit/chain/lightclient/proof.test.ts +const std = @import("std"); + +const ForkSeq = @import("config").ForkSeq; +const Node = @import("persistent_merkle_tree").Node; +const ct = @import("consensus_types"); +const preset = @import("preset").preset; +const hashOne = @import("hashing").hashOne; +const AnyBeaconState = @import("fork_types").AnyBeaconState; +const verifyMerkleBranch = @import("./utils/verify_merkle_branch.zig").verifyMerkleBranch; + +/// 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 = 0, + 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, + out: *SyncCommitteeWitness, +) !void { + std.debug.assert(fork.gte(.altair)); + const n1 = root_node; + + var current: Node.Id = undefined; + var next: Node.Id = undefined; + // 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); + + current = try Node.Id.getLeft(n43, pool); // n86 + 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 + + out.witness_buf = .{ + w0.getRoot(pool).*, + w1.getRoot(pool).*, + w2.getRoot(pool).*, + w3.getRoot(pool).*, + w4.getRoot(pool).*, + }; + out.witness_len = 5; + } + // 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 + + current = try Node.Id.getLeft(n27, pool); // n54 — 1011[0] + 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 + + out.witness_buf = .{ + w0.getRoot(pool).*, + w1.getRoot(pool).*, + w2.getRoot(pool).*, + w3.getRoot(pool).*, + std.mem.zeroes([32]u8), + }; + out.witness_len = 4; + } + + out.current_sync_committee_root = current.getRoot(pool).*; + out.next_sync_committee_root = next.getRoot(pool).*; +} + +const NUM_WITNESS: u8 = 4; +const NUM_WITNESS_ELECTRA: u8 = 5; + +fn fillSyncCommittee(byte: u8) ct.altair.SyncCommittee.Type { + return .{ + .pubkeys = [_][48]u8{[_]u8{byte} ** 48} ** preset.SYNC_COMMITTEE_SIZE, + .aggregate_pubkey = [_]u8{byte} ** 48, + }; +} + +/// Convert a gindex to (depth, index-at-depth) +fn fromGindex(gindex: usize) struct { depth: usize, index: usize } { + const depth = std.math.log2_int(usize, gindex); + const first_index = @as(usize, 1) << @intCast(depth); + return .{ .depth = depth, .index = gindex - first_index }; +} + +/// Pack a variable-length witness branch into the fixed [33]Root proof buffer +/// that verifyMerkleBranch expects. Only the first `depth` slots are read by +/// verifyMerkleBranch. +fn packProof(branch: []const [32]u8) [33][32]u8 { + var proof: [33][32]u8 = .{[_]u8{0} ** 32} ** 33; + for (branch, 0..) |w, i| proof[i] = w; + return proof; +} + +/// Sets up a sync-committee proof. Only used for tests. +const ProofFixture = struct { + pool: Node.Pool, + state: AnyBeaconState, + state_root: [32]u8, + root_node: Node.Id, + current_sync_committee: ct.altair.SyncCommittee.Type, + next_sync_committee: ct.altair.SyncCommittee.Type, + + fn init(fork: ForkSeq) !ProofFixture { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(allocator, 500_000); + errdefer pool.deinit(); + + var state = switch (fork) { + .altair => try AnyBeaconState.fromValue(allocator, &pool, .altair, &ct.altair.BeaconState.default_value), + .electra => try AnyBeaconState.fromValue(allocator, &pool, .electra, &ct.electra.BeaconState.default_value), + else => return error.UnsupportedFork, + }; + errdefer state.deinit(); + + const current_sync_committee = fillSyncCommittee(0xbb); + const next_sync_committee = fillSyncCommittee(0xcc); + try state.setCurrentSyncCommittee(¤t_sync_committee); + try state.setNextSyncCommittee(&next_sync_committee); + + try state.commit(); + const state_root = (try state.hashTreeRoot()).*; + const root_node = switch (state) { + inline else => |view| view.root, + }; + + return .{ + .pool = pool, + .state = state, + .state_root = state_root, + .root_node = root_node, + .current_sync_committee = current_sync_committee, + .next_sync_committee = next_sync_committee, + }; + } + + fn deinit(self: *ProofFixture) void { + self.state.deinit(); + self.pool.deinit(); + } +}; + +test "getSyncCommitteesWitness: SyncCommittees proof" { + const TestCase = struct { + fork_seq: ForkSeq, + num_witness: u8, + sync_committees_gindex: usize, + }; + + const test_cases: [2]TestCase = .{ + .{ + .fork_seq = .altair, + .num_witness = NUM_WITNESS, + .sync_committees_gindex = 27, + }, + .{ + .fork_seq = .electra, + .num_witness = NUM_WITNESS_ELECTRA, + .sync_committees_gindex = 43, + }, + }; + + for (test_cases) |tc| { + var fixture = try ProofFixture.init(tc.fork_seq); + defer fixture.deinit(); + + var witness_data: SyncCommitteeWitness = undefined; + try getSyncCommitteesWitness(tc.fork_seq, fixture.root_node, &fixture.pool, &witness_data); + + var sync_committees_leaf: [32]u8 = undefined; + hashOne(&sync_committees_leaf, &witness_data.current_sync_committee_root, &witness_data.next_sync_committee_root); + + try std.testing.expectEqual(@as(u8, tc.num_witness), witness_data.witness_len); + + const pos = fromGindex(tc.sync_committees_gindex); + const proof = packProof(witness_data.witness()); + try std.testing.expect(verifyMerkleBranch(sync_committees_leaf, &proof, pos.depth, pos.index, fixture.state_root)); + } +} + +test "getSyncCommitteesWitness: currentSyncCommittee proof" { + const TestCase = struct { + fork_seq: ForkSeq, + num_witness: u8, + current_sync_committee_gindex: usize, + }; + + const test_cases: [2]TestCase = .{ + .{ + .fork_seq = .altair, + .num_witness = NUM_WITNESS, + .current_sync_committee_gindex = 54, + }, + .{ + .fork_seq = .electra, + .num_witness = NUM_WITNESS_ELECTRA, + .current_sync_committee_gindex = 86, + }, + }; + + inline for (test_cases) |tc| { + var fixture = try ProofFixture.init(tc.fork_seq); + defer fixture.deinit(); + + var witness_data: SyncCommitteeWitness = undefined; + try getSyncCommitteesWitness(tc.fork_seq, fixture.root_node, &fixture.pool, &witness_data); + + // currentSyncCommitteeBranch = [nextSyncCommitteeRoot, ...witness] + var branch_buf: [tc.num_witness + 1][32]u8 = undefined; + branch_buf[0] = witness_data.next_sync_committee_root; + for (witness_data.witness(), 0..) |w, i| branch_buf[1 + i] = w; + + try std.testing.expectEqual(@as(u8, tc.num_witness), witness_data.witness_len); + + var current_leaf: [32]u8 = undefined; + try ct.altair.SyncCommittee.hashTreeRoot(&fixture.current_sync_committee, ¤t_leaf); + + const pos = fromGindex(tc.current_sync_committee_gindex); + const proof = packProof(&branch_buf); + try std.testing.expect(verifyMerkleBranch(current_leaf, &proof, pos.depth, pos.index, fixture.state_root)); + } +} + +test "getSyncCommitteesWitness: nextSyncCommittee proof" { + const TestCase = struct { + fork_seq: ForkSeq, + num_witness: u8, + next_sync_committee_gindex: usize, + }; + + const test_cases: [2]TestCase = .{ + .{ + .fork_seq = .altair, + .num_witness = NUM_WITNESS, + .next_sync_committee_gindex = 55, + }, + .{ + .fork_seq = .electra, + .num_witness = NUM_WITNESS_ELECTRA, + .next_sync_committee_gindex = 87, + }, + }; + + inline for (test_cases) |tc| { + var fixture = try ProofFixture.init(tc.fork_seq); + defer fixture.deinit(); + + var witness_data: SyncCommitteeWitness = undefined; + try getSyncCommitteesWitness(tc.fork_seq, fixture.root_node, &fixture.pool, &witness_data); + + // nextSyncCommitteeBranch = [currentSyncCommitteeRoot, ...witness] + var branch_buf: [tc.num_witness + 1][32]u8 = undefined; + branch_buf[0] = witness_data.current_sync_committee_root; + for (witness_data.witness(), 0..) |w, i| branch_buf[1 + i] = w; + + try std.testing.expectEqual(@as(u8, tc.num_witness), witness_data.witness_len); + + var next_leaf: [32]u8 = undefined; + try ct.altair.SyncCommittee.hashTreeRoot(&fixture.next_sync_committee, &next_leaf); + + const pos = fromGindex(tc.next_sync_committee_gindex); + const proof = packProof(&branch_buf); + try std.testing.expect(verifyMerkleBranch(next_leaf, &proof, pos.depth, pos.index, fixture.state_root)); + } +} From 09a214f68b2cc09a1112585b35db2712114937bd Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 19:28:51 +0800 Subject: [PATCH 24/46] fix: better generation of rand scalar (#388) --- bindings/napi/blst.zig | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 09be0434c..3eedc7a71 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -464,11 +464,10 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole const wrapped_sig = try e.unwrap(Signature, sig_napi); sigs[i] = &wrapped_sig.raw; - rand.bytes(&rands[i]); - // Ensure first 8 bytes (RAND_BITS=64) are non-zero - while (std.mem.allEqual(u8, rands[i][0..8], 0)) { - rand.bytes(rands[i][0..8]); - } + var scalar = rand.int(u64); + while (scalar == 0) scalar = rand.int(u64); + std.mem.writeInt(u64, rands[i][0..8], scalar, .little); + @memset(rands[i][8..], 0); } const pool = thread_pool orelse return error.ThreadPoolNotInitialized; From 764710258eee74d89a15055cdc73c380219a1a8a Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 19:29:08 +0800 Subject: [PATCH 25/46] fix(blst): default signature infinity check to true if not provided (#387) `Signature.fromBytes` should do infinity check by default if not provided as an argument (in other words provided as `null`) source: https://github.com/ChainSafe/blst-ts/blob/86c49590d37d4e1dd44b3b5ba604132f3b51d99d/src/lib.rs#L301 --- bindings/napi/blst.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 3eedc7a71..ee4b6b59e 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -172,7 +172,7 @@ pub const Signature = struct { const slice = try bytes.toSlice(); var sig = NativeSignature.deserialize(slice) catch return error.DeserializationFailed; if (try boolOrDefault(sig_validate, false)) { - try sig.validate(try boolOrDefault(sig_infcheck, false)); + try sig.validate(try boolOrDefault(sig_infcheck, true)); } return .{ .raw = sig }; } From 12dd46eec8e0b05446d715390c1947a2f49cbee2 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 19:29:44 +0800 Subject: [PATCH 26/46] refactor: allocate `AsyncAggRandData` in one obj (#384) instead of allocating 5 smaller arrays, we just use fixed size buffers (capped at `bls.MAX_AGGREGATE_PER_JOB`) within `AsyncAggRandData` and create that struct to send to worker threads @twoeths suggested this in a previous PR review but i wrongly shot it down (sorry :p ) --- bindings/napi/blst.zig | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index ee4b6b59e..092f5fe51 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -659,11 +659,12 @@ pub fn aggregateWithRandomness(sets: js.Array) !js.Value { /// All input data should be copied into this struct so the worker thread doesn't depend on /// any JS-managed memory staying alive. const AsyncAggRandData = struct { - pks: []NativePublicKey, - sigs: []NativeSignature, - pk_ptrs: []*const NativePublicKey, - sig_ptrs: []*const NativeSignature, - randomness: []u8, + n: usize, + pks: [MAX_AGGREGATE_PER_JOB]NativePublicKey, + sigs: [MAX_AGGREGATE_PER_JOB]NativeSignature, + pk_ptrs: [MAX_AGGREGATE_PER_JOB]*const NativePublicKey, + sig_ptrs: [MAX_AGGREGATE_PER_JOB]*const NativeSignature, + randomness: [MAX_AGGREGATE_PER_JOB * 32]u8, pk_out: NativePublicKey, sig_out: NativeSignature, err: ?anyerror, @@ -671,11 +672,6 @@ const AsyncAggRandData = struct { work: napi.c.napi_async_work, fn destroy(self: *AsyncAggRandData) void { - allocator.free(self.pks); - allocator.free(self.sigs); - allocator.free(self.pk_ptrs); - allocator.free(self.sig_ptrs); - allocator.free(self.randomness); allocator.destroy(self); } }; @@ -694,9 +690,9 @@ fn asyncAggRand_execute(_: napi.Env, data: *AsyncAggRandData) void { }; pool.aggregateWithRandomness( napi_io.get(), - data.pk_ptrs, - data.sig_ptrs, - data.randomness, + data.pk_ptrs[0..data.n], + data.sig_ptrs[0..data.n], + data.randomness[0 .. data.n * 32], false, // pks already validated implicitly by being deserialized PublicKey instances true, // sigs were deserialized but not group-checked on the JS thread &data.pk_out, @@ -783,23 +779,13 @@ pub fn asyncAggregateWithRandomness(sets: js.Array) !js.Value { const data = try allocator.create(AsyncAggRandData); errdefer allocator.destroy(data); - data.pks = try allocator.alloc(NativePublicKey, n); - errdefer allocator.free(data.pks); - data.sigs = try allocator.alloc(NativeSignature, n); - errdefer allocator.free(data.sigs); - data.pk_ptrs = try allocator.alloc(*const NativePublicKey, n); - errdefer allocator.free(data.pk_ptrs); - data.sig_ptrs = try allocator.alloc(*const NativeSignature, n); - errdefer allocator.free(data.sig_ptrs); - data.randomness = try allocator.alloc(u8, n * 32); - errdefer allocator.free(data.randomness); - + data.n = n; data.pk_out = .{}; data.sig_out = .{}; data.err = null; data.deferred = undefined; data.work = undefined; - napi_io.get().random(data.randomness); + napi_io.get().random(data.randomness[0 .. n * 32]); for (0..n) |i| { const set = (try sets.get(@intCast(i))).toValue(); From 5837b0d7f273d0805f1ad039fbe1e33eb72f211c Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 20:02:42 +0800 Subject: [PATCH 27/46] refactor(bls): remove single-threaded fallback (#390) for parity with rust, which also uses the pool only --- src/bls/ThreadPool.zig | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index 98fb9f19c..8c83e79af 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -275,23 +275,6 @@ pub fn verifyMultipleAggregateSignatures( rands.len != n_elems) return BlstError.VerifyFail; - // Single-threaded fallback for small inputs or single worker - if (n_elems <= 2 or pool.n_workers <= 1) { - var buf: PairingBuf = .{}; - const fast_verify = @import("fast_verify.zig"); - return fast_verify.verifyMultipleAggregateSignatures( - &buf.data, - n_elems, - msgs, - dst, - pks, - pks_validate, - sigs, - sigs_groupcheck, - rands, - ); - } - const n_active = @min(pool.n_workers, n_elems); var result_bufs: [MAX_WORKERS]PairingBuf = undefined; From 53018d0c26d9c4d885fb3278501bfc532a2827c5 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 20:49:52 +0800 Subject: [PATCH 28/46] chore(deps): compile blst with ReleaseFast (#391) By default --release on cargo build also compiles with a lot less guarantee checks, so we should do the same on the blst level --- build.zig.zon | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.zig.zon b/build.zig.zon index 133571916..4ea60ffc9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -22,6 +22,10 @@ .blst = .{ .url = "git+https://github.com/ChainSafe/blst.zig.git#5d9543be8a7a1a9942c62a097644286751f95dc7", .hash = "blst_zig-0.0.0-cnAxzisLAADc_A14MqXu_f_rLBRS6m6ki8zHrUjLXYoo", + .args = .{ + .optimize = .ReleaseFast, + .portable = true, + }, }, .hashtree = .{ .url = "git+https://github.com/ChainSafe/hashtree-z.git#81b7303f72e202e966f71eedf4be1d8d33e2fcaa", From cd5e01713745b9402747febdbab89ad915c686a5 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 6 Jun 2026 20:58:25 +0800 Subject: [PATCH 29/46] refactor(bls): remove need for tracking results for verifyMultipleAggregateSignatures (#389) We don't actually need to count the number of the results, we just write into the bufs sequentially --- src/bls/ThreadPool.zig | 46 ++++++++++++------------------------------ 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index 8c83e79af..f7850cdb3 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -203,23 +203,20 @@ const VerifyMultiJob = struct { sigs_groupcheck: bool, counter: std.atomic.Value(usize), err_flag: std.atomic.Value(bool), - /// Workers write committed pairing results here. Indexed by result_count. - result_bufs: []PairingBuf, - result_count: std.atomic.Value(usize), + /// Workers write committed pairing results here, indexed by work item id. + result_bufs: *[MAX_WORKERS]PairingBuf, }; const VerifyMultiWorkItem = struct { base: WorkItem, job: *VerifyMultiJob, + worker_id: usize, fn exec(base_item: *WorkItem) void { const self: *VerifyMultiWorkItem = @fieldParentPtr("base", base_item); const job = self.job; - var buf: PairingBuf = .{}; - var pairing = Pairing.init(&buf.data, true, job.dst); - - var did_work = false; + var pairing = Pairing.init(&job.result_bufs[self.worker_id].data, true, job.dst); const n_elems = job.pks.len; while (true) { @@ -227,8 +224,6 @@ const VerifyMultiWorkItem = struct { if (i >= n_elems) break; if (job.err_flag.load(.monotonic)) break; - did_work = true; - pairing.mulAndAggregate( job.pks[i], job.pks_validate, @@ -243,11 +238,7 @@ const VerifyMultiWorkItem = struct { }; } - if (did_work) { - pairing.commit(); - const slot = job.result_count.fetchAdd(1, .acq_rel); - job.result_bufs[slot] = buf; - } + if (!job.err_flag.load(.monotonic)) pairing.commit(); } }; @@ -290,7 +281,6 @@ pub fn verifyMultipleAggregateSignatures( .counter = std.atomic.Value(usize).init(0), .err_flag = std.atomic.Value(bool).init(false), .result_bufs = &result_bufs, - .result_count = std.atomic.Value(usize).init(0), }; // Create work items on the stack — one per active worker @@ -300,6 +290,7 @@ pub fn verifyMultipleAggregateSignatures( work_items[i] = .{ .base = .{ .exec_fn = VerifyMultiWorkItem.exec }, .job = &job, + .worker_id = i, }; item_ptrs[i] = &work_items[i].base; } @@ -308,10 +299,7 @@ pub fn verifyMultipleAggregateSignatures( if (job.err_flag.load(.acquire)) return BlstError.VerifyFail; - const n_results = job.result_count.load(.acquire); - if (n_results == 0) return BlstError.VerifyFail; - - return mergeAndVerify(&result_bufs, n_results, null); + return mergeAndVerify(&result_bufs, n_active, null); } const AggVerifyJob = struct { @@ -322,20 +310,19 @@ const AggVerifyJob = struct { n_elems: usize, counter: std.atomic.Value(usize), err_flag: std.atomic.Value(bool), - result_bufs: []PairingBuf, - result_count: std.atomic.Value(usize), + result_bufs: *[MAX_WORKERS]PairingBuf, }; const AggVerifyWorkItem = struct { base: WorkItem, job: *AggVerifyJob, + worker_id: usize, fn exec(base_item: *WorkItem) void { const self: *AggVerifyWorkItem = @fieldParentPtr("base", base_item); const job = self.job; - var buf: PairingBuf = .{}; - var pairing = Pairing.init(&buf.data, true, job.dst); + var pairing = Pairing.init(&job.result_bufs[self.worker_id].data, true, job.dst); var did_work = false; @@ -359,11 +346,7 @@ const AggVerifyWorkItem = struct { }; } - if (did_work) { - pairing.commit(); - const slot = job.result_count.fetchAdd(1, .acq_rel); - job.result_bufs[slot] = buf; - } + if (!job.err_flag.load(.monotonic)) pairing.commit(); } }; @@ -413,7 +396,6 @@ pub fn aggregateVerify( .counter = std.atomic.Value(usize).init(0), .err_flag = std.atomic.Value(bool).init(false), .result_bufs = &result_bufs, - .result_count = std.atomic.Value(usize).init(0), }; var work_items: [MAX_WORKERS]AggVerifyWorkItem = undefined; @@ -422,6 +404,7 @@ pub fn aggregateVerify( work_items[i] = .{ .base = .{ .exec_fn = AggVerifyWorkItem.exec }, .job = &job, + .worker_id = i, }; item_ptrs[i] = &work_items[i].base; } @@ -430,13 +413,10 @@ pub fn aggregateVerify( if (job.err_flag.load(.acquire)) return false; - const n_results = job.result_count.load(.acquire); - if (n_results == 0) return false; - var gtsig = c.blst_fp12{}; Pairing.aggregated(>sig, sig); - return mergeAndVerify(&result_bufs, n_results, >sig); + return mergeAndVerify(&result_bufs, n_active, >sig); } /// Merges the first `n_results` pairing buffers and executes `finalVerify`. From 8462c8f197225fc2a055852962494d5b6574c216 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Tue, 9 Jun 2026 15:27:18 +0800 Subject: [PATCH 30/46] perf(pmt): chunked-leaf packing for basic lists and container_struct (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation State transition is dominated by PMT operations on `BeaconState`'s large basic-element lists (`Balances`, `EpochParticipation`, `InactivityScores`, ~1.4M items each) and per-field tree access for struct-shaped containers (`Validator`). On a mainnet fulu state with 2.18M validators, these account for the bulk of `processEpoch` and `processBlock` runtime. ## Description Five PMT-level changes that compose: 1. **`u64` payload column** — collapses every node kind's payload (branch left/right Ids, chunked_leaf pointer, container_struct vtable pointer, free-list link) into one machine word. State packs `[free_bit:1 | kind:3 | ref_count:28]` in `u32`. Cache validity moves out of `kind` into a `0xFF…` sentinel in the `root` column. Hot tree-walk visits touch only `state` (1 B) + 4-8 B from `payload`, fitting one cache line. 2. **`chunked_leaf`** — opt-in via `opts.chunked_leaf=true` on `FixedListType` / `FixedVectorType`. Bottom `k_log2 = 6` levels of the chunks subtree fold into one `*ChunkedLeaf` heap blob holding K=64 chunks. For 1M-item `List` pool metadata drops ~64× (256K Node.Id → 4096 ChunkedLeaves + 4096 heap blobs). Bulk read/write get SIMD-batched root recomputation and amortized CoW (one 2 KB memcpy per dirty leaf instead of 6 path clones per dirty chunk). 3. **`container_struct`** (originally #232) — a node kind whose payload is `*ContainerStructRef` (vtable + caller-allocated `T`). Backs `StructContainerType` for `Validator` etc. Field access = O(1) struct read instead of per-field tree walk; `hashTreeRoot` calls type's cached `get_root` directly. 4. **Pool dual-allocator** — `Pool` keeps two allocators routed by allocation kind: `page_allocator` for the MultiArrayList node columns (one large, infrequent allocation), and `allocator` (default `c_allocator`) for every per-node out-of-line heap blob — `ContainerStructRef`, `WrappedT`, and the 2 KB chunked_leaf blobs. Page-per-alloc on the small lane wastes ~70 GB of virtual address space at 2.18M validators on macOS arm64 and thrashes the TLB; the bucket allocator packs them densely. `Pool.init` switched to options-struct shape (`Pool.init(.{})` for production defaults). This unblocks `serializeValidators` / `getEffectiveBalanceIncrementsZeroInactive` / `getSingleProof` binding tests at mainnet scale (24 s → ~500 ms, 50× speedup, equal to main). 5. **Zero-copy validator access** — completes the container_struct value chain. PR #232 added `pool.getStructPtr(node, T)` but no list-iteration API was built on top, so callers still cloned the full 263 MB validators slice per epoch transition. This PR adds: - `StructContainerType.tree.getValuePtr(node, pool) -> *const T` — direct typed pointer into the pool's container_struct payload. - `ListCompositeTreeView.ReadonlyIterator.nextValuePtr() -> *const Element.Type` — list iteration that hands out per-element pointers as the depth-iterator walks the tree. - `BeaconState.validatorsPtrSlice(allocator) -> []*const Validator.Type` — random-access pointer slice for callers that need sort / parallel workers / multi-pass. The two APIs are complementary: iterator wins for single forward read passes (`epoch_transition_cache.init`, `getEffectiveBalanceIncrementsZeroInactive`); pointer slice wins for sort + random index access + parallel workers (`epoch_cache.init` calling `syncPubkeys`, `slashings_cache.buildFromStateIfNeeded`, `upgrade_state_to_altair`). 8 of 9 hot callers migrated; the last (`upgrade_state_to_electra`) keeps the value slice because its mutate-then-reread pattern would invalidate pointers. ## Bench `bench_process_epoch` and `bench_process_block` on mainnet era, fulu fork, slot 13336576 (**2.18M validators**), ReleaseFast, Apple Silicon. Both branches run with the same bench harness using `c_allocator` (no DebugAllocator overhead) for apples-to-apples comparison. `*_total` rows exclude the final `hashTreeRoot` (state-root recompute), which the bench tracks as its own segment. ### Process epoch (segmented breakdown, ms/run averaged over 50 runs) | step | main | this branch | speedup | |------|------|-------------|---------| | **epoch_total** | **418.0** | **74.9** | **5.58×** | | `before_process_epoch` | 193.8 | 39.9 | **4.86×** | | `inactivity_updates` | 58.6 | 4.0 | **14.7×** | | `rewards_and_penalties` | 80.8 | 14.2 | **5.69×** | | `effective_balance_updates` | 67.2 | 5.7 | **11.8×** | | `proposer_lookahead` | 16.5 | 10.9 | 1.51× | `before_process_epoch` (`EpochTransitionCache.init`) drops 4.86×: container_struct gives O(1) per-field reads on validators, and the `nextValuePtr` iterator skips the 263 MB clone that `validatorsSlice` used to do every epoch. `inactivity_updates`, `rewards_and_penalties`, `effective_balance_updates` get 5-15× from chunked_leaf making bulk reads/writes on `Balances` / `InactivityScores` / `EpochParticipation` SIMD-friendly + amortized CoW. ### Process block (segmented breakdown, ms/run averaged over 50 runs) | step | main | this branch | speedup | |------|------|-------------|---------| | **block_total** | **166.1** | **67.3** | **2.47×** | | `operations` | 162.9 | 64.0 | **2.55×** | | `block_header` | 0.243 | 0.244 | ~same | | `withdrawals` | 0.021 | 0.021 | ~same | | `execution_payload` | 0.201 | 0.196 | ~same | | `randao` | 1.088 | 1.140 | ~same | | `sync_aggregate` | 1.675 | 1.492 | ~same | `operations` (bulk of block processing) gets 2.55× — chunked_leaf on the balance writes plus zero-copy validator reads in `slashings_cache.buildFromStateIfNeeded`. `sync_aggregate`'s scattered sync-committee balance writes CoW a 2 KB `ChunkedLeaf` blob; its residual cost is BLS aggregate verification (~1.1 ms fixed, identical across branches). ### Linux verification (AMD EPYC 9V74, 16 vCPU codespace, ReleaseFast) Same fixture (mainnet era, fulu fork, slot 13336576, **2.18M validators**), 50 runs/step. Speedup ratios reproduce on Linux/x86; absolute numbers are higher than Apple Silicon due to per-core differences. #### Process epoch (segmented breakdown, ms/run) | step | main | this branch | speedup | |------|------|-------------|---------| | **epoch_total** | **666.0** | **123.0** | **5.41×** | | `before_process_epoch` | 344.8 | 69.7 | **4.95×** | | `inactivity_updates` | 67.6 | 4.4 | **15.4×** | | `rewards_and_penalties` | 112.9 | 24.1 | **4.69×** | | `effective_balance_updates` | 93.0 | 6.2 | **15.0×** | | `proposer_lookahead` | 43.8 | 15.5 | 2.83× | #### Process block (segmented breakdown, ms/run) | step | main | this branch | speedup | |------|------|-------------|---------| | **block_total** | **350.8** | **110.7** | **3.17×** | | `operations` | 346.0 | 106.4 | **3.25×** | #### Process block (end-to-end fused, ms/run) | variant | main | this branch | speedup | |---------|------|-------------|---------| | `process_block` (with BLS) | 49.3 | 38.5 | 1.28× | | `process_block_no_sig` | 10.63 | 2.79 | **3.81×** | `process_block_no_sig` (BLS bypassed) drops 3.81× — the optimizations land cleanly on the non-BLS portion. The fused 1.28× reflects ~36 ms going to BLS aggregate signature verification per block, which is unaffected by PMT changes. potentially fix #243 --- bench/ssz/list_chunked_leaf.zig | 356 +++++ bench/state_transition/process_block.zig | 54 +- bench/state_transition/process_epoch.zig | 23 +- bindings/napi/pool.zig | 10 +- build.zig.zon | 6 + src/consensus_types/altair.zig | 14 +- src/consensus_types/bellatrix.zig | 6 +- src/consensus_types/capella.zig | 24 +- src/consensus_types/deneb.zig | 24 +- src/consensus_types/electra.zig | 36 +- src/consensus_types/fulu.zig | 34 +- src/consensus_types/gloas.zig | 40 +- src/consensus_types/phase0.zig | 28 +- src/era/Reader.zig | 2 +- src/fork_types/any_beacon_state.zig | 99 +- src/fork_types/beacon_state.zig | 22 + src/persistent_merkle_tree/ChunkedLeaf.zig | 363 +++++ src/persistent_merkle_tree/Node.zig | 1240 ++++++++++++----- src/persistent_merkle_tree/node_bench.zig | 5 +- src/persistent_merkle_tree/node_test.zig | 268 +++- src/persistent_merkle_tree/proof.zig | 88 +- src/persistent_merkle_tree/proof_test.zig | 142 +- src/persistent_merkle_tree/root.zig | 1 + src/persistent_merkle_tree/view_test.zig | 2 +- src/ssz/root.zig | 2 + src/ssz/tree_view/array_basic.zig | 52 +- src/ssz/tree_view/array_composite.zig | 46 +- src/ssz/tree_view/bit_list.zig | 26 +- src/ssz/tree_view/bit_vector.zig | 24 +- src/ssz/tree_view/chunks.zig | 198 ++- src/ssz/tree_view/container.zig | 272 +++- src/ssz/tree_view/list_basic.zig | 1005 ++++++++++++- src/ssz/tree_view/list_composite.zig | 141 +- src/ssz/tree_view/root.zig | 1 + src/ssz/tree_view/utils/tree_view_state.zig | 21 +- src/ssz/type/bit_list.zig | 8 +- src/ssz/type/bit_vector.zig | 8 +- src/ssz/type/bool.zig | 21 +- src/ssz/type/byte_list.zig | 21 +- src/ssz/type/byte_vector.zig | 19 +- src/ssz/type/container.zig | 322 ++++- src/ssz/type/list.zig | 367 ++++- src/ssz/type/path.zig | 2 +- src/ssz/type/root.zig | 1 + src/ssz/type/uint.zig | 17 +- src/ssz/type/vector.zig | 249 +++- .../block/process_attestations.zig | 2 +- .../block/process_block_header.zig | 2 +- .../block/process_eth1_data.zig | 2 +- .../block/process_execution_payload.zig | 4 +- .../block/process_operations.zig | 2 +- src/state_transition/block/process_randao.zig | 2 +- .../block/process_sync_committee.zig | 2 +- .../block/process_voluntary_exit.zig | 12 +- .../block/process_withdrawals.zig | 2 +- src/state_transition/cache/epoch_cache.zig | 9 +- .../cache/epoch_transition_cache.zig | 14 +- src/state_transition/cache/pubkey_cache.zig | 31 +- .../cache/slashings_cache.zig | 16 +- src/state_transition/cache/state_cache.zig | 4 +- .../process_effective_balance_updates.zig | 2 +- src/state_transition/epoch/process_epoch.zig | 2 +- .../epoch/process_eth1_data_reset.zig | 2 +- .../process_historical_summaries_update.zig | 2 +- .../epoch/process_inactivity_updates.zig | 2 +- ...process_justification_and_finalization.zig | 2 +- .../process_participation_flag_updates.zig | 2 +- .../epoch/process_pending_consolidations.zig | 2 +- .../epoch/process_pending_deposits.zig | 2 +- .../epoch/process_proposer_lookahead.zig | 2 +- .../epoch/process_randao_mixes_reset.zig | 2 +- .../epoch/process_registry_updates.zig | 2 +- .../epoch/process_rewards_and_penalties.zig | 2 +- .../epoch/process_slashings.zig | 2 +- .../epoch/process_slashings_reset.zig | 2 +- .../epoch/process_sync_committee_updates.zig | 2 +- src/state_transition/load_state.zig | 147 +- .../slot/upgrade_state_to_altair.zig | 2 +- src/state_transition/ssz_container.zig | 4 +- src/state_transition/state_transition.zig | 4 +- .../test_utils/generate_state.zig | 9 +- src/state_transition/utils/balance.zig | 16 +- .../utils/target_unslashed_balance.zig | 4 +- src/testing_allocators.zig | 38 + test/fuzz/README.md | 1 + test/fuzz/build.zig | 12 + test/fuzz/build.zig.zon | 4 + .../seed_u32_empty_pushes.bin | Bin 0 -> 17 bytes .../seed_u32_pop_set_get.bin | Bin 0 -> 17 bytes .../seed_u64_clone_set_commit.bin | Bin 0 -> 17 bytes .../seed_u64_empty_pushes.bin | Bin 0 -> 29 bytes .../seed_u64_pop_cross_cl.bin | Bin 0 -> 17 bytes .../seed_u64_pop_path3_path2_path3.bin | Bin 0 -> 17 bytes .../seed_u64_pop_set_commit.bin | Bin 0 -> 13 bytes .../seed_u32_empty_pushes.bin | Bin 0 -> 17 bytes .../seed_u32_pop_set_get.bin | Bin 0 -> 17 bytes .../seed_u64_clone_set_commit.bin | Bin 0 -> 17 bytes .../seed_u64_empty_pushes.bin | Bin 0 -> 29 bytes .../seed_u64_pop_cross_cl.bin | Bin 0 -> 17 bytes .../seed_u64_pop_path3_path2_path3.bin | Bin 0 -> 17 bytes .../seed_u64_pop_set_commit.bin | Bin 0 -> 13 bytes test/fuzz/fuzz-loop.sh | 2 +- test/fuzz/replay-crashes.sh | 2 +- test/fuzz/src/fuzz_ssz_chunked_leaf_set.zig | 199 +++ test/fuzz/src/fuzz_ssz_lists.zig | 6 +- .../fuzz/src/fuzz_ssz_nested_opaque_proof.zig | 107 ++ test/fuzz/src/fuzz_ssz_opaque_roundtrip.zig | 201 +++ test/spec/runner/epoch_processing.zig | 2 +- test/spec/runner/fork.zig | 2 +- test/spec/runner/merkle_proof.zig | 2 +- test/spec/runner/operations.zig | 2 +- test/spec/runner/rewards.zig | 4 +- test/spec/runner/transition.zig | 2 +- test/spec/ssz/generic_types.zig | 146 +- test/spec/ssz/test_case.zig | 2 +- test/spec/writer/finality.zig | 2 +- test/spec/writer/random.zig | 2 +- test/spec/writer/sanity.zig | 2 +- 118 files changed, 5539 insertions(+), 1233 deletions(-) create mode 100644 bench/ssz/list_chunked_leaf.zig create mode 100644 src/persistent_merkle_tree/ChunkedLeaf.zig create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_empty_pushes.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_pop_set_get.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_clone_set_commit.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_empty_pushes.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_cross_cl.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_path3_path2_path3.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_set_commit.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u32_empty_pushes.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u32_pop_set_get.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u64_clone_set_commit.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u64_empty_pushes.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u64_pop_cross_cl.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u64_pop_path3_path2_path3.bin create mode 100644 test/fuzz/corpus/ssz_chunked_leaf_set-initial/seed_u64_pop_set_commit.bin create mode 100644 test/fuzz/src/fuzz_ssz_chunked_leaf_set.zig create mode 100644 test/fuzz/src/fuzz_ssz_nested_opaque_proof.zig create mode 100644 test/fuzz/src/fuzz_ssz_opaque_roundtrip.zig diff --git a/bench/ssz/list_chunked_leaf.zig b/bench/ssz/list_chunked_leaf.zig new file mode 100644 index 000000000..06b486402 --- /dev/null +++ b/bench/ssz/list_chunked_leaf.zig @@ -0,0 +1,356 @@ +//! Bench comparing FixedListType(Uint64, 2^20) leaf-default vs opts.chunked_leaf=true +//! on representative balances-scale workloads. +//! +//! Run with: +//! zig build run:bench_list_chunked_leaf -Doptimize=ReleaseFast +//! +//! Every workload is registered as a pair — `... leaf` vs `... chunked_leaf` — +//! so each line in the output is a direct A/B comparison of the two layouts. +//! +//! Workloads (1M u64 items unless noted): +//! - fromValue: build tree from a populated value +//! - getRoot: compute root hash from a freshly built tree +//! - toValue: decode all items back from the tree (bulk read) +//! - get: single-item reads at scattered indices (point read) +//! - sparseSet: single-item set + commit + getRoot via TreeView (CoW +//! path), repeated `SparseSetIters` times per run — +//! a pessimistic commit-per-set extreme +//! - batchedSparseSet: `BatchedSparseCount` scattered sets staged, then ONE +//! commit + getRoot — matches per-block balance updates +//! - bulkSetAndRoot: set every item then getRoot (epoch-rewards-shaped) +//! - proof: single-chunk Merkle proof for a fixed gindex +const std = @import("std"); +const zbench = @import("zbench"); + +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const ChunkedLeaf = pmt.ChunkedLeaf; +const Gindex = pmt.Gindex; +const proof = pmt.proof; + +const ssz = @import("ssz"); +const FixedListType = ssz.FixedListType; +const UintType = ssz.UintType; + +const Limit: comptime_int = 1 << 20; +const ItemCount: usize = 1 << 20; + +const ListLeaf = FixedListType(UintType(64), Limit, .{}); +const ListChunkedLeaf = FixedListType(UintType(64), Limit, .{ .chunked_leaf = true }); + +// Scattered point reads per `get` run — enough to clear timer noise. +const ProbeCount: usize = 1024; +// set+commit+getRoot cycles per `sparseSet` run. +const SparseSetIters: usize = 100; +// Sparse sets per `batchedSparseSet` run — the per-block sync-committee count. +const BatchedSparseCount: usize = 512; +// Stride for scattered sets: 41 chunked_leaves apart (K * 4 u64 items each) +// so each consecutive set lands in a distinct chunked_leaf — worst case for +// the chunked_leaf CoW path. The +1 keeps it odd, hence coprime with the +// 2^20 ItemCount. +const ScatterStride: usize = ChunkedLeaf.K * 4 * 41 + 1; + +fn scatterIndex(i: usize) usize { + return (i *% ScatterStride) % ItemCount; +} + +// Proof target chunk. Tree shape is layout-independent, so one gindex serves both. +const ProofChunkIndex: usize = (ItemCount / 2) / 4; +const proof_gindex: Gindex = Gindex.fromDepth(ListLeaf.chunk_depth + 1, ProofChunkIndex); + +// Shared input value used by all build-side benches. +var input_value: ListLeaf.Type = ListLeaf.Type.empty; + +fn populateInput(allocator: std.mem.Allocator) !void { + try input_value.ensureTotalCapacity(allocator, ItemCount); + for (0..ItemCount) |i| { + try input_value.append(allocator, @as(u64, @intCast(i * 31 + 1))); + } +} + +const FromValueLeaf = struct { + pool: *Node.Pool, + pub fn run(self: *FromValueLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListLeaf.tree.fromValue(self.pool, &input_value) catch unreachable; + self.pool.unref(id); + } +}; + +const FromValueChunkedLeaf = struct { + pool: *Node.Pool, + pub fn run(self: *FromValueChunkedLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListChunkedLeaf.tree.fromValue(self.pool, &input_value) catch unreachable; + self.pool.unref(id); + } +}; + +const GetRootLeaf = struct { + pool: *Node.Pool, + pub fn run(self: *GetRootLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListLeaf.tree.fromValue(self.pool, &input_value) catch unreachable; + const root = id.getRoot(self.pool); + std.mem.doNotOptimizeAway(root); + self.pool.unref(id); + } +}; + +const GetRootChunkedLeaf = struct { + pool: *Node.Pool, + pub fn run(self: *GetRootChunkedLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListChunkedLeaf.tree.fromValue(self.pool, &input_value) catch unreachable; + const root = id.getRoot(self.pool); + std.mem.doNotOptimizeAway(root); + self.pool.unref(id); + } +}; + +const ToValueLeaf = struct { + pool: *Node.Pool, + tree_id: Node.Id, + pub fn run(self: *ToValueLeaf, allocator: std.mem.Allocator) void { + var dst = ListLeaf.Type.empty; + defer dst.deinit(allocator); + ListLeaf.tree.toValue(allocator, self.tree_id, self.pool, &dst) catch unreachable; + std.mem.doNotOptimizeAway(dst.items[0]); + } +}; + +const ToValueChunkedLeaf = struct { + pool: *Node.Pool, + tree_id: Node.Id, + pub fn run(self: *ToValueChunkedLeaf, allocator: std.mem.Allocator) void { + var dst = ListChunkedLeaf.Type.empty; + defer dst.deinit(allocator); + ListChunkedLeaf.tree.toValue(allocator, self.tree_id, self.pool, &dst) catch unreachable; + std.mem.doNotOptimizeAway(dst.items[0]); + } +}; + +const GetLeaf = struct { + view: *ListLeaf.TreeView, + pub fn run(self: *GetLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + var sum: u64 = 0; + for (0..ProbeCount) |i| { + sum +%= self.view.get(scatterIndex(i)) catch unreachable; + } + std.mem.doNotOptimizeAway(sum); + } +}; + +const GetChunkedLeaf = struct { + view: *ListChunkedLeaf.TreeView, + pub fn run(self: *GetChunkedLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + var sum: u64 = 0; + for (0..ProbeCount) |i| { + sum +%= self.view.get(scatterIndex(i)) catch unreachable; + } + std.mem.doNotOptimizeAway(sum); + } +}; + +const SparseSetLeaf = struct { + pool: *Node.Pool, + base: Node.Id, + pub fn run(self: *SparseSetLeaf, allocator: std.mem.Allocator) void { + // `TreeView.init` consumes a ref; lend it one so `self.base` survives + // for the next run. + self.pool.ref(self.base) catch unreachable; + const view = ListLeaf.TreeView.init(allocator, self.pool, self.base) catch unreachable; + defer view.deinit(); + for (0..SparseSetIters) |iter| { + view.set(scatterIndex(iter), @as(u64, @intCast(iter))) catch unreachable; + view.commit() catch unreachable; + std.mem.doNotOptimizeAway(view.getRoot().getRoot(self.pool)); + } + } +}; + +const SparseSetChunkedLeaf = struct { + pool: *Node.Pool, + base: Node.Id, + pub fn run(self: *SparseSetChunkedLeaf, allocator: std.mem.Allocator) void { + // `TreeView.init` consumes a ref; lend it one so `self.base` survives + // for the next run. + self.pool.ref(self.base) catch unreachable; + const view = ListChunkedLeaf.TreeView.init(allocator, self.pool, self.base) catch unreachable; + defer view.deinit(); + for (0..SparseSetIters) |iter| { + view.set(scatterIndex(iter), @as(u64, @intCast(iter))) catch unreachable; + view.commit() catch unreachable; + std.mem.doNotOptimizeAway(view.getRoot().getRoot(self.pool)); + } + } +}; + +const BatchedSparseSetLeaf = struct { + pool: *Node.Pool, + base: Node.Id, + pub fn run(self: *BatchedSparseSetLeaf, allocator: std.mem.Allocator) void { + self.pool.ref(self.base) catch unreachable; + const view = ListLeaf.TreeView.init(allocator, self.pool, self.base) catch unreachable; + defer view.deinit(); + for (0..BatchedSparseCount) |i| { + view.set(scatterIndex(i), @as(u64, @intCast(i))) catch unreachable; + } + view.commit() catch unreachable; + std.mem.doNotOptimizeAway(view.getRoot().getRoot(self.pool)); + } +}; + +const BatchedSparseSetChunkedLeaf = struct { + pool: *Node.Pool, + base: Node.Id, + pub fn run(self: *BatchedSparseSetChunkedLeaf, allocator: std.mem.Allocator) void { + self.pool.ref(self.base) catch unreachable; + const view = ListChunkedLeaf.TreeView.init(allocator, self.pool, self.base) catch unreachable; + defer view.deinit(); + for (0..BatchedSparseCount) |i| { + view.set(scatterIndex(i), @as(u64, @intCast(i))) catch unreachable; + } + view.commit() catch unreachable; + std.mem.doNotOptimizeAway(view.getRoot().getRoot(self.pool)); + } +}; + +const BulkSetAndRootLeaf = struct { + pool: *Node.Pool, + mutated: *ListLeaf.Type, + pub fn run(self: *BulkSetAndRootLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListLeaf.tree.fromValue(self.pool, self.mutated) catch unreachable; + const root = id.getRoot(self.pool); + std.mem.doNotOptimizeAway(root); + self.pool.unref(id); + } +}; + +const BulkSetAndRootChunkedLeaf = struct { + pool: *Node.Pool, + mutated: *ListChunkedLeaf.Type, + pub fn run(self: *BulkSetAndRootChunkedLeaf, allocator: std.mem.Allocator) void { + _ = allocator; + const id = ListChunkedLeaf.tree.fromValue(self.pool, self.mutated) catch unreachable; + const root = id.getRoot(self.pool); + std.mem.doNotOptimizeAway(root); + self.pool.unref(id); + } +}; + +const ProofLeaf = struct { + pool: *Node.Pool, + root: Node.Id, + pub fn run(self: *ProofLeaf, allocator: std.mem.Allocator) void { + var single = proof.createSingleProof(allocator, self.pool, self.root, proof_gindex) catch unreachable; + defer single.deinit(allocator); + std.mem.doNotOptimizeAway(single.leaf[0]); + } +}; + +const ProofChunkedLeaf = struct { + pool: *Node.Pool, + root: Node.Id, + pub fn run(self: *ProofChunkedLeaf, allocator: std.mem.Allocator) void { + var single = proof.createSingleProof(allocator, self.pool, self.root, proof_gindex) catch unreachable; + defer single.deinit(allocator); + std.mem.doNotOptimizeAway(single.leaf[0]); + } +}; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const allocator = std.heap.page_allocator; + var bench = zbench.Benchmark.init(allocator, .{}); + defer bench.deinit(); + + // Pin c_allocator for the small-object lane, like the production bindings. + var pool = try Node.Pool.init(.{ .allocator = std.heap.c_allocator, .pool_size = 8_000_000 }); + defer pool.deinit(); + + try populateInput(allocator); + defer input_value.deinit(allocator); + + // Build per-layout reference trees once for the read-side benches. + const tree_leaf = try ListLeaf.tree.fromValue(&pool, &input_value); + defer pool.unref(tree_leaf); + _ = tree_leaf.getRoot(&pool); // warm + + const tree_chunked_leaf = try ListChunkedLeaf.tree.fromValue(&pool, &input_value); + defer pool.unref(tree_chunked_leaf); + _ = tree_chunked_leaf.getRoot(&pool); // warm + + // Read-only views for `get`. `TreeView.init` takes the root ref, so lend + // it a fresh one — `tree_*` stay owned for the other benches. + try pool.ref(tree_leaf); + const view_leaf = try ListLeaf.TreeView.init(allocator, &pool, tree_leaf); + defer view_leaf.deinit(); + + try pool.ref(tree_chunked_leaf); + const view_chunked_leaf = try ListChunkedLeaf.TreeView.init(allocator, &pool, tree_chunked_leaf); + defer view_chunked_leaf.deinit(); + + // bulkSet input: each iteration rebuilds tree.fromValue on this value; + // matches the shape of "epoch rewards rewrite all balances + recompute root". + var mutated_leaf: ListLeaf.Type = ListLeaf.Type.empty; + defer mutated_leaf.deinit(allocator); + try mutated_leaf.ensureTotalCapacity(allocator, ItemCount); + for (0..ItemCount) |i| { + try mutated_leaf.append(allocator, @as(u64, @intCast(i * 17 + 3))); + } + + var mutated_chunked_leaf: ListChunkedLeaf.Type = ListChunkedLeaf.Type.empty; + defer mutated_chunked_leaf.deinit(allocator); + try mutated_chunked_leaf.ensureTotalCapacity(allocator, ItemCount); + for (0..ItemCount) |i| { + try mutated_chunked_leaf.append(allocator, @as(u64, @intCast(i * 17 + 3))); + } + + const fv_leaf = FromValueLeaf{ .pool = &pool }; + const fv_chunked_leaf = FromValueChunkedLeaf{ .pool = &pool }; + try bench.addParam("fromValue 1M leaf", &fv_leaf, .{}); + try bench.addParam("fromValue 1M chunked_leaf", &fv_chunked_leaf, .{}); + + const gr_leaf = GetRootLeaf{ .pool = &pool }; + const gr_chunked_leaf = GetRootChunkedLeaf{ .pool = &pool }; + try bench.addParam("fromValue+getRoot 1M leaf", &gr_leaf, .{}); + try bench.addParam("fromValue+getRoot 1M chunked_leaf", &gr_chunked_leaf, .{}); + + const tv_leaf = ToValueLeaf{ .pool = &pool, .tree_id = tree_leaf }; + const tv_chunked_leaf = ToValueChunkedLeaf{ .pool = &pool, .tree_id = tree_chunked_leaf }; + try bench.addParam("toValue 1M leaf", &tv_leaf, .{}); + try bench.addParam("toValue 1M chunked_leaf", &tv_chunked_leaf, .{}); + + const get_leaf = GetLeaf{ .view = view_leaf }; + const get_chunked_leaf = GetChunkedLeaf{ .view = view_chunked_leaf }; + try bench.addParam("get 1K-scattered leaf", &get_leaf, .{}); + try bench.addParam("get 1K-scattered chunked_leaf", &get_chunked_leaf, .{}); + + const ss_leaf = SparseSetLeaf{ .pool = &pool, .base = tree_leaf }; + const ss_chunked_leaf = SparseSetChunkedLeaf{ .pool = &pool, .base = tree_chunked_leaf }; + try bench.addParam("sparseSet 100x leaf", &ss_leaf, .{}); + try bench.addParam("sparseSet 100x chunked_leaf", &ss_chunked_leaf, .{}); + + const bss_leaf = BatchedSparseSetLeaf{ .pool = &pool, .base = tree_leaf }; + const bss_chunked_leaf = BatchedSparseSetChunkedLeaf{ .pool = &pool, .base = tree_chunked_leaf }; + try bench.addParam("batchedSparseSet 512 leaf", &bss_leaf, .{}); + try bench.addParam("batchedSparseSet 512 chunked_leaf", &bss_chunked_leaf, .{}); + + const bs_leaf = BulkSetAndRootLeaf{ .pool = &pool, .mutated = &mutated_leaf }; + const bs_chunked_leaf = BulkSetAndRootChunkedLeaf{ .pool = &pool, .mutated = &mutated_chunked_leaf }; + try bench.addParam("bulkSet+getRoot 1M leaf", &bs_leaf, .{}); + try bench.addParam("bulkSet+getRoot 1M chunked_leaf", &bs_chunked_leaf, .{}); + + const proof_leaf = ProofLeaf{ .pool = &pool, .root = tree_leaf }; + const proof_chunked_leaf = ProofChunkedLeaf{ .pool = &pool, .root = tree_chunked_leaf }; + try bench.addParam("proof single-chunk leaf", &proof_leaf, .{}); + try bench.addParam("proof single-chunk chunked_leaf", &proof_chunked_leaf, .{}); + + try bench.run(io, std.Io.File.stdout()); + + _ = ChunkedLeaf; // silence unused if chunked_leaf code path proves unreachable in some build mode +} diff --git a/bench/state_transition/process_block.zig b/bench/state_transition/process_block.zig index e24e6af39..6d43f9edb 100644 --- a/bench/state_transition/process_block.zig +++ b/bench/state_transition/process_block.zig @@ -4,6 +4,7 @@ //! Run with: zig build run:bench_process_block -Doptimize=ReleaseFast [-- /path/to/state.ssz /path/to/block.ssz] const std = @import("std"); +const builtin = @import("builtin"); const zbench = @import("zbench"); const Node = @import("persistent_merkle_tree").Node; const state_transition = @import("state_transition"); @@ -205,6 +206,31 @@ fn ProcessBlockBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { }; } +/// processBlock + hashTreeRoot — the honest per-block cost: processBlock only +/// stages writes, the chunked_leaf re-merkleization happens in hashTreeRoot. +fn ProcessBlockRootBench(comptime fork: ForkSeq, comptime opts: BenchOpts) type { + return struct { + block: *const BeaconBlock(.full, fork), + + pub fn run(self: *@This(), allocator: std.mem.Allocator) void { + const external_data = BlockExternalData{ .execution_payload_status = .valid, .data_availability_status = .available }; + state_transition.processBlock( + fork, + allocator, + BenchState.cloned_cached_state.config, + BenchState.cloned_cached_state.epoch_cache, + BenchState.cloned_cached_state.state.castToFork(fork), + &BenchState.cloned_cached_state.slashings_cache, + .full, + self.block, + external_data, + .{ .verify_signature = opts.verify_signature }, + ) catch unreachable; + _ = BenchState.cloned_cached_state.state.hashTreeRoot() catch unreachable; + } + }; +} + /// We segregate block processing into `Step`s for more insight into the perf of each part of the process. const Step = enum { block_total, @@ -215,6 +241,8 @@ const Step = enum { eth1_data, operations, sync_aggregate, + commit, + state_root, }; const step_count = std.enums.values(Step).len; @@ -371,22 +399,33 @@ fn ProcessBlockSegmentedBench(comptime fork: ForkSeq) type { recordSegment(.sync_aggregate, @as(u64, @intCast(time.since(io, sync_start).nanoseconds))); } + const commit_start = time.timestampNow(io); + BenchState.cloned_cached_state.state.commit() catch unreachable; + recordSegment(.commit, @as(u64, @intCast(time.since(io, commit_start).nanoseconds))); + + const root_start = time.timestampNow(io); + _ = BenchState.cloned_cached_state.state.hashTreeRoot() catch unreachable; + recordSegment(.state_root, @as(u64, @intCast(time.since(io, root_start).nanoseconds))); + recordSegment(.block_total, @as(u64, @intCast(time.since(io, block_start).nanoseconds))); } }; } +var gpa: std.heap.DebugAllocator(.{}) = .init; + pub fn main(init: std.process.Init) !void { - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer std.debug.assert(gpa.deinit() == .ok); + defer if (builtin.mode == .Debug) std.debug.assert(gpa.deinit() == .ok); - const allocator = gpa.allocator(); + const allocator = if (builtin.mode == .Debug) + gpa.allocator() + else + std.heap.c_allocator; const io = init.io; var stdout_buf: [4096]u8 = undefined; var stdout_file_writer = std.Io.File.stdout().writer(io, &stdout_buf); var stdout = &stdout_file_writer.interface; - - var pool = try Node.Pool.init(allocator, 10_000_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10_000_000 }); defer pool.deinit(); // Use download_era_options.era_files[0] for state @@ -467,7 +506,7 @@ fn runBenchmark( allocator.destroy(index_pubkey_cache); } - const validators = try beacon_state.?.validatorsSlice(allocator); + const validators = try beacon_state.?.validatorsPtrSlice(allocator); defer allocator.free(validators); try state_transition.syncPubkeys(allocator, validators, &pubkey_index_map, index_pubkey_cache); @@ -525,6 +564,9 @@ fn runBenchmark( try bench.addParam("process_block", &ProcessBlockBench(fork, .{ .verify_signature = true }){ .block = block }, .{ .hooks = hooks }); try bench.addParam("process_block_no_sig", &ProcessBlockBench(fork, .{ .verify_signature = false }){ .block = block }, .{ .hooks = hooks }); + try bench.addParam("process_block+root", &ProcessBlockRootBench(fork, .{ .verify_signature = true }){ .block = block }, .{ .hooks = hooks }); + try bench.addParam("process_block+root_no_sig", &ProcessBlockRootBench(fork, .{ .verify_signature = false }){ .block = block }, .{ .hooks = hooks }); + // // Segmented benchmark (step-by-step timing) resetSegmentStats(); diff --git a/bench/state_transition/process_epoch.zig b/bench/state_transition/process_epoch.zig index 42626cc17..839acc924 100644 --- a/bench/state_transition/process_epoch.zig +++ b/bench/state_transition/process_epoch.zig @@ -4,6 +4,7 @@ //! Run with: zig build run:bench_process_epoch -Doptimize=ReleaseFast const std = @import("std"); +const builtin = @import("builtin"); const zbench = @import("zbench"); const Node = @import("persistent_merkle_tree").Node; const state_transition = @import("state_transition"); @@ -333,6 +334,7 @@ const Step = enum { participation_record, sync_committee_updates, proposer_lookahead, + state_root, }; const step_count = std.enums.values(Step).len; @@ -398,6 +400,9 @@ fn ProcessEpochBench(comptime fork: ForkSeq) type { BenchState.cloned_cached_state.state.castToFork(fork), &cache, ) catch unreachable; + // hashTreeRoot, not commit: the re-merkleization runs here — the + // real per-epoch cost (state_root is verified each epoch). + _ = BenchState.cloned_cached_state.state.hashTreeRoot() catch unreachable; } }; } @@ -568,6 +573,10 @@ fn ProcessEpochSegmentedBench(comptime fork: ForkSeq) type { recordSegment(.proposer_lookahead, @as(u64, @intCast(time.since(io, lookahead_start).nanoseconds))); } + const state_root_start = time.timestampNow(io); + _ = BenchState.cloned_cached_state.state.hashTreeRoot() catch unreachable; + recordSegment(.state_root, @as(u64, @intCast(time.since(io, state_root_start).nanoseconds))); + recordSegment(.epoch_total, @as(u64, @intCast(time.since(io, epoch_start).nanoseconds))); } }; @@ -606,16 +615,20 @@ fn loadStateBytesFromConfiguredEraFiles(allocator: std.mem.Allocator, io: std.Io return error.NoUsableEraStateFound; } +var gpa: std.heap.DebugAllocator(.{}) = .init; + pub fn main(init: std.process.Init) !void { - var gpa: std.heap.DebugAllocator(.{}) = .init; - defer std.debug.assert(gpa.deinit() == .ok); + defer if (builtin.mode == .Debug) std.debug.assert(gpa.deinit() == .ok); - const allocator = gpa.allocator(); + const allocator = if (builtin.mode == .Debug) + gpa.allocator() + else + std.heap.c_allocator; const io = init.io; var stdout_buf: [4096]u8 = undefined; var stdout_file_writer = std.Io.File.stdout().writer(io, &stdout_buf); var stdout = &stdout_file_writer.interface; - var pool = try Node.Pool.init(allocator, 10_000_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10_000_000 }); defer pool.deinit(); const state_bytes = try loadStateBytesFromConfiguredEraFiles(allocator, io, stdout); @@ -670,7 +683,7 @@ fn runBenchmark( allocator.destroy(index_pubkey_cache); } - const validators = try beacon_state.?.validatorsSlice(allocator); + const validators = try beacon_state.?.validatorsPtrSlice(allocator); defer allocator.free(validators); try state_transition.syncPubkeys(allocator, validators, &pubkey_index_map, index_pubkey_cache); diff --git a/bindings/napi/pool.zig b/bindings/napi/pool.zig index b04b5468e..2e0ec480b 100644 --- a/bindings/napi/pool.zig +++ b/bindings/napi/pool.zig @@ -3,8 +3,8 @@ const js = @import("zapi:zapi").js; const Node = @import("persistent_merkle_tree").Node; const RefCount = @import("state_transition").RefCount; -/// Pool uses page allocator for internal allocations. -/// It's recommended to never reallocate the pool after initialization. +/// Backs the `PoolRc` wrapper allocation only — `Node.Pool` uses its own +/// `InitOptions` allocators. const allocator = std.heap.page_allocator; const default_pool_size: u32 = 0; @@ -21,8 +21,12 @@ pub const State = struct { pub fn init(self: *State) !void { if (self.pool_rc != null) return; - var pool_value = try Node.Pool.init(allocator, default_pool_size); + + // Small-object lane must stay non-page: page_allocator rounds each + // alloc to 4 KB and blows up once the binding preheats 10M nodes. + var pool_value = try Node.Pool.init(.{ .allocator = std.heap.c_allocator, .pool_size = default_pool_size }); errdefer pool_value.deinit(); + self.pool_rc = try PoolRc.init(allocator, pool_value); } diff --git a/build.zig.zon b/build.zig.zon index 4ea60ffc9..2c60bd3a4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -270,6 +270,12 @@ .imports = .{ .config, .consensus_types, .download_era_options, .era, .ssz, .zbench }, }, }, + .bench_list_chunked_leaf = .{ + .root_module = .{ + .root_source_file = "bench/ssz/list_chunked_leaf.zig", + .imports = .{ .persistent_merkle_tree, .ssz, .zbench }, + }, + }, .bench_merkle_gindex = .{ .root_module = .{ .root_source_file = "bench/ssz/gindex.zig", diff --git a/src/consensus_types/altair.zig b/src/consensus_types/altair.zig index c0f01eb95..21dab7921 100644 --- a/src/consensus_types/altair.zig +++ b/src/consensus_types/altair.zig @@ -47,7 +47,7 @@ pub const SyncAggregate = ssz.FixedContainerType(struct { }); pub const SyncCommittee = ssz.FixedContainerType(struct { - pubkeys: ssz.FixedVectorType(p.BLSPubkey, preset.SYNC_COMMITTEE_SIZE), + pubkeys: ssz.FixedVectorType(p.BLSPubkey, preset.SYNC_COMMITTEE_SIZE, .{}), aggregate_pubkey: p.BLSPubkey, }); @@ -71,8 +71,8 @@ pub const BeaconBlock = ssz.VariableContainerType(struct { body: BeaconBlockBody, }); -pub const InactivityScores = ssz.FixedListType(p.Uint64, preset.VALIDATOR_REGISTRY_LIMIT); -pub const EpochParticipation = ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT); +pub const InactivityScores = ssz.FixedListType(p.Uint64, preset.VALIDATOR_REGISTRY_LIMIT, .{ .chunked_leaf = true }); +pub const EpochParticipation = ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT, .{ .chunked_leaf = true }); pub const BeaconState = ssz.VariableContainerType(struct { genesis_time: p.Uint64, @@ -144,15 +144,15 @@ pub const LightClientHeader = ssz.FixedContainerType(struct { pub const LightClientBootstrap = ssz.FixedContainerType(struct { header: LightClientHeader, current_sync_committee: SyncCommittee, - current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX)), + current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX), .{}), }); pub const LightClientUpdate = ssz.FixedContainerType(struct { attested_header: LightClientHeader, next_sync_committee: SyncCommittee, - next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX)), + next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX), .{}), finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -160,7 +160,7 @@ pub const LightClientUpdate = ssz.FixedContainerType(struct { pub const LightClientFinalityUpdate = ssz.FixedContainerType(struct { attested_header: LightClientHeader, finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); diff --git a/src/consensus_types/bellatrix.zig b/src/consensus_types/bellatrix.zig index cabf9e6ab..1ca5fa8d4 100644 --- a/src/consensus_types/bellatrix.zig +++ b/src/consensus_types/bellatrix.zig @@ -122,11 +122,11 @@ pub const BlindedBeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, eth1_data: Eth1Data, graffiti: p.Bytes32, - proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS), + proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS, .{}), attester_slashings: ssz.VariableListType(AttesterSlashing, preset.MAX_ATTESTER_SLASHINGS), attestations: ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS), - deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS), - voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS), + deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS, .{}), + voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS, .{}), sync_aggregate: SyncAggregate, execution_payload_header: ExecutionPayloadHeader, }); diff --git a/src/consensus_types/capella.zig b/src/consensus_types/capella.zig index 340920f42..7e47754b6 100644 --- a/src/consensus_types/capella.zig +++ b/src/consensus_types/capella.zig @@ -59,21 +59,21 @@ pub const Transactions = bellatrix.Transactions; pub const LightClientHeader = ssz.VariableContainerType(struct { beacon: BeaconBlockHeader, execution: ExecutionPayloadHeader, - execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX)), + execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX), .{}), }); pub const LightClientBootstrap = ssz.VariableContainerType(struct { header: LightClientHeader, current_sync_committee: SyncCommittee, - current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX)), + current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX), .{}), }); pub const LightClientUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, next_sync_committee: SyncCommittee, - next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX)), + next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX), .{}), finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -81,7 +81,7 @@ pub const LightClientUpdate = ssz.VariableContainerType(struct { pub const LightClientFinalityUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -110,14 +110,14 @@ pub const SignedBLSToExecutionChange = ssz.FixedContainerType(struct { signature: p.BLSSignature, }); -pub const SignedBLSToExecutionChanges = ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES); +pub const SignedBLSToExecutionChanges = ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES, .{}); pub const HistoricalSummary = ssz.FixedContainerType(struct { block_summary_root: p.Root, state_summary_root: p.Root, }); -pub const Withdrawals = ssz.FixedListType(Withdrawal, preset.MAX_WITHDRAWALS_PER_PAYLOAD); +pub const Withdrawals = ssz.FixedListType(Withdrawal, preset.MAX_WITHDRAWALS_PER_PAYLOAD, .{}); pub const ExecutionPayload = ssz.VariableContainerType(struct { parent_hash: p.Bytes32, @@ -181,14 +181,14 @@ pub const BlindedBeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, eth1_data: Eth1Data, graffiti: p.Bytes32, - proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS), + proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS, .{}), attester_slashings: ssz.VariableListType(AttesterSlashing, preset.MAX_ATTESTER_SLASHINGS), attestations: ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS), - deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS), - voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS), + deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS, .{}), + voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS, .{}), sync_aggregate: SyncAggregate, execution_payload_header: ExecutionPayloadHeader, - bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES), + bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES, .{}), }); pub const BlindedBeaconBlock = ssz.VariableContainerType(struct { @@ -204,7 +204,7 @@ pub const SignedBlindedBeaconBlock = ssz.VariableContainerType(struct { signature: p.BLSSignature, }); -pub const HistoricalSummaries = ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT); +pub const HistoricalSummaries = ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT, .{}); pub const BeaconState = ssz.VariableContainerType(struct { genesis_time: p.Uint64, diff --git a/src/consensus_types/deneb.zig b/src/consensus_types/deneb.zig index 125e78c55..21c8cea10 100644 --- a/src/consensus_types/deneb.zig +++ b/src/consensus_types/deneb.zig @@ -70,7 +70,7 @@ pub const BlobSidecar = ssz.FixedContainerType(struct { kzg_commitment: p.KZGCommitment, kzg_proof: p.KZGProof, signed_block_header: SignedBeaconBlockHeader, - kzg_commitment_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENT_INCLUSION_PROOF_DEPTH), + kzg_commitment_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENT_INCLUSION_PROOF_DEPTH, .{}), }); pub const BlobIdentifier = ssz.FixedContainerType(struct { @@ -81,21 +81,21 @@ pub const BlobIdentifier = ssz.FixedContainerType(struct { pub const LightClientHeader = ssz.VariableContainerType(struct { beacon: BeaconBlockHeader, execution: ExecutionPayloadHeader, - execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX)), + execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX), .{}), }); pub const LightClientBootstrap = ssz.VariableContainerType(struct { header: LightClientHeader, current_sync_committee: SyncCommittee, - current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX)), + current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX), .{}), }); pub const LightClientUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, next_sync_committee: SyncCommittee, - next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX)), + next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX), .{}), finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -103,7 +103,7 @@ pub const LightClientUpdate = ssz.VariableContainerType(struct { pub const LightClientFinalityUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -154,7 +154,7 @@ pub const ExecutionPayloadHeader = ssz.VariableContainerType(struct { excess_blob_gas: p.Uint64, }); -pub const BlobKzgCommitments = ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK); +pub const BlobKzgCommitments = ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}); pub const BeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, @@ -183,15 +183,15 @@ pub const BlindedBeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, eth1_data: Eth1Data, graffiti: p.Bytes32, - proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS), + proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS, .{}), attester_slashings: ssz.VariableListType(AttesterSlashing, preset.MAX_ATTESTER_SLASHINGS), attestations: ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS), - deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS), - voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS), + deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS, .{}), + voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS, .{}), sync_aggregate: SyncAggregate, execution_payload_header: ExecutionPayloadHeader, - bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES), - blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), + bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES, .{}), + blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), }); pub const BlindedBeaconBlock = ssz.VariableContainerType(struct { diff --git a/src/consensus_types/electra.zig b/src/consensus_types/electra.zig index cecf68975..32d20e34a 100644 --- a/src/consensus_types/electra.zig +++ b/src/consensus_types/electra.zig @@ -100,9 +100,9 @@ pub const ConsolidationRequest = ssz.FixedContainerType(struct { }); pub const ExecutionRequests = ssz.VariableContainerType(struct { - deposits: ssz.FixedListType(DepositRequest, preset.MAX_DEPOSIT_REQUESTS_PER_PAYLOAD), - withdrawals: ssz.FixedListType(WithdrawalRequest, preset.MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD), - consolidations: ssz.FixedListType(ConsolidationRequest, preset.MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD), + deposits: ssz.FixedListType(DepositRequest, preset.MAX_DEPOSIT_REQUESTS_PER_PAYLOAD, .{}), + withdrawals: ssz.FixedListType(WithdrawalRequest, preset.MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD, .{}), + consolidations: ssz.FixedListType(ConsolidationRequest, preset.MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD, .{}), }); pub const SingleAttestation = ssz.FixedContainerType(struct { @@ -122,7 +122,7 @@ pub const Attestation = ssz.VariableContainerType(struct { pub const Attestations = ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS_ELECTRA); pub const IndexedAttestation = ssz.VariableContainerType(struct { - attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT), + attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.MAX_VALIDATORS_PER_COMMITTEE * preset.MAX_COMMITTEES_PER_SLOT, .{}), data: AttestationData, signature: p.BLSSignature, }); @@ -149,27 +149,27 @@ pub const BlobSidecar = ssz.FixedContainerType(struct { kzg_commitment: p.KZGCommitment, kzg_proof: p.KZGProof, signed_block_header: SignedBeaconBlockHeader, - kzg_commitment_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENT_INCLUSION_PROOF_DEPTH), + kzg_commitment_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENT_INCLUSION_PROOF_DEPTH, .{}), }); pub const LightClientHeader = ssz.VariableContainerType(struct { beacon: BeaconBlockHeader, execution: ExecutionPayloadHeader, - execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX)), + execution_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.EXECUTION_PAYLOAD_GINDEX), .{}), }); pub const LightClientBootstrap = ssz.VariableContainerType(struct { header: LightClientHeader, current_sync_committee: SyncCommittee, - current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX_ELECTRA)), + current_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.CURRENT_SYNC_COMMITTEE_GINDEX_ELECTRA), .{}), }); pub const LightClientUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, next_sync_committee: SyncCommittee, - next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX_ELECTRA)), + next_sync_committee_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.NEXT_SYNC_COMMITTEE_GINDEX_ELECTRA), .{}), finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX_ELECTRA)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX_ELECTRA), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -177,7 +177,7 @@ pub const LightClientUpdate = ssz.VariableContainerType(struct { pub const LightClientFinalityUpdate = ssz.VariableContainerType(struct { attested_header: LightClientHeader, finalized_header: LightClientHeader, - finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX_ELECTRA)), + finality_branch: ssz.FixedVectorType(p.Bytes32, std.math.log2(c.FINALIZED_ROOT_GINDEX_ELECTRA), .{}), sync_aggregate: SyncAggregate, signature_slot: p.Slot, }); @@ -216,15 +216,15 @@ pub const BlindedBeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, eth1_data: Eth1Data, graffiti: p.Bytes32, - proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS), + proposer_slashings: ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS, .{}), attester_slashings: ssz.VariableListType(AttesterSlashing, preset.MAX_ATTESTER_SLASHINGS), attestations: ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS_ELECTRA), - deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS), - voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS), + deposits: ssz.FixedListType(Deposit, preset.MAX_DEPOSITS, .{}), + voluntary_exits: ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS, .{}), sync_aggregate: SyncAggregate, execution_payload_header: ExecutionPayloadHeader, - bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES), - blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), + bls_to_execution_changes: ssz.FixedListType(SignedBLSToExecutionChange, preset.MAX_BLS_TO_EXECUTION_CHANGES, .{}), + blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), execution_requests: ExecutionRequests, }); @@ -241,9 +241,9 @@ pub const SignedBlindedBeaconBlock = ssz.VariableContainerType(struct { signature: p.BLSSignature, }); -pub const PendingDeposits = ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT); -pub const PendingPartialWithdrawals = ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT); -pub const PendingConsolidations = ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT); +pub const PendingDeposits = ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT, .{}); +pub const PendingPartialWithdrawals = ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT, .{}); +pub const PendingConsolidations = ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT, .{}); pub const BeaconState = ssz.VariableContainerType(struct { genesis_time: p.Uint64, diff --git a/src/consensus_types/fulu.zig b/src/consensus_types/fulu.zig index c0866478b..38fcc3c1d 100644 --- a/src/consensus_types/fulu.zig +++ b/src/consensus_types/fulu.zig @@ -88,11 +88,11 @@ pub const Cell = ssz.ByteVectorType(c.BYTES_PER_FIELD_ELEMENT * preset.FIELD_ELE // New containers for Data Availability Sampling pub const DataColumnSidecar = ssz.VariableContainerType(struct { index: ColumnIndex, - column: ssz.FixedListType(Cell, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), - kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), - kzg_proofs: ssz.FixedListType(p.KZGProof, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), + column: ssz.FixedListType(Cell, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), + kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), + kzg_proofs: ssz.FixedListType(p.KZGProof, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), signed_block_header: SignedBeaconBlockHeader, - kzg_commitments_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH), + kzg_commitments_inclusion_proof: ssz.FixedVectorType(p.Bytes32, preset.KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH, .{}), }); pub const MatrixEntry = ssz.FixedContainerType(struct { @@ -116,7 +116,7 @@ pub const BlindedBeaconBlockBody = electra.BlindedBeaconBlockBody; pub const BlindedBeaconBlock = electra.BlindedBeaconBlock; pub const SignedBlindedBeaconBlock = electra.SignedBlindedBeaconBlock; -pub const ProposerLookahead = ssz.FixedVectorType(p.ValidatorIndex, (preset.MIN_SEED_LOOKAHEAD + 1) * preset.SLOTS_PER_EPOCH); +pub const ProposerLookahead = ssz.FixedVectorType(p.ValidatorIndex, (preset.MIN_SEED_LOOKAHEAD + 1) * preset.SLOTS_PER_EPOCH, .{}); // BeaconState with new proposer_lookahead field pub const BeaconState = ssz.VariableContainerType(struct { @@ -127,36 +127,36 @@ pub const BeaconState = ssz.VariableContainerType(struct { latest_block_header: BeaconBlockHeader, block_roots: HistoricalBlockRoots, state_roots: HistoricalStateRoots, - historical_roots: ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT), + historical_roots: ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT, .{}), eth1_data: Eth1Data, eth1_data_votes: phase0.Eth1DataVotes, eth1_deposit_index: p.Uint64, - validators: ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT), - balances: ssz.FixedListType(p.Gwei, preset.VALIDATOR_REGISTRY_LIMIT), - randao_mixes: ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR), - slashings: ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR), - previous_epoch_participation: ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT), - current_epoch_participation: ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT), + validators: ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT, .{}), + balances: phase0.Balances, + randao_mixes: ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR, .{}), + slashings: ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR, .{}), + previous_epoch_participation: altair.EpochParticipation, + current_epoch_participation: altair.EpochParticipation, justification_bits: ssz.BitVectorType(c.JUSTIFICATION_BITS_LENGTH), previous_justified_checkpoint: Checkpoint, current_justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, - inactivity_scores: ssz.FixedListType(p.Uint64, preset.VALIDATOR_REGISTRY_LIMIT), + inactivity_scores: altair.InactivityScores, current_sync_committee: SyncCommittee, next_sync_committee: SyncCommittee, latest_execution_payload_header: ExecutionPayloadHeader, next_withdrawal_index: p.WithdrawalIndex, next_withdrawal_validator_index: p.ValidatorIndex, - historical_summaries: ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT), + historical_summaries: ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT, .{}), deposit_requests_start_index: p.Uint64, deposit_balance_to_consume: p.Gwei, exit_balance_to_consume: p.Gwei, earliest_exit_epoch: p.Epoch, consolidation_balance_to_consume: p.Gwei, earliest_consolidation_epoch: p.Epoch, - pending_deposits: ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT), - pending_partial_withdrawals: ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT), - pending_consolidations: ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT), + pending_deposits: ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT, .{}), + pending_partial_withdrawals: ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT, .{}), + pending_consolidations: ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT, .{}), proposer_lookahead: ProposerLookahead, }); diff --git a/src/consensus_types/gloas.zig b/src/consensus_types/gloas.zig index 5231ef447..393b4bf6d 100644 --- a/src/consensus_types/gloas.zig +++ b/src/consensus_types/gloas.zig @@ -141,7 +141,7 @@ pub const PayloadAttestationMessage = ssz.FixedContainerType(struct { }); pub const IndexedPayloadAttestation = ssz.VariableContainerType(struct { - attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.PTC_SIZE), + attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.PTC_SIZE, .{}), data: PayloadAttestationData, signature: p.BLSSignature, }); @@ -169,7 +169,7 @@ pub const ExecutionPayloadBid = ssz.VariableContainerType(struct { slot: p.Slot, value: p.Uint64, execution_payment: p.Uint64, - blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), + blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), }); pub const SignedExecutionPayloadBid = ssz.VariableContainerType(struct { @@ -208,7 +208,7 @@ pub const BeaconBlockBody = ssz.VariableContainerType(struct { // blobKzgCommitments removed in Gloas (EIP-7732) // executionRequests removed in Gloas (EIP-7732) signed_execution_payload_bid: SignedExecutionPayloadBid, - payload_attestations: ssz.FixedListType(PayloadAttestation, preset.MAX_PAYLOAD_ATTESTATIONS), + payload_attestations: ssz.FixedListType(PayloadAttestation, preset.MAX_PAYLOAD_ATTESTATIONS, .{}), }); pub const BeaconBlock = ssz.VariableContainerType(struct { @@ -227,8 +227,8 @@ pub const SignedBeaconBlock = ssz.VariableContainerType(struct { // DataColumnSidecar simplified in Gloas (EIP-7732) pub const DataColumnSidecar = ssz.VariableContainerType(struct { index: ColumnIndex, - column: ssz.FixedListType(Cell, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), - kzg_proofs: ssz.FixedListType(p.KZGProof, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK), + column: ssz.FixedListType(Cell, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), + kzg_proofs: ssz.FixedListType(p.KZGProof, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), slot: p.Slot, beacon_block_root: p.Root, }); @@ -243,44 +243,44 @@ pub const BeaconState = ssz.VariableContainerType(struct { latest_block_header: BeaconBlockHeader, block_roots: HistoricalBlockRoots, state_roots: HistoricalStateRoots, - historical_roots: ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT), + historical_roots: ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT, .{}), eth1_data: Eth1Data, eth1_data_votes: phase0.Eth1DataVotes, eth1_deposit_index: p.Uint64, - validators: ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT), - balances: ssz.FixedListType(p.Gwei, preset.VALIDATOR_REGISTRY_LIMIT), - randao_mixes: ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR), - slashings: ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR), - previous_epoch_participation: ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT), - current_epoch_participation: ssz.FixedListType(p.Uint8, preset.VALIDATOR_REGISTRY_LIMIT), + validators: ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT, .{}), + balances: phase0.Balances, + randao_mixes: ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR, .{}), + slashings: ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR, .{}), + previous_epoch_participation: altair.EpochParticipation, + current_epoch_participation: altair.EpochParticipation, justification_bits: ssz.BitVectorType(c.JUSTIFICATION_BITS_LENGTH), previous_justified_checkpoint: Checkpoint, current_justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, - inactivity_scores: ssz.FixedListType(p.Uint64, preset.VALIDATOR_REGISTRY_LIMIT), + inactivity_scores: altair.InactivityScores, current_sync_committee: SyncCommittee, next_sync_committee: SyncCommittee, // latestExecutionPayloadHeader removed in Gloas (EIP-7732) latest_execution_payload_bid: ExecutionPayloadBid, next_withdrawal_index: p.WithdrawalIndex, next_withdrawal_validator_index: p.ValidatorIndex, - historical_summaries: ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT), + historical_summaries: ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT, .{}), deposit_requests_start_index: p.Uint64, deposit_balance_to_consume: p.Gwei, exit_balance_to_consume: p.Gwei, earliest_exit_epoch: p.Epoch, consolidation_balance_to_consume: p.Gwei, earliest_consolidation_epoch: p.Epoch, - pending_deposits: ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT), - pending_partial_withdrawals: ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT), - pending_consolidations: ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT), + pending_deposits: ssz.FixedListType(PendingDeposit, preset.PENDING_DEPOSITS_LIMIT, .{}), + pending_partial_withdrawals: ssz.FixedListType(PendingPartialWithdrawal, preset.PENDING_PARTIAL_WITHDRAWALS_LIMIT, .{}), + pending_consolidations: ssz.FixedListType(PendingConsolidation, preset.PENDING_CONSOLIDATIONS_LIMIT, .{}), proposer_lookahead: ProposerLookahead, // New in Gloas (EIP-7732) - builders: ssz.FixedListType(Builder, preset.BUILDER_REGISTRY_LIMIT), + builders: ssz.FixedListType(Builder, preset.BUILDER_REGISTRY_LIMIT, .{}), next_withdrawal_builder_index: BuilderIndex, execution_payload_availability: ssz.BitVectorType(preset.SLOTS_PER_HISTORICAL_ROOT), - builder_pending_payments: ssz.FixedVectorType(BuilderPendingPayment, 2 * preset.SLOTS_PER_EPOCH), - builder_pending_withdrawals: ssz.FixedListType(BuilderPendingWithdrawal, preset.BUILDER_PENDING_WITHDRAWALS_LIMIT), + builder_pending_payments: ssz.FixedVectorType(BuilderPendingPayment, 2 * preset.SLOTS_PER_EPOCH, .{}), + builder_pending_withdrawals: ssz.FixedListType(BuilderPendingWithdrawal, preset.BUILDER_PENDING_WITHDRAWALS_LIMIT, .{}), latest_block_hash: p.Bytes32, payload_expected_withdrawals: Withdrawals, }); diff --git a/src/consensus_types/phase0.zig b/src/consensus_types/phase0.zig index cd1e6b397..12bd5e67b 100644 --- a/src/consensus_types/phase0.zig +++ b/src/consensus_types/phase0.zig @@ -19,7 +19,7 @@ pub const Checkpoint = ssz.FixedContainerType(struct { root: p.Root, }); -pub const Validator = ssz.FixedContainerType(struct { +pub const Validator = ssz.StructContainerType(struct { pubkey: p.BLSPubkey, withdrawal_credentials: p.Root, effective_balance: p.Gwei, @@ -30,7 +30,7 @@ pub const Validator = ssz.FixedContainerType(struct { withdrawable_epoch: p.Epoch, }); -pub const Validators = ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT); +pub const Validators = ssz.FixedListType(Validator, preset.VALIDATOR_REGISTRY_LIMIT, .{}); pub const AttestationData = ssz.FixedContainerType(struct { slot: p.Slot, @@ -41,7 +41,7 @@ pub const AttestationData = ssz.FixedContainerType(struct { }); pub const IndexedAttestation = ssz.VariableContainerType(struct { - attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.MAX_VALIDATORS_PER_COMMITTEE), + attesting_indices: ssz.FixedListType(p.ValidatorIndex, preset.MAX_VALIDATORS_PER_COMMITTEE, .{}), data: AttestationData, signature: p.BLSSignature, }); @@ -59,11 +59,11 @@ pub const Eth1Data = ssz.FixedContainerType(struct { block_hash: p.Bytes32, }); -pub const Eth1DataVotes = ssz.FixedListType(Eth1Data, preset.EPOCHS_PER_ETH1_VOTING_PERIOD * preset.SLOTS_PER_EPOCH); +pub const Eth1DataVotes = ssz.FixedListType(Eth1Data, preset.EPOCHS_PER_ETH1_VOTING_PERIOD * preset.SLOTS_PER_EPOCH, .{}); pub const JustificationBits = ssz.BitVectorType(c.JUSTIFICATION_BITS_LENGTH); -pub const HistoricalBlockRoots = ssz.FixedVectorType(p.Root, preset.SLOTS_PER_HISTORICAL_ROOT); -pub const HistoricalStateRoots = ssz.FixedVectorType(p.Root, preset.SLOTS_PER_HISTORICAL_ROOT); +pub const HistoricalBlockRoots = ssz.FixedVectorType(p.Root, preset.SLOTS_PER_HISTORICAL_ROOT, .{}); +pub const HistoricalStateRoots = ssz.FixedVectorType(p.Root, preset.SLOTS_PER_HISTORICAL_ROOT, .{}); pub const HistoricalBatch = ssz.FixedContainerType(struct { block_roots: HistoricalBlockRoots, @@ -120,7 +120,7 @@ pub const Attestation = ssz.VariableContainerType(struct { }); pub const Deposit = ssz.FixedContainerType(struct { - proof: ssz.FixedVectorType(p.Bytes32, c.DEPOSIT_CONTRACT_TREE_DEPTH + 1), + proof: ssz.FixedVectorType(p.Bytes32, c.DEPOSIT_CONTRACT_TREE_DEPTH + 1, .{}), data: DepositData, }); @@ -129,15 +129,15 @@ pub const VoluntaryExit = ssz.FixedContainerType(struct { validator_index: p.ValidatorIndex, }); -pub const ProposerSlashings = ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS); +pub const ProposerSlashings = ssz.FixedListType(ProposerSlashing, preset.MAX_PROPOSER_SLASHINGS, .{}); pub const AttesterSlashings = ssz.VariableListType(AttesterSlashing, preset.MAX_ATTESTER_SLASHINGS); pub const Attestations = ssz.VariableListType(Attestation, preset.MAX_ATTESTATIONS); -pub const Deposits = ssz.FixedListType(Deposit, preset.MAX_DEPOSITS); +pub const Deposits = ssz.FixedListType(Deposit, preset.MAX_DEPOSITS, .{}); -pub const VoluntaryExits = ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS); +pub const VoluntaryExits = ssz.FixedListType(SignedVoluntaryExit, preset.MAX_VOLUNTARY_EXITS, .{}); pub const BeaconBlockBody = ssz.VariableContainerType(struct { randao_reveal: p.BLSSignature, @@ -165,13 +165,13 @@ pub const SignedBeaconBlockHeader = ssz.FixedContainerType(struct { pub const EpochAttestations = ssz.VariableListType(PendingAttestation, preset.MAX_ATTESTATIONS * preset.SLOTS_PER_EPOCH); -pub const Balances = ssz.FixedListType(p.Gwei, preset.VALIDATOR_REGISTRY_LIMIT); +pub const Balances = ssz.FixedListType(p.Gwei, preset.VALIDATOR_REGISTRY_LIMIT, .{ .chunked_leaf = true }); -pub const RandaoMixes = ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR); +pub const RandaoMixes = ssz.FixedVectorType(p.Bytes32, preset.EPOCHS_PER_HISTORICAL_VECTOR, .{}); -pub const Slashings = ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR); +pub const Slashings = ssz.FixedVectorType(p.Gwei, preset.EPOCHS_PER_SLASHINGS_VECTOR, .{}); -pub const HistoricalRoots = ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT); +pub const HistoricalRoots = ssz.FixedListType(p.Root, preset.HISTORICAL_ROOTS_LIMIT, .{}); pub const BeaconState = ssz.VariableContainerType(struct { genesis_time: p.Uint64, diff --git a/src/era/Reader.zig b/src/era/Reader.zig index 42409ec88..e5a02514b 100644 --- a/src/era/Reader.zig +++ b/src/era/Reader.zig @@ -42,7 +42,7 @@ pub fn open(allocator: std.mem.Allocator, io: std.Io, config: c.BeaconConfig, pa const pool = try allocator.create(Node.Pool); errdefer allocator.destroy(pool); - pool.* = try Node.Pool.init(allocator, 500_000); + pool.* = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); errdefer pool.deinit(); return .{ .config = config, diff --git a/src/fork_types/any_beacon_state.zig b/src/fork_types/any_beacon_state.zig index f519eeaff..3cf890d02 100644 --- a/src/fork_types/any_beacon_state.zig +++ b/src/fork_types/any_beacon_state.zig @@ -410,6 +410,27 @@ pub const AnyBeaconState = union(ForkSeq) { }; } + /// Pointer-slice version of `validatorsSlice` that hands out + /// `*const Validator.Type` into the pool's container_struct payloads — + /// no clone. Pointers are valid only while the validators list is not + /// mutated; copy out values that must survive a `tree.set`. + pub fn validatorsPtrSlice(self: *AnyBeaconState, allocator: Allocator) ![]*const ct.phase0.Validator.Type { + return switch (self.*) { + inline else => |state| { + var validators_view = try state.getReadonly("validators"); + try validators_view.commit(); + const len = try validators_view.length(); + const out = try allocator.alloc(*const ct.phase0.Validator.Type, len); + errdefer allocator.free(out); + var it = validators_view.iteratorReadonly(0); + for (0..len) |i| { + out[i] = try it.nextValuePtr(); + } + return out; + }, + }; + } + pub fn balances(self: *AnyBeaconState) !*ct.phase0.Balances.TreeView { return switch (self.*) { inline else => |state| try state.get("balances"), @@ -992,7 +1013,7 @@ pub const AnyBeaconState = union(ForkSeq) { test "electra - sanity" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); defer pool.deinit(); var beacon_state = try AnyBeaconState.fromValue(allocator, &pool, .electra, &ct.electra.BeaconState.default_value); @@ -1014,7 +1035,7 @@ test "electra - sanity" { test "clone - sanity" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); defer pool.deinit(); var beacon_state = try AnyBeaconState.fromValue(allocator, &pool, .electra, &ct.electra.BeaconState.default_value); @@ -1046,7 +1067,7 @@ test "clone - cases" { }; inline for (test_Case) |tc| { - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); defer pool.deinit(); var beacon_state = try AnyBeaconState.fromValue(allocator, &pool, .electra, &ct.electra.BeaconState.default_value); @@ -1072,7 +1093,7 @@ test "clone - cases" { test "upgrade state - sanity" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); defer pool.deinit(); var phase0_state = try AnyBeaconState.fromValue(allocator, &pool, .phase0, &ct.phase0.BeaconState.default_value); @@ -1106,3 +1127,73 @@ test "upgrade state - sanity" { defer gloas_state.deinit(); try expect(gloas_state.forkSeq() == .gloas); } + +test "single proof: validators[0].withdrawal_credentials" { + const allocator = std.testing.allocator; + const ssz = @import("ssz"); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); + defer pool.deinit(); + + var beacon_state = try AnyBeaconState.fromValue( + allocator, + &pool, + .electra, + &ct.electra.BeaconState.default_value, + ); + defer beacon_state.deinit(); + + // Bootstrap one validator so `validators[0]` exists. + var validators_view = try beacon_state.validators(); + const validator_value = ct.electra.Validator.Type{ + .pubkey = [_]u8{1} ** 48, + .withdrawal_credentials = [_]u8{0xab} ** 32, + .effective_balance = 32_000_000_000, + .slashed = false, + .activation_eligibility_epoch = 0, + .activation_epoch = 0, + .exit_epoch = std.math.maxInt(u64), + .withdrawable_epoch = std.math.maxInt(u64), + }; + try validators_view.pushValue(&validator_value); + try beacon_state.commit(); + + const gindex = ssz.getPathGindex(ct.electra.BeaconState, "validators.0.withdrawal_credentials"); + var proof = try beacon_state.getSingleProof(allocator, @intFromEnum(gindex)); + defer proof.deinit(allocator); + + // The proof should be non-empty and the leaf should match the value + // we set above. (We do not yet verify witness chain correctness — just + // that proof generation does not error out with InvalidNode.) + try std.testing.expect(proof.witnesses.len > 0); + try std.testing.expectEqualSlices(u8, &[_]u8{0xab} ** 32, &proof.leaf); +} + +test "single proof: balances[0]" { + const allocator = std.testing.allocator; + const ssz = @import("ssz"); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); + defer pool.deinit(); + + var beacon_state = try AnyBeaconState.fromValue( + allocator, + &pool, + .electra, + &ct.electra.BeaconState.default_value, + ); + defer beacon_state.deinit(); + + var balances_view = try beacon_state.balances(); + try balances_view.push(31_000_000_000); + try beacon_state.commit(); + + const gindex = ssz.getPathGindex(ct.electra.BeaconState, "balances.0"); + var proof = try beacon_state.getSingleProof(allocator, @intFromEnum(gindex)); + defer proof.deinit(allocator); + + try std.testing.expect(proof.witnesses.len > 0); + // balances[0] is a packed u64; only the low 8 bytes of the leaf carry + // the value (LE-encoded), the rest of the chunk is zero-padded. + var expected_leaf: [32]u8 = [_]u8{0} ** 32; + std.mem.writeInt(u64, expected_leaf[0..8], 31_000_000_000, .little); + try std.testing.expectEqualSlices(u8, &expected_leaf, &proof.leaf); +} diff --git a/src/fork_types/beacon_state.zig b/src/fork_types/beacon_state.zig index 88d671c43..4ef407622 100644 --- a/src/fork_types/beacon_state.zig +++ b/src/fork_types/beacon_state.zig @@ -144,6 +144,28 @@ pub fn BeaconState(comptime f: ForkSeq) type { return validators_view.getAllReadonlyValues(allocator); } + /// Like `validatorsSlice` but returns a slice of pointers into the + /// pool-resident `Validator` values. ~16× lower memory than + /// `validatorsSlice` (8 B/elem vs 121 B/elem) and zero per-element + /// memcpy. + /// + /// Pointers are valid only while the underlying validator nodes + /// remain unchanged. Any mutation through `tree.set` (or equivalent) + /// invalidates pointers to the affected slot — caller must drop the + /// slice before mutating, or copy out values needed past mutation. + pub fn validatorsPtrSlice(self: *Self, allocator: std.mem.Allocator) ![]*const ForkTypes(f).Validator.Type { + var validators_view = try self.inner.getReadonly("validators"); + try validators_view.commit(); + const len = try validators_view.length(); + const out = try allocator.alloc(*const ForkTypes(f).Validator.Type, len); + errdefer allocator.free(out); + var it = validators_view.iteratorReadonly(0); + for (0..len) |i| { + out[i] = try it.nextValuePtr(); + } + return out; + } + pub fn balances(self: *Self) !*ForkTypes(f).Balances.TreeView { return try self.inner.get("balances"); } diff --git a/src/persistent_merkle_tree/ChunkedLeaf.zig b/src/persistent_merkle_tree/ChunkedLeaf.zig new file mode 100644 index 000000000..c1703a964 --- /dev/null +++ b/src/persistent_merkle_tree/ChunkedLeaf.zig @@ -0,0 +1,363 @@ +//! Chunked-leaf payload. +//! +//! Replaces K individual leaf Nodes with a single heap blob (chunk +//! array + length) referenced by one `.chunked_leaf` Node. Self-contained, +//! ref-counted via the Pool's Node ref count, copy-on-write on mutation. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const hashing = @import("hashing"); +const hash = hashing.hash; + +// K = 2^k_log2 = 64 chunks per blob (2 KiB). Larger K folds more of the subtree +// (fewer Node.Ids, more SIMD lanes per root → faster bulk build/read) but copies the +// whole K × 32 B blob on every CoW write. Tuned with bench/ssz/list_chunked_leaf.zig +// plus the process_epoch/process_block benches: 64 wins the (bulk-read-bound, BLS-free) +// epoch ~5% over 32 and ties the BLS-dominated block; 128+ regress the CoW paths. +pub const k_log2: u8 = 6; +pub const K: u16 = 1 << k_log2; + +const ChunkedLeaf = @This(); + +/// Chunk bytes, 64-byte aligned for cache-line locality. Chunks at indices +/// `>= len` MUST be zero-bytes — caller establishes this invariant when +/// populating `chunks`, and CoW writes preserve it. `chunks` is at offset +/// 0 within ChunkedLeaf. +chunks: [K][32]u8 align(64), +/// Number of valid chunks in this payload. The last chunked-leaf in a +/// list/vector may be partial (`len < K`); all earlier ones satisfy +/// `len == K`. +len: u16, + +/// Compute the chunked_leaf subtree root: K-leaf perfect binary tree, no +/// padding. Each reduction is one batched `hash()` call so hashtree's +/// SIMD lanes stay saturated. +/// +/// `scratch` is a caller-supplied K/2-element buffer. `computeRootAllocating` +/// wraps this with a per-call `allocator.alignedAlloc` + free. +/// +/// First round reads `chunks` directly into `scratch` (avoids the +/// in-place mutation that `hashing.merkleize` would require on `*const +/// chunks`). Later rounds halve in-place on `scratch`. +pub fn computeRoot(self: *const ChunkedLeaf, scratch: *align(64) [K / 2][32]u8, out: *[32]u8) void { + hash(scratch[0..], self.chunks[0..]) catch unreachable; + + var width: usize = K / 2; + while (width > 1) : (width /= 2) { + hash(scratch[0 .. width / 2], scratch[0..width]) catch unreachable; + } + + out.* = scratch[0]; +} + +/// `computeRoot` wrapper that owns the scratch via `allocator`. +pub fn computeRootAllocating(self: *const ChunkedLeaf, allocator: Allocator, out: *[32]u8) void { + const scratch_slice = allocator.alignedAlloc([32]u8, .@"64", K / 2) catch @panic("OOM"); + defer allocator.free(scratch_slice); + const scratch_arr: *align(64) [K / 2][32]u8 = @ptrCast(scratch_slice.ptr); + self.computeRoot(scratch_arr, out); +} + +const Node = @import("Node.zig"); + +test "computeRoot for all-zero chunked_leaf equals getZeroHash(k_log2)" { + const allocator = std.testing.allocator; + const chunked_leaf = try allocator.create(ChunkedLeaf); + defer allocator.destroy(chunked_leaf); + chunked_leaf.* = std.mem.zeroes(ChunkedLeaf); + + const scratch_slice = try allocator.alignedAlloc([32]u8, .@"64", K / 2); + defer allocator.free(scratch_slice); + const scratch: *align(64) [K / 2][32]u8 = @ptrCast(scratch_slice.ptr); + + var chunked_leaf_root: [32]u8 = undefined; + chunked_leaf.computeRoot(scratch, &chunked_leaf_root); + + const expected = hashing.getZeroHash(k_log2); + try std.testing.expectEqualSlices(u8, expected, &chunked_leaf_root); +} + +test "computeRoot for non-zero pattern matches std merkleize" { + const allocator = std.testing.allocator; + const chunked_leaf = try allocator.create(ChunkedLeaf); + defer allocator.destroy(chunked_leaf); + chunked_leaf.len = K; + + for (0..K) |i| { + std.mem.writeInt(u256, &chunked_leaf.chunks[i], @as(u256, @intCast(i + 1)), .little); + } + + const scratch_slice = try allocator.alignedAlloc([32]u8, .@"64", K / 2); + defer allocator.free(scratch_slice); + const scratch: *align(64) [K / 2][32]u8 = @ptrCast(scratch_slice.ptr); + + var chunked_leaf_root: [32]u8 = undefined; + chunked_leaf.computeRoot(scratch, &chunked_leaf_root); + + var pairs = try allocator.alloc([2][32]u8, K / 2); + defer allocator.free(pairs); + for (0..K / 2) |i| { + pairs[i][0] = chunked_leaf.chunks[2 * i]; + pairs[i][1] = chunked_leaf.chunks[2 * i + 1]; + } + var ref_root: [32]u8 = undefined; + try hashing.merkleize(pairs, k_log2, &ref_root); + + try std.testing.expectEqualSlices(u8, &ref_root, &chunked_leaf_root); +} + +test "Pool.createChunkedLeaf: round-trips chunks via getChunkedLeafChunks/getChunkedLeafLen" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + var src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + src[0][0] = 0xAB; + src[K - 1][31] = 0xCD; + + const chunked_leaf_id = try pool.createChunkedLeaf(&src, K); + defer pool.unref(chunked_leaf_id); + + const got = try chunked_leaf_id.getChunkedLeafChunks(&pool); + try std.testing.expectEqual(@as(u8, 0xAB), got[0][0]); + try std.testing.expectEqual(@as(u8, 0xCD), got[K - 1][31]); + try std.testing.expectEqual(@as(u16, K), try chunked_leaf_id.getChunkedLeafLen(&pool)); +} + +test "Pool.unref: chunked_leaf payload heap is freed (no leak under test allocator)" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + const src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + const chunked_leaf_id = try pool.createChunkedLeaf(&src, K); + pool.unref(chunked_leaf_id); +} + +test "Id.getRoot: Pool-created chunked_leaf returns merkleized root and caches it" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + var src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + for (0..K) |i| { + std.mem.writeInt(u256, &src[i], @as(u256, @intCast(i + 1)), .little); + } + + const chunked_leaf_id = try pool.createChunkedLeaf(&src, K); + defer pool.unref(chunked_leaf_id); + + const root_first = chunked_leaf_id.getRoot(&pool); + + var ref: [32]u8 = undefined; + var pairs = try allocator.alloc([2][32]u8, K / 2); + defer allocator.free(pairs); + for (0..K / 2) |i| { + pairs[i][0] = src[2 * i]; + pairs[i][1] = src[2 * i + 1]; + } + try hashing.merkleize(pairs, k_log2, &ref); + try std.testing.expectEqualSlices(u8, &ref, root_first); + + const root_second = chunked_leaf_id.getRoot(&pool); + try std.testing.expectEqualSlices(u8, root_first, root_second); +} + +test "Id.setChunkedLeafChunk: CoW one chunk; original unchanged; root differs" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); + defer pool.deinit(); + + var src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + src[42][0] = 0x11; + const a = try pool.createChunkedLeaf(&src, K); + defer pool.unref(a); + + var new_chunk: [32]u8 = [_]u8{0} ** 32; + new_chunk[0] = 0x22; + const b = try a.setChunkedLeafChunk(&pool, 42, &new_chunk); + defer pool.unref(b); + + try std.testing.expect(a != b); + + const a_chunks = try a.getChunkedLeafChunks(&pool); + const b_chunks = try b.getChunkedLeafChunks(&pool); + try std.testing.expectEqual(@as(u8, 0x11), a_chunks[42][0]); + try std.testing.expectEqual(@as(u8, 0x22), b_chunks[42][0]); + + try std.testing.expectEqualSlices(u8, &a_chunks[0], &b_chunks[0]); + try std.testing.expectEqualSlices(u8, &a_chunks[K - 1], &b_chunks[K - 1]); + + try std.testing.expect(!std.mem.eql(u8, a.getRoot(&pool), b.getRoot(&pool))); +} + +test "Id.setChunkedLeafChunk: preserves len" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + const src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + const test_len: u16 = K - 1; + const a = try pool.createChunkedLeaf(&src, test_len); + defer pool.unref(a); + + var new_chunk: [32]u8 = [_]u8{0xFF} ** 32; + const b = try a.setChunkedLeafChunk(&pool, K / 4, &new_chunk); + defer pool.unref(b); + + try std.testing.expectEqual(test_len, try b.getChunkedLeafLen(&pool)); +} + +test "Id.setChunkedLeafChunks: batch CoW with multiple updates" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); + defer pool.deinit(); + + const src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + const a = try pool.createChunkedLeaf(&src, K); + defer pool.unref(a); + + const idxs = [_]u16{ 0, 7, K / 2, K - 1 }; + const c0 = [_]u8{0xAA} ** 32; + const c1 = [_]u8{0xBB} ** 32; + const c2 = [_]u8{0xCC} ** 32; + const c3 = [_]u8{0xDD} ** 32; + const ptrs = [_]*const [32]u8{ &c0, &c1, &c2, &c3 }; + + const b = try a.setChunkedLeafChunks(&pool, &idxs, &ptrs); + defer pool.unref(b); + + const got = try b.getChunkedLeafChunks(&pool); + try std.testing.expectEqual(@as(u8, 0xAA), got[idxs[0]][0]); + try std.testing.expectEqual(@as(u8, 0xBB), got[idxs[1]][0]); + try std.testing.expectEqual(@as(u8, 0xCC), got[idxs[2]][0]); + try std.testing.expectEqual(@as(u8, 0xDD), got[idxs[3]][0]); + + const a_chunks = try a.getChunkedLeafChunks(&pool); + try std.testing.expectEqualSlices(u8, &([_]u8{0} ** 32), &a_chunks[0]); +} + +test "Id.setChunkedLeafChunks: empty batch produces a clone with empty dirty" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + const src: [K][32]u8 align(64) = [_][32]u8{[_]u8{0} ** 32} ** K; + const a = try pool.createChunkedLeaf(&src, K); + defer pool.unref(a); + + const idxs: []const u16 = &.{}; + const ptrs: []const *const [32]u8 = &.{}; + const b = try a.setChunkedLeafChunks(&pool, idxs, ptrs); + defer pool.unref(b); + + try std.testing.expect(a != b); + try std.testing.expectEqualSlices(u8, a.getRoot(&pool), b.getRoot(&pool)); +} + +test "Id.setChunkedLeafChunk: non-chunked_leaf Id returns Error.InvalidNode" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16 }); + defer pool.deinit(); + + const leaf_id = try pool.createLeaf(&([_]u8{0xEE} ** 32)); + defer pool.unref(leaf_id); + + var new_chunk: [32]u8 = [_]u8{0xFF} ** 32; + try std.testing.expectError(error.InvalidNode, leaf_id.setChunkedLeafChunk(&pool, 0, &new_chunk)); +} + +test "tree of chunked leaves: build via FillWithContentsIterator; root matches per-leaf tree" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 << 14 }); + defer pool.deinit(); + + var raw: [4][K][32]u8 align(64) = undefined; + @memset(std.mem.asBytes(&raw), 0); + for (0..4) |s| for (0..K) |i| { + std.mem.writeInt(u256, &raw[s][i], @as(u256, @intCast(s * K + i + 1)), .little); + }; + + var chunked_leaf_it = Node.FillWithContentsIterator.init(&pool, 2); + errdefer chunked_leaf_it.deinit(); + for (0..4) |s| { + const sid = try pool.createChunkedLeaf(&raw[s], K); + try chunked_leaf_it.append(sid); + } + const chunked_leaf_root_id = try chunked_leaf_it.finish(); + defer pool.unref(chunked_leaf_root_id); + + var leaf_it = Node.FillWithContentsIterator.init(&pool, k_log2 + 2); + errdefer leaf_it.deinit(); + for (0..4) |s| for (0..K) |i| { + var c = raw[s][i]; + try leaf_it.append(try pool.createLeaf(&c)); + }; + const leaf_root_id = try leaf_it.finish(); + defer pool.unref(leaf_root_id); + + try std.testing.expectEqualSlices(u8, chunked_leaf_root_id.getRoot(&pool), leaf_root_id.getRoot(&pool)); +} + +test "FillWithContentsIterator: initWithOffset enables chunked_leaf leaves with correct zero filler" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 << 14 }); + defer pool.deinit(); + + var raw: [4][K][32]u8 align(64) = undefined; + @memset(std.mem.asBytes(&raw), 0); + for (0..4) |s| for (0..K) |i| { + std.mem.writeInt(u256, &raw[s][i], @as(u256, @intCast(s * K + i + 1)), .little); + }; + + var chunked_leaf_it = Node.FillWithContentsIterator.initWithOffset(&pool, 2, k_log2); + errdefer chunked_leaf_it.deinit(); + for (0..4) |s| { + const sid = try pool.createChunkedLeaf(&raw[s], K); + try chunked_leaf_it.append(sid); + } + const chunked_leaf_root_id = try chunked_leaf_it.finish(); + defer pool.unref(chunked_leaf_root_id); + + var leaf_it = Node.FillWithContentsIterator.init(&pool, k_log2 + 2); + errdefer leaf_it.deinit(); + for (0..4) |s| for (0..K) |i| { + var c = raw[s][i]; + try leaf_it.append(try pool.createLeaf(&c)); + }; + const leaf_root_id = try leaf_it.finish(); + defer pool.unref(leaf_root_id); + + try std.testing.expectEqualSlices(u8, chunked_leaf_root_id.getRoot(&pool), leaf_root_id.getRoot(&pool)); +} + +test "FillWithContentsIterator: initWithOffset with partial fill (zero-padded chunked leaves)" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 << 14 }); + defer pool.deinit(); + + var raw: [3][K][32]u8 align(64) = undefined; + @memset(std.mem.asBytes(&raw), 0); + for (0..3) |s| for (0..K) |i| { + std.mem.writeInt(u256, &raw[s][i], @as(u256, @intCast(s * K + i + 1)), .little); + }; + + var chunked_leaf_it = Node.FillWithContentsIterator.initWithOffset(&pool, 2, k_log2); + errdefer chunked_leaf_it.deinit(); + for (0..3) |s| { + const sid = try pool.createChunkedLeaf(&raw[s], K); + try chunked_leaf_it.append(sid); + } + const chunked_leaf_root_id = try chunked_leaf_it.finish(); + defer pool.unref(chunked_leaf_root_id); + + var leaf_it = Node.FillWithContentsIterator.init(&pool, k_log2 + 2); + errdefer leaf_it.deinit(); + for (0..3) |s| for (0..K) |i| { + var c = raw[s][i]; + try leaf_it.append(try pool.createLeaf(&c)); + }; + const leaf_root_id = try leaf_it.finish(); + defer pool.unref(leaf_root_id); + + try std.testing.expectEqualSlices(u8, chunked_leaf_root_id.getRoot(&pool), leaf_root_id.getRoot(&pool)); +} diff --git a/src/persistent_merkle_tree/Node.zig b/src/persistent_merkle_tree/Node.zig index 5edef5092..bdee1fbd4 100644 --- a/src/persistent_merkle_tree/Node.zig +++ b/src/persistent_merkle_tree/Node.zig @@ -1,4 +1,22 @@ -///! Merkle node backed by a memory pool +//! Merkle node backed by a memory pool. +//! +//! This file IS the node: top-level fields become per-node SoA columns via +//! `MultiArrayList(@This())`. Variants are distinguished by `state.kind()`. +//! +//! Layout (3 columns total): +//! - `payload`: u64, overloaded by kind +//! branch : low 32 = left Id, high 32 = right Id +//! chunked_leaf : full 64-bit `*ChunkedLeaf` pointer +//! container_struct : full 64-bit `*ContainerStructRef` pointer +//! free : low 32 = next-free Id (high 32 unused) +//! zero/leaf : unused (zero/leaf root lives in `root`) +//! - `root`: [32]u8 — leaf bytes, zero hash, or cached subtree root +//! (`lazy_sentinel` = uncomputed for branch/chunked_leaf/container_struct) +//! - `state`: State (packed u32 — see `State` doc) +//! +//! Maximum compactness: kind+ref_count packed into one u32 (`State`), and +//! `left`+`right`+`cache` collapse into one u64 (`payload`). Branch +//! navigation reads exactly two columns per visit (state + payload). const std = @import("std"); const Allocator = std.mem.Allocator; @@ -7,10 +25,11 @@ const getZeroHash = @import("hashing").getZeroHash; const max_depth = @import("hashing").max_depth; const Depth = @import("hashing").Depth; const Gindex = @import("gindex.zig").Gindex; +const ChunkedLeaf = @import("ChunkedLeaf.zig"); -hash: [32]u8, -left: Id, -right: Id, +// Flat node fields (file-as-struct). Order doesn't affect SoA layout. +payload: u64, +root: [32]u8, state: State, const Node = @This(); @@ -20,207 +39,349 @@ pub const Error = error{ InvalidNode, /// Attempt to use a length beyond the tree's length at a given depth. InvalidLength, - // Attempt to increment the reference count of a node that has reached the maximum reference count. + /// Attempt to increment the reference count beyond `max_ref_count`. RefCountOverflow, - // Out of memory + /// Out of memory. OutOfMemory, }; -/// An enum which manages `node_type`, `ref_count`, and `next_free`. -/// Used by the Pool to manage the free list (single-linked-list) and reference count. +/// Maximum reference count an in-use node may hold. /// -/// The high bit is used to indicate if the node is free or not. -/// If the high bit is set, the `next_free` is stored in the next 31 bits. -/// -/// `[1, next_free]` -/// -/// If the high bit is not set, the next two bits determine the `node_type` -/// The following 29 bits are used for the `ref_count`. -/// -/// `[0, node_type, ref_count]` +/// `State` packs ref_count into 28 bits, so the saturating sentinel is the +/// largest 28-bit value. +pub const max_ref_count: u32 = State.rc_mask; + +/// Variant tag exposed to callers. The encoded form lives inside `State`'s +/// 3-bit kind field; `.free` is special — encoded as `state.isFree()` (the +/// high bit) rather than as a 3-bit value, so the in-use kinds occupy +/// values 1..5 and the 3-bit slot stores `tag - 1`. +pub const NodeKind = enum(u8) { + free = 0, + zero = 1, + leaf = 2, + branch = 3, + chunked_leaf = 4, + container_struct = 5, +}; + +/// Packed `[ free_bit | kind:3 | ref_count:28 ]` (in-use) or +/// `[ free_bit | next_free:31 ]` (free). pub const State = enum(u32) { _, - pub const free: State = @enumFromInt(0x80000000); - - pub const max_next_free = 0x7FFFFFFF; - - // four types of nodes - const node_type = 0x60000000; - pub const zero: State = @enumFromInt(0x00000000); - pub const leaf: State = @enumFromInt(0x20000000); - pub const branch_lazy: State = @enumFromInt(0x40000000); - pub const branch_computed: State = @enumFromInt(0x60000000); + pub const free_bit: u32 = 0x8000_0000; + pub const next_free_mask: u32 = 0x7FFF_FFFF; + pub const kind_shift: u5 = 28; + pub const kind_field_mask: u32 = 0x7000_0000; + pub const rc_mask: u32 = 0x0FFF_FFFF; - pub const max_ref_count = 0x1FFFFFFF; - - pub inline fn isFree(node: State) bool { - return @intFromEnum(node) & @intFromEnum(free) != 0; + pub inline fn isFree(s: State) bool { + return @intFromEnum(s) & free_bit != 0; } - - pub inline fn initNextFree(next_free: Id) State { - return @enumFromInt(@intFromEnum(free) | @intFromEnum(next_free)); + pub inline fn isZero(s: State) bool { + return s.kind() == .zero; } - - pub inline fn getNextFree(node: State) Id { - return @enumFromInt(@intFromEnum(node) & max_next_free); + pub inline fn isLeaf(s: State) bool { + return s.kind() == .leaf; } - - pub inline fn isZero(node: State) bool { - return @intFromEnum(node) & node_type == @intFromEnum(zero); + pub inline fn isBranch(s: State) bool { + return s.kind() == .branch; } - - pub inline fn isLeaf(node: State) bool { - return @intFromEnum(node) & node_type == @intFromEnum(leaf); + pub inline fn isChunkedLeaf(s: State) bool { + return s.kind() == .chunked_leaf; + } + pub inline fn isContainerStruct(s: State) bool { + return s.kind() == .container_struct; } - pub inline fn isBranch(node: State) bool { - return @intFromEnum(node) & @intFromEnum(branch_lazy) != 0; + /// Branch-free decode: free → NodeKind(0)=.free, in-use → NodeKind(enc+1). + pub inline fn kind(s: State) NodeKind { + const raw = @intFromEnum(s); + const is_in_use: u32 = @intFromBool((raw & free_bit) == 0); + const enc: u32 = (raw & kind_field_mask) >> kind_shift; + return @enumFromInt(@as(u8, @intCast(is_in_use * (enc + 1)))); } - pub inline fn isBranchLazy(node: State) bool { - return @intFromEnum(node) & node_type == @intFromEnum(branch_lazy); + pub inline fn refCount(s: State) u32 { + std.debug.assert(!s.isFree()); + return @intFromEnum(s) & rc_mask; } - pub inline fn isBranchComputed(node: State) bool { - return @intFromEnum(node) & node_type == @intFromEnum(branch_computed); + pub inline fn nextFree(s: State) Id { + std.debug.assert(s.isFree()); + return @enumFromInt(@intFromEnum(s) & next_free_mask); } - pub inline fn setBranchComputed(node: *State) void { - node.* = @enumFromInt(@intFromEnum(node.*) | @intFromEnum(branch_computed)); + pub inline fn initFree(next: Id) State { + std.debug.assert(@intFromEnum(next) <= next_free_mask); + return @enumFromInt(free_bit | @intFromEnum(next)); } - pub inline fn initRefCount(node: State) State { - return node; + pub inline fn initInUse(k: NodeKind, rc: u32) State { + std.debug.assert(k != .free); + std.debug.assert(rc <= rc_mask); + const enc: u32 = (@as(u32, @intFromEnum(k)) - 1) << kind_shift; + return @enumFromInt(enc | rc); } - pub inline fn getRefCount(node: State) u32 { - return @intFromEnum(node) & max_ref_count; + /// Replaces the kind tag while preserving the ref count. + pub inline fn setKind(s: *State, k: NodeKind) void { + std.debug.assert(!s.isFree()); + std.debug.assert(k != .free); + const enc: u32 = (@as(u32, @intFromEnum(k)) - 1) << kind_shift; + s.* = @enumFromInt((@intFromEnum(s.*) & rc_mask) | enc); } - pub inline fn incRefCount(node: *State) Error!u32 { - const ref_count = node.getRefCount(); - if (ref_count == max_ref_count) { - return error.RefCountOverflow; - } - node.* = @enumFromInt(@intFromEnum(node.*) + 1); - return ref_count + 1; + pub inline fn incRefCount(s: *State) Error!u32 { + std.debug.assert(!s.isFree()); + const rc = s.refCount(); + if (rc == rc_mask) return Error.RefCountOverflow; + s.* = @enumFromInt(@intFromEnum(s.*) + 1); + return rc + 1; } - pub inline fn decRefCount(node: *State) u32 { - const ref_count = node.getRefCount(); - if (ref_count == 0) { - return 0; - } - node.* = @enumFromInt(@intFromEnum(node.*) - 1); - return ref_count - 1; + pub inline fn decRefCount(s: *State) u32 { + std.debug.assert(!s.isFree()); + const rc = s.refCount(); + if (rc == 0) return 0; + s.* = @enumFromInt(@intFromEnum(s.*) - 1); + return rc - 1; } }; -/// Stores nodes in a memory pool +/// Vtable + struct pointer that backs every `.container_struct` Node. +/// +/// The Pool owns this allocation. `ptr` is an opaque-typed pointer to a +/// caller-supplied wrapped struct that implements the required methods: +/// - `init(allocator, *const T) Error!*const T` — clone the struct into the pool +/// - `deinit(allocator) void` — free the cloned struct +/// - `getRoot(out: *[32]u8) void` — compute the merkle root from cached fields +/// - `toTree(pool: *Pool) Error!Id` — materialize a temporary, fully-navigable +/// PMT subtree from the cached struct so that proof traversal can walk +/// into the container's interior. The returned Id is owned by the caller +/// (typically created with refcount=0 by the underlying field-tree +/// constructors); the proof code wraps it with `pool.unref` on cleanup. +pub const ContainerStructRef = struct { + ptr: *anyopaque, + get_root: *const fn (ptr: *const anyopaque, out: *[32]u8) void, + to_tree: *const fn (ptr: *const anyopaque, pool: *Pool) Error!Id, + deinit: *const fn (ptr: *anyopaque, allocator: Allocator) void, +}; + +/// Sentinel value marking an uncomputed root cache on `branch`, +/// `chunked_leaf`, and `container_struct` slots. Equality with this value +/// means "lazy — recompute on next getRoot"; any other 32-byte value +/// is treated as the cached merkle root. +/// +/// All-`0xFF` is safe because cryptographic SHA-256 outputs collide +/// with this value at probability ~1/2^256. +/// +/// Kept in the root column rather than in `State.kind` so the lazy/computed +/// flag is independent of node kind. `getRoot` flips it by writing only the +/// root column, leaving `state` untouched. +pub const lazy_sentinel: [32]u8 = [_]u8{0xFF} ** 32; + +/// Pack a `(left, right)` Id pair into the u64 `payload` field. +/// Convention: `left` low 32 bits, `right` high 32 bits. +inline fn packChildren(left: Id, right: Id) u64 { + return @as(u64, @intFromEnum(left)) | (@as(u64, @intFromEnum(right)) << 32); +} + +/// Decode the left child Id from a packed `payload`. +inline fn unpackLeft(p: u64) Id { + return @enumFromInt(@as(u32, @truncate(p))); +} + +/// Decode the right child Id from a packed `payload`. +inline fn unpackRight(p: u64) Id { + return @enumFromInt(@as(u32, @intCast(p >> 32))); +} + +/// Reinterpret a `payload` as a heap pointer (chunked_leaf / container_struct). +inline fn payloadAsPtr(p: u64) *anyopaque { + return @ptrFromInt(p); +} + +/// Encode a heap pointer as a `payload` value. +inline fn ptrAsPayload(ptr: *anyopaque) u64 { + return @intFromPtr(ptr); +} + +/// Pair of child Ids. Always defined when `noChild` is false. +const Children = struct { left: Id, right: Id }; + +/// Resolve the (left, right) child Ids for a navigable node. +inline fn childrenOf( + node_id: Id, + kind: NodeKind, + payloads: []const u64, +) Children { + const idx = @intFromEnum(node_id); + return switch (kind) { + .branch => blk: { + const p = payloads[idx]; + break :blk .{ .left = unpackLeft(p), .right = unpackRight(p) }; + }, + .zero => blk: { + std.debug.assert(idx >= 1); + const prev: Id = @enumFromInt(idx - 1); + break :blk .{ .left = prev, .right = prev }; + }, + // `noChild` guards prevent reaching here for these variants. + .leaf, .free, .chunked_leaf, .container_struct => unreachable, + }; +} + +/// Inline helper: returns true if navigation to a child is impossible. +inline fn noChildKind(node_id: Id, kind: NodeKind) bool { + return switch (kind) { + .leaf, + .free, + .chunked_leaf, + .container_struct, + => true, + .zero, .branch => @intFromEnum(node_id) == 0, + }; +} + +/// Decode the `*ChunkedLeaf` payload pointer for a slot known to be +/// `.chunked_leaf`. Caller is responsible for the kind check. +inline fn chunkedLeafPtr(payloads: []const u64, idx: u32) *ChunkedLeaf { + return @ptrCast(@alignCast(payloadAsPtr(payloads[idx]))); +} + +/// Read the ContainerStructRef for a slot known to be `.container_struct`. +inline fn containerStructRef(payloads: []const u64, idx: u32) *ContainerStructRef { + return @ptrCast(@alignCast(payloadAsPtr(payloads[idx]))); +} + +/// Stores nodes in a memory pool, with reference counting and a free list. pub const Pool = struct { + page_allocator: Allocator, allocator: Allocator, nodes: std.MultiArrayList(Node).Slice, next_free_node: Id, - pub const free_bit: u32 = 0x80000000; - pub const max_ref_count: u32 = 0x7FFFFFFF; - - /// Initializes the memory pool with `pool_size` + `zero_hash_max_depth` items. - pub fn init(allocator: Allocator, pool_size: u32) Error!Pool { + pub const InitOptions = struct { + page_allocator: Allocator = std.heap.page_allocator, + // Pure-Zig default keeps the pool libc-free; hot paths pin c_allocator. + allocator: Allocator = std.heap.smp_allocator, + // Default 0 = lazy growth via preheat/ensureCapacity. Pre-PR NAPI + // bindings relied on this to avoid hundreds of MB upfront. Bench + // sites that want a pre-sized pool pass an explicit value. + pool_size: u32 = 0, + }; + + pub fn init(opts: InitOptions) Error!Pool { var pool: Pool = .{ - .allocator = allocator, + .page_allocator = opts.page_allocator, + .allocator = opts.allocator, .nodes = undefined, .next_free_node = @enumFromInt(max_depth), }; - if (pool_size + max_depth >= free_bit) { - return error.OutOfMemory; - } - - var nodes = std.MultiArrayList(Node).empty; - try nodes.resize(allocator, pool_size + max_depth); - nodes.len = max_depth; - - pool.nodes = nodes.slice(); + var list = std.MultiArrayList(Node).empty; + try list.resize(opts.page_allocator, opts.pool_size + max_depth); + list.len = opts.pool_size + max_depth; + pool.nodes = list.slice(); - // Populate zero hashes (at index 0 to zero_hash_max_depth - 1) + // Pre-populate zero-hash sentinels at indices 0..max_depth-1. for (0..max_depth) |i| { - pool.nodes.set(@intCast(i), Node{ - .hash = getZeroHash(@intCast(i)).*, - .left = if (i == 0) undefined else @enumFromInt(i - 1), - .right = if (i == 0) undefined else @enumFromInt(i - 1), - .state = .zero, + pool.nodes.set(@intCast(i), .{ + .payload = 0, + .root = getZeroHash(@intCast(i)).*, + .state = State.initInUse(.zero, 0), }); } - try pool.preheat(pool_size); + // Initialize the free list across the user slots. `state` carries + // kind=free + next_free link in the low 31 bits; `payload`/`root` + // are unused for free slots. + const state_col = pool.nodes.items(.state); + for (max_depth..pool.nodes.len) |i| { + const next: Id = @enumFromInt(@as(u32, @intCast(i + 1))); + state_col[i] = State.initFree(next); + } return pool; } pub fn deinit(self: *Pool) void { - self.nodes.deinit(self.allocator); + // Release heap payloads owned by `.chunked_leaf` and `.container_struct` slots. + // The MultiArrayList only owns its own column buffers; payload + // pointers are heap-allocated separately and become unreachable + // when callers tear down the pool without first unref'ing every + // root. + const states = self.nodes.items(.state); + const payloads = self.nodes.items(.payload); + for (states, 0..) |s, i| { + if (s.isFree()) continue; + const idx: u32 = @intCast(i); + switch (s.kind()) { + .chunked_leaf => self.allocator.destroy(chunkedLeafPtr(payloads, idx)), + .container_struct => { + const struct_ref = containerStructRef(payloads, idx); + struct_ref.deinit(struct_ref.ptr, self.allocator); + self.allocator.destroy(struct_ref); + }, + else => {}, + } + } + var list = self.nodes.toMultiArrayList(); + list.deinit(self.page_allocator); self.* = undefined; } - /// Preheats the memory pool by pre-allocating `size` items. - /// This allows up to `size` active allocations before an - /// `OutOfMemory` error might happen when calling `create*()`. + /// Preheat the memory pool by extending the backing storage by + /// `additional_size` slots and threading them onto the free list. pub fn preheat(self: *Pool, additional_size: u32) Allocator.Error!void { const size = self.nodes.len; const new_size = size + additional_size; - if (new_size >= free_bit) { - return error.OutOfMemory; - } - - var nodes = self.nodes.toMultiArrayList(); - try nodes.resize(self.allocator, new_size); - self.nodes = nodes.slice(); - - const states = self.nodes.items(.state); + var list = self.nodes.toMultiArrayList(); + try list.resize(self.page_allocator, new_size); + self.nodes = list.slice(); + const state_col = self.nodes.items(.state); for (size..new_size) |i| { - states[i] = State.initNextFree(@enumFromInt(@as(u32, @intCast(i + 1)))); + const next: Id = @enumFromInt(@as(u32, @intCast(i + 1))); + state_col[i] = State.initFree(next); } } - /// Assumes that self.next_free_node is in bounds and will not allocate - /// Assumes that the caller will initialize the Id's state / ref count - inline fn createUnsafe(self: *Pool, states: []State) Id { - // pop from the free list + /// Returns the number of nodes currently in use (not free). + pub fn getNodesInUse(self: *Pool) usize { + var count: usize = 0; + for (self.nodes.items(.state)) |s| { + if (!s.isFree()) count += 1; + } + return count; + } + + /// Pop the next free slot from the free list. Caller must initialise + /// the returned slot. + inline fn createUnsafe(self: *Pool) Id { const n: Id = self.next_free_node; - // mask away the free bit - self.next_free_node = states[@intFromEnum(n)].getNextFree(); + const idx = @intFromEnum(n); + const state_col = self.nodes.items(.state); + std.debug.assert(state_col[idx].isFree()); + self.next_free_node = state_col[idx].nextFree(); return n; } fn create(self: *Pool) Allocator.Error!Id { std.debug.assert(@intFromEnum(self.next_free_node) <= self.nodes.len); - - if (@intFromEnum(self.next_free_node) == self.nodes.len) { + if (@intFromEnum(self.next_free_node) >= self.nodes.len) { try self.preheat(1); } - return self.createUnsafe(self.nodes.items(.state)); - } - - /// Returns the number of nodes currently in use (not free) - pub fn getNodesInUse(self: *Pool) usize { - var count: usize = 0; - const states = self.nodes.items(.state); - for (states) |state| { - if (!state.isFree()) { - count += 1; - } - } - return count; + return self.createUnsafe(); } pub fn createLeaf(self: *Pool, hash: *const [32]u8) Allocator.Error!Id { const node_id = try self.create(); - self.nodes.items(.hash)[@intFromEnum(node_id)] = hash.*; - self.nodes.items(.state)[@intFromEnum(node_id)] = State.leaf.initRefCount(); + const idx = @intFromEnum(node_id); + self.nodes.items(.root)[idx] = hash.*; + self.nodes.items(.state)[idx] = State.initInUse(.leaf, 0); return node_id; } @@ -233,71 +394,207 @@ pub const Pool = struct { pub fn createBranch(self: *Pool, left_id: Id, right_id: Id) Error!Id { std.debug.assert(@intFromEnum(left_id) < self.nodes.len); std.debug.assert(@intFromEnum(right_id) < self.nodes.len); + std.debug.assert(!self.nodes.items(.state)[@intFromEnum(left_id)].isFree()); + std.debug.assert(!self.nodes.items(.state)[@intFromEnum(right_id)].isFree()); + + // Acquire both child refs BEFORE writing parent metadata. If + // `refUnsafe(right)` fails we mustn't leave the parent slot in + // `.branch` pointing at a child whose rc was never incremented; + // a later `unref(parent)` would then recursively dec the + // un-ref'd child. + try self.refUnsafe(left_id); + errdefer self.unrefUnsafe(left_id); + try self.refUnsafe(right_id); + errdefer self.unrefUnsafe(right_id); + + // `self.create()` may grow the pool — bind column slices AFTER it. + const node_id = try self.create(); + const idx = @intFromEnum(node_id); + self.nodes.items(.state)[idx] = State.initInUse(.branch, 0); + self.nodes.items(.payload)[idx] = packChildren(left_id, right_id); + self.nodes.items(.root)[idx] = lazy_sentinel; + return node_id; + } + + /// Creates a chunked_leaf Node owning a heap-allocated `ChunkedLeaf` initialized + /// from `chunks`. `len` is the count of valid chunks (`<= K`); the caller + /// is responsible for ensuring chunks at indices `>= len` are zero-bytes + /// (the chunked_leaf trailing-zero invariant). The returned Node has a + /// lazy root; `Id.getRoot` will compute and cache it on first access. + pub fn createChunkedLeaf(self: *Pool, chunks: *align(64) const [ChunkedLeaf.K][32]u8, len: u16) Error!Id { + std.debug.assert(len <= ChunkedLeaf.K); + const storage = try self.allocator.create(ChunkedLeaf); + errdefer self.allocator.destroy(storage); + + storage.chunks = chunks.*; + storage.len = len; const node_id = try self.create(); - const states = self.nodes.items(.state); - std.debug.assert(!states[@intFromEnum(left_id)].isFree()); - std.debug.assert(!states[@intFromEnum(right_id)].isFree()); - self.nodes.items(.left)[@intFromEnum(node_id)] = left_id; - self.nodes.items(.right)[@intFromEnum(node_id)] = right_id; - states[@intFromEnum(node_id)] = State.branch_lazy.initRefCount(); - // If a ref below overflows, hand this half-built node's slot back to the free list. - errdefer { - states[@intFromEnum(node_id)] = State.initNextFree(self.next_free_node); - self.next_free_node = node_id; - } + const idx = @intFromEnum(node_id); + self.nodes.items(.payload)[idx] = ptrAsPayload(@ptrCast(storage)); + self.nodes.items(.root)[idx] = lazy_sentinel; + self.nodes.items(.state)[idx] = State.initInUse(.chunked_leaf, 0); + return node_id; + } + + /// Creates a chunked_leaf Node owning a freshly-zeroed heap-allocated + /// `ChunkedLeaf`. Caller fills via `Id.getChunkedLeafPtr`. + pub fn createChunkedLeafEmpty(self: *Pool, len: u16) Error!Id { + std.debug.assert(len <= ChunkedLeaf.K); + const storage = try self.allocator.create(ChunkedLeaf); + errdefer self.allocator.destroy(storage); + + @memset(std.mem.asBytes(&storage.chunks), 0); + storage.len = len; + + const node_id = try self.create(); + const idx = @intFromEnum(node_id); + self.nodes.items(.payload)[idx] = ptrAsPayload(@ptrCast(storage)); + self.nodes.items(.root)[idx] = lazy_sentinel; + self.nodes.items(.state)[idx] = State.initInUse(.chunked_leaf, 0); + return node_id; + } - try self.refUnsafe(left_id, states); - // refUnsafe skips zero nodes, so only undo the ref when left is non-zero. - errdefer if (!states[@intFromEnum(left_id)].isZero()) { - _ = states[@intFromEnum(left_id)].decRefCount(); + /// Create a `.container_struct` Node holding a cloned `T` instance. + /// + /// `T` must implement: + /// - `pub fn init(allocator: Allocator, *const T) Error!*const T` + /// - `pub fn deinit(*T, allocator: Allocator) void` + /// - `pub fn getRoot(*const T, out: *[32]u8) void` + /// + /// The Pool clones `ptr` (so the caller retains ownership of its copy) and + /// owns the resulting `ContainerStructRef`. The returned Node has a lazy root; + /// `Id.getRoot` computes and caches it on first access. + pub fn createContainerStruct(self: *Pool, comptime T: type, ptr: *const T) Error!Id { + const cloned = try T.init(self.allocator, ptr); + errdefer @constCast(cloned).deinit(self.allocator); + + const ref_ptr = try self.allocator.create(ContainerStructRef); + errdefer self.allocator.destroy(ref_ptr); + + ref_ptr.* = .{ + .ptr = @ptrCast(@constCast(cloned)), + .get_root = struct { + fn call(erased: *const anyopaque, out: *[32]u8) void { + const typed: *const T = @ptrCast(@alignCast(erased)); + T.getRoot(typed, out); + } + }.call, + .to_tree = struct { + fn call(erased: *const anyopaque, p: *Pool) Error!Id { + const typed: *const T = @ptrCast(@alignCast(erased)); + return try T.toTree(typed, p); + } + }.call, + .deinit = struct { + fn call(erased: *anyopaque, allocator: Allocator) void { + const typed: *T = @ptrCast(@alignCast(erased)); + T.deinit(typed, allocator); + } + }.call, }; - try self.refUnsafe(right_id, states); + const node_id = try self.create(); + const idx = @intFromEnum(node_id); + // `payload` carries the encoded `*ContainerStructRef`; this slot has + // no Id-children (`noChild` returns true for container_struct). + self.nodes.items(.payload)[idx] = ptrAsPayload(@ptrCast(ref_ptr)); + self.nodes.items(.root)[idx] = lazy_sentinel; + self.nodes.items(.state)[idx] = State.initInUse(.container_struct, 0); return node_id; } + /// Returns a read-only pointer to the wrapped struct held by a + /// `.container_struct` Node. Returns `Error.InvalidNode` if the slot is not + /// a branch-struct variant. + pub fn getStructPtr(self: *Pool, node_id: Id, comptime T: type) Error!*const T { + const idx = @intFromEnum(node_id); + if (self.nodes.items(.state)[idx].kind() != .container_struct) { + return Error.InvalidNode; + } + const ref_ptr = containerStructRef(self.nodes.items(.payload), idx); + return @ptrCast(@alignCast(ref_ptr.ptr)); + } + + /// Materializes a temporary, fully-navigable PMT subtree from a + /// `.container_struct` slot's wrapped struct. The returned Id has refcount + /// matching the underlying field-tree constructors (typically 0 — caller + /// is responsible for `unref`'ing it once the temporary tree is no longer + /// needed). Returns `Error.InvalidNode` if the slot is not a branch-struct + /// variant. + pub fn materializeContainerStruct(self: *Pool, node_id: Id) Error!Id { + const idx = @intFromEnum(node_id); + if (self.nodes.items(.state)[idx].kind() != .container_struct) { + return Error.InvalidNode; + } + const ref_ptr = containerStructRef(self.nodes.items(.payload), idx); + return try ref_ptr.to_tree(ref_ptr.ptr, self); + } + + /// Materializes a temporary, fully-navigable PMT subtree from a `.chunked_leaf` + /// slot's K packed chunks. The chunked_leaf represents a depth-`ChunkedLeaf.k_log2` + /// subtree of leaves; this builds it explicitly so that proof traversal + /// can walk into individual chunks. Trailing zero subtrees fill the chunked_leaf + /// to full K. The returned Id has refcount=0; caller is responsible for + /// `unref`'ing it. Returns `Error.InvalidNode` if the slot is not a chunked_leaf. + pub fn materializeChunkedLeaf(self: *Pool, node_id: Id) Error!Id { + const idx = @intFromEnum(node_id); + if (self.nodes.items(.state)[idx].kind() != .chunked_leaf) { + return Error.InvalidNode; + } + const storage = chunkedLeafPtr(self.nodes.items(.payload), idx); + + // Build a depth-k_log2 perfect tree spanning all K chunks. We always + // emit K leaves (even those at indices >= storage.len, which are + // guaranteed to be zero-bytes by the trailing-zero invariant) so + // that the resulting subtree's root matches the chunked_leaf's + // `computeRoot` exactly. + var it = FillWithContentsIterator.init(self, ChunkedLeaf.k_log2); + errdefer it.deinit(); + for (0..ChunkedLeaf.K) |i| { + const leaf = try self.createLeaf(&storage.chunks[i]); + try it.append(leaf); + } + return try it.finish(); + } + /// Allocates nodes into the pool. /// /// All nodes are allocated with refcount=0. /// Nodes allocated here are expected to be attached via `rebind`. - /// Return true if pool had to allocate more memory, false otherwise. + /// Returns true if pool had to allocate more memory, false otherwise. pub fn alloc(self: *Pool, out: []Id) Allocator.Error!bool { - var states = self.nodes.items(.state); var allocated: bool = false; for (0..out.len) |i| { std.debug.assert(@intFromEnum(self.next_free_node) <= self.nodes.len); - if (@intFromEnum(self.next_free_node) == self.nodes.len) { + if (@intFromEnum(self.next_free_node) >= self.nodes.len) { const remaining = out.len - i; self.preheat(@intCast(remaining)) catch |err| { // Preheat ran out of memory: put back the slots we already took. They're // unreferenced, so push them back onto the free list (unref would underflow). const states_now = self.nodes.items(.state); for (out[0..i]) |id| { - states_now[@intFromEnum(id)] = State.initNextFree(self.next_free_node); + states_now[@intFromEnum(id)] = State.initFree(self.next_free_node); self.next_free_node = id; } return err; }; - - states = self.nodes.items(.state); allocated = true; } - out[i] = self.createUnsafe(states); - states[@intFromEnum(out[i])] = State.branch_lazy.initRefCount(); - - // Initialize left/right children to zero. - // - // The node is marked as `branch_lazy`, so `unref` will attempt to traverse its children during cleanup. - // If an error occurs before the node is fully constructed and `free` is called, stale values in `left`/`right` - // could lead to accessing invalid memory. Setting them to zero ensures safe cleanup. - self.nodes.items(.left)[@intFromEnum(out[i])] = @enumFromInt(0); - self.nodes.items(.right)[@intFromEnum(out[i])] = @enumFromInt(0); + out[i] = self.createUnsafe(); + + // Initialize as a lazy branch with zero(0) children so that any + // errdefer-driven cleanup walks safely. Caller is expected to + // overwrite via `rebind`. + const idx = @intFromEnum(out[i]); + self.nodes.items(.payload)[idx] = 0; + self.nodes.items(.root)[idx] = lazy_sentinel; + self.nodes.items(.state)[idx] = State.initInUse(.branch, 0); } return allocated; } - /// Unrefs nodes from the pool. + /// Unrefs each node in `out`. pub fn free(self: *Pool, out: []Id) void { for (out) |node_id| { self.unref(node_id); @@ -311,53 +608,61 @@ pub const Pool = struct { std.debug.assert(out.len == left_ids.len); std.debug.assert(out.len == right_ids.len); - const lefts = self.nodes.items(.left); - const rights = self.nodes.items(.right); - const states = self.nodes.items(.state); + const state_col = self.nodes.items(.state); + const payload_col = self.nodes.items(.payload); + const root_col = self.nodes.items(.root); for (0..out.len) |i| { - std.debug.assert(@intFromEnum(out[i]) < self.nodes.len); - - lefts[@intFromEnum(out[i])] = left_ids[i]; - rights[@intFromEnum(out[i])] = right_ids[i]; - - try self.refUnsafe(left_ids[i], states); - try self.refUnsafe(right_ids[i], states); + const idx = @intFromEnum(out[i]); + std.debug.assert(idx < self.nodes.len); + + // Acquire both child refs BEFORE writing parent metadata. If + // `refUnsafe(right)` fails we mustn't leave the parent slot in + // `.branch` pointing at a child whose rc was never incremented; + // a later `unref(parent)` would then recursively dec the + // un-ref'd child. Note: parent's own rc is preserved — an + // earlier iteration may have already ref'd this slot (when + // out[i] is the child of an earlier out[j]). + try self.refUnsafe(left_ids[i]); + errdefer self.unref(left_ids[i]); + try self.refUnsafe(right_ids[i]); + errdefer self.unref(right_ids[i]); + + state_col[idx].setKind(.branch); + payload_col[idx] = packChildren(left_ids[i], right_ids[i]); + root_col[idx] = lazy_sentinel; } } pub fn ref(self: *Pool, node_id: Id) Error!void { - // Check if the node is in bounds - if (@intFromEnum(node_id) >= self.nodes.len) { - return; - } - - const states = self.nodes.items(.state); - - // Check if the node is free - if (states[@intFromEnum(node_id)].isFree()) { - return; - } + if (@intFromEnum(node_id) >= self.nodes.len) return; + if (self.nodes.items(.state)[@intFromEnum(node_id)].isFree()) return; + try self.refUnsafe(node_id); + } - try self.refUnsafe(node_id, states); + /// Increment the reference count. Assumes `node_id` is in bounds and not free. + fn refUnsafe(self: *Pool, node_id: Id) Error!void { + const s = &self.nodes.items(.state)[@intFromEnum(node_id)]; + if (s.kind() == .zero) return; + _ = try s.incRefCount(); } - // Assumes `node_id` to be in bounds and not free - fn refUnsafe(self: *Pool, node_id: Id, states: []Node.State) Error!void { - _ = self; // suppress unused for now (no member access needed) - if (states[@intFromEnum(node_id)].isZero()) { - return; - } - _ = try states[@intFromEnum(node_id)].incRefCount(); + /// Exact inverse of `refUnsafe`: drop the ref it took WITHOUT freeing at + /// rc 0. On a failed multi-ref acquire (e.g. createBranch) or a partially + /// built view, this restores a freshly-ref'd rc-0 orphan to its pre-call + /// allocated state so the caller can still reclaim it; `unref` would free + /// it and cause a double-free. + pub fn unrefUnsafe(self: *Pool, node_id: Id) void { + const s = &self.nodes.items(.state)[@intFromEnum(node_id)]; + if (s.kind() == .zero) return; + _ = s.decRefCount(); } pub fn unref(self: *Pool, node_id: Id) void { - const states = self.nodes.items(.state); - const lefts = self.nodes.items(.left); - const rights = self.nodes.items(.right); var stack: [max_depth]Id = undefined; var current: ?Id = node_id; var sp: Depth = 0; + while (true) { const id = current orelse { if (sp == 0) { @@ -367,92 +672,236 @@ pub const Pool = struct { current = stack[sp]; continue; }; - // Continue if the the node is out of bounds + + // Continue if the node is out of bounds. if (@intFromEnum(id) >= self.nodes.len) { current = null; continue; } - // Detect unref on already-freed node (indicates a bug in ref counting) - // Must check isFree() before isZero() because freed nodes have node_type bits = 0 - const is_free = states[@intFromEnum(id)].isFree(); - if (is_free) { + + const states = self.nodes.items(.state); + const k = states[@intFromEnum(id)].kind(); + + // Rollback errdefers unref the same spine twice (via node_id and + // path_parents); tolerate the already-freed slot, don't panic. + if (k == .free) { current = null; continue; } - // Continue if zero node (zero nodes are not ref counted) - if (states[@intFromEnum(id)].isZero()) { + // Zero nodes are not ref counted; nothing to do. + if (k == .zero) { current = null; continue; } - // Decrement the reference count - const ref_count = states[@intFromEnum(id)].decRefCount(); - // If the reference count is not zero, continue - if (ref_count != 0) { + + // Decrement the reference count, saturating at zero. A node at + // rc==0 (freshly created and never additionally ref'd) still + // gets freed on unref (legacy semantics). + const new_rc = states[@intFromEnum(id)].decRefCount(); + + if (new_rc != 0) { current = null; continue; } - // If the node is a branch, push its children onto the stack - if (states[@intFromEnum(id)].isBranch()) { - stack[sp] = rights[@intFromEnum(id)]; - sp += 1; - current = lefts[@intFromEnum(id)]; - } else { - current = null; + + // Reached zero: traverse children before freeing the slot. + switch (k) { + .branch => { + const c = self.nodes.items(.payload)[@intFromEnum(id)]; + stack[sp] = unpackRight(c); + sp += 1; + current = unpackLeft(c); + }, + .chunked_leaf => { + const storage = chunkedLeafPtr(self.nodes.items(.payload), @intFromEnum(id)); + self.allocator.destroy(storage); + current = null; + }, + .container_struct => { + // Free the wrapped struct + the ContainerStructRef heap-allocation. + const ref_ptr = containerStructRef(self.nodes.items(.payload), @intFromEnum(id)); + ref_ptr.deinit(ref_ptr.ptr, self.allocator); + self.allocator.destroy(ref_ptr); + current = null; + }, + else => { + current = null; + }, } - // Return the node to the free list - states[@intFromEnum(id)] = State.initNextFree(self.next_free_node); + // Return the node to the free list. Free-list link is encoded + // in `state` (the State.initFree representation). + states[@intFromEnum(id)] = State.initFree(self.next_free_node); self.next_free_node = id; } } }; - -/// A handle which uniquely identifies the node -/// -/// This handle only has meaning in the context of a `Pool`. +/// A handle which uniquely identifies the node within a `Pool`. pub const Id = enum(u32) { _, - /// Returns true if navigation to the child node is not possible - pub inline fn noChild(node_id: Id, state: State) bool { - return state.isLeaf() or @intFromEnum(node_id) == 0; + /// Returns true if navigation to a child node is impossible at `node`. + /// + /// Matches legacy semantics: leaves and `Id(0)` (the depth-0 zero + /// sentinel) have no navigable children. Zero nodes at depth >= 1 + /// remain navigable — both children point to `zero(d-1)`. + pub inline fn noChild(node_id: Id, kind: NodeKind) bool { + return noChildKind(node_id, kind); } - /// Returns the root hash of the tree, computing any lazy branches as needed. + /// Returns the root hash, computing any lazy branch nodes on demand. pub fn getRoot(node_id: Id, pool: *Pool) *const [32]u8 { - const state = &pool.nodes.items(.state)[@intFromEnum(node_id)]; - const hash = &pool.nodes.items(.hash)[@intFromEnum(node_id)]; - - if (state.isBranchLazy()) { - const left = pool.nodes.items(.left)[@intFromEnum(node_id)].getRoot(pool); - const right = pool.nodes.items(.right)[@intFromEnum(node_id)].getRoot(pool); - hashOne(hash, left, right); - state.setBranchComputed(); + const idx = @intFromEnum(node_id); + const states = pool.nodes.items(.state); + const roots = pool.nodes.items(.root); + const kind = states[idx].kind(); + + switch (kind) { + .zero, .leaf => return &roots[idx], + .free => @panic("getRoot called on .free slot — use-after-free"), + .branch => { + if (!std.mem.eql(u8, &roots[idx], &lazy_sentinel)) { + return &roots[idx]; + } + const c = pool.nodes.items(.payload)[idx]; + const left_root = unpackLeft(c).getRoot(pool); + const right_root = unpackRight(c).getRoot(pool); + var hash: [32]u8 = undefined; + hashOne(&hash, left_root, right_root); + roots[idx] = hash; + return &roots[idx]; + }, + .chunked_leaf => { + if (!std.mem.eql(u8, &roots[idx], &lazy_sentinel)) { + return &roots[idx]; + } + const storage = chunkedLeafPtr(pool.nodes.items(.payload), idx); + var hash: [32]u8 = undefined; + storage.computeRootAllocating(pool.allocator, &hash); + roots[idx] = hash; + return &roots[idx]; + }, + .container_struct => { + if (!std.mem.eql(u8, &roots[idx], &lazy_sentinel)) { + return &roots[idx]; + } + const ref_ptr = containerStructRef(pool.nodes.items(.payload), idx); + var hash: [32]u8 = undefined; + ref_ptr.get_root(ref_ptr.ptr, &hash); + roots[idx] = hash; + return &roots[idx]; + }, } - return hash; } pub fn getLeft(node_id: Id, pool: *Pool) Error!Id { - const state = pool.nodes.items(.state)[@intFromEnum(node_id)]; - if (node_id.noChild(state)) { - return Error.InvalidNode; - } - - return pool.nodes.items(.left)[@intFromEnum(node_id)]; + const idx = @intFromEnum(node_id); + const kind = pool.nodes.items(.state)[idx].kind(); + if (noChildKind(node_id, kind)) return Error.InvalidNode; + return childrenOf(node_id, kind, pool.nodes.items(.payload)).left; } pub fn getRight(node_id: Id, pool: *Pool) Error!Id { - const state = pool.nodes.items(.state)[@intFromEnum(node_id)]; - if (node_id.noChild(state)) { - return Error.InvalidNode; + const idx = @intFromEnum(node_id); + const kind = pool.nodes.items(.state)[idx].kind(); + if (noChildKind(node_id, kind)) return Error.InvalidNode; + return childrenOf(node_id, kind, pool.nodes.items(.payload)).right; + } + + pub fn getChunkedLeafChunks(node_id: Id, pool: *Pool) Error!*align(64) const [ChunkedLeaf.K][32]u8 { + const idx = @intFromEnum(node_id); + if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + return &chunkedLeafPtr(pool.nodes.items(.payload), idx).chunks; + } + + pub fn getChunkedLeafLen(node_id: Id, pool: *Pool) Error!u16 { + const idx = @intFromEnum(node_id); + if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + return chunkedLeafPtr(pool.nodes.items(.payload), idx).len; + } + + pub fn getChunkedLeafPtr(node_id: Id, pool: *Pool) Error!*ChunkedLeaf { + const idx = @intFromEnum(node_id); + if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + return chunkedLeafPtr(pool.nodes.items(.payload), idx); + } + + pub fn setChunkedLeafChunk(node_id: Id, pool: *Pool, intra_index: u16, chunk: *const [32]u8) Error!Id { + std.debug.assert(intra_index < ChunkedLeaf.K); + + const idx = @intFromEnum(node_id); + if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + const old_storage = chunkedLeafPtr(pool.nodes.items(.payload), idx); + + const new_storage = try pool.allocator.create(ChunkedLeaf); + errdefer pool.allocator.destroy(new_storage); + + new_storage.chunks = old_storage.chunks; + new_storage.len = old_storage.len; + new_storage.chunks[intra_index] = chunk.*; + + const new_id = try pool.create(); + const new_idx = @intFromEnum(new_id); + pool.nodes.items(.state)[new_idx] = State.initInUse(.chunked_leaf, 0); + pool.nodes.items(.payload)[new_idx] = ptrAsPayload(@ptrCast(new_storage)); + pool.nodes.items(.root)[new_idx] = lazy_sentinel; + return new_id; + } + + /// Returns a new chunked_leaf `Id` with each `intra_indices[i]` chunk replaced by + /// `new_chunks[i]`. Heap blob cloned once; all updates applied in-place + /// in the new blob. Returns `Error.InvalidNode` if the receiver is not + /// a chunked_leaf variant. `intra_indices` and `new_chunks` must have equal length. + pub fn setChunkedLeafChunks( + node_id: Id, + pool: *Pool, + intra_indices: []const u16, + new_chunks: []const *const [32]u8, + ) Error!Id { + std.debug.assert(intra_indices.len == new_chunks.len); + + const idx = @intFromEnum(node_id); + if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + const old_storage = chunkedLeafPtr(pool.nodes.items(.payload), idx); + + const new_storage = try pool.allocator.create(ChunkedLeaf); + errdefer pool.allocator.destroy(new_storage); + + new_storage.chunks = old_storage.chunks; + new_storage.len = old_storage.len; + + for (intra_indices, new_chunks) |i, ptr| { + std.debug.assert(i < ChunkedLeaf.K); + new_storage.chunks[i] = ptr.*; } - return pool.nodes.items(.right)[@intFromEnum(node_id)]; + const new_id = try pool.create(); + const new_idx = @intFromEnum(new_id); + pool.nodes.items(.state)[new_idx] = State.initInUse(.chunked_leaf, 0); + pool.nodes.items(.payload)[new_idx] = ptrAsPayload(@ptrCast(new_storage)); + pool.nodes.items(.root)[new_idx] = lazy_sentinel; + return new_id; } - pub fn getState(node_id: Id, pool: *Pool) State { + /// Returns the slot's packed `State`. Call methods on the result + /// (`isFree()`, `kind()`, `refCount()`, `nextFree()`). + pub inline fn getState(node_id: Id, pool: *Pool) State { return pool.nodes.items(.state)[@intFromEnum(node_id)]; } + /// Returns true iff this is a `.branch` slot whose root is still the + /// lazy sentinel. Lives on `Id` (not `State`) because it needs the + /// `root` column too. + pub inline fn isBranchLazy(node_id: Id, pool: *Pool) bool { + if (node_id.getState(pool).kind() != .branch) return false; + return std.mem.eql(u8, &pool.nodes.items(.root)[@intFromEnum(node_id)], &lazy_sentinel); + } + + /// Returns true iff this is a `.branch` slot with a cached (non-lazy) root. + pub inline fn isBranchComputed(node_id: Id, pool: *Pool) bool { + if (node_id.getState(pool).kind() != .branch) return false; + return !std.mem.eql(u8, &pool.nodes.items(.root)[@intFromEnum(node_id)], &lazy_sentinel); + } + pub fn getNode(root_node: Id, pool: *Pool, gindex: Gindex) Error!Id { if (@intFromEnum(gindex) <= 1) { return root_node; @@ -462,18 +911,20 @@ pub const Id = enum(u32) { var path = gindex.toPath(); const states = pool.nodes.items(.state); - const lefts = pool.nodes.items(.left); - const rights = pool.nodes.items(.right); + const payloads = pool.nodes.items(.payload); var node_id: Id = root_node; for (0..path_len) |_| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); if (path.left()) { - node_id = lefts[@intFromEnum(node_id)]; + node_id = c.left; } else { - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; } path.next(); } @@ -508,37 +959,44 @@ pub const Id = enum(u32) { errdefer pool.free(path_parents); const states = pool.nodes.items(.state); - const lefts = pool.nodes.items(.left); - const rights = pool.nodes.items(.right); + const payloads = pool.nodes.items(.payload); var id = root_node; for (0..path_len - 1) |i| { - if (id.noChild(states[@intFromEnum(id)])) { + const idx = @intFromEnum(id); + const k = states[idx].kind(); + if (noChildKind(id, k)) { return Error.InvalidNode; } + const c = childrenOf(id, k, payloads); if (path.left()) { path_lefts[i] = path_parents[i + 1]; - path_rights[i] = rights[@intFromEnum(id)]; - id = lefts[@intFromEnum(id)]; + path_rights[i] = c.right; + id = c.left; } else { - path_lefts[i] = lefts[@intFromEnum(id)]; + path_lefts[i] = c.left; path_rights[i] = path_parents[i + 1]; - id = rights[@intFromEnum(id)]; + id = c.right; } path.next(); } // final layer - if (id.noChild(states[@intFromEnum(id)])) { - return Error.InvalidNode; - } - if (path.left()) { - path_lefts[path_len - 1] = node_id; - path_rights[path_len - 1] = rights[@intFromEnum(id)]; - } else { - path_lefts[path_len - 1] = lefts[@intFromEnum(id)]; - path_rights[path_len - 1] = node_id; + { + const idx = @intFromEnum(id); + const k = states[idx].kind(); + if (noChildKind(id, k)) { + return Error.InvalidNode; + } + const c = childrenOf(id, k, payloads); + if (path.left()) { + path_lefts[path_len - 1] = node_id; + path_rights[path_len - 1] = c.right; + } else { + path_lefts[path_len - 1] = c.left; + path_rights[path_len - 1] = node_id; + } } try pool.rebind( @@ -558,7 +1016,7 @@ pub const Id = enum(u32) { ); } - /// Get multiple nodes in a single traversal + /// Get multiple nodes in a single traversal. /// /// Stores `out.len` nodes at the specified `depth`, starting from `start_index`. pub fn getNodesAtDepth(root_node: Id, pool: *Pool, depth: Depth, start_index: usize, out: []Id) Error!void { @@ -578,8 +1036,7 @@ pub const Id = enum(u32) { var diffi = depth; const states = pool.nodes.items(.state); - const lefts = pool.nodes.items(.left); - const rights = pool.nodes.items(.right); + const payloads = pool.nodes.items(.payload); // For each index specified for (0..out.len) |i| { @@ -593,14 +1050,17 @@ pub const Id = enum(u32) { // Navigate down (from the depth diff) to the current index, populating parents for (d..path_len) |bit_i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); parents_buf[bit_i] = node_id; if (path.left()) { - node_id = lefts[@intFromEnum(node_id)]; + node_id = c.left; } else { - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; } path.next(); } @@ -624,8 +1084,8 @@ pub const Id = enum(u32) { /// - indexes MUST be sorted in ascending order beforehand. /// - All indexes must be at the exact same depth. /// - Depth must be > 0, if 0 just replace the root node. - pub fn setNodesAtDepth(root_node: Id, pool: *Pool, depth: Depth, indices: []const usize, nodes: []Id) Error!Id { - std.debug.assert(nodes.len == indices.len); + pub fn setNodesAtDepth(root_node: Id, pool: *Pool, depth: Depth, indices: []const usize, nodes_in: []Id) Error!Id { + std.debug.assert(nodes_in.len == indices.len); if (indices.len == 0) { return root_node; } @@ -637,7 +1097,7 @@ pub const Id = enum(u32) { const base_gindex = Gindex.fromDepth(depth, 0); if (@intFromEnum(base_gindex) <= 1) { - return nodes[0]; + return nodes_in[0]; } const path_len = base_gindex.pathLen(); @@ -673,8 +1133,7 @@ pub const Id = enum(u32) { var d_offset: Depth = 0; var states = pool.nodes.items(.state); - var lefts = pool.nodes.items(.left); - var rights = pool.nodes.items(.right); + var payloads = pool.nodes.items(.payload); // For each index specified, maintain/update path_lefts and path_rights from root (depth 0) all the way to path_len // but only allocate and update path_parents from the next shared depth to path_len @@ -691,8 +1150,7 @@ pub const Id = enum(u32) { path_len - @as(Depth, @intCast(@bitSizeOf(usize) - @clz(index ^ indices[i + 1]))); if (try pool.alloc(path_parents[next_d_offset..path_len])) { states = pool.nodes.items(.state); - lefts = pool.nodes.items(.left); - rights = pool.nodes.items(.right); + payloads = pool.nodes.items(.payload); } var path = gindex.toPath(); @@ -727,37 +1185,45 @@ pub const Id = enum(u32) { // Navigate down (from the depth offset) to the current index, populating parents for (d_offset..path_len - 1) |bit_i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); if (path.left()) { path_lefts[bit_i] = path_parents[bit_i + 1]; - path_rights[bit_i] = rights[@intFromEnum(node_id)]; - node_id = lefts[@intFromEnum(node_id)]; + path_rights[bit_i] = c.right; + node_id = c.left; right_move[bit_i] = false; unfinalized_parents_buf[bit_i] = path_parents[bit_i]; } else { - path_lefts[bit_i] = lefts[@intFromEnum(node_id)]; + path_lefts[bit_i] = c.left; path_rights[bit_i] = path_parents[bit_i + 1]; - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; right_move[bit_i] = true; } path.next(); } // final layer - if (node_id.noChild(states[@intFromEnum(node_id)])) { - return Error.InvalidNode; - } - if (path.left()) { - path_lefts[path_len - 1] = nodes[i]; - path_rights[path_len - 1] = rights[@intFromEnum(node_id)]; - right_move[path_len - 1] = false; - unfinalized_parents_buf[path_len - 1] = path_parents[path_len - 1]; - } else { - path_lefts[path_len - 1] = lefts[@intFromEnum(node_id)]; - path_rights[path_len - 1] = nodes[i]; - right_move[path_len - 1] = true; + { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { + return Error.InvalidNode; + } + const c = childrenOf(node_id, k, payloads); + if (path.left()) { + path_lefts[path_len - 1] = nodes_in[i]; + path_rights[path_len - 1] = c.right; + right_move[path_len - 1] = false; + unfinalized_parents_buf[path_len - 1] = path_parents[path_len - 1]; + } else { + path_lefts[path_len - 1] = c.left; + path_rights[path_len - 1] = nodes_in[i]; + right_move[path_len - 1] = true; + } } // Rebind upwards depth diff times @@ -784,6 +1250,20 @@ pub const Id = enum(u32) { /// Zeroes every node strictly to the right of `index` at the provided `depth`. pub fn truncateAfterIndex(root_node: Id, pool: *Pool, depth: Depth, index: usize) Error!Id { + return truncateAfterIndexWithLeafOffset(root_node, pool, depth, index, 0); + } + + /// Like `truncateAfterIndex`, but `leaf_offset` declares how many extra + /// levels each "leaf" at `depth` represents underneath. ChunkedLeaf-mode + /// trees pass `leaf_offset = ChunkedLeaf.k_log2` so zeroed positions use + /// `ZeroHash[depthi + leaf_offset]` instead of `ZeroHash[depthi]`. + /// + /// Pool's ZeroHash sentinels live at Node.Id `0..max_depth-1`. `depthi` + /// tops out at `depth-1`, so the largest sentinel index used is + /// `depth-1 + leaf_offset`; `depth + leaf_offset <= max_depth` keeps every + /// `@enumFromInt(zero_depth)` within that range. + pub fn truncateAfterIndexWithLeafOffset(root_node: Id, pool: *Pool, depth: Depth, index: usize, leaf_offset: Depth) Error!Id { + std.debug.assert(@as(usize, depth) + @as(usize, leaf_offset) <= max_depth); if (depth == 0) { return root_node; } @@ -810,41 +1290,48 @@ pub const Id = enum(u32) { errdefer pool.free(path_parents); const states = pool.nodes.items(.state); - const lefts = pool.nodes.items(.left); - const rights = pool.nodes.items(.right); + const payloads = pool.nodes.items(.payload); var node_id = root_node; for (0..path_len - 1) |i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); const depthi = path_len - i - 1; const go_left = isLeftIndex(depthi, index); if (go_left) { path_lefts[i] = path_parents[i + 1]; - const zero_depth: Depth = @intCast(depthi); + const zero_depth: Depth = @intCast(depthi + @as(usize, leaf_offset)); path_rights[i] = @enumFromInt(zero_depth); - node_id = lefts[@intFromEnum(node_id)]; + node_id = c.left; } else { - path_lefts[i] = lefts[@intFromEnum(node_id)]; + path_lefts[i] = c.left; path_rights[i] = path_parents[i + 1]; - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; } } - if (node_id.noChild(states[@intFromEnum(node_id)])) { - return Error.InvalidNode; - } + { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { + return Error.InvalidNode; + } + const c = childrenOf(node_id, k, payloads); - const go_left_last = isLeftIndex(0, index); - if (go_left_last) { - path_lefts[path_len - 1] = lefts[@intFromEnum(node_id)]; - path_rights[path_len - 1] = @enumFromInt(0); - } else { - path_lefts[path_len - 1] = lefts[@intFromEnum(node_id)]; - path_rights[path_len - 1] = rights[@intFromEnum(node_id)]; + const go_left_last = isLeftIndex(0, index); + if (go_left_last) { + path_lefts[path_len - 1] = c.left; + path_rights[path_len - 1] = @enumFromInt(leaf_offset); + } else { + path_lefts[path_len - 1] = c.left; + path_rights[path_len - 1] = c.right; + } } try pool.rebind(path_parents, path_lefts, path_rights); @@ -858,15 +1345,15 @@ pub const Id = enum(u32) { /// Set multiple nodes in batch, editing and traversing nodes strictly once. /// - gindexes MUST be sorted in ascending order beforehand. - pub fn setNodes(root_node: Id, pool: *Pool, gindices: []const Gindex, nodes: []Id) Error!Id { - std.debug.assert(nodes.len == gindices.len); + pub fn setNodes(root_node: Id, pool: *Pool, gindices: []const Gindex, nodes_in: []Id) Error!Id { + std.debug.assert(nodes_in.len == gindices.len); if (gindices.len == 0) { return root_node; } const base_gindex = gindices[0]; if (@intFromEnum(base_gindex) <= 1) { - return nodes[0]; + return nodes_in[0]; } const path_len = base_gindex.pathLen(); @@ -898,8 +1385,7 @@ pub const Id = enum(u32) { var d_offset: Depth = 0; var states = pool.nodes.items(.state); - var lefts = pool.nodes.items(.left); - var rights = pool.nodes.items(.right); + var payloads = pool.nodes.items(.payload); // For each index specified, maintain/update path_lefts and path_rights from root (depth 0) all the way to path_len // but only allocate and update path_parents from the next shared depth to path_len @@ -916,8 +1402,7 @@ pub const Id = enum(u32) { if (try pool.alloc(path_parents_buf[next_d_offset..path_len])) { states = pool.nodes.items(.state); - lefts = pool.nodes.items(.left); - rights = pool.nodes.items(.right); + payloads = pool.nodes.items(.payload); } var path = gindex.toPath(); @@ -952,37 +1437,45 @@ pub const Id = enum(u32) { // Navigate down (from the depth offset) to the current index, populating parents for (d_offset..path_len - 1) |bit_i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); if (path.left()) { path_lefts_buf[bit_i] = path_parents_buf[bit_i + 1]; - path_rights_buf[bit_i] = rights[@intFromEnum(node_id)]; - node_id = lefts[@intFromEnum(node_id)]; + path_rights_buf[bit_i] = c.right; + node_id = c.left; right_move[bit_i] = false; unfinalized_parents_buf[bit_i] = path_parents_buf[bit_i]; } else { - path_lefts_buf[bit_i] = lefts[@intFromEnum(node_id)]; + path_lefts_buf[bit_i] = c.left; path_rights_buf[bit_i] = path_parents_buf[bit_i + 1]; - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; right_move[bit_i] = true; } path.next(); } // final layer - if (node_id.noChild(states[@intFromEnum(node_id)])) { - return Error.InvalidNode; - } - if (path.left()) { - path_lefts_buf[path_len - 1] = nodes[i]; - path_rights_buf[path_len - 1] = rights[@intFromEnum(node_id)]; - right_move[path_len - 1] = false; - unfinalized_parents_buf[path_len - 1] = path_parents_buf[path_len - 1]; - } else { - path_lefts_buf[path_len - 1] = lefts[@intFromEnum(node_id)]; - path_rights_buf[path_len - 1] = nodes[i]; - right_move[path_len - 1] = true; + { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { + return Error.InvalidNode; + } + const c = childrenOf(node_id, k, payloads); + if (path.left()) { + path_lefts_buf[path_len - 1] = nodes_in[i]; + path_rights_buf[path_len - 1] = c.right; + right_move[path_len - 1] = false; + unfinalized_parents_buf[path_len - 1] = path_parents_buf[path_len - 1]; + } else { + path_lefts_buf[path_len - 1] = c.left; + path_rights_buf[path_len - 1] = nodes_in[i]; + right_move[path_len - 1] = true; + } } // Rebind upwards depth diff times @@ -1077,32 +1570,40 @@ pub fn fillToLength(pool: *Pool, leaf: Id, depth: Depth, length: usize) Error!Id const path_rights = rights_buf[0..path_len]; const states = pool.nodes.items(.state); - const lefts = pool.nodes.items(.left); - const rights = pool.nodes.items(.right); + const payloads = pool.nodes.items(.payload); for (0..path_len - 1) |i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); if (path.left()) { path_lefts[i] = path_parents[i + 1]; - path_rights[i] = rights[@intFromEnum(node_id)]; - node_id = lefts[@intFromEnum(node_id)]; + path_rights[i] = c.right; + node_id = c.left; } else { - path_lefts[i] = lefts[@intFromEnum(node_id)]; + path_lefts[i] = c.left; path_rights[i] = path_parents[i + 1]; - node_id = rights[@intFromEnum(node_id)]; + node_id = c.right; } path.next(); } // and rebind with zero(0) - if (path.left()) { - path_lefts[path_len - 1] = @enumFromInt(0); - path_rights[path_len - 1] = rights[@intFromEnum(node_id)]; - } else { - path_lefts[path_len - 1] = lefts[@intFromEnum(node_id)]; - path_rights[path_len - 1] = @enumFromInt(0); + { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) return Error.InvalidNode; + const c = childrenOf(node_id, k, payloads); + if (path.left()) { + path_lefts[path_len - 1] = @enumFromInt(0); + path_rights[path_len - 1] = c.right; + } else { + path_lefts[path_len - 1] = c.left; + path_rights[path_len - 1] = @enumFromInt(0); + } } // and rebind with zero(0) @@ -1117,7 +1618,7 @@ pub fn fillToLength(pool: *Pool, leaf: Id, depth: Depth, length: usize) Error!Id /// Fill a view with the specified contents, returning the new root node id. /// -/// Note: contents is mutated +/// Note: contents is mutated. pub fn fillWithContents(pool: *Pool, contents: []Id, depth: Depth) !Id { if (contents.len == 0) { return @enumFromInt(depth); @@ -1183,8 +1684,7 @@ pub const DepthIterator = struct { if (self.index >= max_length) return Error.InvalidLength; const states = self.pool.nodes.items(.state); - const lefts = self.pool.nodes.items(.left); - const rights = self.pool.nodes.items(.right); + const payloads = self.pool.nodes.items(.payload); // Compute gindex for current index at the requested depth. const gindex = Gindex.fromUint(@intCast(@intFromEnum(self.base_gindex) | self.index)); @@ -1199,14 +1699,14 @@ pub const DepthIterator = struct { // Navigate down from the shared prefix (d) to the target, updating parents. for (d..path_len) |bit_i| { - if (node_id.noChild(states[@intFromEnum(node_id)])) { + const idx = @intFromEnum(node_id); + const k = states[idx].kind(); + if (noChildKind(node_id, k)) { return Error.InvalidNode; } + const c = childrenOf(node_id, k, payloads); self.parents_buf[bit_i] = node_id; - node_id = if (path.left()) - lefts[@intFromEnum(node_id)] - else - rights[@intFromEnum(node_id)]; + node_id = if (path.left()) c.left else c.right; path.next(); } @@ -1235,13 +1735,30 @@ pub const DepthIterator = struct { pub const FillWithContentsIterator = struct { pool: *Pool, depth: Depth, + /// Absolute depth, in chunks, of each appended Id. For depth-0 leaves + /// (chunks) this is 0. For chunked_leaf Ids it is `ChunkedLeaf.k_log2`. Zero fillers + /// emitted by `finish()` for missing right siblings at iterator level L + /// must therefore be at absolute depth `L + leaf_offset`. + leaf_offset: Depth, // At each level i, holds either null or the unpaired left node at that level. lefts: [max_depth]?Id, + /// Initialize an iterator where each appended Id is a depth-0 leaf + /// (a chunk). Equivalent to `initWithOffset(pool, depth, 0)`. pub fn init(pool: *Pool, depth: Depth) FillWithContentsIterator { + return initWithOffset(pool, depth, 0); + } + + /// Initialize an iterator where each appended Id is a depth-`leaf_offset` + /// subtree (e.g. a chunked_leaf Id is a depth-`ChunkedLeaf.k_log2` subtree of K chunks). + /// Zero fillers in `finish()` use `@enumFromInt(level + leaf_offset)` + /// so the resulting tree's root is correct under standard SSZ + /// merkleization at absolute depth `depth + leaf_offset`. + pub fn initWithOffset(pool: *Pool, depth: Depth, leaf_offset: Depth) FillWithContentsIterator { return .{ .pool = pool, .depth = depth, + .leaf_offset = leaf_offset, .lefts = [_]?Id{null} ** max_depth, }; } @@ -1291,19 +1808,23 @@ pub const FillWithContentsIterator = struct { return root; } - var carry: Id = @enumFromInt(self.depth); + // Initial carry = zero subtree at absolute depth `depth + leaf_offset`. + var carry: Id = @enumFromInt(@as(u32, self.depth) + @as(u32, self.leaf_offset)); var start_level: usize = self.depth; - // Find the lowest non-null as starting carry. + // Find the lowest non-null as starting carry. Its absolute depth is + // `level + leaf_offset` because each appended Id sits at depth `leaf_offset`. for (0..self.depth) |level| { if (self.lefts[level] != null) { - carry = @enumFromInt(@as(u32, @intCast(level))); + carry = @enumFromInt(@as(u32, @intCast(level)) + @as(u32, self.leaf_offset)); start_level = level; break; } } // Starting from the lowest non-null, build upwards with zero-nodes. + // A missing right sibling at iterator level `level` is a zero subtree + // at absolute depth `level + leaf_offset`. for (start_level..self.depth) |level| { if (self.lefts[level]) |left| { // Same as above: keep `left` reclaimable and release `carry` on failure. Here @@ -1315,7 +1836,8 @@ pub const FillWithContentsIterator = struct { self.lefts[level] = null; carry = branch; } else { - carry = self.pool.createBranch(carry, @enumFromInt(@as(u32, @intCast(level)))) catch |err| { + // Release `carry` on failure; the missing right sibling is a zero-node sentinel. + carry = self.pool.createBranch(carry, @enumFromInt(@as(u32, @intCast(level)) + @as(u32, self.leaf_offset))) catch |err| { self.pool.unref(carry); return err; }; diff --git a/src/persistent_merkle_tree/node_bench.zig b/src/persistent_merkle_tree/node_bench.zig index 9b84a8966..3a4cf59e7 100644 --- a/src/persistent_merkle_tree/node_bench.zig +++ b/src/persistent_merkle_tree/node_bench.zig @@ -5,7 +5,6 @@ const Depth = @import("hashing").Depth; const Node = @import("Node.zig"); const Pool = Node.Pool; -const global_allocator = std.heap.page_allocator; var pool: Pool = undefined; const GetNodeRandomly = struct { @@ -84,11 +83,11 @@ const SetNodeRandomly = struct { pub fn main(init: std.process.Init) !void { const io = init.io; - const allocator = global_allocator; + const allocator = std.heap.page_allocator; var bench = zbench.Benchmark.init(allocator, .{}); defer bench.deinit(); - pool = try Pool.init(allocator, 50_000_000); + pool = try Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 50_000_000 }); defer pool.deinit(); const depth = 40; diff --git a/src/persistent_merkle_tree/node_test.zig b/src/persistent_merkle_tree/node_test.zig index 7124eda60..7eeefe706 100644 --- a/src/persistent_merkle_tree/node_test.zig +++ b/src/persistent_merkle_tree/node_test.zig @@ -20,7 +20,7 @@ fn drainPoolToFull(pool: *Node.Pool, out: *std.ArrayList(Node.Id)) !void { // reclaimed. Here they are two different nodes. test "FillWithContentsIterator - createBranch OOM with distinct nodes does not leak" { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); - var pool = try Node.Pool.init(failing.allocator(), 4); + var pool = try Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 4 }); defer pool.deinit(); const baseline = pool.getNodesInUse(); @@ -46,7 +46,7 @@ test "FillWithContentsIterator - createBranch OOM with distinct nodes does not l // unref `carry` — it's the same node as `left`, which deinit reclaims; doing both double-frees. test "FillWithContentsIterator - createBranch OOM with aliased node does not double-free" { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); - var pool = try Node.Pool.init(failing.allocator(), 4); + var pool = try Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 4 }); defer pool.deinit(); const baseline = pool.getNodesInUse(); @@ -67,52 +67,67 @@ test "FillWithContentsIterator - createBranch OOM with aliased node does not dou try std.testing.expectEqual(baseline, pool.getNodesInUse()); } -test "Node.State" { - const State = Node.State; - - var state: State = State.initNextFree(@enumFromInt(100)); - try std.testing.expect(state.isFree()); - try std.testing.expectEqual(@as(Node.Id, @enumFromInt(100)), state.getNextFree()); - - state = State.branch_lazy; - try std.testing.expect(state.isBranch()); - try std.testing.expect(state.isBranchLazy()); - try std.testing.expect(!state.isZero()); - try std.testing.expect(!state.isLeaf()); - try std.testing.expect(!state.isBranchComputed()); - - _ = try state.incRefCount(); - try std.testing.expect(state.isBranch()); - try std.testing.expect(state.isBranchLazy()); - try std.testing.expect(!state.isZero()); - try std.testing.expect(!state.isLeaf()); - try std.testing.expect(!state.isBranchComputed()); - - state.setBranchComputed(); - try std.testing.expect(state.isBranch()); - try std.testing.expect(state.isBranchComputed()); - try std.testing.expect(!state.isBranchLazy()); - try std.testing.expect(!state.isZero()); - try std.testing.expect(!state.isLeaf()); - - state = State.zero; - try std.testing.expect(state.isZero()); - try std.testing.expect(!state.isLeaf()); - try std.testing.expect(!state.isBranch()); - try std.testing.expect(!state.isBranchLazy()); - try std.testing.expect(!state.isBranchComputed()); - - state = State.leaf; - try std.testing.expect(state.isLeaf()); - try std.testing.expect(!state.isZero()); - try std.testing.expect(!state.isBranch()); - try std.testing.expect(!state.isBranchLazy()); - try std.testing.expect(!state.isBranchComputed()); +test "Node.State predicates" { + // Exercises State predicates via `id.getState(pool)` over each variant: + // zero sentinel, leaf, lazy/computed branch, free slot. + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4 }); + defer pool.deinit(); + const p = &pool; + + // Zero-node sentinel at index 0 + const zero0: Node.Id = @enumFromInt(0); + const zero_state = zero0.getState(p); + try std.testing.expect(zero_state.isZero()); + try std.testing.expect(!zero_state.isLeaf()); + try std.testing.expect(!zero_state.isBranch()); + try std.testing.expect(!zero0.isBranchLazy(p)); + try std.testing.expect(!zero0.isBranchComputed(p)); + try std.testing.expect(!zero_state.isFree()); + + // Leaf + const leaf = try pool.createLeafFromUint(42); + const leaf_state = leaf.getState(p); + try std.testing.expect(leaf_state.isLeaf()); + try std.testing.expect(!leaf_state.isZero()); + try std.testing.expect(!leaf_state.isBranch()); + try std.testing.expect(!leaf.isBranchLazy(p)); + try std.testing.expect(!leaf.isBranchComputed(p)); + try std.testing.expect(!leaf_state.isFree()); + + // Lazy branch (root not yet computed) + const branch = try pool.createBranch(leaf, leaf); + defer pool.unref(branch); + try std.testing.expect(branch.getState(p).isBranch()); + try std.testing.expect(branch.isBranchLazy(p)); + try std.testing.expect(!branch.isBranchComputed(p)); + try std.testing.expect(!branch.getState(p).isZero()); + try std.testing.expect(!branch.getState(p).isLeaf()); + try std.testing.expect(!branch.getState(p).isFree()); + + // After computing the root the same slot reports computed. + _ = branch.getRoot(p); + try std.testing.expect(branch.getState(p).isBranch()); + try std.testing.expect(branch.isBranchComputed(p)); + try std.testing.expect(!branch.isBranchLazy(p)); + try std.testing.expect(!branch.getState(p).isZero()); + try std.testing.expect(!branch.getState(p).isLeaf()); + + // Free slot: allocate, then unref so the slot is back on the free list. + const transient = try pool.createLeafFromUint(7); + pool.unref(transient); + const free_state = transient.getState(p); + try std.testing.expect(free_state.isFree()); + try std.testing.expect(!free_state.isLeaf()); + try std.testing.expect(!free_state.isZero()); + try std.testing.expect(!free_state.isBranch()); + // Free slots expose the next-free link via `state.nextFree()`. + _ = free_state.nextFree(); } test "Pool" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 10); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); defer pool.deinit(); const p = &pool; @@ -142,15 +157,15 @@ test "Pool" { // check if the free list is correct const next_free: Node.Id = pool.next_free_node; try std.testing.expectEqual(leaf2_id, next_free); - try std.testing.expectEqual(branch3_id, next_free.getState(p).getNextFree()); - try std.testing.expectEqual(leaf1_id, next_free.getState(p).getNextFree().getState(p).getNextFree()); - try std.testing.expectEqual(branch1_id, next_free.getState(p).getNextFree().getState(p).getNextFree().getState(p).getNextFree()); - try std.testing.expectEqual(branch2_id, next_free.getState(p).getNextFree().getState(p).getNextFree().getState(p).getNextFree().getState(p).getNextFree()); + try std.testing.expectEqual(branch3_id, next_free.getState(p).nextFree()); + try std.testing.expectEqual(leaf1_id, next_free.getState(p).nextFree().getState(p).nextFree()); + try std.testing.expectEqual(branch1_id, next_free.getState(p).nextFree().getState(p).nextFree().getState(p).nextFree()); + try std.testing.expectEqual(branch2_id, next_free.getState(p).nextFree().getState(p).nextFree().getState(p).nextFree().getState(p).nextFree()); } test "Pool - automatic capacity growth beyond pre-heat" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1); // intentionally tiny + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 }); // intentionally tiny defer pool.deinit(); const p = &pool; @@ -170,7 +185,7 @@ test "Pool - automatic capacity growth beyond pre-heat" { } test "All zero hashes (depth>0) point both children to the previous depth" { - var pool = try Node.Pool.init(std.testing.allocator, 1); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1 }); defer pool.deinit(); const p = &pool; @@ -185,7 +200,7 @@ test "All zero hashes (depth>0) point both children to the previous depth" { } test "Node free-list re-uses the lowest recently-freed Id first" { - var pool = try Node.Pool.init(std.testing.allocator, 2); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 2 }); defer pool.deinit(); const n1 = try pool.createLeafFromUint(1); @@ -197,7 +212,7 @@ test "Node free-list re-uses the lowest recently-freed Id first" { test "Navigation - invalid node access is rejected" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 8); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8 }); defer pool.deinit(); const p = &pool; @@ -215,7 +230,7 @@ test "Navigation - invalid node access is rejected" { test "alloc returns a set of unique nodes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 }); defer pool.deinit(); const p = &pool; @@ -235,7 +250,7 @@ test "alloc returns a set of unique nodes" { test "get/setNode" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1 }); defer pool.deinit(); const p = &pool; @@ -251,7 +266,7 @@ test "get/setNode" { test "setNodes for checkpoint tree" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 10); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); defer pool.deinit(); const p = &pool; @@ -278,7 +293,7 @@ test "setNodes for checkpoint tree" { // Empty `indices` should return the root untouched, not panic on the `for (1..0)` assert. test "setNodesAtDepth - empty indices returns root unchanged" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const p = &pool; @@ -293,7 +308,7 @@ test "setNodesAtDepth - empty indices returns root unchanged" { test "Depth helpers - round-trip setNodesAtDepth / getNodesAtDepth" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const p = &pool; @@ -327,7 +342,7 @@ test "Depth helpers - round-trip setNodesAtDepth / getNodesAtDepth" { // the zero-fill makes those frees no-ops instead of unref-ing garbage. test "setNodesAtDepth - early-iteration error frees cleanly without leaking or corrupting" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const p = &pool; @@ -359,6 +374,131 @@ test "setNodesAtDepth - early-iteration error frees cleanly without leaking or c try std.testing.expectEqual(new_leaf, try new_root.getNode(p, Gindex.fromDepth(1, 0))); } +// A later-index OOM makes rollback double-unref the spine onto already-freed slots. +test "setNodesAtDepth - later-iteration OOM rolls back without panicking on a freed slot" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); + + var pool = try Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = std.testing.allocator, .pool_size = 16 }); + defer pool.deinit(); + + const p = &pool; + + // Navigable depth-2 tree: branch(branch(la, lb), branch(lc, ld)). + const left = try pool.createBranch(try pool.createLeafFromUint(1), try pool.createLeafFromUint(2)); + const right = try pool.createBranch(try pool.createLeafFromUint(3), try pool.createLeafFromUint(4)); + const root = try pool.createBranch(left, right); + defer pool.unref(root); + + const new0 = try pool.createLeafFromUint(100); + const new3 = try pool.createLeafFromUint(103); + + // Fill to capacity, then free 2 (iteration 0's path_parents): iteration 1 then + // grows past capacity and hits the armed OOM. Within-capacity growth doesn't alloc. + failing.fail_index = failing.alloc_index; + + var filler: std.ArrayList(Node.Id) = .empty; + defer filler.deinit(std.testing.allocator); + + try drainPoolToFull(&pool, &filler); + pool.unref(filler.pop().?); + pool.unref(filler.pop().?); + + const in_use_before = pool.getNodesInUse(); + + var nodes_in = [_]Node.Id{ new0, new3 }; + const indices = [_]usize{ 0, 3 }; + try std.testing.expectError(error.OutOfMemory, root.setNodesAtDepth(p, 2, &indices, &nodes_in)); + + failing.fail_index = std.math.maxInt(usize); // disarm + + // Only the one already-inserted input leaf (new0) was reclaimed with the + // torn-down spine; the original tree is intact and nothing leaked. + try std.testing.expectEqual(in_use_before - 1, pool.getNodesInUse()); + + for (filler.items) |id| pool.unref(id); + + // Free list is uncorrupted: a fresh batch set round-trips. + const ok_tree = try pool.createBranch(@enumFromInt(1), @enumFromInt(1)); + defer pool.unref(ok_tree); + + const fresh = try pool.createLeafFromUint(7); + var ok_leaves = [_]Node.Id{fresh}; + const ok_indices = [_]usize{0}; + + const ok_root = try ok_tree.setNodesAtDepth(p, 1, &ok_indices, &ok_leaves); + defer pool.unref(ok_root); + + try std.testing.expectEqual(fresh, try ok_root.getNode(p, Gindex.fromDepth(1, 0))); +} + +// Same later-iteration OOM rollback as above, through `setNodes` (gindex API). +test "setNodes - later-iteration OOM rolls back without panicking on a freed slot" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .resize_fail_index = 0 }); + + var pool = try Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = std.testing.allocator, .pool_size = 16 }); + defer pool.deinit(); + + const p = &pool; + + const left = try pool.createBranch(try pool.createLeafFromUint(1), try pool.createLeafFromUint(2)); + const right = try pool.createBranch(try pool.createLeafFromUint(3), try pool.createLeafFromUint(4)); + const root = try pool.createBranch(left, right); + defer pool.unref(root); + + const new0 = try pool.createLeafFromUint(100); + const new3 = try pool.createLeafFromUint(103); + + // Same fill-to-capacity + free-2 setup as the setNodesAtDepth variant above. + failing.fail_index = failing.alloc_index; + + var filler: std.ArrayList(Node.Id) = .empty; + defer filler.deinit(std.testing.allocator); + + try drainPoolToFull(&pool, &filler); + pool.unref(filler.pop().?); + pool.unref(filler.pop().?); + + const in_use_before = pool.getNodesInUse(); + + var nodes_in = [_]Node.Id{ new0, new3 }; + const gindices = [_]Gindex{ Gindex.fromDepth(2, 0), Gindex.fromDepth(2, 3) }; + try std.testing.expectError(error.OutOfMemory, root.setNodes(p, &gindices, &nodes_in)); + + failing.fail_index = std.math.maxInt(usize); // disarm + try std.testing.expectEqual(in_use_before - 1, pool.getNodesInUse()); + + for (filler.items) |id| pool.unref(id); + + const ok_tree = try pool.createBranch(@enumFromInt(1), @enumFromInt(1)); + defer pool.unref(ok_tree); + + const fresh = try pool.createLeafFromUint(7); + var ok_leaves = [_]Node.Id{fresh}; + + const ok_root = try ok_tree.setNodes(p, &[_]Gindex{Gindex.fromDepth(1, 0)}, &ok_leaves); + defer pool.unref(ok_root); + + try std.testing.expectEqual(fresh, try ok_root.getNode(p, Gindex.fromDepth(1, 0))); +} + +test "Node.State - refcount overflow saturates at rc_mask without corrupting kind" { + var at_max = Node.State.initInUse(.leaf, Node.State.rc_mask); + try std.testing.expectError(Node.Error.RefCountOverflow, at_max.incRefCount()); + try std.testing.expectEqual(Node.NodeKind.leaf, at_max.kind()); + try std.testing.expectEqual(Node.State.rc_mask, at_max.refCount()); + + var near_max = Node.State.initInUse(.branch, Node.State.rc_mask - 1); + try std.testing.expectEqual(Node.State.rc_mask, try near_max.incRefCount()); + try std.testing.expectEqual(Node.NodeKind.branch, near_max.kind()); +} + +test "Node.State - free-list link round-trips at the 31-bit boundary" { + const max_link: Node.Id = @enumFromInt(Node.State.next_free_mask); + const fs = Node.State.initFree(max_link); + try std.testing.expect(fs.isFree()); + try std.testing.expectEqual(max_link, fs.nextFree()); +} + const TestCase = struct { depth: u6, gindexes: []const usize, @@ -424,7 +564,7 @@ const test_cases = [_]TestCase{ test "setNodesAtDepth, setNodes vs setNode multiple times" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 10); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); defer pool.deinit(); const p = &pool; @@ -482,7 +622,7 @@ test "setNodesAtDepth, setNodes vs setNode multiple times" { test "truncateAfterIndex zeros nodes after index" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 128 }); defer pool.deinit(); const p = &pool; @@ -525,7 +665,7 @@ test "truncateAfterIndex zeros nodes after index" { test "hashing sanity check" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 10); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); defer pool.deinit(); const p = &pool; @@ -546,7 +686,7 @@ test "hashing sanity check" { // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/persistent-merkle-tree/test/unit/tree/zeroAfterIndex.test.ts#L4-L39 test "truncateAfterIndex matches zeroAfterIndex test suite" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 8192); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8192 }); defer pool.deinit(); const p = &pool; @@ -633,7 +773,7 @@ fn treeZeroAfterIndexNaive( test "DepthIterator matches getNodesAtDepth" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const p = &pool; @@ -671,7 +811,7 @@ test "DepthIterator matches getNodesAtDepth" { test "FillWithContentsIterator matches fillWithContents" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 128 }); defer pool.deinit(); const p = &pool; diff --git a/src/persistent_merkle_tree/proof.zig b/src/persistent_merkle_tree/proof.zig index 8aa936012..ce74e7031 100644 --- a/src/persistent_merkle_tree/proof.zig +++ b/src/persistent_merkle_tree/proof.zig @@ -53,6 +53,54 @@ pub const SingleProof = struct { } }; +/// Returns true if the node is "opaque" — terminal in our PMT model but +/// represents a navigable subtree underneath (container_struct = deserialized +/// container struct; chunked_leaf = K packed chunks). Proof traversal must +/// materialize a temporary explicit subtree before walking inside. +inline fn isOpaqueNode(pool: *Node.Pool, node_id: Node.Id) bool { + const kind = pool.nodes.items(.state)[@intFromEnum(node_id)].kind(); + return kind == .container_struct or kind == .chunked_leaf; +} + +/// Materializes a temporary navigable subtree for an opaque node. Caller is +/// responsible for `unref`'ing the returned Id once the temporary tree is no +/// longer needed (single-proof and compact-multi-proof both park the Id in +/// a deferred-unref ArrayList). +inline fn materializeOpaque(pool: *Node.Pool, node_id: Node.Id) Node.Error!Node.Id { + const kind = pool.nodes.items(.state)[@intFromEnum(node_id)].kind(); + return switch (kind) { + .container_struct => try pool.materializeContainerStruct(node_id), + .chunked_leaf => try pool.materializeChunkedLeaf(node_id), + else => unreachable, + }; +} + +/// Proof traversal needs real left/right child nodes. For an opaque node +/// (container_struct or chunked_leaf), materialize a temporary plain tree +/// and append it to the deferred-unref list so it stays alive until proof +/// creation finishes. +/// +/// Materializing one opaque node can yield another. A single-field +/// StructContainerType has no enclosing branch, so its tree IS its only +/// field's tree; if that field is also opaque, the result is still opaque. +/// Loop until the node is navigable. +fn materializeIfOpaque( + allocator: Allocator, + pool: *Node.Pool, + node_id: Node.Id, + temporary_roots: *std.ArrayListUnmanaged(Node.Id), +) (Node.Error || Error)!Node.Id { + var current = node_id; + while (isOpaqueNode(pool, current)) { + const materialized = try materializeOpaque(pool, current); + errdefer pool.unref(materialized); + + try temporary_roots.append(allocator, materialized); + current = materialized; + } + return current; +} + /// Produces a single Merkle proof for the node at `gindex`. pub fn createSingleProof( allocator: Allocator, @@ -68,6 +116,18 @@ pub fn createSingleProof( var witnesses = try allocator.alloc([32]u8, path_len); errdefer allocator.free(witnesses); + // Nested opaque (container_struct → chunked_leaf, or any future combination) + // is legal: e.g. StructContainerType holding a FixedVectorType with + // .chunked_leaf=true. Track every materialized temporary root and unref + // them on exit, matching createCompactMultiProof's pattern. + var temporary_roots: std.ArrayListUnmanaged(Node.Id) = .empty; + defer { + for (temporary_roots.items) |temp_root| { + pool.unref(temp_root); + } + temporary_roots.deinit(allocator); + } + if (path_len == 0) { return SingleProof{ .leaf = root.getRoot(pool).*, @@ -81,6 +141,8 @@ pub fn createSingleProof( for (0..path_len) |depth_idx| { const witness_index = path_len - 1 - depth_idx; + node_id = try materializeIfOpaque(allocator, pool, node_id, &temporary_roots); + if (path.left()) { const right_id = try node_id.getRight(pool); witnesses[witness_index] = right_id.getRoot(pool).*; @@ -420,6 +482,7 @@ fn nodeToCompactMultiProof( node_id: Node.Id, bitlist: []const bool, bit_index: usize, + temporary_roots: *std.ArrayListUnmanaged(Node.Id), ) (Node.Error || Error)![][32]u8 { // If bit is 1, this node is a leaf in the proof if (bitlist[bit_index]) { @@ -428,13 +491,18 @@ fn nodeToCompactMultiProof( return leaves; } + // Materialize opaque (container_struct/chunked_leaf) nodes lazily so we can navigate + // into their children. The temporary root is owned by `temporary_roots` + // and unref'd when the outer caller exits. + const current = try materializeIfOpaque(allocator, pool, node_id, temporary_roots); + // Otherwise, recurse into children - const left_id = try node_id.getLeft(pool); - const left = try nodeToCompactMultiProof(allocator, pool, left_id, bitlist, bit_index + 1); + const left_id = try current.getLeft(pool); + const left = try nodeToCompactMultiProof(allocator, pool, left_id, bitlist, bit_index + 1, temporary_roots); defer allocator.free(left); - const right_id = try node_id.getRight(pool); - const right = try nodeToCompactMultiProof(allocator, pool, right_id, bitlist, bit_index + left.len * 2); + const right_id = try current.getRight(pool); + const right = try nodeToCompactMultiProof(allocator, pool, right_id, bitlist, bit_index + left.len * 2, temporary_roots); defer allocator.free(right); const result = try allocator.alloc([32]u8, left.len + right.len); @@ -453,7 +521,15 @@ pub fn createCompactMultiProof( const bitlist = try descriptorToBitlist(allocator, descriptor); defer allocator.free(bitlist); - return nodeToCompactMultiProof(allocator, pool, root, bitlist, 0); + var temporary_roots: std.ArrayListUnmanaged(Node.Id) = .empty; + defer { + for (temporary_roots.items) |temp_root| { + pool.unref(temp_root); + } + temporary_roots.deinit(allocator); + } + + return nodeToCompactMultiProof(allocator, pool, root, bitlist, 0, &temporary_roots); } /// Pointer to track position in bitlist and leaves during reconstruction @@ -488,7 +564,7 @@ pub fn createNodeFromCompactMultiProof( leaves: [][32]u8, descriptor: []const u8, ) (Node.Error || Error)!Node.Id { - var arena = std.heap.ArenaAllocator.init(pool.allocator); + var arena = std.heap.ArenaAllocator.init(pool.page_allocator); defer arena.deinit(); const temp_allocator = arena.allocator(); diff --git a/src/persistent_merkle_tree/proof_test.zig b/src/persistent_merkle_tree/proof_test.zig index feaace62f..f6840717b 100644 --- a/src/persistent_merkle_tree/proof_test.zig +++ b/src/persistent_merkle_tree/proof_test.zig @@ -5,6 +5,7 @@ const Node = @import("Node.zig"); const Gindex = @import("gindex.zig").Gindex; const proof = @import("proof.zig"); const Depth = @import("hashing").Depth; +const ChunkedLeaf = @import("ChunkedLeaf.zig"); const DescriptorTestCase = struct { input: []const u8, @@ -55,9 +56,16 @@ fn buildFullTree(pool: *Node.Pool, depth: usize, next_value: *u8) Node.Error!Nod return pool.createBranch(left, right); } +// Fill `chunks[0..valid]` with distinct non-zero leaves; the rest stay zero, +// satisfying the chunked_leaf trailing-zero invariant for partial payloads. +fn fillChunks(chunks: *align(64) [ChunkedLeaf.K][32]u8, valid: usize) void { + chunks.* = [_][32]u8{[_]u8{0} ** 32} ** ChunkedLeaf.K; + for (0..valid) |i| chunks[i] = makeLeaf(@truncate(i +% 1)); +} + // Verifies a proof for gindex 6 (depth 2, index 2) reconstructs the original root. test "single proof roundtrip" { - var pool = try Node.Pool.init(testing.allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = 128 }); defer pool.deinit(); const leaf_hashes = [_][32]u8{ @@ -87,7 +95,7 @@ test "single proof roundtrip" { const root_hash = root.getRoot(&pool).*; - var pool2 = try Node.Pool.init(testing.allocator, 128); + var pool2 = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = 128 }); defer pool2.deinit(); const reconstructed = try proof.createNodeFromSingleProof(&pool2, gindex, single_proof.leaf, single_proof.witnesses); @@ -102,7 +110,7 @@ test "single proof root matches across leaves" { const build_depth: usize = 4; const pool_capacity: u32 = @intCast((@as(usize, 1) << (build_depth + 1))); - var pool = try Node.Pool.init(testing.allocator, pool_capacity); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); defer pool.deinit(); var next_value: u8 = 1; @@ -118,7 +126,7 @@ test "single proof root matches across leaves" { var single_proof = try proof.createSingleProof(testing.allocator, &pool, raw_root, gindex); defer single_proof.deinit(testing.allocator); - var temp_pool = try Node.Pool.init(testing.allocator, 64); + var temp_pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = 64 }); defer temp_pool.deinit(); const rebuilt = try proof.createNodeFromSingleProof(&temp_pool, gindex, single_proof.leaf, single_proof.witnesses); @@ -131,7 +139,7 @@ test "single proof root matches across leaves" { // Attempting to prove beyond the tree height should bubble up Node.InvalidNode. test "single proof invalid navigation" { - var pool = try Node.Pool.init(testing.allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = 64 }); defer pool.deinit(); const leaf_hash = makeLeaf(42); @@ -144,7 +152,7 @@ test "single proof invalid navigation" { // Zero gindex must be rejected by both proof creation and reconstruction entry points. test "single proof invalid gindex" { - var pool = try Node.Pool.init(testing.allocator, 8); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = 8 }); defer pool.deinit(); const leaf_hash = makeLeaf(9); @@ -186,7 +194,7 @@ test "compact multiproof - should roundtrip node -> proof -> node" { const build_depth: usize = 5; const pool_capacity: u32 = @intCast((@as(usize, 1) << (build_depth + 1)) * 2); - var pool = try Node.Pool.init(testing.allocator, pool_capacity); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); defer pool.deinit(); var next_value: u8 = 1; @@ -197,7 +205,7 @@ test "compact multiproof - should roundtrip node -> proof -> node" { const leaves = try proof.createCompactMultiProof(testing.allocator, &pool, root, case.input); defer testing.allocator.free(leaves); - var pool2 = try Node.Pool.init(testing.allocator, pool_capacity); + var pool2 = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); defer pool2.deinit(); const reconstructed = try proof.createNodeFromCompactMultiProof(&pool2, leaves, case.input); @@ -208,3 +216,121 @@ test "compact multiproof - should roundtrip node -> proof -> node" { try testing.expectEqualSlices(u8, &original_root, &reconstructed_root); } } + +// Prove individual chunks inside a `.chunked_leaf` node: createSingleProof +// must materialize the packed leaf to collect intermediate witnesses. +test "single proof through chunked_leaf" { + const K: usize = ChunkedLeaf.K; + const pool_capacity: u32 = @intCast(K * 4); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool.deinit(); + + var chunks: [ChunkedLeaf.K][32]u8 align(64) = undefined; + fillChunks(&chunks, K); + + const cl = try pool.createChunkedLeaf(&chunks, ChunkedLeaf.K); + const sibling = try pool.createLeaf(&makeLeaf(0xFF)); + const root = try pool.createBranch(cl, sibling); + defer pool.unref(root); + + const expected_root = root.getRoot(&pool).*; + // The chunked_leaf is the root's left child (depth 1) and expands to a + // depth-k_log2 subtree, so chunk i sits at gindex fromDepth(1+k_log2, i). + const chunk_depth: Depth = ChunkedLeaf.k_log2 + 1; + + for ([_]usize{ 0, 1, K / 2, K - 1 }) |chunk_index| { + const gindex = Gindex.fromDepth(chunk_depth, chunk_index); + var single_proof = try proof.createSingleProof(testing.allocator, &pool, root, gindex); + defer single_proof.deinit(testing.allocator); + + try testing.expectEqualSlices(u8, &chunks[chunk_index], &single_proof.leaf); + + var pool2 = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool2.deinit(); + + const rebuilt = try proof.createNodeFromSingleProof(&pool2, gindex, single_proof.leaf, single_proof.witnesses); + defer pool2.unref(rebuilt); + + const rebuilt_root = rebuilt.getRoot(&pool2).*; + try testing.expectEqualSlices(u8, &expected_root, &rebuilt_root); + } +} + +// Compact multiproof descending through a `.chunked_leaf`: exercises the +// opaque-materialization path in nodeToCompactMultiProof, which the plain +// `compact multiproof` test never reaches. +test "compact multiproof through chunked_leaf" { + const K: usize = ChunkedLeaf.K; + const pool_capacity: u32 = @intCast(K * 6); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool.deinit(); + + var chunks: [ChunkedLeaf.K][32]u8 align(64) = undefined; + fillChunks(&chunks, K); + + const cl = try pool.createChunkedLeaf(&chunks, ChunkedLeaf.K); + const sibling = try pool.createLeaf(&makeLeaf(0xFF)); + const root = try pool.createBranch(cl, sibling); + defer pool.unref(root); + + const chunk_depth: Depth = ChunkedLeaf.k_log2 + 1; + // Three leaves inside the chunked_leaf, ascending gindex order. + const descriptor = try proof.computeDescriptor(testing.allocator, &[_]Gindex{ + Gindex.fromDepth(chunk_depth, 0), + Gindex.fromDepth(chunk_depth, K / 2), + Gindex.fromDepth(chunk_depth, K - 1), + }); + defer testing.allocator.free(descriptor); + + const leaves = try proof.createCompactMultiProof(testing.allocator, &pool, root, descriptor); + defer testing.allocator.free(leaves); + + var pool2 = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool2.deinit(); + + const reconstructed = try proof.createNodeFromCompactMultiProof(&pool2, leaves, descriptor); + defer pool2.unref(reconstructed); + + const original_root = root.getRoot(&pool).*; + const reconstructed_root = reconstructed.getRoot(&pool2).*; + try testing.expectEqualSlices(u8, &original_root, &reconstructed_root); +} + +// A partial `.chunked_leaf` (len < K) zero-pads its tail. Proofs must work +// for both populated chunks and the zero-padding region. +test "single proof through partial chunked_leaf" { + const K: usize = ChunkedLeaf.K; + const pool_capacity: u32 = @intCast(K * 4); + var pool = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool.deinit(); + + const valid: usize = K / 2 + 1; + var chunks: [ChunkedLeaf.K][32]u8 align(64) = undefined; + fillChunks(&chunks, valid); + + const cl = try pool.createChunkedLeaf(&chunks, @intCast(valid)); + const sibling = try pool.createLeaf(&makeLeaf(0xFF)); + const root = try pool.createBranch(cl, sibling); + defer pool.unref(root); + + const expected_root = root.getRoot(&pool).*; + const chunk_depth: Depth = ChunkedLeaf.k_log2 + 1; + + // populated, last populated, first zero-pad, last (zero-pad) chunk. + for ([_]usize{ 0, valid - 1, valid, K - 1 }) |chunk_index| { + const gindex = Gindex.fromDepth(chunk_depth, chunk_index); + var single_proof = try proof.createSingleProof(testing.allocator, &pool, root, gindex); + defer single_proof.deinit(testing.allocator); + + try testing.expectEqualSlices(u8, &chunks[chunk_index], &single_proof.leaf); + + var pool2 = try Node.Pool.init(.{ .page_allocator = testing.allocator, .allocator = testing.allocator, .pool_size = pool_capacity }); + defer pool2.deinit(); + + const rebuilt = try proof.createNodeFromSingleProof(&pool2, gindex, single_proof.leaf, single_proof.witnesses); + defer pool2.unref(rebuilt); + + const rebuilt_root = rebuilt.getRoot(&pool2).*; + try testing.expectEqualSlices(u8, &expected_root, &rebuilt_root); + } +} diff --git a/src/persistent_merkle_tree/root.zig b/src/persistent_merkle_tree/root.zig index ba2c6dbdd..a7b38654d 100644 --- a/src/persistent_merkle_tree/root.zig +++ b/src/persistent_merkle_tree/root.zig @@ -9,6 +9,7 @@ pub const Gindex = @import("gindex.zig").Gindex; pub const Node = @import("Node.zig"); pub const View = @import("View.zig"); pub const proof = @import("proof.zig"); +pub const ChunkedLeaf = @import("ChunkedLeaf.zig"); test { testing.refAllDecls(@This()); diff --git a/src/persistent_merkle_tree/view_test.zig b/src/persistent_merkle_tree/view_test.zig index bd9878523..1864086a8 100644 --- a/src/persistent_merkle_tree/view_test.zig +++ b/src/persistent_merkle_tree/view_test.zig @@ -6,7 +6,7 @@ const Node = @import("Node.zig"); test "View" { const allocator = std.testing.allocator; - var node_pool = try Node.Pool.init(allocator, 10); + var node_pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); defer node_pool.deinit(); var pool = try View.Pool.init(allocator, 10, &node_pool); diff --git a/src/ssz/root.zig b/src/ssz/root.zig index 4c6b9237c..27efd442c 100644 --- a/src/ssz/root.zig +++ b/src/ssz/root.zig @@ -32,6 +32,7 @@ pub const FixedVectorType = types.FixedVectorType; pub const VariableVectorType = types.VariableVectorType; pub const FixedContainerType = types.FixedContainerType; +pub const StructContainerType = types.StructContainerType; pub const VariableContainerType = types.VariableContainerType; pub const getPathGindex = types.getPathGindex; @@ -42,6 +43,7 @@ pub const HasherData = hasher.HasherData; const tree_view = @import("tree_view/root.zig"); pub const ContainerTreeView = tree_view.ContainerTreeView; +pub const StructContainerTreeView = tree_view.StructContainerTreeView; pub const ArrayBasicTreeView = tree_view.ArrayBasicTreeView; pub const ArrayCompositeTreeView = tree_view.ArrayCompositeTreeView; pub const ListBasicTreeView = tree_view.ListBasicTreeView; diff --git a/src/ssz/tree_view/array_basic.zig b/src/ssz/tree_view/array_basic.zig index 36756355b..2692f89bd 100644 --- a/src/ssz/tree_view/array_basic.zig +++ b/src/ssz/tree_view/array_basic.zig @@ -38,7 +38,7 @@ pub fn ArrayBasicTreeView(comptime ST: type) type { const base_chunk_depth: Depth = @intCast(ST.chunk_depth); const chunk_depth: Depth = chunkDepth(Depth, base_chunk_depth, ST); const items_per_chunk: usize = itemsPerChunk(ST.Element); - const Chunks = BasicPackedChunks(ST, chunk_depth, items_per_chunk); + const Chunks = BasicPackedChunks(ST, chunk_depth, items_per_chunk, ST.opts.chunked_leaf); pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self { const ptr = try allocator.create(Self); @@ -103,7 +103,7 @@ pub fn ArrayBasicTreeView(comptime ST: type) type { pub fn set(self: *Self, index: usize, value: Element) !void { if (index >= length) return error.IndexOutOfBounds; - try self.chunks.set(index, value); + try self.chunks.set(index, value, length); } /// Caller is responsible for freeing the returned slice using the same allocator. @@ -137,11 +137,11 @@ const FixedVectorType = @import("../type/vector.zig").FixedVectorType; test "TreeView vector element roundtrip" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 128 }); defer pool.deinit(); const Uint64 = UintType(64); - const VectorType = FixedVectorType(Uint64, 4); + const VectorType = FixedVectorType(Uint64, 4, .{}); const original: VectorType.Type = [_]u64{ 11, 22, 33, 44 }; @@ -176,11 +176,11 @@ test "TreeView vector element roundtrip" { test "TreeView vector getAll fills provided buffer" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const VectorType = FixedVectorType(Uint32, 8); + const VectorType = FixedVectorType(Uint32, 8, .{}); const values = [_]u32{ 9, 8, 7, 6, 5, 4, 3, 2 }; const root_node = try VectorType.tree.fromValue(&pool, &values); @@ -202,11 +202,11 @@ test "TreeView vector getAll fills provided buffer" { test "TreeView vector getAllAlloc roundtrip" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint16 = UintType(16); - const VectorType = FixedVectorType(Uint16, 5); + const VectorType = FixedVectorType(Uint16, 5, .{}); const values = [_]u16{ 3, 1, 4, 1, 5 }; const root_node = try VectorType.tree.fromValue(&pool, &values); @@ -221,11 +221,11 @@ test "TreeView vector getAllAlloc roundtrip" { test "TreeView vector getAllAlloc repeat reflects updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const VectorType = FixedVectorType(Uint32, 6); + const VectorType = FixedVectorType(Uint32, 6, .{}); var values = [_]u32{ 10, 20, 30, 40, 50, 60 }; const root_node = try VectorType.tree.fromValue(&pool, &values); @@ -246,11 +246,11 @@ test "TreeView vector getAllAlloc repeat reflects updates" { test "TreeView vector clone isolates subsequent updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint16 = UintType(16); - const Vec4 = FixedVectorType(Uint16, 4); + const Vec4 = FixedVectorType(Uint16, 4, .{}); const value: Vec4.Type = [_]u16{ 0, 0, 0, 0 }; const root = try Vec4.tree.fromValue(&pool, &value); @@ -270,11 +270,11 @@ test "TreeView vector clone isolates subsequent updates" { test "TreeView vector clone reads committed state" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint16 = UintType(16); - const Vec4 = FixedVectorType(Uint16, 4); + const Vec4 = FixedVectorType(Uint16, 4, .{}); const value: Vec4.Type = [_]u16{ 0, 0, 0, 0 }; const root = try Vec4.tree.fromValue(&pool, &value); @@ -293,11 +293,11 @@ test "TreeView vector clone reads committed state" { test "TreeView vector clone drops uncommitted changes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint16 = UintType(16); - const Vec4 = FixedVectorType(Uint16, 4); + const Vec4 = FixedVectorType(Uint16, 4, .{}); const value: Vec4.Type = [_]u16{ 1, 2, 3, 4 }; const root = try Vec4.tree.fromValue(&pool, &value); @@ -317,11 +317,11 @@ test "TreeView vector clone drops uncommitted changes" { test "TreeView vector clone(false) does not transfer cache" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint16 = UintType(16); - const Vec4 = FixedVectorType(Uint16, 4); + const Vec4 = FixedVectorType(Uint16, 4, .{}); const value: Vec4.Type = [_]u16{ 1, 2, 3, 4 }; const root = try Vec4.tree.fromValue(&pool, &value); @@ -341,11 +341,11 @@ test "TreeView vector clone(false) does not transfer cache" { test "TreeView vector clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint16 = UintType(16); - const Vec4 = FixedVectorType(Uint16, 4); + const Vec4 = FixedVectorType(Uint16, 4, .{}); const value: Vec4.Type = [_]u16{ 1, 2, 3, 4 }; const root = try Vec4.tree.fromValue(&pool, &value); @@ -368,9 +368,9 @@ test "ArrayBasicTreeView - serialize (uint64 vector)" { const allocator = std.testing.allocator; const Uint64 = UintType(64); - const VecU64Type = FixedVectorType(Uint64, 4); + const VecU64Type = FixedVectorType(Uint64, 4, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -421,9 +421,9 @@ test "ArrayBasicTreeView - serialize (uint8 vector)" { const allocator = std.testing.allocator; const Uint8 = UintType(8); - const VecU8Type = FixedVectorType(Uint8, 8); + const VecU8Type = FixedVectorType(Uint8, 8, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value = [8]u8{ 1, 2, 3, 4, 5, 6, 7, 8 }; @@ -449,9 +449,9 @@ test "ArrayBasicTreeView - get and set" { const allocator = std.testing.allocator; const Uint64 = UintType(64); - const VecU64Type = FixedVectorType(Uint64, 4); + const VecU64Type = FixedVectorType(Uint64, 4, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value = [4]u64{ 100, 200, 300, 400 }; diff --git a/src/ssz/tree_view/array_composite.zig b/src/ssz/tree_view/array_composite.zig index 396ea5ea4..a3d434a9e 100644 --- a/src/ssz/tree_view/array_composite.zig +++ b/src/ssz/tree_view/array_composite.zig @@ -177,7 +177,7 @@ const ByteVectorType = @import("../type/byte_vector.zig").ByteVectorType; test "TreeView vector composite element set/get/commit" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); const Uint32 = UintType(32); @@ -185,7 +185,7 @@ test "TreeView vector composite element set/get/commit" { a: Uint32, b: ByteVectorType(4), }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const v0: Inner.Type = .{ .a = 1, .b = [_]u8{ 1, 1, 1, 1 } }; const v1: Inner.Type = .{ .a = 2, .b = [_]u8{ 2, 2, 2, 2 } }; @@ -227,11 +227,11 @@ test "TreeView vector composite element set/get/commit" { test "TreeView vector composite index bounds" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Inner = FixedContainerType(struct { x: UintType(64) }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .x = 1 }, .{ .x = 2 } }; const root_node = try VectorType.tree.fromValue(&pool, &original); @@ -249,16 +249,16 @@ test "TreeView vector composite index bounds" { test "TreeView vector composite clearCache does not break subsequent commits" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); const Uint32 = UintType(32); - const InnerVec = FixedVectorType(Uint32, 2); + const InnerVec = FixedVectorType(Uint32, 2, .{}); const Inner = FixedContainerType(struct { id: Uint32, vec: InnerVec, }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const v0: Inner.Type = .{ .id = 1, .vec = [_]u32{ 0, 1 } }; const v1: Inner.Type = .{ .id = 2, .vec = [_]u32{ 2, 3 } }; @@ -288,12 +288,12 @@ test "TreeView vector composite clearCache does not break subsequent commits" { test "TreeView vector composite clone isolates updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); const Inner = FixedContainerType(struct { a: Uint32 }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .a = 1 }, .{ .a = 2 } }; const root = try VectorType.tree.fromValue(&pool, &original); @@ -327,12 +327,12 @@ test "TreeView vector composite clone isolates updates" { test "TreeView vector composite clone reads committed state" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); const Inner = FixedContainerType(struct { a: Uint32 }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .a = 1 }, .{ .a = 2 } }; const root = try VectorType.tree.fromValue(&pool, &original); @@ -360,12 +360,12 @@ test "TreeView vector composite clone reads committed state" { test "TreeView vector composite clone drops uncommitted changes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); const Inner = FixedContainerType(struct { a: Uint32 }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .a = 1 }, .{ .a = 2 } }; const root = try VectorType.tree.fromValue(&pool, &original); @@ -402,14 +402,14 @@ test "TreeView vector composite clone drops uncommitted changes" { test "TreeView vector composite clone(false) does not transfer cache" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); const Uint32 = UintType(32); const Inner = FixedContainerType(struct { a: Uint32, }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .a = 1 }, .{ .a = 2 } }; const root_node = try VectorType.tree.fromValue(&pool, &original); @@ -430,14 +430,14 @@ test "TreeView vector composite clone(false) does not transfer cache" { test "TreeView vector composite clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); const Uint32 = UintType(32); const Inner = FixedContainerType(struct { a: Uint32, }); - const VectorType = FixedVectorType(Inner, 2); + const VectorType = FixedVectorType(Inner, 2, .{}); const original: VectorType.Type = .{ .{ .a = 1 }, .{ .a = 2 } }; const root_node = try VectorType.tree.fromValue(&pool, &original); @@ -461,9 +461,9 @@ test "ArrayCompositeTreeView - serialize (ByteVector32 vector)" { const allocator = std.testing.allocator; const Root32 = ByteVectorType(32); - const VecRootsType = FixedVectorType(Root32, 4); + const VecRootsType = FixedVectorType(Root32, 4, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value = [4][32]u8{ @@ -504,9 +504,9 @@ test "ArrayCompositeTreeView - serialize (Container vector)" { b: UintType(64), }); _ = Uint64; - const VecContainerType = FixedVectorType(TestContainer, 4); + const VecContainerType = FixedVectorType(TestContainer, 4, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value = [4]TestContainer.Type{ @@ -549,9 +549,9 @@ test "ArrayCompositeTreeView - get and set" { const allocator = std.testing.allocator; const Root32 = ByteVectorType(32); - const VecRootsType = FixedVectorType(Root32, 4); + const VecRootsType = FixedVectorType(Root32, 4, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value = [4][32]u8{ diff --git a/src/ssz/tree_view/bit_list.zig b/src/ssz/tree_view/bit_list.zig index 7a1cb7f21..0097fa8f3 100644 --- a/src/ssz/tree_view/bit_list.zig +++ b/src/ssz/tree_view/bit_list.zig @@ -117,7 +117,7 @@ test "BitListTreeView get/set roundtrip" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var expected = try Bits.Type.fromBitLen(allocator, 12); @@ -154,7 +154,7 @@ test "BitListTreeView clone(false) does not transfer cache" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 12); @@ -180,7 +180,7 @@ test "BitListTreeView clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 12); @@ -206,7 +206,7 @@ test "BitListTreeView clone isolates updates" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 12); @@ -230,7 +230,7 @@ test "BitListTreeView clone reads committed state" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 12); @@ -253,7 +253,7 @@ test "BitListTreeView clone drops uncommitted changes" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 12); @@ -277,7 +277,7 @@ test "BitListTreeView toBoolArray roundtrip" { const allocator = std.testing.allocator; const Bits = BitListType(16); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const expected_bools = [_]bool{ true, false, true, true, false, true, false, true, true, false, true, true }; @@ -297,7 +297,7 @@ test "BitListTreeView toBoolArrayInto roundtrip" { const allocator = std.testing.allocator; const Bits = BitListType(16); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const expected_bools = [_]bool{ true, false, true, true, false, true, false, true, true, false, true, true }; @@ -317,7 +317,7 @@ test "BitListTreeView set reflects in toBoolArray" { const allocator = std.testing.allocator; const Bits = BitListType(16); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 8); @@ -342,7 +342,7 @@ test "BitListTreeView multi-chunk" { // 300 bits requires 2 chunks (256 bits per chunk) const Bits = BitListType(512); - var pool = try Node.Pool.init(allocator, 8192); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8192 }); defer pool.deinit(); var value = try Bits.Type.fromBitLen(allocator, 300); @@ -389,7 +389,7 @@ test "BitListTreeView padding bit roundtrip" { const allocator = std.testing.allocator; const Bits = BitListType(64); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); const test_cases = [_]usize{ 1, 7, 8, 9, 15, 16, 17, 31, 32, 33 }; @@ -429,7 +429,7 @@ test "BitListTreeView remainder edge cases (1 and 255)" { const allocator = std.testing.allocator; const Bits = BitListType(1024); - var pool = try Node.Pool.init(allocator, 8192); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8192 }); defer pool.deinit(); inline for ([_]usize{ 257, 511 }) |bit_len| { @@ -469,7 +469,7 @@ test "BitListTreeView full-chunk edge cases (remainder=0)" { const allocator = std.testing.allocator; const Bits = BitListType(1024); - var pool = try Node.Pool.init(allocator, 8192); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8192 }); defer pool.deinit(); inline for ([_]usize{ 256, 512 }) |bit_len| { diff --git a/src/ssz/tree_view/bit_vector.zig b/src/ssz/tree_view/bit_vector.zig index adf1667bb..ca93c74e8 100644 --- a/src/ssz/tree_view/bit_vector.zig +++ b/src/ssz/tree_view/bit_vector.zig @@ -115,7 +115,7 @@ test "BitVectorTreeView get/set roundtrip" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); var expected: Bits.Type = Bits.default_value; @@ -151,7 +151,7 @@ test "BitVectorTreeView clone(false) does not transfer cache" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); var value: Bits.Type = Bits.default_value; @@ -177,7 +177,7 @@ test "BitVectorTreeView clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); var value: Bits.Type = Bits.default_value; @@ -203,7 +203,7 @@ test "BitVectorTreeView clone isolates updates" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const root = try Bits.tree.fromValue(&pool, &Bits.default_value); @@ -224,7 +224,7 @@ test "BitVectorTreeView clone reads committed state" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const root = try Bits.tree.fromValue(&pool, &Bits.default_value); @@ -244,7 +244,7 @@ test "BitVectorTreeView clone drops uncommitted changes" { const allocator = std.testing.allocator; const Bits = BitVectorType(44); - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const root = try Bits.tree.fromValue(&pool, &Bits.default_value); @@ -265,7 +265,7 @@ test "BitVectorTreeView toBoolArray roundtrip" { const allocator = std.testing.allocator; const Bits = BitVectorType(16); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const expected_bools = [_]bool{ true, false, true, true, false, true, false, true, true, false, true, true, false, false, true, false }; @@ -284,7 +284,7 @@ test "BitVectorTreeView toBoolArrayInto roundtrip" { const allocator = std.testing.allocator; const Bits = BitVectorType(12); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const expected_bools = [_]bool{ true, false, true, true, false, true, false, true, true, false, true, true }; @@ -303,7 +303,7 @@ test "BitVectorTreeView set reflects in toBoolArray" { const allocator = std.testing.allocator; const Bits = BitVectorType(8); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const initial_bools = [_]bool{ false, false, false, false, false, false, false, false }; @@ -328,7 +328,7 @@ test "BitVectorTreeView multi-chunk" { // 300 bits requires 2 chunks (256 bits per chunk) const Bits = BitVectorType(300); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value: Bits.Type = Bits.default_value; @@ -376,7 +376,7 @@ test "BitVectorTreeView remainder edge cases (1 and 255)" { inline for ([_]usize{ 257, 511 }) |len| { const Bits = BitVectorType(len); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value: Bits.Type = Bits.default_value; @@ -409,7 +409,7 @@ test "BitVectorTreeView full-chunk edge cases (remainder=0)" { inline for ([_]usize{ 256, 512 }) |len| { const Bits = BitVectorType(len); - var pool = try Node.Pool.init(allocator, 4096); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); defer pool.deinit(); var value: Bits.Type = Bits.default_value; diff --git a/src/ssz/tree_view/chunks.zig b/src/ssz/tree_view/chunks.zig index 4aee7fefc..121674f0b 100644 --- a/src/ssz/tree_view/chunks.zig +++ b/src/ssz/tree_view/chunks.zig @@ -4,8 +4,9 @@ const Allocator = std.mem.Allocator; const hashing = @import("hashing"); const Depth = hashing.Depth; -const Node = @import("persistent_merkle_tree").Node; -const Gindex = @import("persistent_merkle_tree").Gindex; +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const Gindex = pmt.Gindex; const isFixedType = @import("../type/type_kind.zig").isFixedType; @@ -14,10 +15,18 @@ const TreeViewState = @import("utils/tree_view_state.zig").TreeViewState; const CloneOpts = @import("utils/clone_opts.zig").CloneOpts; /// Shared helpers for basic element types packed into chunks. +/// +/// `use_chunked_leaf` selects between two leaf layouts: +/// * false (default) — one chunk per leaf, navigated by Node.Id. +/// * true — chunked_leaf-leaf navigation: the bottom `ChunkedLeaf.k_log2` levels of the +/// tree are folded into a single ChunkedLeaf Node, addressed at `chunked_leaf_depth = +/// chunk_depth - ChunkedLeaf.k_log2`. get/set/getAllInto read and CoW-write +/// chunk bytes through `Id.getChunkedLeafChunks` / `Id.setChunkedLeafChunk`. pub fn BasicPackedChunks( comptime ST: type, comptime chunk_depth: Depth, comptime items_per_chunk: usize, + comptime use_chunked_leaf: bool, ) type { return struct { state: TreeViewState, @@ -26,6 +35,12 @@ pub fn BasicPackedChunks( const Self = @This(); + // ChunkedLeaf-related comptime constants. Only meaningful when `use_chunked_leaf = true`. + // The `else` placeholders keep the symbols valid in non-chunked_leaf instantiations + // without referencing the ChunkedLeaf module. + const ChunkedLeaf = if (use_chunked_leaf) pmt.ChunkedLeaf else struct {}; + const chunked_leaf_depth: Depth = if (use_chunked_leaf) chunk_depth - ChunkedLeaf.k_log2 else 0; + pub fn init(self: *Self, allocator: Allocator, pool: *Node.Pool, root: Node.Id) !void { try self.state.init(allocator, pool, root); } @@ -38,6 +53,11 @@ pub fn BasicPackedChunks( self.state.deinit(); } + /// Cleanup when the owning view's `init` failed; leaves `root` for the caller. + pub fn deinitAfterInitFailure(self: *Self) void { + self.state.deinitAfterInitFailure(); + } + pub fn commit(self: *Self) !void { try self.state.commitNodes(); } @@ -48,18 +68,119 @@ pub fn BasicPackedChunks( pub fn get(self: *Self, index: usize) !Element { var value: Element = undefined; - const child_node = try self.state.getChildNode(Gindex.fromDepth(chunk_depth, index / items_per_chunk)); - try ST.Element.tree.toValuePacked(child_node, self.state.pool, index, &value); + if (comptime use_chunked_leaf) { + const chunk_idx = index / items_per_chunk; + const chunked_leaf_idx = chunk_idx / ChunkedLeaf.K; + const intra_chunk = chunk_idx % ChunkedLeaf.K; + const chunked_leaf_id = try self.state.getChildNode(Gindex.fromDepth(chunked_leaf_depth, chunked_leaf_idx)); + // Navigation may land on a zero sentinel when the tree was built + // empty or sparsely (early-return path in tree.fromValue, or + // chunked_leaf_idx beyond filled chunked leaves). A zero subtree at the chunked_leaf + // boundary is semantically an all-zero chunked_leaf; the decoded value + // is therefore the element's zero value. + if (self.state.pool.nodes.items(.state)[@intFromEnum(chunked_leaf_id)].kind() == .zero) { + return std.mem.zeroes(Element); + } + const chunks = try chunked_leaf_id.getChunkedLeafChunks(self.state.pool); + ST.Element.tree.toValuePackedFromBytes(&chunks[intra_chunk], index, &value); + } else { + const child_node = try self.state.getChildNode(Gindex.fromDepth(chunk_depth, index / items_per_chunk)); + try ST.Element.tree.toValuePacked(child_node, self.state.pool, index, &value); + } return value; } - pub fn set(self: *Self, index: usize, value: Element) !void { + pub fn set(self: *Self, index: usize, value: Element, container_len: usize) !void { + std.debug.assert(index < container_len); + if (comptime use_chunked_leaf) { + return self.setChunkedLeaf(index, value, container_len); + } const gindex = Gindex.fromDepth(chunk_depth, index / items_per_chunk); const child_node = try self.state.getChildNode(gindex); const new_node = try ST.Element.tree.fromValuePacked(child_node, self.state.pool, index, &value); try self.state.setChildNode(gindex, new_node); } + /// `set` for chunked_leaf layouts. CoW-writes one element into the + /// boundary ChunkedLeaf via one of three ownership paths. + fn setChunkedLeaf(self: *Self, index: usize, value: Element, container_len: usize) !void { + const chunk_idx = index / items_per_chunk; + const chunked_leaf_idx = chunk_idx / ChunkedLeaf.K; + const intra_chunk = chunk_idx % ChunkedLeaf.K; + const intra_chunk_u16: u16 = @intCast(intra_chunk); + const gindex = Gindex.fromDepth(chunked_leaf_depth, chunked_leaf_idx); + + // Valid chunk count of the target ChunkedLeaf, derived from the + // container length — authoritative, not inferred from the write + // position. `index` is in range, so this is always >= intra_chunk + 1. + const total_chunks = (container_len + items_per_chunk - 1) / items_per_chunk; + const chunked_leaf_len: u16 = @intCast(@min( + @as(usize, ChunkedLeaf.K), + total_chunks - chunked_leaf_idx * @as(usize, ChunkedLeaf.K), + )); + + const existing_id = try self.state.getChildNode(gindex); + const state_col = self.state.pool.nodes.items(.state); + const existing_kind = state_col[@intFromEnum(existing_id)].kind(); + + // Path 1: navigation landed on a zero sentinel (sparse tree). + // Materialize a fresh zero-filled chunked_leaf and mutate it in place + // (rc=0 ⇒ exclusively owned by us). Then setChildNode publishes + // it to the cache and `changed` set. + if (existing_kind == .zero) { + var fresh_id_opt: ?Node.Id = try self.state.pool.createChunkedLeafEmpty(chunked_leaf_len); + errdefer if (fresh_id_opt) |id| self.state.pool.unref(id); + + const fresh_id = fresh_id_opt.?; + const fresh_storage = try fresh_id.getChunkedLeafPtr(self.state.pool); + ST.Element.tree.fromValuePackedIntoChunk(&fresh_storage.chunks[intra_chunk], index, &value); + self.state.pool.nodes.items(.root)[@intFromEnum(fresh_id)] = Node.lazy_sentinel; + try self.state.setChildNode(gindex, fresh_id); + fresh_id_opt = null; + return; + } + + // Path 2: existing chunked_leaf is `transient` — exclusively owned by + // this TreeView (rc==0, only the children_nodes cache holds it). + // This is the steady state after the first write produces a + // CoW chunked_leaf. Mutate in place: byte-write into the heap chunks, + // accumulate dirty bits, invalidate the cached chunked_leaf root. + // The gindex was already added to `changed` by the prior + // setChildNode call that produced this transient chunked_leaf, so we + // do NOT call setChildNode again (which would unref-then-store + // the same Id and free our chunked_leaf). + if (state_col[@intFromEnum(existing_id)].refCount() == 0) { + // Path 2 owner invariant: rc=0 transient was registered by + // a prior Path 1/3 in this commit cycle (which added gindex + // to `changed`). If this assertion fires, the rc state + // machine has drifted. + std.debug.assert(existing_kind == .chunked_leaf); + std.debug.assert(self.state.changed.contains(gindex)); + const storage = try existing_id.getChunkedLeafPtr(self.state.pool); + ST.Element.tree.fromValuePackedIntoChunk(&storage.chunks[intra_chunk], index, &value); + storage.len = chunked_leaf_len; + self.state.pool.nodes.items(.root)[@intFromEnum(existing_id)] = Node.lazy_sentinel; + return; + } + + // Path 3: shared chunked_leaf (rc >= 1 — owned by the persistent tree). + // Must CoW: produce a fresh chunked_leaf via setChunkedLeafChunk and publish + // it. From this point onward subsequent writes hit Path 2. + std.debug.assert(existing_kind == .chunked_leaf); + const existing_chunks = try existing_id.getChunkedLeafChunks(self.state.pool); + var new_chunk: [32]u8 = existing_chunks[intra_chunk]; + ST.Element.tree.fromValuePackedIntoChunk(&new_chunk, index, &value); + + // Owned by us (rc=0) until setChildNode publishes it; reclaim on OOM. + var new_id_opt: ?Node.Id = try existing_id.setChunkedLeafChunk(self.state.pool, intra_chunk_u16, &new_chunk); + errdefer if (new_id_opt) |id| self.state.pool.unref(id); + + const new_chunked_leaf_id = new_id_opt.?; + (try new_chunked_leaf_id.getChunkedLeafPtr(self.state.pool)).len = chunked_leaf_len; + try self.state.setChildNode(gindex, new_chunked_leaf_id); + new_id_opt = null; + } + pub fn getAll( self: *Self, allocator: Allocator, @@ -78,6 +199,10 @@ pub fn BasicPackedChunks( if (values.len != len) return error.InvalidSize; if (len == 0) return values; + if (comptime use_chunked_leaf) { + return self.getAllIntoChunkedLeaf(len, values); + } + const len_full_chunks = len / items_per_chunk; const remainder = len % items_per_chunk; const chunk_count = len_full_chunks + @intFromBool(remainder != 0); @@ -111,7 +236,62 @@ pub fn BasicPackedChunks( return values; } + /// `getAllInto` for chunked_leaf layouts. `values` is caller-validated + /// to be non-empty with `values.len == len`. + fn getAllIntoChunkedLeaf(self: *Self, len: usize, values: []Element) ![]Element { + const chunk_count = (len + items_per_chunk - 1) / items_per_chunk; + const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; + const chunked_leaf_ids = try self.state.allocator.alloc(Node.Id, chunked_leaf_count); + defer self.state.allocator.free(chunked_leaf_ids); + + try self.state.root.getNodesAtDepth(self.state.pool, chunked_leaf_depth, 0, chunked_leaf_ids); + + // Override with staged children_nodes entries so uncommitted + // set/push are visible. The bulk root walk above sees only the + // committed root. + for (0..chunked_leaf_count) |i| { + const gindex = Gindex.fromDepth(chunked_leaf_depth, i); + if (self.state.children_nodes.get(gindex)) |staged| { + chunked_leaf_ids[i] = staged; + } + } + + var item_idx: usize = 0; + outer: for (chunked_leaf_ids) |sid| { + // ChunkedLeaf boundary may be a zero sentinel for sparsely-filled + // trees (e.g. an empty list grown via push, or chunked_leaf slots + // beyond the materialized range). A zero subtree is + // semantically all-zero chunks; emit zero values without + // touching the (non-existent) chunked_leaf payload. + if (self.state.pool.nodes.items(.state)[@intFromEnum(sid)].kind() == .zero) { + const items_in_chunked_leaf = @min(ChunkedLeaf.K * items_per_chunk, len - item_idx); + @memset(values[item_idx..][0..items_in_chunked_leaf], std.mem.zeroes(Element)); + item_idx += items_in_chunked_leaf; + if (item_idx >= len) break :outer; + continue; + } + const chunks_ptr = try sid.getChunkedLeafChunks(self.state.pool); + for (0..ChunkedLeaf.K) |intra_chunk| { + if (item_idx >= len) break :outer; + const items_in_chunk = @min(items_per_chunk, len - item_idx); + for (0..items_in_chunk) |i| { + ST.Element.tree.toValuePackedFromBytes( + &chunks_ptr[intra_chunk], + item_idx + i, + &values[item_idx + i], + ); + } + item_idx += items_in_chunk; + } + } + return values; + } + fn populateAllNodes(self: *Self, chunk_count: usize) !void { + // ChunkedLeaf path doesn't pre-populate per-chunk Ids; getAllInto walks chunked leaves + // directly. No-op to keep external API stable. + if (comptime use_chunked_leaf) return; + if (chunk_count == 0) return; const nodes = try self.state.allocator.alloc(Node.Id, chunk_count); @@ -209,6 +389,14 @@ pub fn CompositeChunks( self.state.deinit(); } + /// Cleanup when the owning view's `init` failed; leaves `root` for the caller. + pub fn deinitAfterInitFailure(self: *Self) void { + const allocator = self.state.allocator; + self.clearChildrenDataCache(); + self.children_data.deinit(allocator); + self.state.deinitAfterInitFailure(); + } + pub fn commit(self: *Self) !void { if (self.state.changed.count() == 0) { return; diff --git a/src/ssz/tree_view/container.zig b/src/ssz/tree_view/container.zig index 028040e32..612e52e81 100644 --- a/src/ssz/tree_view/container.zig +++ b/src/ssz/tree_view/container.zig @@ -42,7 +42,9 @@ pub fn ContainerTreeView(comptime ST: type) type { pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self { try pool.ref(root); - errdefer pool.unref(root); + // Undo the ref without freeing: on init failure the caller still owns + // `root` and releases it; `unref` here would free a fresh rc-0 root. + errdefer pool.unrefUnsafe(root); const ptr = try allocator.create(Self); ptr.* = .{ @@ -416,13 +418,249 @@ pub fn ContainerTreeView(comptime ST: type) type { return TreeView; } +/// TreeView companion to `StructContainerType`. +/// +/// The backing Node is a single `.container_struct` slot whose payload is the +/// fully-decoded struct value. This view caches a copy of that value and +/// re-creates a new container_struct Node on `commit` if any field was mutated. +/// +/// Field reads/writes are O(1) (direct struct access) — there is no per-field +/// child TreeView and no per-field merkle navigation. +pub fn StructContainerTreeView(comptime ST: type) type { + const T = ST.Type; + + const TreeView = struct { + allocator: Allocator, + pool: *Node.Pool, + root: Node.Id, + /// Cached copy of the deserialized struct. Mutated in place by `set`. + value: T, + /// Bit per field; tracks whether `value` diverges from `root`. + changed: std.StaticBitSet(ST.chunk_count), + /// Stable backing store for `getFieldRoot` return pointers. The hash + /// is computed in place here so we can return `*const [32]u8` without + /// allocating a temporary PMT slot per call (which previously leaked). + field_root_cache: [ST.chunk_count][32]u8, + + pub const SszType = ST; + + const Self = @This(); + + pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self { + try pool.ref(root); + // Undo the ref without freeing: on init failure the caller still owns + // `root` and releases it; `unref` here would free a fresh rc-0 root. + errdefer pool.unrefUnsafe(root); + + const ptr = try allocator.create(Self); + errdefer allocator.destroy(ptr); + + try ST.tree.toValue(root, pool, &ptr.value); + + ptr.allocator = allocator; + ptr.pool = pool; + ptr.root = root; + ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty(); + ptr.field_root_cache = undefined; + return ptr; + } + + pub fn clone(self: *Self, opts: CloneOpts) !*Self { + try self.commit(); + + try self.pool.ref(self.root); + errdefer self.pool.unref(self.root); + + const ptr = try self.allocator.create(Self); + errdefer self.allocator.destroy(ptr); + + ptr.allocator = self.allocator; + ptr.pool = self.pool; + ptr.root = self.root; + ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty(); + + if (opts.transfer_cache) { + ptr.value = self.value; + } else { + try ST.tree.toValue(self.root, self.pool, &ptr.value); + } + + return ptr; + } + + pub fn deinit(self: *Self) void { + self.pool.unref(self.root); + self.allocator.destroy(self); + } + + pub fn commit(self: *Self) !void { + if (self.changed.count() == 0) return; + + const new_root = try ST.tree.fromValue(self.pool, &self.value); + try self.pool.ref(new_root); + self.pool.unref(self.root); + self.root = new_root; + self.changed = std.StaticBitSet(ST.chunk_count).initEmpty(); + } + + pub fn getRoot(self: *const Self) Node.Id { + return self.root; + } + + pub fn hashTreeRootInto(self: *Self, out: *[32]u8) !void { + try self.commit(); + out.* = self.root.getRoot(self.pool).*; + } + + pub fn hashTreeRoot(self: *Self) !*const [32]u8 { + try self.commit(); + return self.root.getRoot(self.pool); + } + + pub fn getFieldRoot(self: *Self, comptime field_name: []const u8) !*const [32]u8 { + const ChildST = ST.getFieldType(field_name); + const field_index = comptime ST.getFieldIndex(field_name); + const field_value = try self.get(field_name); + // Materialize a temporary PMT subtree just to compute the cached + // root, then unref it immediately. The hash bytes are copied into + // `field_root_cache` so the returned pointer remains valid for the + // view's lifetime — without leaking a Pool slot per call. + const node = try ChildST.tree.fromValue(self.pool, &field_value); + defer self.pool.unref(node); + self.field_root_cache[field_index] = node.getRoot(self.pool).*; + return &self.field_root_cache[field_index]; + } + + pub fn deserialize(allocator: Allocator, pool: *Node.Pool, bytes: []const u8) !*Self { + const root = try ST.tree.deserializeFromBytes(pool, bytes); + errdefer pool.unref(root); + return try Self.init(allocator, pool, root); + } + + pub fn fromValue(allocator: Allocator, pool: *Node.Pool, value: *const ST.Type) !*Self { + const root = try ST.tree.fromValue(pool, value); + errdefer pool.unref(root); + return try Self.init(allocator, pool, root); + } + + pub fn toValue(self: *Self, allocator: Allocator, out: *ST.Type) !void { + _ = allocator; + try self.commit(); + try ST.clone(&self.value, out); + } + + pub fn Field(comptime field_name: []const u8) type { + const ChildST = ST.getFieldType(field_name); + return ChildST.Type; + } + + pub fn FieldValue(comptime field_name: []const u8) type { + const ChildST = ST.getFieldType(field_name); + return ChildST.Type; + } + + pub fn get(self: *Self, comptime field_name: []const u8) !Field(field_name) { + return @field(self.value, field_name); + } + + pub fn getReadonly(self: *Self, comptime field_name: []const u8) !Field(field_name) { + return @field(self.value, field_name); + } + + pub fn set(self: *Self, comptime field_name: []const u8, value: Field(field_name)) !void { + @field(self.value, field_name) = value; + const idx = comptime ST.getFieldIndex(field_name); + self.changed.set(idx); + } + + pub fn getValue(self: *Self, allocator: Allocator, comptime field_name: []const u8, out: *FieldValue(field_name)) !void { + _ = allocator; + out.* = try self.get(field_name); + } + + pub fn setValue(self: *Self, comptime field_name: []const u8, value: *const FieldValue(field_name)) !void { + try self.set(field_name, value.*); + } + + pub fn serializeIntoBytes(self: *Self, out: []u8) !usize { + try self.commit(); + return ST.serializeIntoBytes(&self.value, out); + } + + pub fn serializedSize(_: *const Self) usize { + return ST.fixed_size; + } + }; + + assertTreeViewType(TreeView); + return TreeView; +} + +test "StructContainerTreeView - basic get/set/commit/root" { + const allocator = std.testing.allocator; + const StructValidator = StructContainerType(struct { + pubkey: ByteVectorType(48), + withdrawal_credentials: ByteVectorType(32), + effective_balance: UintType(64), + slashed: BoolType(), + activation_eligibility_epoch: UintType(64), + activation_epoch: UintType(64), + exit_epoch: UintType(64), + withdrawable_epoch: UintType(64), + }); + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); + defer pool.deinit(); + + const v: StructValidator.Type = .{ + .pubkey = [_]u8{0} ** 48, + .withdrawal_credentials = [_]u8{1} ** 32, + .effective_balance = 32_000_000_000, + .slashed = false, + .activation_eligibility_epoch = 0, + .activation_epoch = 0, + .exit_epoch = std.math.maxInt(u64), + .withdrawable_epoch = std.math.maxInt(u64), + }; + + const root_node = try StructValidator.tree.fromValue(&pool, &v); + + var view = try StructContainerTreeView(StructValidator).init(allocator, &pool, root_node); + defer view.deinit(); + + var view_root: [32]u8 = undefined; + try view.hashTreeRootInto(&view_root); + var expected_root: [32]u8 = undefined; + try StructValidator.hashTreeRoot(&v, &expected_root); + try std.testing.expectEqualSlices(u8, &expected_root, &view_root); + + try std.testing.expectEqual(@as(u64, 32_000_000_000), try view.get("effective_balance")); + try std.testing.expectEqual(false, try view.get("slashed")); + + try view.set("effective_balance", 32_100_000_000); + try view.set("slashed", true); + try view.commit(); + + try std.testing.expectEqual(@as(u64, 32_100_000_000), try view.get("effective_balance")); + try std.testing.expectEqual(true, try view.get("slashed")); + + var v2 = v; + v2.effective_balance = 32_100_000_000; + v2.slashed = true; + var expected2: [32]u8 = undefined; + try StructValidator.hashTreeRoot(&v2, &expected2); + var view_root2: [32]u8 = undefined; + try view.hashTreeRootInto(&view_root2); + try std.testing.expectEqualSlices(u8, &expected2, &view_root2); +} + test "ContainerTreeView" { const Foo = FixedContainerType(struct { a: UintType(64), b: UintType(64), }); - var pool = try Node.Pool.init(std.testing.allocator, 1000); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1000 }); defer pool.deinit(); const foo_value: Foo.Type = .{ @@ -492,8 +730,10 @@ test "ContainerTreeView" { const FixedContainerType = @import("../type/container.zig").FixedContainerType; const VariableContainerType = @import("../type/container.zig").VariableContainerType; +const StructContainerType = @import("../type/container.zig").StructContainerType; const UintType = @import("../type/uint.zig").UintType; const ByteVectorType = @import("../type/byte_vector.zig").ByteVectorType; +const BoolType = @import("../type/bool.zig").BoolType; const ByteListType = @import("../type/byte_list.zig").ByteListType; const FixedListType = @import("../type/list.zig").FixedListType; const VariableListType = @import("../type/list.zig").VariableListType; @@ -505,7 +745,7 @@ const Checkpoint = FixedContainerType(struct { }); test "TreeView container field roundtrip" { - var pool = try Node.Pool.init(std.testing.allocator, 1000); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1000 }); defer pool.deinit(); const checkpoint: Checkpoint.Type = .{ .epoch = 42, @@ -562,7 +802,7 @@ test "TreeView container field roundtrip" { test "TreeView container nested types set/get/commit" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const Uint16 = UintType(16); @@ -570,13 +810,13 @@ test "TreeView container nested types set/get/commit" { const Uint64 = UintType(64); const Bytes = ByteListType(16); - const BasicVec = FixedVectorType(Uint16, 4); + const BasicVec = FixedVectorType(Uint16, 4, .{}); const InnerFixed = FixedContainerType(struct { a: Uint32, b: ByteVectorType(4), }); - const CompVec = FixedVectorType(InnerFixed, 2); + const CompVec = FixedVectorType(InnerFixed, 2, .{}); const InnerVar = VariableContainerType(struct { id: Uint32, @@ -712,7 +952,7 @@ test "TreeView container nested types set/get/commit" { test "TreeView container clone isolates updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint64 = UintType(64); @@ -738,7 +978,7 @@ test "TreeView container clone isolates updates" { test "TreeView container clone drops uncommitted changes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint64 = UintType(64); @@ -764,7 +1004,7 @@ test "TreeView container clone drops uncommitted changes" { test "TreeView container clone(false) does not transfer cache" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint64 = UintType(64); @@ -790,7 +1030,7 @@ test "TreeView container clone(false) does not transfer cache" { test "TreeView container clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint64 = UintType(64); @@ -825,7 +1065,7 @@ test "ContainerTreeView - serialize (basic fields)" { }); _ = Uint64; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -893,7 +1133,7 @@ test "ContainerTreeView - get and set basic fields" { }); _ = Uint64; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const value: TestContainer.Type = .{ .a = 100, .b = 200 }; @@ -921,18 +1161,18 @@ test "ContainerTreeView - serialize (with nested list)" { const allocator = std.testing.allocator; const Uint64 = UintType(64); - const ListU64 = FixedListType(Uint64, 128); + const ListU64 = FixedListType(Uint64, 128, .{}); const TestContainer = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); _ = ListU64; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); var value: TestContainer.Type = .{ - .a = FixedListType(UintType(64), 128).default_value, + .a = FixedListType(UintType(64), 128, .{}).default_value, .b = 0, }; defer TestContainer.deinit(allocator, &value); diff --git a/src/ssz/tree_view/list_basic.zig b/src/ssz/tree_view/list_basic.zig index ad87d3ca5..771a0c2e8 100644 --- a/src/ssz/tree_view/list_basic.zig +++ b/src/ssz/tree_view/list_basic.zig @@ -2,7 +2,10 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const hashing = @import("hashing"); const Depth = hashing.Depth; -const Node = @import("persistent_merkle_tree").Node; +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const Gindex = pmt.Gindex; +const proof = pmt.proof; const isBasicType = @import("../type/type_kind.zig").isBasicType; const type_root = @import("../type/root.zig"); @@ -42,14 +45,20 @@ pub fn ListBasicTreeView(comptime ST: type) type { const base_chunk_depth: Depth = @intCast(ST.chunk_depth); const chunk_depth: Depth = chunkDepth(Depth, base_chunk_depth, ST); const items_per_chunk: usize = itemsPerChunk(ST.Element); - const Chunks = BasicPackedChunks(ST, chunk_depth, items_per_chunk); + const Chunks = BasicPackedChunks(ST, chunk_depth, items_per_chunk, ST.opts.chunked_leaf); + + // ChunkedLeaf binding — only meaningful when `ST.opts.chunked_leaf = true`; + // the empty-struct placeholder keeps symbols valid in non-chunked_leaf + // instantiations. + const ChunkedLeaf = if (ST.opts.chunked_leaf) pmt.ChunkedLeaf else struct {}; + const chunked_leaf_depth: Depth = if (ST.opts.chunked_leaf) chunk_depth - ChunkedLeaf.k_log2 else 0; pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self { const ptr = try allocator.create(Self); errdefer allocator.destroy(ptr); try Chunks.init(&ptr.chunks, allocator, pool, root); - errdefer ptr.chunks.deinit(); + errdefer ptr.chunks.deinitAfterInitFailure(); ptr.allocator = allocator; ptr._orig_len = try ptr.chunks.getLength(); @@ -108,7 +117,10 @@ pub fn ListBasicTreeView(comptime ST: type) type { self._len = new_length; } + /// Read-only iterator over committed elements. Pending `set`/`push` + /// writes are not visible — call `commit()` first if they matter. pub fn iteratorReadonly(self: *const Self, start_index: usize) ReadonlyIterator { + std.debug.assert(self.chunks.state.changed.count() == 0); return ReadonlyIterator.init(self, start_index); } @@ -116,31 +128,102 @@ pub fn ListBasicTreeView(comptime ST: type) type { tree_view: *const Self, depth_iterator: Node.DepthIterator, elem_index: usize, + // Non-chunked_leaf state: cached current chunk Node.Id; cleared + // when we cross a chunk boundary so the next call fetches anew. elem_node: ?Node.Id, + // Chunked_leaf state: cached chunks pointer of the current + // ChunkedLeaf, plus a flag for the all-zero (sparse) case where + // no payload exists. Cleared when we cross a chunked_leaf + // boundary so the next call fetches the next ChunkedLeaf. + current_chunks: ?*align(64) const [if (ST.opts.chunked_leaf) ChunkedLeaf.K else 1][32]u8, + current_is_zero: bool, + last_chunked_leaf_idx: ?usize, pub fn init(tree_view: *const Self, start_index: usize) ReadonlyIterator { - return .{ - .tree_view = tree_view, - .depth_iterator = Node.DepthIterator.init( - tree_view.chunks.state.pool, - tree_view.chunks.state.root, - ST.chunk_depth + 1, - ST.chunkIndex(start_index), - ), - .elem_index = start_index, - .elem_node = null, - }; + if (comptime ST.opts.chunked_leaf) { + const start_chunk = start_index / items_per_chunk; + const start_chunked_leaf = start_chunk / ChunkedLeaf.K; + return .{ + .tree_view = tree_view, + .depth_iterator = Node.DepthIterator.init( + tree_view.chunks.state.pool, + tree_view.chunks.state.root, + chunked_leaf_depth, + start_chunked_leaf, + ), + .elem_index = start_index, + .elem_node = null, + .current_chunks = null, + .current_is_zero = false, + .last_chunked_leaf_idx = null, + }; + } else { + return .{ + .tree_view = tree_view, + .depth_iterator = Node.DepthIterator.init( + tree_view.chunks.state.pool, + tree_view.chunks.state.root, + ST.chunk_depth + 1, + ST.chunkIndex(start_index), + ), + .elem_index = start_index, + .elem_node = null, + .current_chunks = null, + .current_is_zero = false, + .last_chunked_leaf_idx = null, + }; + } } pub fn next(self: *ReadonlyIterator) !Element { const elem_index = self.elem_index; + const pool = self.tree_view.chunks.state.pool; + + if (comptime ST.opts.chunked_leaf) { + const chunk_idx = elem_index / items_per_chunk; + const chunked_leaf_idx = chunk_idx / ChunkedLeaf.K; + const chunked_leaf_offset = chunk_idx % ChunkedLeaf.K; + + // Fetch ChunkedLeaf if first call or just crossed a + // chunked_leaf boundary. + if (self.last_chunked_leaf_idx == null or self.last_chunked_leaf_idx.? != chunked_leaf_idx) { + // Each reload advances `depth_iterator` by exactly one + // ChunkedLeaf, so forward iteration must cross at most + // one boundary per step. + std.debug.assert(self.last_chunked_leaf_idx == null or + chunked_leaf_idx == self.last_chunked_leaf_idx.? + 1); + const sid = try self.depth_iterator.next(); + if (pool.nodes.items(.state)[@intFromEnum(sid)].kind() == .zero) { + self.current_chunks = null; + self.current_is_zero = true; + } else { + self.current_chunks = try sid.getChunkedLeafChunks(pool); + self.current_is_zero = false; + } + self.last_chunked_leaf_idx = chunked_leaf_idx; + } + + var value: Element = undefined; + if (self.current_is_zero) { + value = std.mem.zeroes(Element); + } else { + ST.Element.tree.toValuePackedFromBytes( + &self.current_chunks.?[chunked_leaf_offset], + elem_index, + &value, + ); + } + self.elem_index += 1; + return value; + } + const n = if (self.elem_node) |node| node else try self.depth_iterator.next(); self.elem_node = n; var value: Element = undefined; - try ST.Element.tree.toValuePacked(n, self.tree_view.chunks.state.pool, elem_index, &value); + try ST.Element.tree.toValuePacked(n, pool, elem_index, &value); self.elem_index += 1; if (self.elem_index % items_per_chunk == 0) { self.elem_node = null; @@ -166,7 +249,7 @@ pub fn ListBasicTreeView(comptime ST: type) type { pub fn set(self: *Self, index: usize, value: Element) !void { const list_length = try self.length(); if (index >= list_length) return error.IndexOutOfBounds; - try self.chunks.set(index, value); + try self.chunks.set(index, value, list_length); } /// Caller must free the returned slice with the same allocator. @@ -207,44 +290,136 @@ pub fn ListBasicTreeView(comptime ST: type) type { return error.LengthOverLimit; } + return if (comptime ST.opts.chunked_leaf) + self.sliceToChunkedLeaf(index, new_length) + else + self.sliceToPlain(index, new_length); + } + + /// `sliceTo` for chunked_leaf layouts. Trims the boundary chunked_leaf, + /// truncates the chunked_leaves after it, and reinstalls the length. + fn sliceToChunkedLeaf(self: *Self, index: usize, new_length: usize) !*Self { + const pool = self.chunks.state.pool; const chunk_index = index / items_per_chunk; const chunk_offset = index % items_per_chunk; - const chunk_node = try Node.Id.getNodeAtDepth(self.chunks.state.root, self.chunks.state.pool, chunk_depth, chunk_index); + const keep_bytes = (chunk_offset + 1) * ST.Element.fixed_size; + std.debug.assert(keep_bytes > 0); + std.debug.assert(keep_bytes <= BYTES_PER_CHUNK); + + const chunked_leaf_idx = chunk_index / ChunkedLeaf.K; + const chunked_leaf_offset: u16 = @intCast(chunk_index % ChunkedLeaf.K); + std.debug.assert(chunked_leaf_offset < ChunkedLeaf.K); + + const boundary = try Node.Id.getNodeAtDepth(self.chunks.state.root, pool, chunked_leaf_depth, chunked_leaf_idx); + const boundary_kind = pool.nodes.items(.state)[@intFromEnum(boundary)].kind(); + + const truncate_input: Node.Id = blk: { + if (boundary_kind == .zero) { + // The boundary chunked_leaf is an all-zero subtree, so + // the elements we keep from it are already zero. There + // is nothing to trim; truncate the original tree. + break :blk self.chunks.state.root; + } + // At chunked_leaf_depth a correctly built tree only ever + // has chunked_leaf or zero nodes; anything else is corrupt. + std.debug.assert(boundary_kind == .chunked_leaf); + + // The boundary chunked_leaf straddles the cut. Build a + // trimmed copy: copy chunks 0 through chunked_leaf_offset, zero the + // unused tail bytes of chunk chunked_leaf_offset, and leave the + // chunks after it zero. Install it, then truncate the rest. + var trimmed_boundary: ?Node.Id = try pool.createChunkedLeafEmpty(chunked_leaf_offset + 1); + defer if (trimmed_boundary) |id| pool.unref(id); + + { + const old_chunks = try boundary.getChunkedLeafChunks(pool); + const new_leaf = try trimmed_boundary.?.getChunkedLeafPtr(pool); + @memcpy(new_leaf.chunks[0 .. chunked_leaf_offset + 1], old_chunks[0 .. chunked_leaf_offset + 1]); + if (keep_bytes < BYTES_PER_CHUNK) { + @memset(new_leaf.chunks[chunked_leaf_offset][keep_bytes..], 0); + } + } - var chunk_bytes = chunk_node.getRoot(self.chunks.state.pool).*; + const updated = try Node.Id.setNodeAtDepth( + self.chunks.state.root, + pool, + chunked_leaf_depth, + chunked_leaf_idx, + trimmed_boundary.?, + ); + trimmed_boundary = null; + break :blk updated; + }; + // `truncate_input` is either the original root (boundary was + // zero, nothing allocated) or the fresh tree built above. The + // fresh tree has refcount 0 and belongs to us; truncate and + // setNode below do not take a ref, so hold onto it and unref + // it when this function returns. + const truncate_input_handle: ?Node.Id = if (boundary_kind != .zero) truncate_input else null; + defer if (truncate_input_handle) |id| pool.unref(id); + + // Zero every chunked_leaf after chunked_leaf_idx. A node at + // chunked_leaf_depth stands for a k_log2-deep subtree, so + // truncate needs the k_log2 offset to pick the right zero hash. + const new_root = try Node.Id.truncateAfterIndexWithLeafOffset(truncate_input, pool, chunked_leaf_depth, chunked_leaf_idx, ChunkedLeaf.k_log2); + defer pool.unref(new_root); + + // truncate also zeroed the length leaf (gindex 3); reinstall it. + var length_node: ?Node.Id = try pool.createLeafFromUint(@intCast(new_length)); + defer if (length_node) |id| pool.unref(id); + + const root_with_length = try Node.Id.setNode(new_root, pool, @enumFromInt(3), length_node.?); + errdefer pool.unref(root_with_length); + length_node = null; + + return try Self.init(self.allocator, pool, root_with_length); + } + + /// `sliceTo` for non-chunked_leaf layouts. Byte-masks the boundary + /// chunk, truncates the chunks after it, and reinstalls the length. + fn sliceToPlain(self: *Self, index: usize, new_length: usize) !*Self { + const pool = self.chunks.state.pool; + const chunk_index = index / items_per_chunk; + const chunk_offset = index % items_per_chunk; const keep_bytes = (chunk_offset + 1) * ST.Element.fixed_size; + std.debug.assert(keep_bytes > 0); + std.debug.assert(keep_bytes <= BYTES_PER_CHUNK); + + const boundary = try Node.Id.getNodeAtDepth(self.chunks.state.root, pool, chunk_depth, chunk_index); + + var chunk_bytes = boundary.getRoot(pool).*; if (keep_bytes < BYTES_PER_CHUNK) { @memset(chunk_bytes[keep_bytes..], 0); } - var truncated_chunk_node: ?Node.Id = try self.chunks.state.pool.createLeaf(&chunk_bytes); - defer if (truncated_chunk_node) |id| self.chunks.state.pool.unref(id); + var trimmed_boundary: ?Node.Id = try pool.createLeaf(&chunk_bytes); + defer if (trimmed_boundary) |id| pool.unref(id); const updated = try Node.Id.setNodeAtDepth( self.chunks.state.root, - self.chunks.state.pool, + pool, chunk_depth, chunk_index, - truncated_chunk_node.?, + trimmed_boundary.?, ); // `updated` is a fresh orphan root from setNodeAtDepth; we own it, so unref it. - defer self.chunks.state.pool.unref(updated); - truncated_chunk_node = null; + defer pool.unref(updated); + trimmed_boundary = null; - const new_root = try Node.Id.truncateAfterIndex(updated, self.chunks.state.pool, chunk_depth, chunk_index); + const new_root = try Node.Id.truncateAfterIndex(updated, pool, chunk_depth, chunk_index); // Likewise `new_root` is a fresh orphan from truncateAfterIndex; unref it. - defer self.chunks.state.pool.unref(new_root); + defer pool.unref(new_root); - var length_node: ?Node.Id = try self.chunks.state.pool.createLeafFromUint(@intCast(new_length)); - defer if (length_node) |id| self.chunks.state.pool.unref(id); + var length_node: ?Node.Id = try pool.createLeafFromUint(@intCast(new_length)); + defer if (length_node) |id| pool.unref(id); // setNode takes `length_node` into the tree, so null it below to keep the defer from // unref-ing what the tree now owns. - const root_with_length = try Node.Id.setNode(new_root, self.chunks.state.pool, @enumFromInt(3), length_node.?); - errdefer self.chunks.state.pool.unref(root_with_length); + const root_with_length = try Node.Id.setNode(new_root, pool, @enumFromInt(3), length_node.?); + errdefer pool.unref(root_with_length); length_node = null; - return try Self.init(self.allocator, self.chunks.state.pool, root_with_length); + return try Self.init(self.allocator, pool, root_with_length); } /// Serialize the tree view into a provided buffer. @@ -278,11 +453,11 @@ const UintType = @import("../type/uint.zig").UintType; const FixedListType = @import("../type/list.zig").FixedListType; test "TreeView list element roundtrip" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); const base_values = [_]u32{ 5, 15, 25, 35, 45 }; @@ -326,11 +501,11 @@ test "TreeView list element roundtrip" { test "TreeView list push updates cached length" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -366,11 +541,11 @@ test "TreeView list push updates cached length" { test "TreeView list getAllAlloc handles zero length" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const Uint8 = UintType(8); - const ListType = FixedListType(Uint8, 4); + const ListType = FixedListType(Uint8, 4, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -387,11 +562,11 @@ test "TreeView list getAllAlloc handles zero length" { test "TreeView list getAllAlloc spans multiple chunks" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); const Uint16 = UintType(16); - const ListType = FixedListType(Uint16, 64); + const ListType = FixedListType(Uint16, 64, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -414,11 +589,11 @@ test "TreeView list getAllAlloc spans multiple chunks" { test "TreeView list push batches before commit" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -455,11 +630,11 @@ test "TreeView list push batches before commit" { test "TreeView list push across chunk boundary resets prefetch" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 32); + const ListType = FixedListType(Uint32, 32, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -487,11 +662,11 @@ test "TreeView list push across chunk boundary resets prefetch" { test "TreeView list push enforces limit" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 2); + const ListType = FixedListType(Uint32, 2, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -507,11 +682,11 @@ test "TreeView list push enforces limit" { test "TreeView list basic clone isolates updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -533,11 +708,11 @@ test "TreeView list basic clone isolates updates" { test "TreeView list basic clone reads committed state" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -558,11 +733,11 @@ test "TreeView list basic clone reads committed state" { test "TreeView list basic clone drops uncommitted changes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -584,11 +759,11 @@ test "TreeView list basic clone drops uncommitted changes" { test "TreeView list basic clone(false) does not transfer cache" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -610,11 +785,11 @@ test "TreeView list basic clone(false) does not transfer cache" { test "TreeView list basic clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -637,12 +812,12 @@ test "TreeView list basic clone(true) transfers cache and clears source" { // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/ssz/test/unit/byType/listBasic/tree.test.ts#L180-L203 test "TreeView basic list getAll reflects pushes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const list_limit = 32; const Uint64 = UintType(64); - const ListType = FixedListType(Uint64, list_limit); + const ListType = FixedListType(Uint64, list_limit, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -674,11 +849,11 @@ test "TreeView basic list getAll reflects pushes" { test "TreeView list sliceTo returns original when truncation unnecessary" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 16); + const ListType = FixedListType(Uint32, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -707,11 +882,11 @@ test "TreeView list sliceTo returns original when truncation unnecessary" { // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/ssz/test/unit/byType/listBasic/tree.test.ts#L219-L247 test "TreeView basic list sliceTo matches incremental snapshots" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const Uint64 = UintType(64); - const ListType = FixedListType(Uint64, 1024); + const ListType = FixedListType(Uint64, 1024, .{}); const total_values: usize = 16; var base_values: [total_values]u64 = undefined; @@ -771,11 +946,11 @@ test "TreeView basic list sliceTo matches incremental snapshots" { // std.testing.allocator can't see pool-slot leaks, so check getNodesInUse() against a baseline. test "TreeView basic list sliceTo does not leak pool nodes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); const Uint64 = UintType(64); - const ListType = FixedListType(Uint64, 1024); + const ListType = FixedListType(Uint64, 1024, .{}); var empty_list: ListType.Type = .empty; defer empty_list.deinit(allocator); @@ -797,11 +972,11 @@ test "TreeView basic list sliceTo does not leak pool nodes" { test "TreeView list sliceTo truncates tail elements" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); defer pool.deinit(); const Uint32 = UintType(32); - const ListType = FixedListType(Uint32, 32); + const ListType = FixedListType(Uint32, 32, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -843,9 +1018,9 @@ test "ListBasicTreeView - serialize (uint8 list)" { const allocator = std.testing.allocator; const Uint8 = UintType(8); - const ListU8Type = FixedListType(Uint8, 128); + const ListU8Type = FixedListType(Uint8, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -906,9 +1081,9 @@ test "ListBasicTreeView - serialize (uint64 list)" { const allocator = std.testing.allocator; const Uint64 = UintType(64); - const ListU64Type = FixedListType(Uint64, 128); + const ListU64Type = FixedListType(Uint64, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -970,9 +1145,9 @@ test "ListBasicTreeView - push and serialize" { const allocator = std.testing.allocator; const Uint8 = UintType(8); - const ListU8Type = FixedListType(Uint8, 128); + const ListU8Type = FixedListType(Uint8, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); var value: ListU8Type.Type = ListU8Type.default_value; @@ -1008,9 +1183,9 @@ test "ListBasicTreeView - sliceTo and serialize" { const allocator = std.testing.allocator; const Uint8 = UintType(8); - const ListU8Type = FixedListType(Uint8, 128); + const ListU8Type = FixedListType(Uint8, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); var value: ListU8Type.Type = ListU8Type.default_value; @@ -1036,3 +1211,687 @@ test "ListBasicTreeView - sliceTo and serialize" { try std.testing.expectEqualSlices(u8, &[_]u8{ 1, 2 }, serialized); try std.testing.expectEqual(@as(usize, 2), try sliced.length()); } + +// Aliased rather than named `ChunkedLeaf` to avoid shadowing the same-named +// binding inside `ListBasicTreeView`. +const ChunkedLeafType = pmt.ChunkedLeaf; + +test "ListBasicTreeView chunked_leaf: iteratorReadonly within first chunked_leaf" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const item_count: usize = 100; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 7 + 3))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + var it = view.iteratorReadonly(0); + for (0..item_count) |i| { + const got = try it.next(); + try std.testing.expectEqual(src.items[i], got); + } +} + +test "ListBasicTreeView chunked_leaf: iteratorReadonly across chunked_leaf boundary" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + // Spans several ChunkedLeaves including a partial last one. items_per_chunk=4 + // for u64; one ChunkedLeaf holds K * items_per_chunk items. + const item_count: usize = 2 * 4096 + 17; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 31 + 1))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + var it = view.iteratorReadonly(0); + for (0..item_count) |i| { + const got = try it.next(); + try std.testing.expectEqual(src.items[i], got); + } +} + +// Pushing across chunk boundaries must keep each ChunkedLeaf's `len` (valid +// chunk count) in sync. Root-equivalence checks cannot catch a stale `len` — +// `ChunkedLeaf.computeRoot` hashes all K chunks and ignores `len` — so this +// asserts `getChunkedLeafLen` and the trailing-zero invariant directly. +test "ListBasicTreeView chunked_leaf: push keeps ChunkedLeaf.len in sync" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const K: usize = ChunkedLeafType.K; + const items_per_chunk: usize = 4; // 32 / @sizeOf(u64) + // +1 for the list's length-mixin level above the data subtree. + const cl_depth: Depth = ListT.chunk_depth + 1 - ChunkedLeafType.k_log2; + + // Fills ChunkedLeaf 0 completely (len must be K) and ChunkedLeaf 1 + // partially (3 chunks). + const item_count: usize = K * items_per_chunk + 2 * items_per_chunk + 3; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + const root0 = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root0); + defer view.deinit(); + + for (0..item_count) |i| try view.push(@as(u64, @intCast(i + 1))); + try view.commit(); + + const total_chunks = (item_count + items_per_chunk - 1) / items_per_chunk; + const chunked_leaf_count = (total_chunks + K - 1) / K; + const zero_chunk = [_]u8{0} ** 32; + + for (0..chunked_leaf_count) |cl_idx| { + const cl = try view.chunks.state.root.getNodeAtDepth(&pool, cl_depth, cl_idx); + const expected_len: usize = @min(K, total_chunks - cl_idx * K); + try std.testing.expectEqual(@as(u16, @intCast(expected_len)), try cl.getChunkedLeafLen(&pool)); + + // Trailing-zero invariant: chunks at indices >= len must be zero. + const chunks = try cl.getChunkedLeafChunks(&pool); + for (expected_len..K) |c| { + try std.testing.expectEqualSlices(u8, &zero_chunk, &chunks[c]); + } + } +} + +test "ListBasicTreeView chunked_leaf: iteratorReadonly with start_index mid-chunked_leaf" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const item_count: usize = 5000; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 13 + 5))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + // Start in the second ChunkedLeaf (index >= 4096) and at non-chunk boundary. + const start: usize = 4500; + var it = view.iteratorReadonly(start); + for (start..item_count) |i| { + const got = try it.next(); + try std.testing.expectEqual(src.items[i], got); + } +} + +test "ListBasicTreeView chunked_leaf: iteratorReadonly on sparsely grown list" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + // Grow from empty via push so initial chunked_leaves are zero sentinels + // until they get materialized. After pushing N elements, only the + // ChunkedLeaves up to ceil(N / (K * items_per_chunk)) are real. + const item_count: usize = 6000; + + var empty: ListT.Type = .empty; + defer empty.deinit(allocator); + const root_id = try ListT.tree.fromValue(&pool, &empty); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + for (0..item_count) |i| { + try view.push(@as(u64, @intCast(i + 1))); + } + try view.commit(); + + var it = view.iteratorReadonly(0); + for (0..item_count) |i| { + const got = try it.next(); + try std.testing.expectEqual(@as(u64, @intCast(i + 1)), got); + } +} + +test "ListBasicTreeView chunked_leaf: sliceTo within first chunked_leaf" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const ListTLeaf = FixedListType(UintType(64), 1 << 20, .{}); + + const item_count: usize = 50; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i + 100))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + const cut: usize = 17; + var sliced = try view.sliceTo(cut); + defer sliced.deinit(); + + try std.testing.expectEqual(@as(usize, cut + 1), try sliced.length()); + + // Element-level equality. + for (0..cut + 1) |i| { + try std.testing.expectEqual(src.items[i], try sliced.get(i)); + } + + // Root matches the non-chunked_leaf reference at the same length. + var ref: ListTLeaf.Type = .empty; + defer ref.deinit(allocator); + try ref.appendSlice(allocator, src.items[0 .. cut + 1]); + var expected_root: [32]u8 = undefined; + try ListTLeaf.hashTreeRoot(allocator, &ref, &expected_root); + + var actual_root: [32]u8 = undefined; + try sliced.hashTreeRootInto(&actual_root); + try std.testing.expectEqualSlices(u8, &expected_root, &actual_root); +} + +test "ListBasicTreeView chunked_leaf: sliceTo at chunked_leaf boundary" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const ListTLeaf = FixedListType(UintType(64), 1 << 20, .{}); + const item_count: usize = 2 * 4096 + 100; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 11 + 7))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + // Cut at the last index of the first ChunkedLeaf (4095). Boundary + // exercises chunked_leaf_offset = K-1 and all chunks past it are zeroed. + const cut: usize = 4095; + var sliced = try view.sliceTo(cut); + defer sliced.deinit(); + + try std.testing.expectEqual(@as(usize, cut + 1), try sliced.length()); + + var ref: ListTLeaf.Type = .empty; + defer ref.deinit(allocator); + try ref.appendSlice(allocator, src.items[0 .. cut + 1]); + + var expected_root: [32]u8 = undefined; + try ListTLeaf.hashTreeRoot(allocator, &ref, &expected_root); + var actual_root: [32]u8 = undefined; + try sliced.hashTreeRootInto(&actual_root); + try std.testing.expectEqualSlices(u8, &expected_root, &actual_root); +} + +test "ListBasicTreeView chunked_leaf: sliceTo across chunked_leaf boundary" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const ListTLeaf = FixedListType(UintType(64), 1 << 20, .{}); + const item_count: usize = 3 * 4096 + 50; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 23 + 9))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + // Cut in the middle of the second ChunkedLeaf. + const cut: usize = 4096 + 1234; + var sliced = try view.sliceTo(cut); + defer sliced.deinit(); + + try std.testing.expectEqual(@as(usize, cut + 1), try sliced.length()); + + // toValue round-trip. + var dst: ListT.Type = .empty; + defer dst.deinit(allocator); + try ListT.tree.toValue(allocator, sliced.getRoot(), &pool, &dst); + try std.testing.expectEqual(@as(usize, cut + 1), dst.items.len); + try std.testing.expectEqualSlices(u64, src.items[0 .. cut + 1], dst.items); + + // Root matches reference. + var ref: ListTLeaf.Type = .empty; + defer ref.deinit(allocator); + try ref.appendSlice(allocator, src.items[0 .. cut + 1]); + var expected_root: [32]u8 = undefined; + try ListTLeaf.hashTreeRoot(allocator, &ref, &expected_root); + var actual_root: [32]u8 = undefined; + try sliced.hashTreeRootInto(&actual_root); + try std.testing.expectEqualSlices(u8, &expected_root, &actual_root); +} + +test "ListBasicTreeView chunked_leaf: sliceTo returns clone when index >= length-1" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const item_count: usize = 100; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + var sliced = try view.sliceTo(item_count - 1); + defer sliced.deinit(); + + try std.testing.expectEqual(item_count, try sliced.length()); + try std.testing.expectEqualSlices(u8, view.getRoot().getRoot(&pool), sliced.getRoot().getRoot(&pool)); +} + +// Build a list root manually with chunked_leaf 0 real and chunked_leaf 1 forced to a +// zero sentinel; length spans into chunked_leaf 1. Used to exercise defensive +// `.zero` branches in iteratorReadonly and sliceTo that aren't reachable via +// `fromValue` / `push` (those materialize on first write). +fn buildChunkedLeafListWithZeroBoundary( + pool: *Node.Pool, + cl0_chunks: *align(64) const [ChunkedLeafType.K][32]u8, + list_length: usize, + chunked_leaf_subtree_depth: Depth, +) !Node.Id { + const cl0_id = try pool.createChunkedLeaf(cl0_chunks, ChunkedLeafType.K); + + // Build chunks subtree: only append chunked_leaf 0; finish() pads remaining + // positions at chunked_leaf level with ZeroHash[k_log2] sentinels. + var fc_it = Node.FillWithContentsIterator.initWithOffset(pool, chunked_leaf_subtree_depth, ChunkedLeafType.k_log2); + errdefer fc_it.deinit(); + try fc_it.append(cl0_id); + const chunks_root = try fc_it.finish(); + errdefer pool.unref(chunks_root); + + // Mix in length: list_root = hash(chunks_root, length_leaf). + const length_leaf = try pool.createLeafFromUint(@intCast(list_length)); + errdefer pool.unref(length_leaf); + + var list_it = Node.FillWithContentsIterator.init(pool, 1); + errdefer list_it.deinit(); + try list_it.append(chunks_root); + try list_it.append(length_leaf); + return try list_it.finish(); +} + +test "ListBasicTreeView chunked_leaf: iteratorReadonly handles zero-sentinel chunked_leaf" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + + // Fill chunked_leaf 0 with deterministic non-zero u64 data: chunk i has u256 = i + 1. + var raw: [ChunkedLeafType.K][32]u8 align(64) = undefined; + @memset(std.mem.asBytes(&raw), 0); + for (0..ChunkedLeafType.K) |i| { + std.mem.writeInt(u256, &raw[i], @as(u256, @intCast(i + 1)), .little); + } + + // length spans into chunked_leaf 1 (zero sentinel) — last 100 items live in + // the sparse region where iteratorReadonly hits its `.zero` branch. + const items_in_cl0: usize = ChunkedLeafType.K * 4; // 4096 (items_per_chunk = 4 for u64) + const items_in_cl1: usize = 100; + const item_count = items_in_cl0 + items_in_cl1; + const chunked_leaf_subtree_depth: Depth = @intCast(ListT.chunk_depth - ChunkedLeafType.k_log2); + + const list_root = try buildChunkedLeafListWithZeroBoundary(&pool, &raw, item_count, chunked_leaf_subtree_depth); + var view = try ListT.TreeView.init(allocator, &pool, list_root); + defer view.deinit(); + + try std.testing.expectEqual(item_count, try view.length()); + + var it = view.iteratorReadonly(0); + + // First chunked_leaf: real data. Each chunk holds u256 (chunk_idx + 1) = 4 + // little-endian u64s, so item j in chunk c has value = (c+1) >> (j%4 * 64). + for (0..items_in_cl0) |item_idx| { + const got = try it.next(); + const chunk_idx = item_idx / 4; + const u64_idx = item_idx % 4; + const u256_val: u256 = @intCast(chunk_idx + 1); + const expected: u64 = @truncate(u256_val >> @intCast(u64_idx * 64)); + try std.testing.expectEqual(expected, got); + } + + // Crossed into chunked_leaf 1 — zero sentinel. Iterator's `.zero` branch + // should yield zero values without dereferencing payload. + for (0..items_in_cl1) |_| { + const got = try it.next(); + try std.testing.expectEqual(@as(u64, 0), got); + } +} + +test "ListBasicTreeView chunked_leaf: sliceTo handles zero-sentinel boundary" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const ListTLeaf = FixedListType(UintType(64), 1 << 20, .{}); + + var raw: [ChunkedLeafType.K][32]u8 align(64) = undefined; + @memset(std.mem.asBytes(&raw), 0); + for (0..ChunkedLeafType.K) |i| { + std.mem.writeInt(u256, &raw[i], @as(u256, @intCast(i * 7 + 13)), .little); + } + + const items_in_cl0: usize = ChunkedLeafType.K * 4; + const items_in_cl1: usize = 200; + const item_count = items_in_cl0 + items_in_cl1; + const chunked_leaf_subtree_depth: Depth = @intCast(ListT.chunk_depth - ChunkedLeafType.k_log2); + + const list_root = try buildChunkedLeafListWithZeroBoundary(&pool, &raw, item_count, chunked_leaf_subtree_depth); + var view = try ListT.TreeView.init(allocator, &pool, list_root); + defer view.deinit(); + + // Cut at index 4150 — boundary chunked_leaf is the zero-sentinel chunked_leaf 1. + const cut: usize = items_in_cl0 + 50; + var sliced = try view.sliceTo(cut); + defer sliced.deinit(); + + try std.testing.expectEqual(@as(usize, cut + 1), try sliced.length()); + + // The first 4096 items come from chunked_leaf 0 (real data); items 4096..cut + // come from the zero sentinel (zeros). + var ref_items = try allocator.alloc(u64, cut + 1); + defer allocator.free(ref_items); + for (0..items_in_cl0) |item_idx| { + const chunk_idx = item_idx / 4; + const u64_idx = item_idx % 4; + const u256_val: u256 = @intCast(chunk_idx * 7 + 13); + ref_items[item_idx] = @truncate(u256_val >> @intCast(u64_idx * 64)); + } + @memset(ref_items[items_in_cl0..], 0); + + var ref: ListTLeaf.Type = .empty; + defer ref.deinit(allocator); + try ref.appendSlice(allocator, ref_items); + + var expected_root: [32]u8 = undefined; + try ListTLeaf.hashTreeRoot(allocator, &ref, &expected_root); + + var actual_root: [32]u8 = undefined; + try sliced.hashTreeRootInto(&actual_root); + try std.testing.expectEqualSlices(u8, &expected_root, &actual_root); +} + +test "ListBasicTreeView chunked_leaf: getAllInto sees uncommitted set" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const item_count: usize = 5000; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + // Stage writes spanning two chunked_leaves (boundary at 4096 for u64). + try view.set(7, 9001); + try view.set(4500, 9002); + + const out = try allocator.alloc(u64, item_count); + defer allocator.free(out); + _ = try view.getAllInto(out); + + try std.testing.expectEqual(@as(u64, 9001), out[7]); + try std.testing.expectEqual(@as(u64, 9002), out[4500]); + try std.testing.expectEqual(@as(u64, 6), out[6]); + try std.testing.expectEqual(@as(u64, 4499), out[4499]); +} + +test "ListBasicTreeView chunked_leaf: property test cross-commit set + push sequences" { + // Randomized set/push/commit cycles exercising Path 1/2/3. Root + // equivalence is blind to ChunkedLeaf.len (computeRoot hashes all K + // chunks and ignores it), so this also asserts every ChunkedLeaf.len and + // the trailing-zero invariant after each commit — covering the push-grow + // path that drifts `len`. + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 16384 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + const K: usize = ChunkedLeafType.K; + const items_per_chunk: usize = 4; // 32 / @sizeOf(u64) + // +1 for the list length-mixin level above the data subtree. + const cl_depth: Depth = ListT.chunk_depth + 1 - ChunkedLeafType.k_log2; + // Grows past several ChunkedLeaves, last one partial. + const cap: usize = 3 * K * items_per_chunk + 50; + + var prng = std.Random.DefaultPrng.init(0xCAFE_BEEF_DEAD_BABE); + const rand = prng.random(); + + var reference: std.ArrayListUnmanaged(u64) = .empty; + defer reference.deinit(allocator); + for (0..K * items_per_chunk + 7) |i| try reference.append(allocator, @as(u64, @intCast(i * 31 + 7))); + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (reference.items) |v| try src.append(allocator, v); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + const zero_chunk = [_]u8{0} ** 32; + + for (0..20) |_| { + const n_writes = rand.intRangeAtMost(usize, 5, 30); + for (0..n_writes) |_| { + if (reference.items.len < cap and rand.boolean()) { + const val = rand.int(u64); + try reference.append(allocator, val); + try view.push(val); + } else { + const idx = rand.intRangeLessThan(usize, 0, reference.items.len); + const val = rand.int(u64); + reference.items[idx] = val; + try view.set(idx, val); + } + } + + // Root equivalence to a freshly built reference tree. + var ref_src: ListT.Type = .empty; + defer ref_src.deinit(allocator); + for (reference.items) |v| try ref_src.append(allocator, v); + const ref_root_id = try ListT.tree.fromValue(&pool, &ref_src); + defer pool.unref(ref_root_id); + const view_root = (try view.hashTreeRoot()).*; + try std.testing.expectEqualSlices(u8, ref_root_id.getRoot(&pool), &view_root); + + // Every ChunkedLeaf.len tracks the list length; chunks >= len are zero. + const total_chunks = (reference.items.len + items_per_chunk - 1) / items_per_chunk; + const cl_count = (total_chunks + K - 1) / K; + for (0..cl_count) |cl_idx| { + const cl = try view.chunks.state.root.getNodeAtDepth(&pool, cl_depth, cl_idx); + const expected: usize = @min(K, total_chunks - cl_idx * K); + try std.testing.expectEqual(@as(u16, @intCast(expected)), try cl.getChunkedLeafLen(&pool)); + const chunks = try cl.getChunkedLeafChunks(&pool); + for (expected..K) |c| try std.testing.expectEqualSlices(u8, &zero_chunk, &chunks[c]); + } + + // A single proof on the grown/mutated chunked_leaf list rebuilds to + // the same root — exercises proof through a push-grown ChunkedLeaf. + { + const elem = rand.intRangeLessThan(usize, 0, reference.items.len); + const gindex = Gindex.fromDepth(ListT.chunk_depth + 1, elem / items_per_chunk); + var single = try proof.createSingleProof(allocator, &pool, view.chunks.state.root, gindex); + defer single.deinit(allocator); + + var proof_pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); + defer proof_pool.deinit(); + const rebuilt = try proof.createNodeFromSingleProof(&proof_pool, gindex, single.leaf, single.witnesses); + defer proof_pool.unref(rebuilt); + try std.testing.expectEqualSlices(u8, &view_root, rebuilt.getRoot(&proof_pool)); + } + + // Point reads stay correct. + for (0..16) |_| { + const i = rand.intRangeLessThan(usize, 0, reference.items.len); + try std.testing.expectEqual(reference.items[i], try view.get(i)); + } + } + + const final = try allocator.alloc(u64, reference.items.len); + defer allocator.free(final); + _ = try view.getAllInto(final); + try std.testing.expectEqualSlices(u64, reference.items, final); +} + +test "ListBasicTreeView chunked_leaf: getAllInto sees uncommitted push" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + try src.append(allocator, 10); + try src.append(allocator, 20); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + try view.push(30); + try view.push(40); + + const out = try allocator.alloc(u64, 4); + defer allocator.free(out); + _ = try view.getAllInto(out); + + try std.testing.expectEqualSlices(u64, &.{ 10, 20, 30, 40 }, out); +} + +test "ListBasicTreeView chunked_leaf: sliceTo doesn't leak pool nodes" { + // sliceTo allocates transient roots via setNodeAtDepth / truncate / + // setNode. Those calls don't consume their input root_node, so the + // caller must unref the intermediates. The test asserts node count + // returns to baseline after repeated sliceTo+deinit. + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (0..100) |i| try src.append(allocator, @as(u64, @intCast(i))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + // One warmup so any one-time lazy initialization isn't counted. + { + var w = try view.sliceTo(50); + w.deinit(); + } + + const before = pool.getNodesInUse(); + for (0..50) |_| { + var s = try view.sliceTo(50); + s.deinit(); + } + const after = pool.getNodesInUse(); + + try std.testing.expectEqual(before, after); +} + +test "ListBasicTreeView non-chunked_leaf: sliceTo doesn't leak pool nodes" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{}); + var src: ListT.Type = .empty; + defer src.deinit(allocator); + for (0..100) |i| try src.append(allocator, @as(u64, @intCast(i))); + + const root_id = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root_id); + defer view.deinit(); + + { + var w = try view.sliceTo(50); + w.deinit(); + } + + const before = pool.getNodesInUse(); + for (0..50) |_| { + var s = try view.sliceTo(50); + s.deinit(); + } + const after = pool.getNodesInUse(); + + try std.testing.expectEqual(before, after); +} + +const ArmOnSizeAllocator = @import("testing_allocators").ArmOnSizeAllocator; + +// Path 3 (shared chunked_leaf) CoWs a fresh node + 2KB blob; if setChildNode OOMs +// it must be reclaimed. Leak shows as getNodesInUse (slot) + testing.allocator (blob). +test "ListBasicTreeView chunked_leaf: set OOM in setChildNode reclaims the CoW chunked_leaf (no leak)" { + const allocator = std.testing.allocator; + var view_failing = std.testing.FailingAllocator.init(allocator, .{}); + // Arm the view allocator to OOM on the CoW blob alloc → fails setChildNode's changed.put. + var armer = ArmOnSizeAllocator{ .backing = allocator, .target = &view_failing, .trigger_len = @sizeOf(ChunkedLeafType) }; + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = armer.allocator(), .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + + for (0..100) |i| try src.append(allocator, @as(u64, @intCast(i))); + const root_id = try ListT.tree.fromValue(&pool, &src); + + var view = try ListT.TreeView.init(view_failing.allocator(), &pool, root_id); + defer view.deinit(); + + const baseline = pool.getNodesInUse(); + + // First set on the committed (shared, rc>=1) chunked_leaf takes Path 3. + armer.armed = true; + try std.testing.expectError(error.OutOfMemory, view.set(0, 999)); + view_failing.fail_index = std.math.maxInt(usize); // disarm for cleanup + armer.armed = false; + + // The freshly-CoW'd node + its 2KB blob were reclaimed, not leaked. + try std.testing.expectEqual(baseline, pool.getNodesInUse()); +} diff --git a/src/ssz/tree_view/list_composite.zig b/src/ssz/tree_view/list_composite.zig index f98a0408a..4c6f99b3f 100644 --- a/src/ssz/tree_view/list_composite.zig +++ b/src/ssz/tree_view/list_composite.zig @@ -50,7 +50,7 @@ pub fn ListCompositeTreeView(comptime ST: type) type { errdefer allocator.destroy(ptr); try Chunks.init(&ptr.chunks, allocator, pool, root); - errdefer ptr.chunks.deinit(); + errdefer ptr.chunks.deinitAfterInitFailure(); ptr.allocator = allocator; ptr._orig_len = try ptr.chunks.getLength(); @@ -205,7 +205,10 @@ pub fn ListCompositeTreeView(comptime ST: type) type { try self.push(child_view); } + /// Read-only iterator over committed elements. Pending `set`/`push` + /// writes are not visible — call `commit()` first if they matter. pub fn iteratorReadonly(self: *const Self, start_index: usize) ReadonlyIterator { + std.debug.assert(self.chunks.state.changed.count() == 0); return ReadonlyIterator.init(self, start_index); } @@ -265,6 +268,23 @@ pub fn ListCompositeTreeView(comptime ST: type) type { return value; } } + + /// Read-only pointer to the next element's value without copying. + /// Only available when `ST.Element` is a `StructContainerType` — + /// the underlying `container_struct` node already holds the value + /// inline, so we can hand back a `*const T` directly. + /// + /// The pointer is valid as long as the iterator's pool retains + /// the node (CoW mutation invalidates it). Use only for + /// transient read passes that don't mutate the list. + pub fn nextValuePtr(self: *ReadonlyIterator) !*const ST.Element.Type { + if (comptime !@hasDecl(ST.Element.tree, "getValuePtr")) { + @compileError("nextValuePtr requires ST.Element to be a StructContainerType"); + } + const node = try self.depth_iterator.next(); + self.elem_index += 1; + return ST.Element.tree.getValuePtr(node, self.tree_view.chunks.state.pool); + } }; /// Return a new view containing all elements up to and including `index`. @@ -378,10 +398,10 @@ const Checkpoint = FixedContainerType(struct { test "TreeView composite list sliceTo truncates elements" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -415,10 +435,10 @@ test "TreeView composite list sliceTo truncates elements" { // std.testing.allocator can't see pool-slot leaks, so check getNodesInUse() against a baseline. test "TreeView composite list sliceTo does not leak pool nodes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -440,7 +460,7 @@ const DoubleFreeDetectAllocator = @import("testing_allocators").DoubleFreeDetect // set takes ownership of the view, so setValue must not deinit it too. Sweep every OOM point. test "TreeView composite list setValue - OOM does not double-free the element view" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -453,7 +473,7 @@ test "TreeView composite list setValue - OOM does not double-free the element vi defer oom.deinit(); const alloc = oom.allocator(); - var pool = Node.Pool.init(alloc, 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; defer pool.deinit(); const root = ListType.tree.fromValue(&pool, &list) catch continue; @@ -470,7 +490,7 @@ test "TreeView composite list setValue - OOM does not double-free the element vi } test "TreeView composite list push - OOM does not double-free" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -483,7 +503,7 @@ test "TreeView composite list push - OOM does not double-free" { defer oom.deinit(); const alloc = oom.allocator(); - var pool = Node.Pool.init(alloc, 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; defer pool.deinit(); const root = ListType.tree.fromValue(&pool, &list) catch continue; @@ -499,7 +519,7 @@ test "TreeView composite list push - OOM does not double-free" { } test "TreeView composite list clone(transfer_cache) - OOM does not double-free cached children" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -511,7 +531,7 @@ test "TreeView composite list clone(transfer_cache) - OOM does not double-free c defer oom.deinit(); const alloc = oom.allocator(); - var pool = Node.Pool.init(alloc, 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; defer pool.deinit(); const root = ListType.tree.fromValue(&pool, &list) catch continue; @@ -533,7 +553,7 @@ test "TreeView composite list clone(transfer_cache) - OOM does not double-free c } test "TreeView composite list commit - OOM does not double-free" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -546,7 +566,7 @@ test "TreeView composite list commit - OOM does not double-free" { defer oom.deinit(); const alloc = oom.allocator(); - var pool = Node.Pool.init(alloc, 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; defer pool.deinit(); const root = ListType.tree.fromValue(&pool, &list) catch continue; @@ -567,7 +587,7 @@ test "TreeView composite list commit - OOM does not double-free" { } test "TreeView composite list fromValue - OOM leaves no orphan pool nodes" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -576,7 +596,7 @@ test "TreeView composite list fromValue - OOM leaves no orphan pool nodes" { var fail_at: usize = 0; while (fail_at < 400) : (fail_at += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); - var pool = Node.Pool.init(failing.allocator(), 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 0 }) catch continue; defer pool.deinit(); const baseline = pool.getNodesInUse(); @@ -591,7 +611,7 @@ test "TreeView composite list fromValue - OOM leaves no orphan pool nodes" { } test "TreeView composite list deserializeFromBytes - OOM leaves no orphan pool nodes" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -604,7 +624,7 @@ test "TreeView composite list deserializeFromBytes - OOM leaves no orphan pool n var fail_at: usize = 0; while (fail_at < 400) : (fail_at += 1) { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); - var pool = Node.Pool.init(failing.allocator(), 0) catch continue; + var pool = Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 0 }) catch continue; defer pool.deinit(); const baseline = pool.getNodesInUse(); @@ -619,7 +639,7 @@ test "TreeView composite list deserializeFromBytes - OOM leaves no orphan pool n } test "TreeView composite list deserializeFromBytes - malformed input errors without leaking" { - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(std.testing.allocator); @@ -629,7 +649,7 @@ test "TreeView composite list deserializeFromBytes - malformed input errors with defer std.testing.allocator.free(bytes); _ = ListType.serializeIntoBytes(&list, bytes); - var pool = try Node.Pool.init(std.testing.allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 512 }); defer pool.deinit(); const baseline = pool.getNodesInUse(); @@ -648,10 +668,10 @@ test "TreeView composite list deserializeFromBytes - malformed input errors with test "TreeView composite list sliceFrom returns suffix" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -689,10 +709,10 @@ test "TreeView composite list sliceFrom returns suffix" { // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/ssz/test/unit/byType/listComposite/tree.test.ts#L209-L229 test "TreeView composite list sliceFrom handles boundary conditions" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 1024); + const ListType = FixedListType(Checkpoint, 1024, .{}); const list_length = 16; var list: ListType.Type = .empty; @@ -761,10 +781,10 @@ test "TreeView composite list sliceFrom handles boundary conditions" { test "TreeView composite list push appends element" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 8); + const ListType = FixedListType(Checkpoint, 8, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -799,10 +819,10 @@ test "TreeView composite list push appends element" { test "TreeView composite list clone isolates updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -837,10 +857,10 @@ test "TreeView composite list clone isolates updates" { test "TreeView composite list clone reads committed state" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -870,10 +890,10 @@ test "TreeView composite list clone reads committed state" { test "TreeView composite list clone drops uncommitted changes" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -912,10 +932,10 @@ test "TreeView composite list clone drops uncommitted changes" { test "TreeView composite list clone(false) does not transfer cache" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -939,10 +959,10 @@ test "TreeView composite list clone(false) does not transfer cache" { test "TreeView composite list clone(true) transfers cache and clears source" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 512); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 16); + const ListType = FixedListType(Checkpoint, 16, .{}); var list: ListType.Type = .empty; defer list.deinit(allocator); @@ -966,13 +986,13 @@ test "TreeView composite list clone(true) transfers cache and clears source" { test "TreeView list of list commits inner length updates" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const Uint32 = UintType(32); const Bytes = ByteListType(32); - const Numbers = FixedListType(Uint32, 8); - const Vec2 = FixedVectorType(Uint32, 2); + const Numbers = FixedListType(Uint32, 8, .{}); + const Vec2 = FixedVectorType(Uint32, 2, .{}); const InnerElement = VariableContainerType(struct { id: Uint32, payload: Bytes, @@ -1143,10 +1163,10 @@ test "TreeView list of list commits inner length updates" { // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/ssz/test/unit/byType/listComposite/tree.test.ts#L182-L207 test "TreeView composite list sliceTo matches incremental snapshots" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); defer pool.deinit(); - const ListType = FixedListType(Checkpoint, 1024); + const ListType = FixedListType(Checkpoint, 1024, .{}); const total_values: usize = 16; var values: [total_values]Checkpoint.Type = undefined; @@ -1215,9 +1235,9 @@ test "ListCompositeTreeView - serialize (ByteVector32 list)" { const allocator = std.testing.allocator; const Root32 = ByteVectorType(32); - const ListRootsType = FixedListType(Root32, 128); + const ListRootsType = FixedListType(Root32, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -1283,9 +1303,9 @@ test "ListCompositeTreeView - serialize (Container list)" { b: UintType(64), }); _ = Uint64; - const ListContainerType = FixedListType(TestContainer, 128); + const ListContainerType = FixedListType(TestContainer, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const TestCase = struct { @@ -1354,9 +1374,9 @@ test "ListCompositeTreeView - push and serialize" { const allocator = std.testing.allocator; const Root32 = ByteVectorType(32); - const ListRootsType = FixedListType(Root32, 128); + const ListRootsType = FixedListType(Root32, 128, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); var value: ListRootsType.Type = ListRootsType.default_value; @@ -1394,3 +1414,32 @@ test "ListCompositeTreeView - push and serialize" { const expected_root = [_]u8{ 0x0c, 0xb9, 0x47, 0x37, 0x7e, 0x17, 0x7f, 0x77, 0x47, 0x19, 0xea, 0xd8, 0xd2, 0x10, 0xaf, 0x9c, 0x64, 0x61, 0xf4, 0x1b, 0xaf, 0x5b, 0x40, 0x82, 0xf8, 0x6a, 0x39, 0x11, 0x45, 0x48, 0x31, 0xb8 }; try std.testing.expectEqualSlices(u8, &expected_root, &hash_root); } + +test "TreeView composite list sliceTo doesn't leak pool nodes" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); + defer pool.deinit(); + + const ListType = FixedListType(Checkpoint, 1024, .{}); + var list: ListType.Type = .empty; + defer list.deinit(allocator); + for (0..16) |i| try list.append(allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + + const root_node = try ListType.tree.fromValue(&pool, &list); + var view = try ListType.TreeView.init(allocator, &pool, root_node); + defer view.deinit(); + + { + var w = try view.sliceTo(7); + w.deinit(); + } + + const before = pool.getNodesInUse(); + for (0..50) |_| { + var s = try view.sliceTo(7); + s.deinit(); + } + const after = pool.getNodesInUse(); + + try std.testing.expectEqual(before, after); +} diff --git a/src/ssz/tree_view/root.zig b/src/ssz/tree_view/root.zig index 99fa4da5d..0abf6a048 100644 --- a/src/ssz/tree_view/root.zig +++ b/src/ssz/tree_view/root.zig @@ -1,6 +1,7 @@ const std = @import("std"); pub const ContainerTreeView = @import("container.zig").ContainerTreeView; +pub const StructContainerTreeView = @import("container.zig").StructContainerTreeView; pub const ArrayBasicTreeView = @import("array_basic.zig").ArrayBasicTreeView; pub const ArrayCompositeTreeView = @import("array_composite.zig").ArrayCompositeTreeView; pub const ListBasicTreeView = @import("list_basic.zig").ListBasicTreeView; diff --git a/src/ssz/tree_view/utils/tree_view_state.zig b/src/ssz/tree_view/utils/tree_view_state.zig index 26ac24fee..b589c1310 100644 --- a/src/ssz/tree_view/utils/tree_view_state.zig +++ b/src/ssz/tree_view/utils/tree_view_state.zig @@ -37,6 +37,18 @@ pub const TreeViewState = struct { self.pool.unref(self.root); } + /// Cleanup for a partially-built view whose `init` failed after this state + /// took its `root` ref. Mirrors `deinit` but drops `root`'s ref WITHOUT + /// freeing, restoring `root` to its pre-init refcount: on the failure path + /// the caller still owns `root` and releases it itself (`unref` here would + /// free a freshly-built rc-0 root and double-free with the caller). + pub fn deinitAfterInitFailure(self: *TreeViewState) void { + self.clearChildrenNodesCache(); + self.children_nodes.deinit(self.allocator); + self.changed.deinit(self.allocator); + self.pool.unrefUnsafe(self.root); + } + pub fn getChildNode(self: *TreeViewState, gindex: Gindex) !Node.Id { const gop = try self.children_nodes.getOrPut(self.allocator, gindex); if (gop.found_existing) { @@ -55,7 +67,7 @@ pub const TreeViewState = struct { node, ); if (opt_old_node) |old_node| { - if (old_node.value.getState(self.pool).getRefCount() == 0) { + if (old_node.value.getState(self.pool).refCount() == 0) { self.pool.unref(old_node.value); } } @@ -92,7 +104,12 @@ pub const TreeViewState = struct { var value_iter = self.children_nodes.valueIterator(); while (value_iter.next()) |node_id_ptr| { const node_id = node_id_ptr.*; - if (node_id.getState(self.pool).getRefCount() == 0) { + const state = node_id.getState(self.pool); + // A cached child root can already be freed via children_data — a child + // view owns the same node — when a failed commit left it here. Skip it + // rather than re-unref (which would hit the .free slot). + if (state.isFree()) continue; + if (state.refCount() == 0) { self.pool.unref(node_id); } } diff --git a/src/ssz/type/bit_list.zig b/src/ssz/type/bit_list.zig index 241b5d8df..2e8305d10 100644 --- a/src/ssz/type/bit_list.zig +++ b/src/ssz/type/bit_list.zig @@ -809,7 +809,7 @@ test "BitListType - tree roundtrip" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -868,7 +868,7 @@ test "BitListType - tree.deserializeFromBytes" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -978,7 +978,7 @@ test "BitListType - default_root" { try Bits2048.hashTreeRoot(std.testing.allocator, &Bits2048.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &Bits2048.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try Bits2048.tree.default(&pool); @@ -990,7 +990,7 @@ test "BitListType - tree.zeros" { const Bits257 = BitListType(257); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (Bits257.limit / 2..Bits257.limit) |len| { diff --git a/src/ssz/type/bit_vector.zig b/src/ssz/type/bit_vector.zig index c3d795384..d5c5f9792 100644 --- a/src/ssz/type/bit_vector.zig +++ b/src/ssz/type/bit_vector.zig @@ -430,7 +430,7 @@ test "BitVectorType - tree roundtrip 128 bits" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -486,7 +486,7 @@ test "BitVectorType - tree roundtrip 512 bits" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -537,7 +537,7 @@ test "BitVectorType - tree.deserializeFromBytes 128 bits" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -651,7 +651,7 @@ test "BitVectorType - default_root" { try Bits513.hashTreeRoot(&Bits513.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &Bits513.default_root, &expected_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node_128 = try Bits128.tree.default(&pool); diff --git a/src/ssz/type/bool.zig b/src/ssz/type/bool.zig index 430eaa79e..ff99cc639 100644 --- a/src/ssz/type/bool.zig +++ b/src/ssz/type/bool.zig @@ -94,6 +94,21 @@ pub fn BoolType() type { return try pool.createLeaf(&new_leaf); } + /// Decode a packed item directly from chunk bytes. Used by chunked_leaf-backed + /// containers where the chunk is already in hand and a Node.Id is unavailable. + pub fn toValuePackedFromBytes(chunk: *const [32]u8, index: usize, out: *Type) void { + const offset = index % 32; + out.* = if (chunk[offset] == 0) false else true; + } + + /// Encode a packed item directly into chunk bytes (mutates `chunk` in place). + /// Used by chunked_leaf-backed containers; the caller is responsible for any CoW + /// of the chunk before calling. + pub fn fromValuePackedIntoChunk(chunk: *[32]u8, index: usize, value: *const Type) void { + const offset = index % 32; + chunk[offset] = if (value.*) 1 else 0; + } + pub fn serializeIntoBytes(node: Node.Id, pool: *Node.Pool, out: []u8) !usize { const hash = node.getRoot(pool); out[0] = hash[0]; @@ -164,7 +179,7 @@ test "BoolType - serializeIntoBytes (false)" { try std.testing.expectEqualSlices(u8, &expected_root, &root); const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try Bool.tree.fromValue(&pool, &value); var tree_serialized: [1]u8 = undefined; @@ -187,7 +202,7 @@ test "BoolType - serializeIntoBytes (true)" { try std.testing.expectEqualSlices(u8, &expected_root, &root); const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try Bool.tree.fromValue(&pool, &value); var tree_serialized: [1]u8 = undefined; @@ -211,7 +226,7 @@ test "BoolType - tree.deserializeFromBytes" { .{ .id = "true", .serialized = [_]u8{0x01}, .expected_value = true, .expected_root = [_]u8{0x01} ++ [_]u8{0x00} ** 31 }, }; - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); for (test_cases) |tc| { diff --git a/src/ssz/type/byte_list.zig b/src/ssz/type/byte_list.zig index 4266bf1e9..4ba8b8ff9 100644 --- a/src/ssz/type/byte_list.zig +++ b/src/ssz/type/byte_list.zig @@ -29,6 +29,7 @@ pub fn ByteListType(comptime _limit: comptime_int) type { pub const kind = TypeKind.list; pub const Element: type = UintType(8); pub const limit: usize = _limit; + pub const opts: @import("list.zig").TypeOpts = .{}; pub const Type: type = std.ArrayListUnmanaged(Element.Type); pub const TreeView: type = ListBasicTreeView(@This()); pub const min_size: usize = 0; @@ -336,7 +337,7 @@ test "ByteListType - serializeIntoBytes (empty)" { const expected_root = [_]u8{ 0xe8, 0xe5, 0x27, 0xe8, 0x4f, 0x66, 0x61, 0x63, 0xa9, 0x0e, 0xf9, 0x00, 0xe0, 0x13, 0xf5, 0x6b, 0x0a, 0x4d, 0x02, 0x01, 0x48, 0xb2, 0x22, 0x40, 0x57, 0xb7, 0x19, 0xf3, 0x51, 0xb0, 0x03, 0xa6 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); const tree_size = try ByteList256.tree.serializedSize(tree_node, &pool); @@ -362,7 +363,7 @@ test "ByteListType - serializeIntoBytes (4 bytes zero)" { const expected_root = [_]u8{ 0xa3, 0x9b, 0xab, 0xe5, 0x65, 0x30, 0x54, 0x29, 0x77, 0x1f, 0xc5, 0x96, 0xa6, 0x39, 0xd6, 0xe0, 0x5b, 0x2d, 0x03, 0x04, 0x29, 0x79, 0x86, 0xcd, 0xd2, 0xef, 0x38, 0x8c, 0x19, 0x36, 0x88, 0x5e }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); var tree_serialized: [4]u8 = undefined; @@ -390,7 +391,7 @@ test "ByteListType - serializeIntoBytes (4 bytes some value)" { const expected_root = [_]u8{ 0x2e, 0x14, 0xda, 0x11, 0x6e, 0xcb, 0xec, 0x4c, 0x8d, 0x69, 0x36, 0x56, 0xfb, 0x5b, 0x69, 0xbb, 0x0e, 0xa9, 0xe8, 0x4e, 0xcd, 0xd1, 0x5a, 0xba, 0x7b, 0xe1, 0xc0, 0x08, 0x63, 0x3f, 0x28, 0x85 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); var tree_serialized: [4]u8 = undefined; @@ -417,7 +418,7 @@ test "ByteListType - serializeIntoBytes (32 bytes zero)" { const expected_root = [_]u8{ 0xba, 0xe1, 0x46, 0xb2, 0x21, 0xec, 0xa7, 0x58, 0x70, 0x2e, 0x29, 0xb4, 0x5e, 0xe7, 0xf7, 0xdc, 0x3e, 0xea, 0x17, 0xd1, 0x19, 0xdd, 0x0a, 0x30, 0x94, 0x48, 0x1e, 0x3f, 0x94, 0x70, 0x6c, 0x96 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); var tree_serialized: [32]u8 = undefined; @@ -446,7 +447,7 @@ test "ByteListType - serializeIntoBytes (32 bytes some value)" { const expected_root = [_]u8{ 0x50, 0x42, 0x5d, 0xbd, 0x7a, 0x34, 0xb5, 0x0b, 0x20, 0x91, 0x6e, 0x96, 0x5c, 0xe5, 0xc0, 0x60, 0xab, 0xe6, 0x51, 0x6a, 0xc7, 0x1b, 0xb0, 0x0a, 0x4a, 0xfe, 0xbe, 0x5d, 0x5c, 0x45, 0x68, 0xb8 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); var tree_serialized: [32]u8 = undefined; @@ -479,7 +480,7 @@ test "ByteListType - serializeIntoBytes (96 bytes some value)" { const expected_root = [_]u8{ 0x5d, 0x3a, 0xe4, 0xb8, 0x86, 0xc2, 0x41, 0xff, 0xe8, 0xdc, 0x7a, 0xe1, 0xb5, 0xf0, 0xe2, 0xfb, 0x9b, 0x68, 0x2e, 0x1e, 0xac, 0x2d, 0xde, 0xa2, 0x92, 0xef, 0x02, 0xcc, 0x17, 0x9e, 0x69, 0x03 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.fromValue(&pool, &value); var tree_serialized: [96]u8 = undefined; @@ -496,7 +497,7 @@ test "ByteListType - tree.deserializeFromBytes (32 bytes)" { // 0x50425dbd7a34b50b20916e965ce5c060abe6516ac71bb00a4afebe5d5c4568b8 const expected_root = [_]u8{ 0x50, 0x42, 0x5d, 0xbd, 0x7a, 0x34, 0xb5, 0x0b, 0x20, 0x91, 0x6e, 0x96, 0x5c, 0xe5, 0xc0, 0x60, 0xab, 0xe6, 0x51, 0x6a, 0xc7, 0x1b, 0xb0, 0x0a, 0x4a, 0xfe, 0xbe, 0x5d, 0x5c, 0x45, 0x68, 0xb8 }; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.deserializeFromBytes(&pool, &serialized); @@ -527,7 +528,7 @@ test "ByteListType - tree.deserializeFromBytes (96 bytes)" { // 0x5d3ae4b886c241ffe8dc7ae1b5f0e2fb9b682e1eac2ddea292ef02cc179e6903 const expected_root = [_]u8{ 0x5d, 0x3a, 0xe4, 0xb8, 0x86, 0xc2, 0x41, 0xff, 0xe8, 0xdc, 0x7a, 0xe1, 0xb5, 0xf0, 0xe2, 0xfb, 0x9b, 0x68, 0x2e, 0x1e, 0xac, 0x2d, 0xde, 0xa2, 0x92, 0xef, 0x02, 0xcc, 0x17, 0x9e, 0x69, 0x03 }; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteList256.tree.deserializeFromBytes(&pool, &serialized); @@ -614,7 +615,7 @@ test "ByteListType - default_root" { try ByteList256.hashTreeRoot(std.testing.allocator, &ByteList256.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &ByteList256.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ByteList256.tree.default(&pool); @@ -626,7 +627,7 @@ test "ByteListType - tree.zeros" { const ByteList256 = ByteListType(256); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (0..ByteList256.limit) |len| { diff --git a/src/ssz/type/byte_vector.zig b/src/ssz/type/byte_vector.zig index ea51d1df4..3963bd747 100644 --- a/src/ssz/type/byte_vector.zig +++ b/src/ssz/type/byte_vector.zig @@ -27,6 +27,7 @@ pub fn ByteVectorType(comptime _length: comptime_int) type { pub const kind = TypeKind.vector; pub const Element: type = UintType(8); pub const length: usize = _length; + pub const opts: @import("list.zig").TypeOpts = .{}; pub const Type: type = [length]Element.Type; pub const TreeView: type = ArrayBasicTreeView(@This()); pub const fixed_size: usize = Element.fixed_size * length; @@ -204,7 +205,7 @@ test "ByteVectorType(4) - serializeIntoBytes (zero)" { const expected_root = [_]u8{0x00} ** 32; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try ByteVector4.tree.fromValue(&pool, &value); var tree_serialized: [4]u8 = undefined; @@ -230,7 +231,7 @@ test "ByteVectorType(4) - serializeIntoBytes (some value)" { const expected_root = [_]u8{ 0x0c, 0xb9, 0x47, 0x37 } ++ [_]u8{0x00} ** 28; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try ByteVector4.tree.fromValue(&pool, &value); var tree_serialized: [4]u8 = undefined; @@ -255,7 +256,7 @@ test "ByteVectorType(32) - serializeIntoBytes (zero)" { const expected_root = [_]u8{0x00} ** 32; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try ByteVector32.tree.fromValue(&pool, &value); var tree_serialized: [32]u8 = undefined; @@ -280,7 +281,7 @@ test "ByteVectorType(32) - serializeIntoBytes (some value)" { // root equals the value itself for 32-byte vector try std.testing.expectEqualSlices(u8, &value, &root); - var pool = try Node.Pool.init(allocator, 32); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 32 }); defer pool.deinit(); const tree_node = try ByteVector32.tree.fromValue(&pool, &value); var tree_serialized: [32]u8 = undefined; @@ -305,7 +306,7 @@ test "ByteVectorType(96) - serializeIntoBytes (zero)" { const expected_root = [_]u8{ 0xdb, 0x56, 0x11, 0x4e, 0x00, 0xfd, 0xd4, 0xc1, 0xf8, 0x5c, 0x89, 0x2b, 0xf3, 0x5a, 0xc9, 0xa8, 0x92, 0x89, 0xaa, 0xec, 0xb1, 0xeb, 0xd0, 0xa9, 0x6c, 0xde, 0x60, 0x6a, 0x74, 0x8b, 0x5d, 0x71 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteVector96.tree.fromValue(&pool, &value); var tree_serialized: [96]u8 = undefined; @@ -332,7 +333,7 @@ test "ByteVectorType(96) - serializeIntoBytes (some value)" { const expected_root = [_]u8{ 0x03, 0x2e, 0xec, 0xca, 0x63, 0x7b, 0x67, 0xfd, 0x92, 0x2e, 0x0e, 0x42, 0x1b, 0x4b, 0xe9, 0xc2, 0x29, 0x48, 0x71, 0x9b, 0xa0, 0x2c, 0x6d, 0x03, 0xeb, 0x2c, 0x61, 0xcf, 0xdc, 0x4c, 0xb3, 0xe3 }; try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteVector96.tree.fromValue(&pool, &value); var tree_serialized: [96]u8 = undefined; @@ -363,7 +364,7 @@ test "ByteVectorType(32) - tree.deserializeFromBytes" { }, }; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); for (test_cases) |tc| { @@ -395,7 +396,7 @@ test "ByteVectorType(96) - tree.deserializeFromBytes" { // 0x032eecca637b67fd922e0e421b4be9c22948719ba02c6d03eb2c61cfdc4cb3e3 const expected_root = [_]u8{ 0x03, 0x2e, 0xec, 0xca, 0x63, 0x7b, 0x67, 0xfd, 0x92, 0x2e, 0x0e, 0x42, 0x1b, 0x4b, 0xe9, 0xc2, 0x29, 0x48, 0x71, 0x9b, 0xa0, 0x2c, 0x6d, 0x03, 0xeb, 0x2c, 0x61, 0xcf, 0xdc, 0x4c, 0xb3, 0xe3 }; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const tree_node = try ByteVector96.tree.deserializeFromBytes(&pool, &serialized); @@ -429,7 +430,7 @@ test "ByteVectorType - default_root" { try ByteVector96.hashTreeRoot(&ByteVector96.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &ByteVector96.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node_4 = try ByteVector4.tree.default(&pool); diff --git a/src/ssz/type/container.zig b/src/ssz/type/container.zig index d7bce1e34..f31fc90e6 100644 --- a/src/ssz/type/container.zig +++ b/src/ssz/type/container.zig @@ -9,10 +9,12 @@ const isBasicType = @import("type_kind.zig").isBasicType; const merkleize = @import("hashing").merkleize; const maxChunksToDepth = @import("hashing").maxChunksToDepth; -const Node = @import("persistent_merkle_tree").Node; -const Gindex = @import("persistent_merkle_tree").Gindex; -const Depth = @import("persistent_merkle_tree").Depth; +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const Gindex = pmt.Gindex; +const Depth = pmt.Depth; const ContainerTreeView = @import("../tree_view/root.zig").ContainerTreeView; +const StructContainerTreeView = @import("../tree_view/root.zig").StructContainerTreeView; pub fn FixedContainerType(comptime ST: type) type { const ssz_fields = switch (@typeInfo(ST)) { @@ -294,6 +296,156 @@ pub fn FixedContainerType(comptime ST: type) type { }; } +/// A fixed-size container whose tree representation is a single +/// `.container_struct` Node (vtable + cached deserialized struct), instead of a +/// merkleized subtree of leaf chunks. +/// +/// This trades a ~50% reduction in struct-decode work (no per-field tree +/// walk + leaf re-decode) for opaque tree navigation: gindex paths into the +/// container's interior are not navigable. Use this for hot-path types where +/// callers consume the whole struct (e.g. `validatorsSlice`). +/// +/// Wraps an underlying `FixedContainerType(ST)` and re-exports its +/// merkleization, serialization, JSON, and field metadata. The differences +/// live entirely in the `tree` namespace, which routes through +/// `Pool.createContainerStruct` / `Pool.getStructPtr`. +pub fn StructContainerType(comptime ST: type) type { + const FixedCT = FixedContainerType(ST); + + return struct { + pub const kind = TypeKind.container; + pub const Fields: type = ST; + pub const fields: []const std.builtin.Type.StructField = FixedCT.fields; + pub const Type: type = FixedCT.Type; + pub const TreeView: type = StructContainerTreeView(@This()); + pub const fixed_size: usize = FixedCT.fixed_size; + pub const field_offsets: [fields.len]usize = FixedCT.field_offsets; + pub const chunk_count: usize = fields.len; + pub const chunk_depth: Depth = maxChunksToDepth(chunk_count); + pub const default_value: Type = FixedCT.default_value; + pub const default_root: [32]u8 = FixedCT.default_root; + pub const serialized = FixedCT.serialized; + const Self = @This(); + + /// Wrapper that satisfies the ContainerStructRef vtable contract. + /// + /// `value` is the full deserialized struct (cached at the leaf-Node + /// level). The Pool clones via `init` and frees via `deinit`. Root + /// computation merkleizes the wrapped value via `FixedCT.hashTreeRoot`. + pub const WrappedT = struct { + value: Type, + + pub fn getRoot(self: *const WrappedT, out: *[32]u8) void { + FixedCT.hashTreeRoot(&self.value, out) catch unreachable; + } + + /// Materialize a temporary navigable PMT subtree representing this + /// container's full hash-tree. Used by proof traversal — see + /// `Pool.materializeContainerStruct`. The returned Id has refcount=0; + /// the caller is responsible for `unref`'ing it. + pub fn toTree(self: *const WrappedT, pool: *Node.Pool) !Node.Id { + return try FixedCT.tree.fromValue(pool, &self.value); + } + + pub fn init(allocator: std.mem.Allocator, wrapped: *const WrappedT) !*const WrappedT { + const ptr = try allocator.create(WrappedT); + errdefer allocator.destroy(ptr); + try FixedCT.clone(&wrapped.value, &ptr.value); + return ptr; + } + + pub fn deinit(self: *WrappedT, allocator: std.mem.Allocator) void { + allocator.destroy(self); + } + }; + + pub fn equals(a: *const Type, b: *const Type) bool { + return FixedCT.equals(a, b); + } + + pub fn clone(value: *const Type, out: anytype) !void { + return FixedCT.clone(value, out); + } + + pub fn hashTreeRoot(value: *const Type, out: *[32]u8) !void { + return FixedCT.hashTreeRoot(value, out); + } + + pub fn serializeIntoBytes(value: *const Type, out: []u8) usize { + return FixedCT.serializeIntoBytes(value, out); + } + + pub fn deserializeFromBytes(data: []const u8, out: *Type) !void { + return FixedCT.deserializeFromBytes(data, out); + } + + pub const tree = struct { + pub fn default(pool: *Node.Pool) !Node.Id { + const wrapped = WrappedT{ .value = default_value }; + return try pool.createContainerStruct(WrappedT, &wrapped); + } + + pub fn deserializeFromBytes(pool: *Node.Pool, data: []const u8) !Node.Id { + if (data.len != fixed_size) { + return error.InvalidSize; + } + var wrapped: WrappedT = undefined; + try Self.deserializeFromBytes(data, &wrapped.value); + return try pool.createContainerStruct(WrappedT, &wrapped); + } + + pub fn toValue(node: Node.Id, pool: *Node.Pool, out: *Type) !void { + const wrapped = try pool.getStructPtr(node, WrappedT); + try Self.clone(&wrapped.value, out); + } + + /// Returns a read-only pointer to the value stored in a + /// `.container_struct` node, with no copy. The pointer is valid + /// as long as the node's refcount is held and the value isn't + /// replaced via CoW. Caller must not retain the pointer past + /// any mutation of the same validator slot. + pub fn getValuePtr(node: Node.Id, pool: *Node.Pool) !*const Type { + const wrapped = try pool.getStructPtr(node, WrappedT); + return &wrapped.value; + } + + pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { + const wrapped = WrappedT{ .value = value.* }; + return try pool.createContainerStruct(WrappedT, &wrapped); + } + + pub fn serializeIntoBytes(node: Node.Id, pool: *Node.Pool, out: []u8) !usize { + const wrapped = try pool.getStructPtr(node, WrappedT); + return Self.serializeIntoBytes(&wrapped.value, out); + } + }; + + pub fn serializeIntoJson(writer: anytype, in: *const Type) !void { + return FixedCT.serializeIntoJson(writer, in); + } + + pub fn deserializeFromJson(source: *std.json.Scanner, out: *Type) !void { + return FixedCT.deserializeFromJson(source, out); + } + + pub fn getFieldIndex(comptime name: []const u8) usize { + return FixedCT.getFieldIndex(name); + } + + pub fn hasField(comptime name: []const u8) bool { + return FixedCT.hasField(name); + } + + pub fn getFieldType(comptime name: []const u8) type { + return FixedCT.getFieldType(name); + } + + pub fn getFieldGindex(comptime name: []const u8) Gindex { + return FixedCT.getFieldGindex(name); + } + }; +} + pub fn VariableContainerType(comptime ST: type) type { const ssz_fields = switch (@typeInfo(ST)) { .@"struct" => |s| s.fields, @@ -751,6 +903,8 @@ const UintType = @import("uint.zig").UintType; const BoolType = @import("bool.zig").BoolType; const ByteVectorType = @import("byte_vector.zig").ByteVectorType; const FixedListType = @import("list.zig").FixedListType; +const FixedVectorType = @import("vector.zig").FixedVectorType; +const proof = pmt.proof; const TypeTestCase = @import("test_utils.zig").TypeTestCase; @@ -770,9 +924,9 @@ test "ContainerType - sanity" { // create a variable container type and instance and round-trip serialize const allocator = std.testing.allocator; const Foo = VariableContainerType(struct { - a: FixedListType(UintType(8), 32), - b: FixedListType(UintType(8), 32), - c: FixedListType(UintType(8), 32), + a: FixedListType(UintType(8), 32, .{}), + b: FixedListType(UintType(8), 32, .{}), + c: FixedListType(UintType(8), 32, .{}), }); var f: Foo.Type = undefined; f.a = try std.ArrayListUnmanaged(u8).initCapacity(allocator, 10); @@ -820,8 +974,8 @@ test "clone FixedContainerType" { test "clone VariableContainerType" { const allocator = std.testing.allocator; - const FieldA = FixedListType(UintType(8), 32); - const FieldB = FixedListType(UintType(8), 32); + const FieldA = FixedListType(UintType(8), 32, .{}); + const FieldB = FixedListType(UintType(8), 32, .{}); const Foo = VariableContainerType(struct { a: FieldA, b: FieldB, @@ -840,7 +994,7 @@ test "clone VariableContainerType" { try std.testing.expect(Foo.equals(&cloned_f, &f)); // clone into a larger container - const FieldC = FixedListType(UintType(8), 32); + const FieldC = FixedListType(UintType(8), 32, .{}); const Foo2 = VariableContainerType(struct { a: FieldA, b: FieldB, @@ -876,7 +1030,7 @@ test "FixedContainerType - serializeIntoBytes (zero)" { try Container.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const node = try Container.tree.fromValue(&pool, &value); var tree_serialized: [Container.fixed_size]u8 = undefined; @@ -907,7 +1061,7 @@ test "FixedContainerType - serializeIntoBytes (some value)" { try Container.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const node = try Container.tree.fromValue(&pool, &value); var tree_serialized: [Container.fixed_size]u8 = undefined; @@ -928,7 +1082,7 @@ test "FixedContainerType - tree.deserializeFromBytes" { const expected_serialized = [_]u8{ 0x40, 0xe2, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf1, 0xfb, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00 }; const expected_root = [_]u8{ 0x53, 0xb3, 0x8a, 0xff, 0x7b, 0xf2, 0xdd, 0x1a, 0x49, 0x90, 0x3d, 0x07, 0xa3, 0x35, 0x09, 0xb9, 0x80, 0xc6, 0xac, 0xc9, 0xf2, 0x23, 0x5a, 0x45, 0xaa, 0xc3, 0x42, 0xb0, 0xa9, 0x52, 0x8c, 0x22 }; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const node = try Container.tree.deserializeFromBytes(&pool, &expected_serialized); @@ -966,7 +1120,7 @@ test "FixedContainerType - serializeIntoBytes (uint64 + ByteVector32)" { try Container.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const node = try Container.tree.fromValue(&pool, &value); var tree_serialized: [Container.fixed_size]u8 = undefined; @@ -979,7 +1133,7 @@ test "FixedContainerType - serializeIntoBytes (uint64 + ByteVector32)" { test "VariableContainerType - serializeIntoBytes (zero)" { const allocator = std.testing.allocator; const Container = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); @@ -1001,7 +1155,7 @@ test "VariableContainerType - serializeIntoBytes (zero)" { try Container.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const node = try Container.tree.fromValue(&pool, &value); const tree_size = try Container.tree.serializedSize(node, &pool); @@ -1016,7 +1170,7 @@ test "VariableContainerType - serializeIntoBytes (zero)" { test "VariableContainerType - serializeIntoBytes (some value)" { const allocator = std.testing.allocator; const Container = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); @@ -1050,7 +1204,7 @@ test "VariableContainerType - serializeIntoBytes (some value)" { try Container.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 128 }); defer pool.deinit(); const node = try Container.tree.fromValue(&pool, &value); const tree_size = try Container.tree.serializedSize(node, &pool); @@ -1065,7 +1219,7 @@ test "VariableContainerType - serializeIntoBytes (some value)" { test "VariableContainerType - tree.deserializeFromBytes" { const allocator = std.testing.allocator; const Container = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); @@ -1080,7 +1234,7 @@ test "VariableContainerType - tree.deserializeFromBytes" { }; const expected_root = [_]u8{ 0x5f, 0xf1, 0xb9, 0x2b, 0x2f, 0xa5, 0x5e, 0xea, 0x1a, 0x14, 0xb2, 0x65, 0x47, 0x03, 0x5b, 0x2f, 0x54, 0x37, 0x81, 0x4b, 0x34, 0x36, 0x17, 0x22, 0x05, 0xfa, 0x7d, 0x6a, 0xf4, 0x09, 0x17, 0x48 }; - var pool = try Node.Pool.init(allocator, 128); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 128 }); defer pool.deinit(); const node = try Container.tree.deserializeFromBytes(&pool, &serialized); @@ -1131,11 +1285,11 @@ test "ContainerType" { } } -test "ContainerType with FixedListType(uint64, 128) and uint64" { +test "ContainerType with FixedListType(uint64, 128, .{}) and uint64" { const allocator = std.testing.allocator; const Container = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); @@ -1197,8 +1351,8 @@ test "FixedContainerType equals" { test "VariableContainerType equals" { const allocator = std.testing.allocator; const Container = VariableContainerType(struct { - list1: FixedListType(UintType(8), 32), - list2: FixedListType(UintType(8), 32), + list1: FixedListType(UintType(8), 32, .{}), + list2: FixedListType(UintType(8), 32, .{}), value: UintType(64), }); @@ -1206,16 +1360,16 @@ test "VariableContainerType equals" { var b: Container.Type = undefined; var c: Container.Type = undefined; - a.list1 = FixedListType(UintType(8), 32).Type.empty; - a.list2 = FixedListType(UintType(8), 32).Type.empty; + a.list1 = FixedListType(UintType(8), 32, .{}).Type.empty; + a.list2 = FixedListType(UintType(8), 32, .{}).Type.empty; a.value = 100; - b.list1 = FixedListType(UintType(8), 32).Type.empty; - b.list2 = FixedListType(UintType(8), 32).Type.empty; + b.list1 = FixedListType(UintType(8), 32, .{}).Type.empty; + b.list2 = FixedListType(UintType(8), 32, .{}).Type.empty; b.value = 100; - c.list1 = FixedListType(UintType(8), 32).Type.empty; - c.list2 = FixedListType(UintType(8), 32).Type.empty; + c.list1 = FixedListType(UintType(8), 32, .{}).Type.empty; + c.list2 = FixedListType(UintType(8), 32, .{}).Type.empty; c.value = 101; // Different value defer a.list1.deinit(allocator); @@ -1249,7 +1403,7 @@ test "FixedContainerType - default_root" { try Container.hashTreeRoot(&Container.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &Container.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try Container.tree.default(&pool); @@ -1259,16 +1413,120 @@ test "FixedContainerType - default_root" { test "VariableContainerType - default_root" { var expected_root: [32]u8 = undefined; const Container = VariableContainerType(struct { - a: FixedListType(UintType(64), 128), + a: FixedListType(UintType(64), 128, .{}), b: UintType(64), }); try Container.hashTreeRoot(std.testing.allocator, &Container.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &Container.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try Container.tree.default(&pool); try std.testing.expectEqualSlices(u8, &expected_root, node.getRoot(&pool)); } + +// StructContainerType makes the root a `.container_struct` opaque, and +// FixedVectorType(.{.chunked_leaf=true}) builds the field from `.chunked_leaf` +// nodes. A single-proof path descending through the chunked vector crosses +// both opaque kinds — proof traversal materializes each in turn. +test "createSingleProof through StructContainer with chunked_leaf vector field" { + const allocator = std.testing.allocator; + const Vec = FixedVectorType(UintType(64), 4096, .{ .chunked_leaf = true }); + const Outer = StructContainerType(struct { + vec: Vec, + tag: UintType(64), + }); + + var value: Outer.Type = .{ + .vec = Vec.default_value, + .tag = 0x1234_5678_9abc_def0, + }; + for (0..16) |i| value.vec[i] = (@as(u64, @intCast(i)) + 1) *% 0x0101_0101_0101_0101; + + var pool = try Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 8192, + }); + defer pool.deinit(); + + const root = try Outer.tree.fromValue(&pool, &value); + defer pool.unref(root); + + // gindex 2048 = outer.vec field (gindex 2 of the materialized container) + // → chunk 0 of the chunked vector (relative gindex 1024 in its 1024-chunk + // subtree). Traversal crosses the container_struct root and the + // chunked_leaf node(s) of the vec field — the nested-opaque case + // single-proof traversal must handle. + const gindex = Gindex.fromUint(2048); + + var single_proof = try proof.createSingleProof(allocator, &pool, root, gindex); + defer single_proof.deinit(allocator); + + var pool2 = try Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 256, + }); + defer pool2.deinit(); + + const rebuilt = try proof.createNodeFromSingleProof(&pool2, gindex, single_proof.leaf, single_proof.witnesses); + defer pool2.unref(rebuilt); + + const original = root.getRoot(&pool).*; + const rebuilt_root = rebuilt.getRoot(&pool2).*; + try std.testing.expectEqualSlices(u8, &original, &rebuilt_root); +} + +// A 1-field StructContainerType has `chunk_depth = 0`, so its `toTree` +// returns the single field's tree directly with no enclosing branch. The +// vector below is sized to exactly one chunked_leaf, so materializing the +// container hands back another opaque node — `materializeIfOpaque` must +// keep materializing until the result is navigable. +test "createSingleProof through single-field StructContainer with chunked_leaf vector" { + const allocator = std.testing.allocator; + const ChunkedLeaf = pmt.ChunkedLeaf; + // Exactly K chunks (4 u64 per chunk) → the vector is one chunked_leaf. + const Vec = FixedVectorType(UintType(64), ChunkedLeaf.K * 4, .{ .chunked_leaf = true }); + const Outer = StructContainerType(struct { + only: Vec, + }); + + var value: Outer.Type = .{ .only = Vec.default_value }; + for (0..8) |i| value.only[i] = (@as(u64, @intCast(i)) +% 1) *% 0x7777_7777_7777_7777; + + var pool = try Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 8192, + }); + defer pool.deinit(); + + const root = try Outer.tree.fromValue(&pool, &value); + defer pool.unref(root); + + // Single field → the container's tree at gindex 1 IS the field's tree; + // the K-chunk vector puts chunk 0 at gindex 1<= K * items_per_chunk` so that + /// `chunk_depth >= k_log2`. + chunked_leaf: bool = false, +}; + +pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int, comptime _opts: TypeOpts) type { comptime { if (!isFixedType(ST)) { @compileError("ST must be fixed type"); @@ -21,11 +36,26 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { if (_limit <= 0) { @compileError("limit must be greater than 0"); } + if (_opts.chunked_leaf and !isBasicType(ST)) { + @compileError("FixedListType: opts.chunked_leaf=true requires isBasicType(Element)"); + } + if (_opts.chunked_leaf) { + const ChunkedLeaf = pmt.ChunkedLeaf; + const items_per_chunk_local = if (isBasicType(ST)) (32 / ST.fixed_size) else 1; + const min_limit = ChunkedLeaf.K * items_per_chunk_local; + if (_limit < min_limit) { + @compileError(std.fmt.comptimePrint( + "FixedListType: opts.chunked_leaf=true requires limit >= K * items_per_chunk = {d} (chunk_depth must be >= ChunkedLeaf.k_log2)", + .{min_limit}, + )); + } + } } return struct { pub const kind = TypeKind.list; pub const Element: type = ST; pub const limit: usize = _limit; + pub const opts: TypeOpts = _opts; pub const Type: type = std.ArrayListUnmanaged(Element.Type); pub const TreeView: type = if (isBasicType(Element)) ListBasicTreeView(@This()) @@ -35,6 +65,9 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { pub const max_size: usize = Element.fixed_size * limit; pub const max_chunk_count: usize = if (isBasicType(Element)) std.math.divCeil(usize, max_size, 32) catch unreachable else limit; pub const chunk_depth: u8 = maxChunksToDepth(max_chunk_count); + pub const use_chunked_leaf: bool = _opts.chunked_leaf; + const ChunkedLeaf = if (use_chunked_leaf) pmt.ChunkedLeaf else struct {}; + const chunked_leaf_depth: u8 = if (use_chunked_leaf) chunk_depth - ChunkedLeaf.k_log2 else 0; pub const default_value: Type = Type.empty; @@ -274,6 +307,45 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { ); } + return if (comptime use_chunked_leaf) + deserializeFromBytesChunkedLeaf(pool, data, len) + else + deserializeFromBytesPlain(pool, data, len, chunk_count); + } + + /// `deserializeFromBytes` for chunked_leaf layouts. + fn deserializeFromBytesChunkedLeaf(pool: *Node.Pool, data: []const u8, len: usize) !Node.Id { + var it = Node.FillWithContentsIterator.initWithOffset(pool, chunked_leaf_depth, ChunkedLeaf.k_log2); + errdefer it.deinit(); + + const bytes_per_chunked_leaf: usize = ChunkedLeaf.K * 32; + var byte_idx: usize = 0; + + while (byte_idx < data.len) { + const remaining = data.len - byte_idx; + const chunked_leaf_bytes = @min(remaining, bytes_per_chunked_leaf); + const valid_chunks: u16 = @intCast((chunked_leaf_bytes + 31) / 32); + var chunked_leaf_id_opt: ?Node.Id = try pool.createChunkedLeafEmpty(valid_chunks); + errdefer if (chunked_leaf_id_opt) |id| pool.unref(id); + + const storage = try chunked_leaf_id_opt.?.getChunkedLeafPtr(pool); + @memcpy(@as([*]u8, @ptrCast(&storage.chunks))[0..chunked_leaf_bytes], data[byte_idx..][0..chunked_leaf_bytes]); + try it.append(chunked_leaf_id_opt.?); + chunked_leaf_id_opt = null; + byte_idx += chunked_leaf_bytes; + } + + const content_root = try it.finish(); + errdefer pool.unref(content_root); + + const len_mixin = try pool.createLeafFromUint(len); + errdefer pool.unref(len_mixin); + + return try pool.createBranch(content_root, len_mixin); + } + + /// `deserializeFromBytes` for non-chunked_leaf layouts. + fn deserializeFromBytesPlain(pool: *Node.Pool, data: []const u8, len: usize, chunk_count: usize) !Node.Id { var it = Node.FillWithContentsIterator.init(pool, chunk_depth); errdefer it.deinit(); @@ -300,6 +372,7 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { const content_root = try it.finish(); errdefer pool.unref(content_root); + const len_mixin = try pool.createLeafFromUint(len); errdefer pool.unref(len_mixin); @@ -324,13 +397,58 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { return; } + try out.resize(allocator, len); + @memset(out.items, Element.default_value); + + return if (comptime use_chunked_leaf) + toValueChunkedLeaf(allocator, node, pool, out, len, chunk_count) + else + toValuePlain(allocator, node, pool, out, len, chunk_count); + } + + /// `toValue` for chunked_leaf layouts. `out.items` is pre-resized + /// to `len` and zero-initialised by the caller. + fn toValueChunkedLeaf(allocator: std.mem.Allocator, node: Node.Id, pool: *Node.Pool, out: *Type, len: usize, chunk_count: usize) !void { + const content_root = try node.getLeft(pool); + const items_per_chunk = 32 / Element.fixed_size; + const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; + const chunked_leaf_ids = try allocator.alloc(Node.Id, chunked_leaf_count); + defer allocator.free(chunked_leaf_ids); + try content_root.getNodesAtDepth(pool, chunked_leaf_depth, 0, chunked_leaf_ids); + + const state_col = pool.nodes.items(.state); + var item_idx: usize = 0; + outer: for (chunked_leaf_ids) |sid| { + // A zero subtree at chunked_leaf boundary is semantically an + // all-zero chunked_leaf — out.items already initialised to + // Element.default_value via the @memset above, so + // skip the chunked_leaf payload read entirely. + if (state_col[@intFromEnum(sid)].kind() == .zero) { + const items_in_chunked_leaf = @min(ChunkedLeaf.K * items_per_chunk, len - item_idx); + item_idx += items_in_chunked_leaf; + if (item_idx >= len) break :outer; + continue; + } + const chunks = try sid.getChunkedLeafChunks(pool); + for (0..ChunkedLeaf.K) |intra_chunk| { + if (item_idx >= len) break :outer; + const items_in_chunk = @min(items_per_chunk, len - item_idx); + for (0..items_in_chunk) |i| { + Element.tree.toValuePackedFromBytes(&chunks[intra_chunk], item_idx + i, &out.items[item_idx + i]); + } + item_idx += items_in_chunk; + } + } + } + + /// `toValue` for non-chunked_leaf layouts. `out.items` is + /// pre-resized to `len` by the caller. + fn toValuePlain(allocator: std.mem.Allocator, node: Node.Id, pool: *Node.Pool, out: *Type, len: usize, chunk_count: usize) !void { const nodes = try allocator.alloc(Node.Id, chunk_count); defer allocator.free(nodes); try node.getNodesAtDepth(pool, chunk_depth + 1, 0, nodes); - try out.resize(allocator, len); - @memset(out.items, Element.default_value); if (comptime isBasicType(Element)) { // tightly packed list for (0..len) |i| { @@ -362,6 +480,55 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { ); } + return if (comptime use_chunked_leaf) + fromValueChunkedLeaf(pool, value, len) + else + fromValuePlain(pool, value, len, chunk_count); + } + + /// `fromValue` for chunked_leaf layouts. + fn fromValueChunkedLeaf(pool: *Node.Pool, value: *const Type, len: usize) !Node.Id { + var it = Node.FillWithContentsIterator.initWithOffset(pool, chunked_leaf_depth, ChunkedLeaf.k_log2); + errdefer it.deinit(); + + const items_per_chunk = 32 / Element.fixed_size; + const items_per_chunked_leaf: usize = items_per_chunk * ChunkedLeaf.K; + var item_idx: usize = 0; + + while (item_idx < len) { + const remaining = len - item_idx; + const items_in_chunked_leaf = @min(remaining, items_per_chunked_leaf); + const valid_chunks: u16 = @intCast((items_in_chunked_leaf + items_per_chunk - 1) / items_per_chunk); + + var chunked_leaf_id_opt: ?Node.Id = try pool.createChunkedLeafEmpty(valid_chunks); + errdefer if (chunked_leaf_id_opt) |id| pool.unref(id); + + const storage = try chunked_leaf_id_opt.?.getChunkedLeafPtr(pool); + + for (0..items_in_chunked_leaf) |k| { + const chunked_leaf_chunk_idx = k / items_per_chunk; + const intra_chunk = k % items_per_chunk; + const dst_off = intra_chunk * Element.fixed_size; + const dst_slice = storage.chunks[chunked_leaf_chunk_idx][dst_off .. dst_off + Element.fixed_size]; + _ = Element.serializeIntoBytes(&value.items[item_idx + k], dst_slice); + } + + try it.append(chunked_leaf_id_opt.?); + chunked_leaf_id_opt = null; + item_idx += items_in_chunked_leaf; + } + + const content_root = try it.finish(); + errdefer pool.unref(content_root); + + const len_mixin = try pool.createLeafFromUint(len); + errdefer pool.unref(len_mixin); + + return try pool.createBranch(content_root, len_mixin); + } + + /// `fromValue` for non-chunked_leaf layouts. + fn fromValuePlain(pool: *Node.Pool, value: *const Type, len: usize, chunk_count: usize) !Node.Id { var it = Node.FillWithContentsIterator.init(pool, chunk_depth); errdefer it.deinit(); @@ -394,6 +561,7 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { const content_root = try it.finish(); errdefer pool.unref(content_root); + const len_mixin = try pool.createLeafFromUint(len); errdefer pool.unref(len_mixin); @@ -411,6 +579,48 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int) type { else len; + return if (comptime use_chunked_leaf) + serializeIntoBytesChunkedLeaf(node, pool, out, len, chunk_count) + else + serializeIntoBytesPlain(node, pool, out, len, chunk_count); + } + + /// `serializeIntoBytes` for chunked_leaf layouts. + fn serializeIntoBytesChunkedLeaf(node: Node.Id, pool: *Node.Pool, out: []u8, len: usize, chunk_count: usize) !usize { + const serialized_size = len * Element.fixed_size; + const content_root = try node.getLeft(pool); + const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; + + const chunked_leaf_ids_buf = try pool.page_allocator.alloc(Node.Id, chunked_leaf_count); + defer pool.page_allocator.free(chunked_leaf_ids_buf); + try content_root.getNodesAtDepth(pool, chunked_leaf_depth, 0, chunked_leaf_ids_buf); + + const state_col = pool.nodes.items(.state); + var byte_idx: usize = 0; + outer: for (chunked_leaf_ids_buf) |sid| { + // Zero subtree at chunked_leaf boundary == all-zero output. + if (state_col[@intFromEnum(sid)].kind() == .zero) { + const remaining = serialized_size - byte_idx; + const zero_bytes = @min(ChunkedLeaf.K * 32, remaining); + @memset(out[byte_idx..][0..zero_bytes], 0); + byte_idx += zero_bytes; + if (byte_idx >= serialized_size) break :outer; + continue; + } + const chunks = try sid.getChunkedLeafChunks(pool); + for (0..ChunkedLeaf.K) |intra_chunk| { + if (byte_idx >= serialized_size) break :outer; + const remaining = serialized_size - byte_idx; + const bytes_to_copy = @min(remaining, 32); + @memcpy(out[byte_idx..][0..bytes_to_copy], chunks[intra_chunk][0..bytes_to_copy]); + byte_idx += bytes_to_copy; + } + } + return serialized_size; + } + + /// `serializeIntoBytes` for non-chunked_leaf layouts. + fn serializeIntoBytesPlain(node: Node.Id, pool: *Node.Pool, out: []u8, len: usize, chunk_count: usize) !usize { var it = Node.DepthIterator.init(pool, node, chunk_depth + 1, 0); if (comptime isBasicType(Element)) { @@ -699,6 +909,7 @@ pub fn VariableListType(comptime ST: type, comptime _limit: comptime_int) type { const content_root = try it.finish(); errdefer pool.unref(content_root); + const len_mixin = try pool.createLeafFromUint(len); errdefer pool.unref(len_mixin); @@ -755,6 +966,7 @@ pub fn VariableListType(comptime ST: type, comptime _limit: comptime_int) type { const content_root = try it.finish(); errdefer pool.unref(content_root); + const len_mixin = try pool.createLeafFromUint(len); errdefer pool.unref(len_mixin); @@ -830,7 +1042,7 @@ test "ListType - sanity" { const allocator = std.testing.allocator; // create a fixed list type and instance and round-trip serialize - const Bytes = FixedListType(UintType(8), 32); + const Bytes = FixedListType(UintType(8), 32, .{}); var b: Bytes.Type = Bytes.default_value; defer b.deinit(allocator); @@ -862,7 +1074,7 @@ test "clone FixedListType" { epoch: UintType(8), root: ByteVectorType(32), }); - const CheckpointList = FixedListType(Checkpoint, 8); + const CheckpointList = FixedListType(Checkpoint, 8, .{}); var list: CheckpointList.Type = CheckpointList.default_value; defer CheckpointList.deinit(allocator, &list); const cp: Checkpoint.Type = .{ @@ -882,7 +1094,7 @@ test "clone FixedListType" { root: ByteVectorType(32), root_hex: ByteVectorType(64), }); - const CheckpointHexList = FixedListType(CheckpointHex, 8); + const CheckpointHexList = FixedListType(CheckpointHex, 8, .{}); var list_hex: CheckpointHexList.Type = CheckpointHexList.default_value; defer list_hex.deinit(allocator); try CheckpointList.clone(allocator, &list, &list_hex); @@ -893,7 +1105,7 @@ test "clone FixedListType" { test "clone VariableListType" { const allocator = std.testing.allocator; - const FieldA = FixedListType(UintType(8), 32); + const FieldA = FixedListType(UintType(8), 32, .{}); const Foo = VariableContainerType(struct { a: FieldA, }); @@ -928,7 +1140,7 @@ test "clone VariableListType" { test "FixedListType - tree roundtrip (ListBasic uint8)" { const allocator = std.testing.allocator; - const ListU8 = FixedListType(UintType(8), 128); + const ListU8 = FixedListType(UintType(8), 128, .{}); const TestCase = struct { id: []const u8, @@ -951,7 +1163,7 @@ test "FixedListType - tree roundtrip (ListBasic uint8)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -991,7 +1203,7 @@ test "FixedListType - tree roundtrip (ListBasic uint8)" { test "FixedListType - tree roundtrip (ListBasic uint64)" { const allocator = std.testing.allocator; - const ListU64 = FixedListType(UintType(64), 128); + const ListU64 = FixedListType(UintType(64), 128, .{}); const TestCase = struct { id: []const u8, @@ -1020,7 +1232,7 @@ test "FixedListType - tree roundtrip (ListBasic uint64)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1058,7 +1270,7 @@ test "FixedListType - tree roundtrip (ListBasic uint64)" { test "FixedListType - serializeIntoBytes (ListComposite ByteVector32 - empty)" { const allocator = std.testing.allocator; const ByteVector32 = ByteVectorType(32); - const ListBV32 = FixedListType(ByteVector32, 128); + const ListBV32 = FixedListType(ByteVector32, 128, .{}); var value: ListBV32.Type = ListBV32.default_value; @@ -1077,7 +1289,7 @@ test "FixedListType - serializeIntoBytes (ListComposite ByteVector32 - empty)" { try ListBV32.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListBV32.tree.fromValue(&pool, &value); const tree_size = try ListBV32.tree.serializedSize(node, &pool); @@ -1091,7 +1303,7 @@ test "FixedListType - serializeIntoBytes (ListComposite ByteVector32 - empty)" { test "FixedListType - serializeIntoBytes (ListComposite ByteVector32 - 2 roots)" { const allocator = std.testing.allocator; const ByteVector32 = ByteVectorType(32); - const ListBV32 = FixedListType(ByteVector32, 128); + const ListBV32 = FixedListType(ByteVector32, 128, .{}); var value: ListBV32.Type = ListBV32.default_value; defer value.deinit(allocator); @@ -1115,7 +1327,7 @@ test "FixedListType - serializeIntoBytes (ListComposite ByteVector32 - 2 roots)" try ListBV32.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListBV32.tree.fromValue(&pool, &value); const tree_size = try ListBV32.tree.serializedSize(node, &pool); @@ -1132,7 +1344,7 @@ test "FixedListType - serializeIntoBytes (ListComposite Container - empty)" { a: UintType(64), b: UintType(64), }); - const ListContainer = FixedListType(Container, 128); + const ListContainer = FixedListType(Container, 128, .{}); var value: ListContainer.Type = ListContainer.default_value; @@ -1151,7 +1363,7 @@ test "FixedListType - serializeIntoBytes (ListComposite Container - empty)" { try ListContainer.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListContainer.tree.fromValue(&pool, &value); const tree_size = try ListContainer.tree.serializedSize(node, &pool); @@ -1168,7 +1380,7 @@ test "FixedListType - serializeIntoBytes (ListComposite Container - 2 values)" { a: UintType(64), b: UintType(64), }); - const ListContainer = FixedListType(Container, 128); + const ListContainer = FixedListType(Container, 128, .{}); var value: ListContainer.Type = ListContainer.default_value; defer value.deinit(allocator); @@ -1197,7 +1409,7 @@ test "FixedListType - serializeIntoBytes (ListComposite Container - 2 values)" { try ListContainer.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListContainer.tree.fromValue(&pool, &value); const tree_size = try ListContainer.tree.serializedSize(node, &pool); @@ -1210,7 +1422,7 @@ test "FixedListType - serializeIntoBytes (ListComposite Container - 2 values)" { test "VariableListType - serializeIntoBytes (List> - empty)" { const allocator = std.testing.allocator; - const InnerList = FixedListType(UintType(16), 2); + const InnerList = FixedListType(UintType(16), 2, .{}); const OuterList = VariableListType(InnerList, 2); var value: OuterList.Type = OuterList.default_value; @@ -1231,7 +1443,7 @@ test "VariableListType - serializeIntoBytes (List> - empty)" { try OuterList.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try OuterList.tree.fromValue(&pool, &value); const tree_size = try OuterList.tree.serializedSize(node, &pool); @@ -1244,7 +1456,7 @@ test "VariableListType - serializeIntoBytes (List> - empty)" { test "VariableListType - serializeIntoBytes (List> - 2 full values)" { const allocator = std.testing.allocator; - const InnerList = FixedListType(UintType(16), 2); + const InnerList = FixedListType(UintType(16), 2, .{}); const OuterList = VariableListType(InnerList, 2); var value: OuterList.Type = OuterList.default_value; @@ -1280,7 +1492,7 @@ test "VariableListType - serializeIntoBytes (List> - 2 full values) try OuterList.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try OuterList.tree.fromValue(&pool, &value); const tree_size = try OuterList.tree.serializedSize(node, &pool); @@ -1293,7 +1505,7 @@ test "VariableListType - serializeIntoBytes (List> - 2 full values) test "VariableListType - serializeIntoBytes (List> - 2 empty values)" { const allocator = std.testing.allocator; - const InnerList = FixedListType(UintType(16), 2); + const InnerList = FixedListType(UintType(16), 2, .{}); const OuterList = VariableListType(InnerList, 2); var value: OuterList.Type = OuterList.default_value; @@ -1323,7 +1535,7 @@ test "VariableListType - serializeIntoBytes (List> - 2 empty values try OuterList.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try OuterList.tree.fromValue(&pool, &value); const tree_size = try OuterList.tree.serializedSize(node, &pool); @@ -1337,7 +1549,7 @@ test "VariableListType - serializeIntoBytes (List> - 2 empty values test "FixedListType - tree.deserializeFromBytes (ListBasic uint8)" { const allocator = std.testing.allocator; - const ListU8 = FixedListType(UintType(8), 128); + const ListU8 = FixedListType(UintType(8), 128, .{}); const TestCase = struct { id: []const u8, @@ -1360,7 +1572,7 @@ test "FixedListType - tree.deserializeFromBytes (ListBasic uint8)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1389,7 +1601,7 @@ test "FixedListType - tree.deserializeFromBytes (ListBasic uint8)" { test "FixedListType - tree.deserializeFromBytes (ListBasic uint64)" { const allocator = std.testing.allocator; - const ListU64 = FixedListType(UintType(64), 128); + const ListU64 = FixedListType(UintType(64), 128, .{}); const TestCase = struct { id: []const u8, @@ -1438,7 +1650,7 @@ test "FixedListType - tree.deserializeFromBytes (ListBasic uint64)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1467,7 +1679,7 @@ test "FixedListType - tree.deserializeFromBytes (ListBasic uint64)" { test "FixedListType - tree.deserializeFromBytes (ListComposite ByteVector32)" { const allocator = std.testing.allocator; const ByteVector32 = ByteVectorType(32); - const ListBV32 = FixedListType(ByteVector32, 128); + const ListBV32 = FixedListType(ByteVector32, 128, .{}); const TestCase = struct { id: []const u8, @@ -1491,7 +1703,7 @@ test "FixedListType - tree.deserializeFromBytes (ListComposite ByteVector32)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1522,7 +1734,7 @@ test "FixedListType - tree.deserializeFromBytes (ListComposite Container)" { a: UintType(64), b: UintType(64), }); - const ListContainer = FixedListType(Container, 128); + const ListContainer = FixedListType(Container, 128, .{}); const TestCase = struct { id: []const u8, @@ -1557,7 +1769,7 @@ test "FixedListType - tree.deserializeFromBytes (ListComposite Container)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1588,7 +1800,7 @@ test "FixedListType - tree.deserializeFromBytes (ListComposite Container)" { test "VariableListType - tree.deserializeFromBytes (List>)" { const allocator = std.testing.allocator; - const InnerList = FixedListType(UintType(16), 2); + const InnerList = FixedListType(UintType(16), 2, .{}); const OuterList = VariableListType(InnerList, 2); const TestCase = struct { @@ -1630,7 +1842,7 @@ test "VariableListType - tree.deserializeFromBytes (List>)" { }, }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (test_cases) |tc| { @@ -1674,7 +1886,7 @@ test "valid test for ListBasicType" { // uint of 8 bytes = u64 const Uint = UintType(64); - const List = FixedListType(Uint, 128); + const List = FixedListType(Uint, 128, .{}); const TypeTest = @import("test_utils.zig").typeTest(List); @@ -1685,7 +1897,7 @@ test "valid test for ListBasicType" { test "FixedListType equals" { const allocator = std.testing.allocator; - const List = FixedListType(UintType(8), 32); + const List = FixedListType(UintType(8), 32, .{}); var a: List.Type = List.Type.empty; var b: List.Type = List.Type.empty; @@ -1718,7 +1930,7 @@ test "ListCompositeType of Root" { const allocator = std.testing.allocator; const ByteVector = ByteVectorType(32); - const List = FixedListType(ByteVector, 128); + const List = FixedListType(ByteVector, 128, .{}); const TypeTest = @import("test_utils.zig").typeTest(List); @@ -1746,7 +1958,7 @@ test "ListCompositeType of Container" { a: Uint, b: Uint, }); - const List = FixedListType(Container, 128); + const List = FixedListType(Container, 128, .{}); const TypeTest = @import("test_utils.zig").typeTest(List); @@ -1785,7 +1997,7 @@ test "VariableListType of FixedList" { }; const allocator = std.testing.allocator; - const FixedList = FixedListType(UintType(16), 2); + const FixedList = FixedListType(UintType(16), 2, .{}); const List = VariableListType(FixedList, 2); const TypeTest = @import("test_utils.zig").typeTest(List); @@ -1796,13 +2008,13 @@ test "VariableListType of FixedList" { } test "FixedListType - default_root" { - const ListU32 = FixedListType(UintType(32), 16); + const ListU32 = FixedListType(UintType(32), 16, .{}); var expected_root: [32]u8 = undefined; try ListU32.hashTreeRoot(std.testing.allocator, &ListU32.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &ListU32.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListU32.tree.default(&pool); @@ -1810,14 +2022,14 @@ test "FixedListType - default_root" { } test "VariableListType - default_root" { - const ListU32 = FixedListType(UintType(32), 16); + const ListU32 = FixedListType(UintType(32), 16, .{}); const ListListU32 = VariableListType(ListU32, 16); var expected_root: [32]u8 = undefined; try ListListU32.hashTreeRoot(std.testing.allocator, &ListListU32.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &ListListU32.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try ListListU32.tree.default(&pool); @@ -1827,9 +2039,9 @@ test "VariableListType - default_root" { test "FixedListType - tree.zeros" { const allocator = std.testing.allocator; - const ListU16 = FixedListType(UintType(16), 8); + const ListU16 = FixedListType(UintType(16), 8, .{}); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (0..ListU16.limit) |len| { @@ -1851,10 +2063,10 @@ test "FixedListType - tree.zeros" { test "VariableListType - tree.zeros" { const allocator = std.testing.allocator; - const ListU32 = FixedListType(UintType(32), 16); + const ListU32 = FixedListType(UintType(32), 16, .{}); const ListListU32 = VariableListType(ListU32, 16); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); for (0..ListListU32.limit) |len| { @@ -1872,3 +2084,60 @@ test "VariableListType - tree.zeros" { try std.testing.expectEqualSlices(u8, &expected_root, tree_node.getRoot(&pool)); } } + +test "FixedListType opts.chunked_leaf=true: round-trip fromValue -> tree -> toValue" { + const allocator = std.testing.allocator; + const ChunkedLeaf = pmt.ChunkedLeaf; + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + var src = ListT.Type.empty; + defer src.deinit(allocator); + const item_count: usize = 2 * @as(usize, ChunkedLeaf.K) * 4 + 7; // odd tail to stress partial chunked_leaf + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i * 31 + 1))); + + const tree_id = try ListT.tree.fromValue(&pool, &src); + defer pool.unref(tree_id); + + var dst = ListT.Type.empty; + defer dst.deinit(allocator); + try ListT.tree.toValue(allocator, tree_id, &pool, &dst); + try std.testing.expectEqual(src.items.len, dst.items.len); + for (src.items, dst.items) |a, b| try std.testing.expectEqual(a, b); + + // Hash matches the leaf-path (non-chunked_leaf) reference root. + const ListLeafT = FixedListType(UintType(64), 1 << 20, .{}); + const leaf_tree_id = try ListLeafT.tree.fromValue(&pool, &src); + defer pool.unref(leaf_tree_id); + try std.testing.expectEqualSlices(u8, leaf_tree_id.getRoot(&pool), tree_id.getRoot(&pool)); +} + +test "FixedListType opts.chunked_leaf=true: serialize -> deserialize round-trip" { + const allocator = std.testing.allocator; + const ChunkedLeaf = pmt.ChunkedLeaf; + const ListT = FixedListType(UintType(64), 1 << 20, .{ .chunked_leaf = true }); + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + var src = ListT.Type.empty; + defer src.deinit(allocator); + const item_count: usize = 2 * @as(usize, ChunkedLeaf.K) * 4; + try src.ensureTotalCapacity(allocator, item_count); + for (0..item_count) |i| try src.append(allocator, @as(u64, @intCast(i))); + + const tree_id = try ListT.tree.fromValue(&pool, &src); + defer pool.unref(tree_id); + + const buf = try allocator.alloc(u8, item_count * @sizeOf(u64)); + defer allocator.free(buf); + const written = try ListT.tree.serializeIntoBytes(tree_id, &pool, buf); + try std.testing.expectEqual(item_count * @sizeOf(u64), written); + + const round_id = try ListT.tree.deserializeFromBytes(&pool, buf); + defer pool.unref(round_id); + try std.testing.expectEqualSlices(u8, tree_id.getRoot(&pool), round_id.getRoot(&pool)); +} diff --git a/src/ssz/type/path.zig b/src/ssz/type/path.zig index c6ee40bfd..d24ac3fb3 100644 --- a/src/ssz/type/path.zig +++ b/src/ssz/type/path.zig @@ -203,7 +203,7 @@ test "getPathGindex" { try std.testing.expectEqual(@as(Gindex.Uint, 7), @intFromEnum(getPathGindex(BeaconState, "finalized_checkpoint.root"))); - const Balances = types.FixedListType(types.UintType(64), 4); + const Balances = types.FixedListType(types.UintType(64), 4, .{}); const SimpleState = types.VariableContainerType(struct { slot: types.UintType(64), balances: Balances, diff --git a/src/ssz/type/root.zig b/src/ssz/type/root.zig index 1579021e4..628f3633c 100644 --- a/src/ssz/type/root.zig +++ b/src/ssz/type/root.zig @@ -26,6 +26,7 @@ pub const FixedVectorType = @import("vector.zig").FixedVectorType; pub const VariableVectorType = @import("vector.zig").VariableVectorType; pub const FixedContainerType = @import("container.zig").FixedContainerType; +pub const StructContainerType = @import("container.zig").StructContainerType; pub const VariableContainerType = @import("container.zig").VariableContainerType; const chunk = @import("chunk.zig"); diff --git a/src/ssz/type/uint.zig b/src/ssz/type/uint.zig index 59064da9e..59ae8f9c0 100644 --- a/src/ssz/type/uint.zig +++ b/src/ssz/type/uint.zig @@ -98,6 +98,21 @@ pub fn UintType(comptime bits: comptime_int) type { return try pool.createLeaf(&new_leaf); } + /// Decode a packed item directly from chunk bytes. Used by chunked_leaf-backed + /// containers where the chunk is already in hand and a Node.Id is unavailable. + pub fn toValuePackedFromBytes(chunk: *const [32]u8, index: usize, out: *Type) void { + const offset = index * fixed_size % 32; + out.* = std.mem.readInt(Type, chunk[offset..][0..fixed_size], .little); + } + + /// Encode a packed item directly into chunk bytes (mutates `chunk` in place). + /// Used by chunked_leaf-backed containers; the caller is responsible for any CoW + /// of the chunk before calling. + pub fn fromValuePackedIntoChunk(chunk: *[32]u8, index: usize, value: *const Type) void { + const offset = (index * bytes) % 32; + std.mem.writeInt(Type, chunk[offset..][0..bytes], value.*, .little); + } + pub fn serializeIntoBytes(node: Node.Id, pool: *Node.Pool, out: []u8) !usize { const hash = node.getRoot(pool); @memcpy(out[0..fixed_size], hash[0..fixed_size]); @@ -170,7 +185,7 @@ fn testFixed( try ST.deserializeFromBytes(&serialized, &value_from_serialized); try std.testing.expectEqual(value, value_from_serialized); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const tree_from_value = try ST.tree.fromValue(&pool, &value); diff --git a/src/ssz/type/vector.zig b/src/ssz/type/vector.zig index 11e95bed1..9c0f57266 100644 --- a/src/ssz/type/vector.zig +++ b/src/ssz/type/vector.zig @@ -7,12 +7,15 @@ const OffsetIterator = @import("offsets.zig").OffsetIterator; const merkleize = @import("hashing").merkleize; const maxChunksToDepth = @import("hashing").maxChunksToDepth; const getZeroHash = @import("hashing").getZeroHash; -const Node = @import("persistent_merkle_tree").Node; +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; const tree_view = @import("../tree_view/root.zig"); const ArrayBasicTreeView = tree_view.ArrayBasicTreeView; const ArrayCompositeTreeView = tree_view.ArrayCompositeTreeView; -pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { +pub const TypeOpts = @import("list.zig").TypeOpts; + +pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int, comptime _opts: TypeOpts) type { comptime { if (!isFixedType(ST)) { @compileError("ST must be fixed type"); @@ -20,11 +23,26 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { if (_length <= 0) { @compileError("length must be greater than 0"); } + if (_opts.chunked_leaf and !isBasicType(ST)) { + @compileError("FixedVectorType: opts.chunked_leaf=true requires isBasicType(Element)"); + } + if (_opts.chunked_leaf) { + const ChunkedLeaf = pmt.ChunkedLeaf; + const items_per_chunk_local = if (isBasicType(ST)) (32 / ST.fixed_size) else 1; + const min_length = ChunkedLeaf.K * items_per_chunk_local; + if (_length < min_length) { + @compileError(std.fmt.comptimePrint( + "FixedVectorType: opts.chunked_leaf=true requires length >= K * items_per_chunk = {d} (chunk_depth must be >= ChunkedLeaf.k_log2)", + .{min_length}, + )); + } + } } return struct { pub const kind = TypeKind.vector; pub const Element: type = ST; pub const length: usize = _length; + pub const opts: TypeOpts = _opts; pub const Type: type = [length]Element.Type; pub const TreeView: type = if (isBasicType(Element)) ArrayBasicTreeView(@This()) @@ -33,6 +51,9 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { pub const fixed_size: usize = Element.fixed_size * length; pub const chunk_count: usize = if (isBasicType(Element)) std.math.divCeil(usize, fixed_size, 32) catch unreachable else length; pub const chunk_depth: u8 = maxChunksToDepth(chunk_count); + pub const use_chunked_leaf: bool = _opts.chunked_leaf; + const ChunkedLeaf = if (use_chunked_leaf) pmt.ChunkedLeaf else struct {}; + const chunked_leaf_depth: u8 = if (use_chunked_leaf) chunk_depth - ChunkedLeaf.k_log2 else 0; pub const default_value: Type = [_]Element.Type{Element.default_value} ** length; @@ -155,6 +176,29 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { return error.InvalidSize; } + if (comptime use_chunked_leaf) { + var it = Node.FillWithContentsIterator.initWithOffset(pool, chunked_leaf_depth, ChunkedLeaf.k_log2); + errdefer it.deinit(); + + const bytes_per_chunked_leaf: usize = ChunkedLeaf.K * 32; + var byte_idx: usize = 0; + + while (byte_idx < data.len) { + const remaining = data.len - byte_idx; + const chunked_leaf_bytes = @min(remaining, bytes_per_chunked_leaf); + const valid_chunks: u16 = @intCast((chunked_leaf_bytes + 31) / 32); + var chunked_leaf_id_opt: ?Node.Id = try pool.createChunkedLeafEmpty(valid_chunks); + errdefer if (chunked_leaf_id_opt) |id| pool.unref(id); + const storage = try chunked_leaf_id_opt.?.getChunkedLeafPtr(pool); + @memcpy(@as([*]u8, @ptrCast(&storage.chunks))[0..chunked_leaf_bytes], data[byte_idx..][0..chunked_leaf_bytes]); + try it.append(chunked_leaf_id_opt.?); + chunked_leaf_id_opt = null; + byte_idx += chunked_leaf_bytes; + } + + return try it.finish(); + } + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); @@ -178,6 +222,38 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { } pub fn toValue(node: Node.Id, pool: *Node.Pool, out: *Type) !void { + if (comptime use_chunked_leaf) { + const items_per_chunk = 32 / Element.fixed_size; + const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; + var chunked_leaf_ids: [chunked_leaf_count]Node.Id = undefined; + try node.getNodesAtDepth(pool, chunked_leaf_depth, 0, &chunked_leaf_ids); + + const state_col = pool.nodes.items(.state); + var item_idx: usize = 0; + outer: for (chunked_leaf_ids) |sid| { + // Zero subtree at chunked_leaf boundary == all-zero values. + if (state_col[@intFromEnum(sid)].kind() == .zero) { + const items_in_chunked_leaf = @min(ChunkedLeaf.K * items_per_chunk, length - item_idx); + for (0..items_in_chunked_leaf) |i| { + out[item_idx + i] = std.mem.zeroes(Element.Type); + } + item_idx += items_in_chunked_leaf; + if (item_idx >= length) break :outer; + continue; + } + const chunks = try sid.getChunkedLeafChunks(pool); + for (0..ChunkedLeaf.K) |intra_chunk| { + if (item_idx >= length) break :outer; + const items_in_chunk = @min(items_per_chunk, length - item_idx); + for (0..items_in_chunk) |i| { + Element.tree.toValuePackedFromBytes(&chunks[intra_chunk], item_idx + i, &out[item_idx + i]); + } + item_idx += items_in_chunk; + } + } + return; + } + var nodes: [chunk_count]Node.Id = undefined; try node.getNodesAtDepth(pool, chunk_depth, 0, &nodes); @@ -204,6 +280,39 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { } pub fn fromValue(pool: *Node.Pool, value: *const Type) !Node.Id { + if (comptime use_chunked_leaf) { + var it = Node.FillWithContentsIterator.initWithOffset(pool, chunked_leaf_depth, ChunkedLeaf.k_log2); + errdefer it.deinit(); + + const items_per_chunk = 32 / Element.fixed_size; + const items_per_chunked_leaf: usize = items_per_chunk * ChunkedLeaf.K; + var item_idx: usize = 0; + + while (item_idx < length) { + const remaining = length - item_idx; + const items_in_chunked_leaf = @min(remaining, items_per_chunked_leaf); + const valid_chunks: u16 = @intCast((items_in_chunked_leaf + items_per_chunk - 1) / items_per_chunk); + + var chunked_leaf_id_opt: ?Node.Id = try pool.createChunkedLeafEmpty(valid_chunks); + errdefer if (chunked_leaf_id_opt) |id| pool.unref(id); + const storage = try chunked_leaf_id_opt.?.getChunkedLeafPtr(pool); + + for (0..items_in_chunked_leaf) |k| { + const chunked_leaf_chunk_idx = k / items_per_chunk; + const intra_chunk = k % items_per_chunk; + const dst_off = intra_chunk * Element.fixed_size; + const dst_slice = storage.chunks[chunked_leaf_chunk_idx][dst_off .. dst_off + Element.fixed_size]; + _ = Element.serializeIntoBytes(&value[item_idx + k], dst_slice); + } + + try it.append(chunked_leaf_id_opt.?); + chunked_leaf_id_opt = null; + item_idx += items_in_chunked_leaf; + } + + return try it.finish(); + } + // Zero-filled so a mid-build error's errdefer is a no-op over the unfilled slots. var nodes: [chunk_count]Node.Id = @splat(@as(Node.Id, @enumFromInt(0))); errdefer pool.free(&nodes); @@ -229,6 +338,35 @@ pub fn FixedVectorType(comptime ST: type, comptime _length: comptime_int) type { } pub fn serializeIntoBytes(node: Node.Id, pool: *Node.Pool, out: []u8) !usize { + if (comptime use_chunked_leaf) { + const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; + var chunked_leaf_ids: [chunked_leaf_count]Node.Id = undefined; + try node.getNodesAtDepth(pool, chunked_leaf_depth, 0, &chunked_leaf_ids); + + const state_col = pool.nodes.items(.state); + var byte_idx: usize = 0; + outer: for (chunked_leaf_ids) |sid| { + // Zero subtree at chunked_leaf boundary == all-zero output bytes. + if (state_col[@intFromEnum(sid)].kind() == .zero) { + const remaining = fixed_size - byte_idx; + const zero_bytes = @min(ChunkedLeaf.K * 32, remaining); + @memset(out[byte_idx..][0..zero_bytes], 0); + byte_idx += zero_bytes; + if (byte_idx >= fixed_size) break :outer; + continue; + } + const chunks = try sid.getChunkedLeafChunks(pool); + for (0..ChunkedLeaf.K) |intra_chunk| { + if (byte_idx >= fixed_size) break :outer; + const remaining = fixed_size - byte_idx; + const bytes_to_copy = @min(remaining, 32); + @memcpy(out[byte_idx..][0..bytes_to_copy], chunks[intra_chunk][0..bytes_to_copy]); + byte_idx += bytes_to_copy; + } + } + return fixed_size; + } + var nodes: [chunk_count]Node.Id = undefined; try node.getNodesAtDepth(pool, chunk_depth, 0, &nodes); @@ -531,7 +669,7 @@ const VariableContainerType = @import("container.zig").VariableContainerType; test "vector - sanity" { // create a fixed vector type and instance and round-trip serialize - const Bytes32 = FixedVectorType(UintType(8), 32); + const Bytes32 = FixedVectorType(UintType(8), 32, .{}); var b0: Bytes32.Type = undefined; var b0_buf: [Bytes32.fixed_size]u8 = undefined; @@ -544,7 +682,7 @@ test "clone FixedVectorType" { epoch: UintType(8), root: ByteVectorType(32), }); - const CheckpointVector = FixedVectorType(Checkpoint, 4); + const CheckpointVector = FixedVectorType(Checkpoint, 4, .{}); var vector: CheckpointVector.Type = CheckpointVector.default_value; vector[0].epoch = 42; @@ -559,7 +697,7 @@ test "clone FixedVectorType" { root: ByteVectorType(32), root_hex: ByteVectorType(64), }); - const CheckpointHexVector = FixedVectorType(CheckpointHex, 4); + const CheckpointHexVector = FixedVectorType(CheckpointHex, 4, .{}); var cloned2: CheckpointHexVector.Type = undefined; try CheckpointVector.clone(&vector, &cloned2); try std.testing.expect(cloned2[0].epoch == 42); @@ -567,7 +705,7 @@ test "clone FixedVectorType" { test "clone VariableVectorType" { const allocator = std.testing.allocator; - const FieldA = FixedListType(UintType(8), 32); + const FieldA = FixedListType(UintType(8), 32, .{}); const Foo = VariableContainerType(struct { a: FieldA, }); @@ -600,7 +738,7 @@ test "clone VariableVectorType" { // Refer to https://github.com/ChainSafe/ssz/blob/f5ed0b457333749b5c3f49fa5eafa096a725f033/packages/ssz/test/unit/byType/vector/valid.test.ts#L15-L85 test "FixedVectorType - serializeIntoBytes (VectorBasic uint64 - 4 values)" { const allocator = std.testing.allocator; - const VectorU64 = FixedVectorType(UintType(64), 4); + const VectorU64 = FixedVectorType(UintType(64), 4, .{}); const value: VectorU64.Type = [_]u64{ 100000, 200000, 300000, 400000 }; @@ -622,7 +760,7 @@ test "FixedVectorType - serializeIntoBytes (VectorBasic uint64 - 4 values)" { try VectorU64.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorU64.tree.fromValue(&pool, &value); var tree_serialized: [VectorU64.fixed_size]u8 = undefined; @@ -633,7 +771,7 @@ test "FixedVectorType - serializeIntoBytes (VectorBasic uint64 - 4 values)" { test "FixedVectorType - serializeIntoBytes (VectorComposite ByteVector32 - 4 roots)" { const allocator = std.testing.allocator; const ByteVector32 = ByteVectorType(32); - const VectorBV32 = FixedVectorType(ByteVector32, 4); + const VectorBV32 = FixedVectorType(ByteVector32, 4, .{}); const value: VectorBV32.Type = [_][32]u8{ [_]u8{0xbb} ** 32, @@ -654,7 +792,7 @@ test "FixedVectorType - serializeIntoBytes (VectorComposite ByteVector32 - 4 roo try VectorBV32.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorBV32.tree.fromValue(&pool, &value); var tree_serialized: [VectorBV32.fixed_size]u8 = undefined; @@ -668,7 +806,7 @@ test "FixedVectorType - serializeIntoBytes (VectorComposite Container - 4 arrays a: UintType(64), b: UintType(64), }); - const VectorContainer = FixedVectorType(Container, 4); + const VectorContainer = FixedVectorType(Container, 4, .{}); const value: VectorContainer.Type = [_]Container.Type{ .{ .a = 0, .b = 0 }, @@ -699,7 +837,7 @@ test "FixedVectorType - serializeIntoBytes (VectorComposite Container - 4 arrays try VectorContainer.hashTreeRoot(&value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorContainer.tree.fromValue(&pool, &value); var tree_serialized: [VectorContainer.fixed_size]u8 = undefined; @@ -709,7 +847,7 @@ test "FixedVectorType - serializeIntoBytes (VectorComposite Container - 4 arrays test "VariableVectorType - serializeIntoBytes (VectorComposite ListBasic - [[1,2],[5,6]])" { const allocator = std.testing.allocator; - const ListU64 = FixedListType(UintType(64), 8); + const ListU64 = FixedListType(UintType(64), 8, .{}); const VectorList = VariableVectorType(ListU64, 2); var value: VectorList.Type = VectorList.default_value; @@ -741,7 +879,7 @@ test "VariableVectorType - serializeIntoBytes (VectorComposite ListBasic - [[1,2 try VectorList.hashTreeRoot(allocator, &value, &root); try std.testing.expectEqualSlices(u8, &expected_root, &root); - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorList.tree.fromValue(&pool, &value); const tree_size = try VectorList.tree.serializedSize(node, &pool); @@ -754,7 +892,7 @@ test "VariableVectorType - serializeIntoBytes (VectorComposite ListBasic - [[1,2 test "FixedVectorType - tree.deserializeFromBytes (VectorBasic uint64)" { const allocator = std.testing.allocator; - const VectorU64 = FixedVectorType(UintType(64), 4); + const VectorU64 = FixedVectorType(UintType(64), 4, .{}); // 0xa086010000000000400d030000000000e093040000000000801a060000000000 const serialized = [_]u8{ @@ -766,7 +904,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorBasic uint64)" { const expected_values = [_]u64{ 100000, 200000, 300000, 400000 }; const expected_root = serialized; // For VectorBasic with 4 uint64 values, root equals serialized - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const tree_node = try VectorU64.tree.deserializeFromBytes(&pool, &serialized); @@ -788,7 +926,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorBasic uint64)" { test "FixedVectorType - tree.deserializeFromBytes (VectorComposite ByteVector32)" { const allocator = std.testing.allocator; const ByteVector32 = ByteVectorType(32); - const VectorBV32 = FixedVectorType(ByteVector32, 4); + const VectorBV32 = FixedVectorType(ByteVector32, 4, .{}); // 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee const serialized = [_]u8{0xbb} ** 32 ++ [_]u8{0xcc} ** 32 ++ [_]u8{0xdd} ** 32 ++ [_]u8{0xee} ** 32; @@ -801,7 +939,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorComposite ByteVector32) // 0x56019bafbc63461b73e21c6eae0c62e8d5b8e05cb0ac065777dc238fcf9604e6 const expected_root = [_]u8{ 0x56, 0x01, 0x9b, 0xaf, 0xbc, 0x63, 0x46, 0x1b, 0x73, 0xe2, 0x1c, 0x6e, 0xae, 0x0c, 0x62, 0xe8, 0xd5, 0xb8, 0xe0, 0x5c, 0xb0, 0xac, 0x06, 0x57, 0x77, 0xdc, 0x23, 0x8f, 0xcf, 0x96, 0x04, 0xe6 }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const tree_node = try VectorBV32.tree.deserializeFromBytes(&pool, &serialized); @@ -828,7 +966,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorComposite Container)" { a: UintType(64), b: UintType(64), }); - const VectorContainer = FixedVectorType(Container, 4); + const VectorContainer = FixedVectorType(Container, 4, .{}); // 0x0000000000000000000000000000000040e2010000000000f1fb0900000000004794030000000000f8ad0b00000000004e46050000000000ff5f0d0000000000 const serialized = [_]u8{ @@ -850,7 +988,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorComposite Container)" { // 0xb1a797eb50654748ba239010edccea7b46b55bf740730b700684f48b0c478372 const expected_root = [_]u8{ 0xb1, 0xa7, 0x97, 0xeb, 0x50, 0x65, 0x47, 0x48, 0xba, 0x23, 0x90, 0x10, 0xed, 0xcc, 0xea, 0x7b, 0x46, 0xb5, 0x5b, 0xf7, 0x40, 0x73, 0x0b, 0x70, 0x06, 0x84, 0xf4, 0x8b, 0x0c, 0x47, 0x83, 0x72 }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const tree_node = try VectorContainer.tree.deserializeFromBytes(&pool, &serialized); @@ -874,7 +1012,7 @@ test "FixedVectorType - tree.deserializeFromBytes (VectorComposite Container)" { test "VariableVectorType - tree.deserializeFromBytes (VectorComposite ListBasic)" { const allocator = std.testing.allocator; - const ListU64 = FixedListType(UintType(64), 8); + const ListU64 = FixedListType(UintType(64), 8, .{}); const VectorList = VariableVectorType(ListU64, 2); // 0x08000000180000000100000000000000020000000000000005000000000000000600000000000000 @@ -889,7 +1027,7 @@ test "VariableVectorType - tree.deserializeFromBytes (VectorComposite ListBasic) // 0x0014c485ce39c8071f69631566b1d1ad51e2b0b5abc3c7a299a6fac1abce9e49 const expected_root = [_]u8{ 0x00, 0x14, 0xc4, 0x85, 0xce, 0x39, 0xc8, 0x07, 0x1f, 0x69, 0x63, 0x15, 0x66, 0xb1, 0xd1, 0xad, 0x51, 0xe2, 0xb0, 0xb5, 0xab, 0xc3, 0xc7, 0xa2, 0x99, 0xa6, 0xfa, 0xc1, 0xab, 0xce, 0x9e, 0x49 }; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const tree_node = try VectorList.tree.deserializeFromBytes(&pool, &serialized); @@ -935,7 +1073,7 @@ test "valid test for VectorBasicType" { // uint of 8 bytes = u64 const Uint = UintType(64); - const Vector = FixedVectorType(Uint, 4); + const Vector = FixedVectorType(Uint, 4, .{}); const TypeTest = @import("test_utils.zig").typeTest(Vector); @@ -945,7 +1083,7 @@ test "valid test for VectorBasicType" { } test "FixedVectorType equals" { - const Vec = FixedVectorType(UintType(8), 4); + const Vec = FixedVectorType(UintType(8), 4, .{}); var a: Vec.Type = [_]u8{ 1, 2, 3, 4 }; var b: Vec.Type = [_]u8{ 1, 2, 3, 4 }; @@ -968,7 +1106,7 @@ test "VectorCompositeType of Root" { const allocator = std.testing.allocator; const ByteVector = ByteVectorType(32); - const Vector = FixedVectorType(ByteVector, 4); + const Vector = FixedVectorType(ByteVector, 4, .{}); const TypeTest = @import("test_utils.zig").typeTest(Vector); @@ -995,7 +1133,7 @@ test "VectorCompositeType of Container" { a: Uint, b: Uint, }); - const Vector = FixedVectorType(Container, 4); + const Vector = FixedVectorType(Container, 4, .{}); const TypeTest = @import("test_utils.zig").typeTest(Vector); @@ -1005,13 +1143,13 @@ test "VectorCompositeType of Container" { } test "FixedVectorType - default_root" { - const VectorU64 = FixedVectorType(UintType(64), 4); + const VectorU64 = FixedVectorType(UintType(64), 4, .{}); var expected_root: [32]u8 = undefined; try VectorU64.hashTreeRoot(&VectorU64.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &VectorU64.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorU64.tree.default(&pool); @@ -1019,16 +1157,67 @@ test "FixedVectorType - default_root" { } test "VariableVectorType - default_root" { - const ListU64 = FixedListType(UintType(64), 8); + const ListU64 = FixedListType(UintType(64), 8, .{}); const VectorList = VariableVectorType(ListU64, 2); var expected_root: [32]u8 = undefined; try VectorList.hashTreeRoot(std.testing.allocator, &VectorList.default_value, &expected_root); try std.testing.expectEqualSlices(u8, &expected_root, &VectorList.default_root); - var pool = try Node.Pool.init(std.testing.allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 1024 }); defer pool.deinit(); const node = try VectorList.tree.default(&pool); try std.testing.expectEqualSlices(u8, &expected_root, node.getRoot(&pool)); } + +test "FixedVectorType opts.chunked_leaf=true: round-trip fromValue -> tree -> toValue" { + const allocator = std.testing.allocator; + const ChunkedLeaf = pmt.ChunkedLeaf; + const length: usize = 2 * @as(usize, ChunkedLeaf.K) * 4 + 7; + const VecT = FixedVectorType(UintType(64), length, .{ .chunked_leaf = true }); + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + var src: VecT.Type = undefined; + for (0..length) |i| src[i] = @as(u64, @intCast(i * 31 + 1)); + + const tree_id = try VecT.tree.fromValue(&pool, &src); + defer pool.unref(tree_id); + + var dst: VecT.Type = undefined; + try VecT.tree.toValue(tree_id, &pool, &dst); + for (src, dst) |a, b| try std.testing.expectEqual(a, b); + + // Hash matches the leaf-path (non-chunked_leaf) reference root. + const VecLeafT = FixedVectorType(UintType(64), length, .{}); + const leaf_tree_id = try VecLeafT.tree.fromValue(&pool, &src); + defer pool.unref(leaf_tree_id); + try std.testing.expectEqualSlices(u8, leaf_tree_id.getRoot(&pool), tree_id.getRoot(&pool)); +} + +test "FixedVectorType opts.chunked_leaf=true: serialize -> deserialize round-trip" { + const allocator = std.testing.allocator; + const ChunkedLeaf = pmt.ChunkedLeaf; + const length: usize = 2 * @as(usize, ChunkedLeaf.K) * 4; + const VecT = FixedVectorType(UintType(64), length, .{ .chunked_leaf = true }); + + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + var src: VecT.Type = undefined; + for (0..length) |i| src[i] = @as(u64, @intCast(i)); + + const tree_id = try VecT.tree.fromValue(&pool, &src); + defer pool.unref(tree_id); + + const buf = try allocator.alloc(u8, length * @sizeOf(u64)); + defer allocator.free(buf); + const written = try VecT.tree.serializeIntoBytes(tree_id, &pool, buf); + try std.testing.expectEqual(length * @sizeOf(u64), written); + + const round_id = try VecT.tree.deserializeFromBytes(&pool, buf); + defer pool.unref(round_id); + try std.testing.expectEqualSlices(u8, tree_id.getRoot(&pool), round_id.getRoot(&pool)); +} diff --git a/src/state_transition/block/process_attestations.zig b/src/state_transition/block/process_attestations.zig index 58e63bf6b..8cd339cd2 100644 --- a/src/state_transition/block/process_attestations.zig +++ b/src/state_transition/block/process_attestations.zig @@ -52,7 +52,7 @@ pub fn processAttestations( test "process attestations - sanity" { const allocator = std.testing.allocator; const pool_size = 16 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 16); diff --git a/src/state_transition/block/process_block_header.zig b/src/state_transition/block/process_block_header.zig index 7c5058a68..a1cf07853 100644 --- a/src/state_transition/block/process_block_header.zig +++ b/src/state_transition/block/process_block_header.zig @@ -84,7 +84,7 @@ const preset = @import("preset").preset; test "process block header - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_eth1_data.zig b/src/state_transition/block/process_eth1_data.zig index 97eea5d2e..b56891850 100644 --- a/src/state_transition/block/process_eth1_data.zig +++ b/src/state_transition/block/process_eth1_data.zig @@ -66,7 +66,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "process eth1 data - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_execution_payload.zig b/src/state_transition/block/process_execution_payload.zig index cd9b7e872..4900511e1 100644 --- a/src/state_transition/block/process_execution_payload.zig +++ b/src/state_transition/block/process_execution_payload.zig @@ -98,7 +98,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "process execution payload - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -130,7 +130,7 @@ test "process execution payload - sanity" { test "process execution payload - blinded" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_operations.zig b/src/state_transition/block/process_operations.zig index 19141124c..95e8e8e3e 100644 --- a/src/state_transition/block/process_operations.zig +++ b/src/state_transition/block/process_operations.zig @@ -98,7 +98,7 @@ const AnyBeaconBlock = @import("fork_types").AnyBeaconBlock; test "process operations" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_randao.zig b/src/state_transition/block/process_randao.zig index 596fd680b..0820c63f0 100644 --- a/src/state_transition/block/process_randao.zig +++ b/src/state_transition/block/process_randao.zig @@ -61,7 +61,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "process randao - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_sync_committee.zig b/src/state_transition/block/process_sync_committee.zig index b336ac4a2..23bd2afc1 100644 --- a/src/state_transition/block/process_sync_committee.zig +++ b/src/state_transition/block/process_sync_committee.zig @@ -178,7 +178,7 @@ const test_utils = @import("../test_utils/root.zig"); test "process sync aggregate - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_voluntary_exit.zig b/src/state_transition/block/process_voluntary_exit.zig index 5a7f4d7fb..3ff089802 100644 --- a/src/state_transition/block/process_voluntary_exit.zig +++ b/src/state_transition/block/process_voluntary_exit.zig @@ -126,7 +126,7 @@ fn makeSignedVoluntaryExit(epoch: u64, validator_index: u64) SignedVoluntaryExit test "voluntary exit - valid" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -149,7 +149,7 @@ test "voluntary exit - valid" { test "voluntary exit - inactive validator (out of bounds index)" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -172,7 +172,7 @@ test "voluntary exit - inactive validator (out of bounds index)" { test "voluntary exit - inactive validator (not active in current epoch)" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -202,7 +202,7 @@ test "voluntary exit - inactive validator (not active in current epoch)" { test "voluntary exit - already exited validator" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -232,7 +232,7 @@ test "voluntary exit - already exited validator" { test "voluntary exit - early epoch" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -257,7 +257,7 @@ test "voluntary exit - early epoch" { test "voluntary exit - short time active" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/block/process_withdrawals.zig b/src/state_transition/block/process_withdrawals.zig index c3f9f5553..07c53d3ec 100644 --- a/src/state_transition/block/process_withdrawals.zig +++ b/src/state_transition/block/process_withdrawals.zig @@ -223,7 +223,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "process withdrawals - sanity" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/cache/epoch_cache.zig b/src/state_transition/cache/epoch_cache.zig index d230aa85f..3dc2ada6e 100644 --- a/src/state_transition/cache/epoch_cache.zig +++ b/src/state_transition/cache/epoch_cache.zig @@ -208,7 +208,7 @@ pub const EpochCache = struct { var exit_queue_epoch = computeActivationExitEpoch(current_epoch); var exit_queue_churn: u64 = 0; - const validators = try state.validatorsSlice(allocator); + const validators = try state.validatorsPtrSlice(allocator); defer allocator.free(validators); const validator_count = validators.len; @@ -243,19 +243,18 @@ pub const EpochCache = struct { for (0..validator_count) |i| { const validator = validators[i]; - // Note: Not usable for fork-choice balances since in-active validators are not zero'ed effective_balance_increments.items[i] = @intCast(@divFloor(validator.effective_balance, preset.EFFECTIVE_BALANCE_INCREMENT)); - if (isActiveValidator(&validator, previous_epoch)) { + if (isActiveValidator(validator, previous_epoch)) { try previous_active_indices_array_list.append(allocator, i); } - if (isActiveValidator(&validator, current_epoch)) { + if (isActiveValidator(validator, current_epoch)) { try current_active_indices_array_list.append(allocator, i); total_active_balance_increments += effective_balance_increments.items[i]; } - if (isActiveValidator(&validator, next_epoch)) { + if (isActiveValidator(validator, next_epoch)) { try next_active_indices_array_list.append(allocator, i); } diff --git a/src/state_transition/cache/epoch_transition_cache.zig b/src/state_transition/cache/epoch_transition_cache.zig index 1c73c140f..77b0809f6 100644 --- a/src/state_transition/cache/epoch_transition_cache.zig +++ b/src/state_transition/cache/epoch_transition_cache.zig @@ -244,9 +244,10 @@ pub const EpochTransitionCache = struct { errdefer indices_to_eject.deinit(allocator); var total_active_stake_by_increment: u64 = 0; - const validators = try state.validatorsSlice(allocator); - defer allocator.free(validators); - const validator_count = validators.len; + var validators_view = try state.validators(); + try validators_view.commit(); + const validator_count = try validators_view.length(); + var validators_it = validators_view.iteratorReadonly(0); // Clone before being mutated in processEffectiveBalanceUpdates try epoch_cache.beforeEpochTransition(); @@ -256,7 +257,8 @@ pub const EpochTransitionCache = struct { var next_epoch_shuffling_active_indices_length: usize = 0; var reused_cache = try getReusedEpochTransitionCache(allocator, io, validator_count); - for (validators, 0..) |validator, i| { + for (0..validator_count) |i| { + const validator = try validators_it.nextValuePtr(); var flag: u8 = 0; if (validator.slashed) { @@ -583,7 +585,7 @@ pub const EpochTransitionCache = struct { test "EpochTransitionCache - finalProcessEpoch" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -607,7 +609,7 @@ test "EpochTransitionCache.beforeProcessEpoch" { inline for (validator_count_arr) |validator_count| { const pool_size = validator_count * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, validator_count); diff --git a/src/state_transition/cache/pubkey_cache.zig b/src/state_transition/cache/pubkey_cache.zig index 500c544e0..652c4d794 100644 --- a/src/state_transition/cache/pubkey_cache.zig +++ b/src/state_transition/cache/pubkey_cache.zig @@ -15,7 +15,7 @@ pub const Index2PubkeyCache = std.ArrayList(bls.PublicKey); /// worker pool, see `syncPubkeysParallel`. pub fn syncPubkeys( allocator: std.mem.Allocator, - validators: []const Validator, + validators: []const *const Validator, pubkey_to_index: *PubkeyIndexMap, index_to_pubkey: *Index2PubkeyCache, ) !void { @@ -42,7 +42,7 @@ pub fn syncPubkeys( fn uncompressPubkeys( start_index: usize, end_index_exclusive: usize, - validators: []const Validator, + validators: []const *const Validator, index_to_pubkey: *Index2PubkeyCache, uncompress_error: *std.atomic.Value(bool), ) void { @@ -67,7 +67,7 @@ fn uncompressPubkeys( pub fn syncPubkeysParallel( allocator: std.mem.Allocator, io: std.Io, - validators: []const Validator, + validators: []const *const Validator, pubkey_to_index: *PubkeyIndexMap, index_to_pubkey: *Index2PubkeyCache, ) !void { @@ -127,9 +127,11 @@ test "syncPubkeys populates both caches" { try interop.interopPubkeysCached(count, &pubkeys); var validators: [count]Validator = undefined; + var validator_ptrs: [count]*const Validator = undefined; for (0..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); @@ -137,12 +139,11 @@ test "syncPubkeys populates both caches" { var index_to_pubkey: Index2PubkeyCache = .empty; defer index_to_pubkey.deinit(allocator); - try syncPubkeys(allocator, &validators, &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey); try testing.expectEqual(@as(usize, count), index_to_pubkey.items.len); try testing.expectEqual(@as(u32, count), pubkey_to_index.count()); - // Verify each pubkey maps to the correct index for (0..count) |i| { const idx = pubkey_to_index.get(pubkeys[i]).?; try testing.expectEqual(@as(u64, i), idx); @@ -158,9 +159,11 @@ test "syncPubkeys incremental sync adds only new validators" { try interop.interopPubkeysCached(total_count, &pubkeys); var validators: [total_count]Validator = undefined; + var validator_ptrs: [total_count]*const Validator = undefined; for (0..total_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); @@ -168,16 +171,13 @@ test "syncPubkeys incremental sync adds only new validators" { var index_to_pubkey: Index2PubkeyCache = .empty; defer index_to_pubkey.deinit(allocator); - // Initial sync with first 2 validators - try syncPubkeys(allocator, validators[0..initial_count], &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, validator_ptrs[0..initial_count], &pubkey_to_index, &index_to_pubkey); try testing.expectEqual(@as(usize, initial_count), index_to_pubkey.items.len); - // Incremental sync with all 4 validators - try syncPubkeys(allocator, &validators, &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey); try testing.expectEqual(@as(usize, total_count), index_to_pubkey.items.len); try testing.expectEqual(@as(u32, total_count), pubkey_to_index.count()); - // Verify all pubkeys are correctly mapped for (0..total_count) |i| { const idx = pubkey_to_index.get(pubkeys[i]).?; try testing.expectEqual(@as(u64, i), idx); @@ -192,9 +192,11 @@ test "syncPubkeys no-op when already synced" { try interop.interopPubkeysCached(count, &pubkeys); var validators: [count]Validator = undefined; + var validator_ptrs: [count]*const Validator = undefined; for (0..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); @@ -202,9 +204,8 @@ test "syncPubkeys no-op when already synced" { var index_to_pubkey: Index2PubkeyCache = .empty; defer index_to_pubkey.deinit(allocator); - try syncPubkeys(allocator, &validators, &pubkey_to_index, &index_to_pubkey); - // Second call should be no-op - try syncPubkeys(allocator, &validators, &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey); + try syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey); try testing.expectEqual(@as(usize, count), index_to_pubkey.items.len); } @@ -216,12 +217,12 @@ test "syncPubkeys detects inconsistent cache" { var index_to_pubkey: Index2PubkeyCache = .empty; defer index_to_pubkey.deinit(allocator); - // Manually desync: add to pubkey_to_index but not index_to_pubkey const dummy_key = [_]u8{0} ** 48; try pubkey_to_index.put(dummy_key, 0); var validators: [1]Validator = undefined; validators[0] = std.mem.zeroes(Validator); + var validator_ptrs: [1]*const Validator = .{&validators[0]}; - try testing.expectError(error.InconsistentCache, syncPubkeys(allocator, &validators, &pubkey_to_index, &index_to_pubkey)); + try testing.expectError(error.InconsistentCache, syncPubkeys(allocator, &validator_ptrs, &pubkey_to_index, &index_to_pubkey)); } diff --git a/src/state_transition/cache/slashings_cache.zig b/src/state_transition/cache/slashings_cache.zig index 1753701ea..f61277fde 100644 --- a/src/state_transition/cache/slashings_cache.zig +++ b/src/state_transition/cache/slashings_cache.zig @@ -21,7 +21,7 @@ pub const SlashingsCache = struct { pub fn initFromValidators( allocator: Allocator, latest_block_slot: Slot, - validators: []const Validator, + validators: []const *const Validator, ) !SlashingsCache { var slashed_validators = try DynamicBitSet.initEmpty(allocator, validators.len); errdefer slashed_validators.deinit(); @@ -91,9 +91,7 @@ pub fn buildFromStateIfNeeded( const latest_block_slot = try latest_block_header.get("slot"); if (slashings_cache.isInitialized(latest_block_slot)) return; - var validators_view = try state.validators(); - try validators_view.commit(); - const validators = try validators_view.getAllReadonlyValues(allocator); + const validators = try state.validatorsPtrSlice(allocator); defer allocator.free(validators); var new_cache = try SlashingsCache.initFromValidators(allocator, latest_block_slot, validators); errdefer new_cache.deinit(); @@ -120,7 +118,10 @@ test "SlashingsCache - initFromValidators populates slashed bits" { validators[1].slashed = true; validators[3].slashed = true; - var cache = try SlashingsCache.initFromValidators(allocator, 42, &validators); + var validator_ptrs: [5]*const Validator = undefined; + for (0..5) |i| validator_ptrs[i] = &validators[i]; + + var cache = try SlashingsCache.initFromValidators(allocator, 42, &validator_ptrs); defer cache.deinit(); try std.testing.expectEqual(@as(?Slot, 42), cache.latest_block_slot); @@ -180,7 +181,10 @@ test "SlashingsCache - clone creates independent copy" { @memset(std.mem.asBytes(&validators), 0); validators[1].slashed = true; - var original = try SlashingsCache.initFromValidators(allocator, 10, &validators); + var validator_ptrs: [3]*const Validator = undefined; + for (0..3) |i| validator_ptrs[i] = &validators[i]; + + var original = try SlashingsCache.initFromValidators(allocator, 10, &validator_ptrs); defer original.deinit(); var cloned = try original.clone(allocator); diff --git a/src/state_transition/cache/state_cache.zig b/src/state_transition/cache/state_cache.zig index 51d9e8a68..b21cb1723 100644 --- a/src/state_transition/cache/state_cache.zig +++ b/src/state_transition/cache/state_cache.zig @@ -174,7 +174,7 @@ pub const CachedBeaconState = struct { test "CachedBeaconState.clone()" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -190,7 +190,7 @@ test "CachedBeaconState.clone()" { test "CachedBeaconState.clone() epoch cache isolation" { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/epoch/process_effective_balance_updates.zig b/src/state_transition/epoch/process_effective_balance_updates.zig index fd1365a53..f95a74646 100644 --- a/src/state_transition/epoch/process_effective_balance_updates.zig +++ b/src/state_transition/epoch/process_effective_balance_updates.zig @@ -121,7 +121,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processEffectiveBalanceUpdates - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_epoch.zig b/src/state_transition/epoch/process_epoch.zig index f3f4a7e46..5bb7d3f2e 100644 --- a/src/state_transition/epoch/process_epoch.zig +++ b/src/state_transition/epoch/process_epoch.zig @@ -110,7 +110,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processEpoch - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_eth1_data_reset.zig b/src/state_transition/epoch/process_eth1_data_reset.zig index 688682788..df303ded3 100644 --- a/src/state_transition/epoch/process_eth1_data_reset.zig +++ b/src/state_transition/epoch/process_eth1_data_reset.zig @@ -25,7 +25,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processEth1DataReset - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_historical_summaries_update.zig b/src/state_transition/epoch/process_historical_summaries_update.zig index 8f2978266..5aa361022 100644 --- a/src/state_transition/epoch/process_historical_summaries_update.zig +++ b/src/state_transition/epoch/process_historical_summaries_update.zig @@ -31,7 +31,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processHistoricalSummariesUpdate - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_inactivity_updates.zig b/src/state_transition/epoch/process_inactivity_updates.zig index d4412053e..22f251a07 100644 --- a/src/state_transition/epoch/process_inactivity_updates.zig +++ b/src/state_transition/epoch/process_inactivity_updates.zig @@ -63,7 +63,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processInactivityUpdates - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_justification_and_finalization.zig b/src/state_transition/epoch/process_justification_and_finalization.zig index 1707aedd8..8c4797ee2 100644 --- a/src/state_transition/epoch/process_justification_and_finalization.zig +++ b/src/state_transition/epoch/process_justification_and_finalization.zig @@ -108,7 +108,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processJustificationAndFinalization - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_participation_flag_updates.zig b/src/state_transition/epoch/process_participation_flag_updates.zig index fb6d217ec..628617409 100644 --- a/src/state_transition/epoch/process_participation_flag_updates.zig +++ b/src/state_transition/epoch/process_participation_flag_updates.zig @@ -16,7 +16,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processParticipationFlagUpdates - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_pending_consolidations.zig b/src/state_transition/epoch/process_pending_consolidations.zig index 8547aa619..78ffdcd98 100644 --- a/src/state_transition/epoch/process_pending_consolidations.zig +++ b/src/state_transition/epoch/process_pending_consolidations.zig @@ -62,7 +62,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processPendingConsolidations - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_pending_deposits.zig b/src/state_transition/epoch/process_pending_deposits.zig index bfb7ca3a8..fa2b16ca5 100644 --- a/src/state_transition/epoch/process_pending_deposits.zig +++ b/src/state_transition/epoch/process_pending_deposits.zig @@ -169,7 +169,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processPendingDeposits - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_proposer_lookahead.zig b/src/state_transition/epoch/process_proposer_lookahead.zig index c78b7e94b..7c4960754 100644 --- a/src/state_transition/epoch/process_proposer_lookahead.zig +++ b/src/state_transition/epoch/process_proposer_lookahead.zig @@ -69,7 +69,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processProposerLookahead sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_randao_mixes_reset.zig b/src/state_transition/epoch/process_randao_mixes_reset.zig index f82c12d7d..78cbf2e0d 100644 --- a/src/state_transition/epoch/process_randao_mixes_reset.zig +++ b/src/state_transition/epoch/process_randao_mixes_reset.zig @@ -28,7 +28,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processRandaoMixesReset - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_registry_updates.zig b/src/state_transition/epoch/process_registry_updates.zig index 45183ddce..3f3ffceb8 100644 --- a/src/state_transition/epoch/process_registry_updates.zig +++ b/src/state_transition/epoch/process_registry_updates.zig @@ -59,7 +59,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processRegistryUpdates - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_rewards_and_penalties.zig b/src/state_transition/epoch/process_rewards_and_penalties.zig index 6e73cdce2..9f0630367 100644 --- a/src/state_transition/epoch/process_rewards_and_penalties.zig +++ b/src/state_transition/epoch/process_rewards_and_penalties.zig @@ -67,7 +67,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processRewardsAndPenalties - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_slashings.zig b/src/state_transition/epoch/process_slashings.zig index 29b07429f..c8674b475 100644 --- a/src/state_transition/epoch/process_slashings.zig +++ b/src/state_transition/epoch/process_slashings.zig @@ -87,7 +87,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processSlashings - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_slashings_reset.zig b/src/state_transition/epoch/process_slashings_reset.zig index 9934a7fb8..5b1546eab 100644 --- a/src/state_transition/epoch/process_slashings_reset.zig +++ b/src/state_transition/epoch/process_slashings_reset.zig @@ -30,7 +30,7 @@ const Node = @import("persistent_merkle_tree").Node; test "processSlashingsReset - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/epoch/process_sync_committee_updates.zig b/src/state_transition/epoch/process_sync_committee_updates.zig index 5e97fd148..e55654ccd 100644 --- a/src/state_transition/epoch/process_sync_committee_updates.zig +++ b/src/state_transition/epoch/process_sync_committee_updates.zig @@ -37,7 +37,7 @@ const TestCachedBeaconState = @import("../test_utils/root.zig").TestCachedBeacon test "processSyncCommitteeUpdates - sanity" { const allocator = std.testing.allocator; const pool_size = 10_000 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 10_000); diff --git a/src/state_transition/load_state.zig b/src/state_transition/load_state.zig index b4cc1e8db..011468f78 100644 --- a/src/state_transition/load_state.zig +++ b/src/state_transition/load_state.zig @@ -242,9 +242,7 @@ fn loadValidators( try applyModifiedValidators( allocator, - seed_validators, migrated_validators, - seed_bytes, new_validators_bytes, modified_validators.items, ); @@ -352,13 +350,11 @@ fn syncScoresLength( return trimmed; } -/// Overwrite each modified validator in `migrated_validators` with a view rebuilt -/// from `new_validators_bytes`, reusing the seed validator's pubkey/withdrawal subtrees. +/// Overwrite each modified validator in `migrated_validators` with a view +/// freshly deserialized from `new_validators_bytes`. fn applyModifiedValidators( allocator: Allocator, - seed_validators: *types.phase0.Validators.TreeView, migrated_validators: *types.phase0.Validators.TreeView, - seed_bytes: []const u8, new_validators_bytes: []const u8, modified_validators: []const ValidatorIndex, ) !void { @@ -366,16 +362,10 @@ fn applyModifiedValidators( const i: usize = @intCast(validator_index); const start = i * types.phase0.Validator.fixed_size; const new_bytes = new_validators_bytes[start .. start + types.phase0.Validator.fixed_size]; - const seed_val_bytes = seed_bytes[start .. start + types.phase0.Validator.fixed_size]; - const seed_validator = try seed_validators.get(i); - // seed_validator is borrowed from seed_validators; do not deinit. - - const new_validator = try loadValidatorWithSeedReuse( + const new_validator = try loadValidator( allocator, migrated_validators.chunks.state.pool, - seed_validator, - seed_val_bytes, new_bytes, ); errdefer new_validator.deinit(); @@ -451,68 +441,17 @@ fn inactivityScoresNodeId(state: *AnyBeaconState) !Node.Id { }; } -/// Load a validator from bytes given a seed validator. -/// - Reuse pubkey and withdrawal credentials subtrees if they are unchanged, to save memory. -/// - Otherwise deserialize the validator fresh. -fn loadValidatorWithSeedReuse( +/// Deserialize a validator from `new_validator_bytes` into a fresh TreeView. +/// No seed/field reuse: `Validator` is a `StructContainerType` (one opaque +/// node, fields inline by value) — there are no per-field subtrees to share. +fn loadValidator( allocator: Allocator, pool: *Node.Pool, - seed_validator: *types.phase0.Validator.TreeView, - seed_validator_bytes: []const u8, new_validator_bytes: []const u8, ) !*types.phase0.Validator.TreeView { - const Validator = types.phase0.Validator; - const PUBKEY_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("pubkey")]; - const PUBKEY_END = PUBKEY_OFFSET + comptime Validator.getFieldType("pubkey").fixed_size; - const WCRED_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("withdrawal_credentials")]; - const WCRED_END = WCRED_OFFSET + comptime Validator.getFieldType("withdrawal_credentials").fixed_size; - - const pubkey_same = std.mem.eql(u8, new_validator_bytes[PUBKEY_OFFSET..PUBKEY_END], seed_validator_bytes[PUBKEY_OFFSET..PUBKEY_END]); - const withdrawal_same = std.mem.eql(u8, new_validator_bytes[WCRED_OFFSET..WCRED_END], seed_validator_bytes[WCRED_OFFSET..WCRED_END]); - - if (!pubkey_same) { - if (!withdrawal_same) { - const root = try types.phase0.Validator.tree.deserializeFromBytes(pool, new_validator_bytes); - errdefer pool.unref(root); - - return try types.phase0.Validator.TreeView.init(allocator, pool, root); - } - } - - var nodes: [types.phase0.Validator.chunk_count]Node.Id = undefined; - var owned_nodes: [types.phase0.Validator.chunk_count]Node.Id = undefined; - var owned_len: usize = 0; - errdefer { - for (owned_nodes[0..owned_len]) |node_id| { - pool.unref(node_id); - } - } - - inline for (types.phase0.Validator.fields, 0..) |field, i| { - const reuse = if (comptime std.mem.eql(u8, field.name, "pubkey")) - pubkey_same - else if (comptime std.mem.eql(u8, field.name, "withdrawal_credentials")) - withdrawal_same - else - false; - - if (reuse) { - nodes[i] = try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, i); - } else { - const start = types.phase0.Validator.field_offsets[i]; - const end = start + field.type.fixed_size; - const bytes = new_validator_bytes[start..end]; - const node_id = try field.type.tree.deserializeFromBytes(pool, bytes); - owned_nodes[owned_len] = node_id; - owned_len += 1; - nodes[i] = node_id; - } - } - - const root = try Node.fillWithContents(pool, &nodes, types.phase0.Validator.chunk_depth); + const root = try types.phase0.Validator.tree.deserializeFromBytes(pool, new_validator_bytes); errdefer pool.unref(root); - owned_len = 0; return try types.phase0.Validator.TreeView.init(allocator, pool, root); } @@ -594,72 +533,6 @@ fn findModifiedInactivityScores( ); } -test "loadValidatorWithSeedReuse: reuse vs rebuild" { - const allocator = std.testing.allocator; - - var pool = try Node.Pool.init(allocator, 1024); - defer pool.deinit(); - - const gen = @import("test_utils/generate_state.zig"); - const chain_config = gen.getConfig(@import("config").minimal.chain_config, .electra, 0); - - const state_ptr = try gen.generateElectraState(allocator, &pool, chain_config, 64); - defer { - state_ptr.deinit(); - allocator.destroy(state_ptr); - } - - // Build a seed BeaconState TreeView in this pool, then take a validator element as the seed. - const seed_state_bytes = try state_ptr.serialize(allocator); - defer allocator.free(seed_state_bytes); - - var seed_state = try AnyBeaconState.deserialize(allocator, &pool, .electra, seed_state_bytes); - defer seed_state.deinit(); - - var seed_validators = try types.phase0.Validators.TreeView.init(allocator, seed_state.nodePool(), try validatorsNodeId(&seed_state)); - defer seed_validators.deinit(); - - const target_index: usize = 3; - var seed_validator = try seed_validators.get(target_index); - // seed_validator is borrowed from seed_validators; do not deinit. - - var seed_validator_bytes: [types.phase0.Validator.fixed_size]u8 = undefined; - _ = try seed_validator.serializeIntoBytes(&seed_validator_bytes); - - var new_validator_bytes = seed_validator_bytes; - // Modify only withdrawal_credentials so the reuse path keeps pubkey but rebuilds wcred. - const Validator = types.phase0.Validator; - const WCRED_OFFSET = comptime Validator.field_offsets[Validator.getFieldIndex("withdrawal_credentials")]; - const WCRED_END = WCRED_OFFSET + comptime Validator.getFieldType("withdrawal_credentials").fixed_size; - @memset(new_validator_bytes[WCRED_OFFSET..WCRED_END], 0x11); - - const new_validator = try loadValidatorWithSeedReuse( - allocator, - &pool, - seed_validator, - seed_validator_bytes[0..], - new_validator_bytes[0..], - ); - defer new_validator.deinit(); - - const pubkey_i = comptime types.phase0.Validator.getFieldIndex("pubkey"); - const withdrawal_i = comptime types.phase0.Validator.getFieldIndex("withdrawal_credentials"); - - try std.testing.expectEqual( - try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, pubkey_i), - try new_validator.root.getNodeAtDepth(new_validator.pool, types.phase0.Validator.chunk_depth, pubkey_i), - ); - try std.testing.expect( - try seed_validator.root.getNodeAtDepth(seed_validator.pool, types.phase0.Validator.chunk_depth, withdrawal_i) != try new_validator.root.getNodeAtDepth(new_validator.pool, types.phase0.Validator.chunk_depth, withdrawal_i), - ); - - const fresh_root = try types.phase0.Validator.tree.deserializeFromBytes(&pool, new_validator_bytes[0..]); - var fresh_validator = try types.phase0.Validator.TreeView.init(allocator, &pool, fresh_root); - defer fresh_validator.deinit(); - - try std.testing.expectEqualSlices(u8, try fresh_validator.hashTreeRoot(), try new_validator.hashTreeRoot()); -} - test "loadState scenarios" { const allocator = std.testing.allocator; const gen = @import("test_utils/generate_state.zig"); @@ -700,7 +573,7 @@ test "loadState scenarios" { }; inline for (cases) |case| { - var pool = try Node.Pool.init(allocator, 8192); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 8192 }); defer pool.deinit(); const state_ptr = try gen.generateElectraState(allocator, &pool, chain_config, 64); @@ -921,7 +794,7 @@ test "diff helpers cases" { test "loadValidators/loadInactivityScores: rejection scenarios" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 1024); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 1024 }); defer pool.deinit(); const gen = @import("test_utils/generate_state.zig"); diff --git a/src/state_transition/slot/upgrade_state_to_altair.zig b/src/state_transition/slot/upgrade_state_to_altair.zig index 8437ddef9..2a0415049 100644 --- a/src/state_transition/slot/upgrade_state_to_altair.zig +++ b/src/state_transition/slot/upgrade_state_to_altair.zig @@ -74,7 +74,7 @@ pub fn upgradeStateToAltair( const previous_epoch = computePreviousEpoch(epoch_cache.epoch); try altair_state.commit(); - const validators = try altair_state.validatorsSlice(allocator); + const validators = try altair_state.validatorsPtrSlice(allocator); defer allocator.free(validators); epoch_cache.previous_target_unslashed_balance_increments = sumTargetUnslashedBalanceIncrements(previous_epoch_participation.items, previous_epoch, validators); epoch_cache.current_target_unslashed_balance_increments = sumTargetUnslashedBalanceIncrements(current_epoch_participation.items, epoch_cache.epoch, validators); diff --git a/src/state_transition/ssz_container.zig b/src/state_transition/ssz_container.zig index 1e1117a91..0e827a710 100644 --- a/src/state_transition/ssz_container.zig +++ b/src/state_transition/ssz_container.zig @@ -55,11 +55,11 @@ pub fn deserializeContainerOverrideFieldsWithRanges( test "deserializeContainerOverrideFields... cleans up pool nodes on error" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 64); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); defer pool.deinit(); const U64 = ssz.UintType(64); - const U64List = ssz.FixedListType(U64, 4); + const U64List = ssz.FixedListType(U64, 4, .{}); const Fields = struct { a: U64, b: U64List, diff --git a/src/state_transition/state_transition.zig b/src/state_transition/state_transition.zig index 6bf4ce10d..8338d33d0 100644 --- a/src/state_transition/state_transition.zig +++ b/src/state_transition/state_transition.zig @@ -285,7 +285,7 @@ test "state transition - electra block" { inline for (test_cases) |tc| { const allocator = std.testing.allocator; const pool_size = 256 * 5; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); @@ -328,7 +328,7 @@ test "state transition - electra block" { test "state transition - a rejected block leaves the pre-state unchanged" { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 256 * 5); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 * 5 }); defer pool.deinit(); defer deinitStateTransition(std.testing.io); diff --git a/src/state_transition/test_utils/generate_state.zig b/src/state_transition/test_utils/generate_state.zig index e601b6ad4..42b017497 100644 --- a/src/state_transition/test_utils/generate_state.zig +++ b/src/state_transition/test_utils/generate_state.zig @@ -142,8 +142,9 @@ pub fn generateElectraState(allocator: Allocator, pool: *Node.Pool, chain_config var validators = try beacon_state.validators(); for (next_sync_committee_indices, 0..next_sync_committee_indices.len) |index, i| { var validator = try validators.get(@intCast(index)); - var pubkey_view = try validator.get("pubkey"); - _ = try pubkey_view.getAllInto(next_sync_committee_pubkeys[i][0..]); + // Validator is now a StructContainerType — `get("pubkey")` returns the + // value directly (a `[48]u8` array), not a child TreeView. + next_sync_committee_pubkeys[i] = try validator.get("pubkey"); next_sync_committee_pubkeys_slices[i] = try bls.PublicKey.uncompress(&next_sync_committee_pubkeys[i]); } @@ -200,7 +201,7 @@ pub const TestCachedBeaconState = struct { errdefer allocator.destroy(config); config.* = BeaconConfig.init(chain_config, (try state.genesisValidatorsRoot()).*); - const validators = try state.validatorsSlice(allocator); + const validators = try state.validatorsPtrSlice(allocator); defer allocator.free(validators); try syncPubkeys(allocator, validators, pubkey_index_map, index_pubkey_cache); @@ -302,7 +303,7 @@ pub fn getConfig(config: ChainConfig, fork: ForkSeq, fork_epoch: Epoch) ChainCon test TestCachedBeaconState { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); defer pool.deinit(); var test_state = try TestCachedBeaconState.init(allocator, &pool, 256); diff --git a/src/state_transition/utils/balance.zig b/src/state_transition/utils/balance.zig index 79eff3c0a..9481e6de9 100644 --- a/src/state_transition/utils/balance.zig +++ b/src/state_transition/utils/balance.zig @@ -25,28 +25,26 @@ pub fn decreaseBalance(comptime fork: ForkSeq, state: *BeaconState(fork), index: pub fn getEffectiveBalanceIncrementsZeroInactive(allocator: Allocator, cached_state: *CachedBeaconState) !EffectiveBalanceIncrements { const active_indices = cached_state.epoch_cache.getCurrentShuffling().active_indices; - // 5x faster than reading from state.validators, with validator Nodes as values - const validators = try cached_state.state.validatorsSlice(allocator); - defer allocator.free(validators); - const validator_count = validators.len; + var validators_view = try cached_state.state.validators(); + try validators_view.commit(); + const validator_count = try validators_view.length(); + var validators_it = validators_view.iteratorReadonly(0); + const effective_balance_increments = cached_state.epoch_cache.getEffectiveBalanceIncrements(); - // Slice up to `validatorCount` since it won't be mutated, nor accessed beyond `validatorCount` var effective_balance_increments_zero_inactive = try EffectiveBalanceIncrements.initCapacity(allocator, validator_count); effective_balance_increments_zero_inactive.appendSliceAssumeCapacity(effective_balance_increments.items[0..validator_count]); var j: usize = 0; - for (validators, 0..) |validator, i| { + for (0..validator_count) |i| { + const validator = try validators_it.nextValuePtr(); const slashed = validator.slashed; if (j < active_indices.len and i == active_indices[j]) { - // active validator j += 1; if (slashed) { - // slashed validator effective_balance_increments_zero_inactive.items[i] = 0; } } else { - // inactive validator effective_balance_increments_zero_inactive.items[i] = 0; } } diff --git a/src/state_transition/utils/target_unslashed_balance.zig b/src/state_transition/utils/target_unslashed_balance.zig index c3fc8f735..154189ecd 100644 --- a/src/state_transition/utils/target_unslashed_balance.zig +++ b/src/state_transition/utils/target_unslashed_balance.zig @@ -7,11 +7,11 @@ const isActiveValidator = @import("./validator.zig").isActiveValidator; const TIMELY_TARGET = 1 << c.TIMELY_TARGET_FLAG_INDEX; -pub fn sumTargetUnslashedBalanceIncrements(participations: []const u8, epoch: Epoch, validators: []const Validator) u64 { +pub fn sumTargetUnslashedBalanceIncrements(participations: []const u8, epoch: Epoch, validators: []const *const Validator) u64 { var total: u64 = 0; for (participations, 0..) |participation, i| { if ((participation & TIMELY_TARGET) == TIMELY_TARGET) { - const validator = &validators[i]; + const validator = validators[i]; if (isActiveValidator(validator, epoch) and !validator.slashed) { total += @divFloor(validator.effective_balance, preset.EFFECTIVE_BALANCE_INCREMENT); } diff --git a/src/testing_allocators.zig b/src/testing_allocators.zig index 928ae63b7..ea9aef6c0 100644 --- a/src/testing_allocators.zig +++ b/src/testing_allocators.zig @@ -46,3 +46,41 @@ pub const DoubleFreeDetectAllocator = struct { } } }; + +/// OOM-injects a specific deep allocation without relying on an allocation count. +/// `FailingAllocator`'s count-based `fail_index` is brittle here — you'd have to know +/// exactly how many allocations run before the target, which shifts with internal +/// data-structure growth. Instead, route an allocation with a recognizable size +/// through this: once `armed`, the first alloc of >= `trigger_len` bytes arms +/// `target` to fail its next alloc — i.e. "fail the allocation right after this +/// distinctively-sized one" (e.g. setChildNode right after a CoW ChunkedLeaf blob). +pub const ArmOnSizeAllocator = struct { + backing: std.mem.Allocator, + target: *std.testing.FailingAllocator, + trigger_len: usize, + armed: bool = false, + + pub fn allocator(self: *ArmOnSizeAllocator) std.mem.Allocator { + return .{ .ptr = self, .vtable = &.{ .alloc = allocFn, .resize = resizeFn, .remap = remapFn, .free = freeFn } }; + } + fn allocFn(ctx: *anyopaque, len: usize, a: std.mem.Alignment, ra: usize) ?[*]u8 { + const self: *ArmOnSizeAllocator = @ptrCast(@alignCast(ctx)); + const p = self.backing.rawAlloc(len, a, ra); + if (self.armed and p != null and len >= self.trigger_len) { + self.target.fail_index = self.target.alloc_index; + } + return p; + } + fn resizeFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, new_len: usize, ra: usize) bool { + const self: *ArmOnSizeAllocator = @ptrCast(@alignCast(ctx)); + return self.backing.rawResize(memory, a, new_len, ra); + } + fn remapFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, new_len: usize, ra: usize) ?[*]u8 { + const self: *ArmOnSizeAllocator = @ptrCast(@alignCast(ctx)); + return self.backing.rawRemap(memory, a, new_len, ra); + } + fn freeFn(ctx: *anyopaque, memory: []u8, a: std.mem.Alignment, ra: usize) void { + const self: *ArmOnSizeAllocator = @ptrCast(@alignCast(ctx)); + self.backing.rawFree(memory, a, ra); + } +}; diff --git a/test/fuzz/README.md b/test/fuzz/README.md index e5f4a7548..5b1a0dfbc 100644 --- a/test/fuzz/README.md +++ b/test/fuzz/README.md @@ -15,6 +15,7 @@ for SSZ deserialization in lodestar-z. | `ssz_bytelist` | `fuzz-ssz_bytelist` | ByteList(32/256/1024) | | `ssz_containers` | `fuzz-ssz_containers` | Fork, Checkpoint, Eth1Data, Attestation, etc. | | `ssz_lists` | `fuzz-ssz_lists` | FixedList(Uint64/32/Bool), VariableList(ByteList) | +| `ssz_chunked_leaf_set` | `fuzz-ssz_chunked_leaf_set` | FixedList(Uint64, chunked_leaf=true): replay set/commit/get op stream, assert root equivalence against fromValue(reference) | Each SSZ input is `[selector_byte][ssz_data...]`. The first byte selects which SSZ type to test within the target. See source files for the mapping. diff --git a/test/fuzz/build.zig b/test/fuzz/build.zig index 813f1d565..2756c85f9 100644 --- a/test/fuzz/build.zig +++ b/test/fuzz/build.zig @@ -20,6 +20,11 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); + const dep_hashtree = b.dependency("hashtree", .{ + .target = target, + .optimize = optimize, + }); + // Tool: extract corpus seeds from spec test vectors { const extract_mod = b.createModule(.{ @@ -68,6 +73,9 @@ pub fn build(b: *std.Build) void { .{ .name = "ssz_bytelist" }, .{ .name = "ssz_containers" }, .{ .name = "ssz_lists" }, + .{ .name = "ssz_chunked_leaf_set", .extra_libs = &.{dep_hashtree.artifact("hashtree")} }, + .{ .name = "ssz_nested_opaque_proof", .extra_libs = &.{dep_hashtree.artifact("hashtree")} }, + .{ .name = "ssz_opaque_roundtrip", .extra_libs = &.{dep_hashtree.artifact("hashtree")} }, .{ .name = "bls_public_key", .extra_libs = &.{dep_blst.artifact("blst")} }, .{ .name = "bls_signature", .extra_libs = &.{dep_blst.artifact("blst")} }, .{ .name = "bls_aggregate_pk", .extra_libs = &.{dep_blst.artifact("blst")} }, @@ -93,6 +101,10 @@ pub fn build(b: *std.Build) void { ); lib_mod.addImport("preset", lodestar_z.module("preset")); lib_mod.addImport("constants", lodestar_z.module("constants")); + lib_mod.addImport( + "persistent_merkle_tree", + lodestar_z.module("persistent_merkle_tree"), + ); const lib = b.addLibrary(.{ .name = fuzzer.name, diff --git a/test/fuzz/build.zig.zon b/test/fuzz/build.zig.zon index 55a5495c4..9a4cff7e0 100644 --- a/test/fuzz/build.zig.zon +++ b/test/fuzz/build.zig.zon @@ -14,6 +14,10 @@ .url = "git+https://github.com/ChainSafe/snappy.zig#713942410426051da6107a955b29f3f152815622", .hash = "snappy-0.1.0-n4AaquQ4AADQScvw9-i6SJvZTTagvxmGgGKnat4T_a4e", }, + .hashtree = .{ + .url = "git+https://github.com/ChainSafe/hashtree-z.git#81b7303f72e202e966f71eedf4be1d8d33e2fcaa", + .hash = "hashtree-0.1.0-sBOoviYWAAA1HcTLhxWt0TtrLFXvYM5gOvxpu_Rreiiw", + }, }, .paths = .{ "build.zig", "build.zig.zon", "src", "tools" }, } diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_empty_pushes.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_empty_pushes.bin new file mode 100644 index 0000000000000000000000000000000000000000..5d2af099a8fca0653c34cdb0d8051038c01abe72 GIT binary patch literal 17 VcmZQ(W?*mvVrL+BVPs%n000NQ0M`Hj literal 0 HcmV?d00001 diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_pop_set_get.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u32_pop_set_get.bin new file mode 100644 index 0000000000000000000000000000000000000000..46e9e54551aca964f346e85e203421db51225ac5 GIT binary patch literal 17 VcmZQ#U}Y$0Vr5`pWME(b(EtYb0FD3v literal 0 HcmV?d00001 diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_clone_set_commit.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_clone_set_commit.bin new file mode 100644 index 0000000000000000000000000000000000000000..b0ffdd18c72b8c5091655198625b4c1827ebbea3 GIT binary patch literal 17 VcmZQzU}Y$0VE_VFhM9~&3IGYa0U!VX literal 0 HcmV?d00001 diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_empty_pushes.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_empty_pushes.bin new file mode 100644 index 0000000000000000000000000000000000000000..d63063add56c6d006056574868f121378039473a GIT binary patch literal 29 ccmZQ%W?*0kVh$kYWMp7qU;<$vg9*q000U0|EdT%j literal 0 HcmV?d00001 diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_cross_cl.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_cross_cl.bin new file mode 100644 index 0000000000000000000000000000000000000000..67cf6958dcdf69c7b8232fd7d19943baf308e537 GIT binary patch literal 17 WcmZQz5MW?rU=Uye5)2Fq0?YscO8{5^ literal 0 HcmV?d00001 diff --git a/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_path3_path2_path3.bin b/test/fuzz/corpus/ssz_chunked_leaf_set-cmin/seed_u64_pop_path3_path2_path3.bin new file mode 100644 index 0000000000000000000000000000000000000000..a1123d5eb87d37b8ddcf4884e0f44a21cbb67ca6 GIT binary patch literal 17 TcmZQzU| CL1 boundary into the new-chunked_leaf path. +const ItemCount: usize = ChunkedLeaf.K * 4 + 1; + +const op_size: usize = 4; +const selector_count: u8 = 4; + +pub export fn zig_fuzz_init() callconv(.c) void {} + +pub export fn zig_fuzz_test(buf: [*]const u8, len: usize) callconv(.c) void { + if (len < 1 + op_size) return; + + var fba = std.heap.FixedBufferAllocator.init(&fuzz_buf); + const allocator = fba.allocator(); + + const data = buf[1..len]; + switch (buf[0] % selector_count) { + 0 => fuzzListOps(ssz.FixedListType(ssz.UintType(64), Capacity, .{ .chunked_leaf = true }), allocator, data, 64), + 1 => fuzzListOps(ssz.FixedListType(ssz.UintType(64), Capacity, .{ .chunked_leaf = true }), allocator, data, 0), + 2 => fuzzListOps(ssz.FixedListType(ssz.UintType(32), Capacity, .{ .chunked_leaf = true }), allocator, data, 64), + 3 => fuzzListOps(ssz.FixedListType(ssz.UintType(32), Capacity, .{ .chunked_leaf = true }), allocator, data, 0), + else => unreachable, + } +} + +fn fuzzListOps( + comptime ListT: type, + allocator: std.mem.Allocator, + data: []const u8, + initial_count: usize, +) void { + const Element = ListT.Element.Type; + const items_per_chunk: usize = 32 / ListT.Element.fixed_size; + const K: usize = ChunkedLeaf.K; + // +1 for the list length-mixin level above the data subtree. + const cl_depth = ListT.chunk_depth + 1 - ChunkedLeaf.k_log2; + + var pool = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 4096, + }) catch return; + defer pool.deinit(); + + // Pool baseline = pre-populated zero sentinels (max_depth of them). + // Any future regression in set/commit/push/clone that fails to unref a + // transient Pool slot will accumulate over the op stream and trip this + // assert at function exit (after view.deinit releases the tree). + const baseline_in_use = pool.getNodesInUse(); + var leak_check_armed = false; + defer { + if (leak_check_armed) { + const final_in_use = pool.getNodesInUse(); + assert(final_in_use == baseline_in_use); + } + } + + var reference = std.ArrayList(Element).empty; + defer reference.deinit(allocator); + reference.ensureTotalCapacity(allocator, ItemCount) catch return; + for (0..initial_count) |i| reference.append(allocator, computeInitial(Element, i)) catch return; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + src.ensureTotalCapacity(allocator, initial_count) catch return; + for (reference.items) |v| src.append(allocator, v) catch return; + + const root_id = ListT.tree.fromValue(&pool, &src) catch return; + var view = ListT.TreeView.init(allocator, &pool, root_id) catch return; + defer view.deinit(); + + // Setup complete: arm the leak check so it fires at function exit. + leak_check_armed = true; + + var i: usize = 0; + while (i + op_size <= data.len) : (i += op_size) { + const op = data[i] % 7; + const arg_lo = data[i + 1]; + const arg_hi = data[i + 2]; + const val_seed = data[i + 3]; + + switch (op) { + 0 => { + if (reference.items.len == 0) continue; + const idx = (@as(usize, arg_hi) << 8 | @as(usize, arg_lo)) % reference.items.len; + const val = elementFromSeed(Element, val_seed); + reference.items[idx] = val; + view.set(idx, val) catch return; + }, + 1 => { + const view_root = (view.hashTreeRoot() catch return).*; + + var ref_src: ListT.Type = .empty; + defer ref_src.deinit(allocator); + ref_src.ensureTotalCapacity(allocator, reference.items.len) catch return; + for (reference.items) |v| ref_src.append(allocator, v) catch return; + + const ref_root_id = ListT.tree.fromValue(&pool, &ref_src) catch return; + defer pool.unref(ref_root_id); + const ref_root = ref_root_id.getRoot(&pool).*; + + assert(std.mem.eql(u8, &ref_root, &view_root)); + + // Each ChunkedLeaf's `len` must track the list length. The + // root check above can't catch a stale `len` — computeRoot + // hashes all K chunks and ignores `len`. + const len = reference.items.len; + if (len > 0) { + const total_chunks = (len + items_per_chunk - 1) / items_per_chunk; + const cl_count = (total_chunks + K - 1) / K; + for (0..cl_count) |cl_idx| { + const cl = view.chunks.state.root.getNodeAtDepth(&pool, cl_depth, cl_idx) catch return; + const expected: u16 = @intCast(@min(K, total_chunks - cl_idx * K)); + assert((cl.getChunkedLeafLen(&pool) catch return) == expected); + } + } + }, + 2 => { + if (reference.items.len == 0) continue; + const idx = (@as(usize, arg_hi) << 8 | @as(usize, arg_lo)) % reference.items.len; + const got = view.get(idx) catch return; + assert(elementEql(Element, got, reference.items[idx])); + }, + 3 => { + if (reference.items.len >= ItemCount) continue; + const val = elementFromSeed(Element, val_seed); + reference.append(allocator, val) catch return; + view.push(val) catch return; + }, + 4 => { + // transfer_cache=false so source's pending writes survive; the + // default true clears source's `changed`, which would silently + // drift `reference` ahead of `view`. + const clone = view.clone(.{ .transfer_cache = false }) catch return; + clone.deinit(); + }, + 5 => { + if (reference.items.len == 0) continue; + const idx = (@as(usize, arg_hi) << 8 | @as(usize, arg_lo)) % reference.items.len; + const sliced = view.sliceTo(idx) catch return; + defer sliced.deinit(); + const sliced_root = (sliced.hashTreeRoot() catch return).*; + + // sliceTo(idx) keeps elements 0..=idx; idx is in [0, len-1]. + const expected_len = idx + 1; + var ref_src: ListT.Type = .empty; + defer ref_src.deinit(allocator); + ref_src.ensureTotalCapacity(allocator, expected_len) catch return; + for (reference.items[0..expected_len]) |v| ref_src.append(allocator, v) catch return; + + const ref_root_id = ListT.tree.fromValue(&pool, &ref_src) catch return; + defer pool.unref(ref_root_id); + + assert(std.mem.eql(u8, ref_root_id.getRoot(&pool), &sliced_root)); + }, + 6 => { + // getAllInto sees uncommitted set/push, so it must match the + // running reference without a commit. + const buf = allocator.alloc(Element, reference.items.len) catch return; + defer allocator.free(buf); + const filled = view.getAllInto(buf) catch return; + assert(filled.len == reference.items.len); + for (filled, reference.items) |a, b| assert(elementEql(Element, a, b)); + }, + else => unreachable, + } + } +} + +inline fn computeInitial(comptime Element: type, i: usize) Element { + if (Element == u64) return @as(u64, @intCast(i)) *% 31 +% 7; + if (Element == u32) return @as(u32, @intCast((i *% 31 +% 7) & 0xFFFFFFFF)); + @compileError("computeInitial: unsupported Element type"); +} + +inline fn elementFromSeed(comptime Element: type, seed: u8) Element { + return @as(Element, @intCast(seed)); +} + +inline fn elementEql(comptime Element: type, a: Element, b: Element) bool { + return a == b; +} diff --git a/test/fuzz/src/fuzz_ssz_lists.zig b/test/fuzz/src/fuzz_ssz_lists.zig index 1b9971e4a..fba6edba5 100644 --- a/test/fuzz/src/fuzz_ssz_lists.zig +++ b/test/fuzz/src/fuzz_ssz_lists.zig @@ -44,17 +44,17 @@ pub export fn zig_fuzz_test( switch (selector % selector_count) { 0 => fuzzFixedList( - ssz.FixedListType(Uint64, 128), + ssz.FixedListType(Uint64, 128, .{}), allocator, data, ), 1 => fuzzFixedList( - ssz.FixedListType(Uint32, 256), + ssz.FixedListType(Uint32, 256, .{}), allocator, data, ), 2 => fuzzFixedList( - ssz.FixedListType(BoolT, 64), + ssz.FixedListType(BoolT, 64, .{}), allocator, data, ), diff --git a/test/fuzz/src/fuzz_ssz_nested_opaque_proof.zig b/test/fuzz/src/fuzz_ssz_nested_opaque_proof.zig new file mode 100644 index 000000000..caa746fd1 --- /dev/null +++ b/test/fuzz/src/fuzz_ssz_nested_opaque_proof.zig @@ -0,0 +1,107 @@ +// Input: [seed_byte][op records of 2 bytes each] +// seed_byte: mixed into the vector's content so identical fuzz inputs +// materialize distinct chunked_leaf chunks +// op record (2 bytes): le u16 reduced to gindex in [1, 4095]; covers +// the container_struct root, both fields, and every internal/leaf node +// of the vec field's 1024-chunk subtree (including the chunked_leaf +// nodes it is built from). Out-of-tree gindices fall in the same band +// and exercise createSingleProof's InvalidNode/InvalidGindex paths. + +const std = @import("std"); +const assert = std.debug.assert; +const ssz = @import("ssz"); +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const Gindex = pmt.Gindex; +const proof = pmt.proof; + +const fuzz_buffer_size: u32 = 64 * 1024 * 1024; +var fuzz_buf: [fuzz_buffer_size]u8 = undefined; + +// StructContainerType makes the root a `.container_struct` opaque, and the +// `vec` field's chunked_leaf list is built from `.chunked_leaf` nodes. Any +// proof targeting nodes inside the vector must traverse both opaque kinds. +const Vec = ssz.FixedVectorType(ssz.UintType(64), 4096, .{ .chunked_leaf = true }); +const Outer = ssz.StructContainerType(struct { + vec: Vec, + tag: ssz.UintType(64), +}); + +const op_size: usize = 2; +const gindex_min: u64 = 1; +const gindex_max: u64 = 4095; +const gindex_span: u64 = gindex_max - gindex_min + 1; + +pub export fn zig_fuzz_init() callconv(.c) void {} + +pub export fn zig_fuzz_test(buf: [*]const u8, len: usize) callconv(.c) void { + if (len < 1 + op_size) return; + + var fba = std.heap.FixedBufferAllocator.init(&fuzz_buf); + const allocator = fba.allocator(); + + var value: Outer.Type = .{ + .vec = Vec.default_value, + .tag = 0, + }; + const seed = buf[0]; + for (0..Vec.length) |i| { + value.vec[i] = (@as(u64, @intCast(i)) +% @as(u64, seed)) *% 0x100000001b3; + } + value.tag = (@as(u64, seed) << 56) | 0x42; + + var pool = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 8192, + }) catch return; + defer pool.deinit(); + + // Pool baseline = pre-populated zero sentinels. Final assert catches + // any transient ref/unref imbalance introduced by createSingleProof or + // its materialize plumbing. + const baseline_in_use = pool.getNodesInUse(); + var leak_check_armed = false; + defer { + if (leak_check_armed) { + const final_in_use = pool.getNodesInUse(); + assert(final_in_use == baseline_in_use); + } + } + + const root = Outer.tree.fromValue(&pool, &value) catch return; + defer pool.unref(root); + + const original_root = root.getRoot(&pool).*; + + leak_check_armed = true; + + var i: usize = 1; + while (i + op_size <= len) : (i += op_size) { + const raw = (@as(u64, buf[i + 1]) << 8) | @as(u64, buf[i]); + const g = gindex_min + (raw % gindex_span); + const gindex = Gindex.fromUint(g); + + var single_proof = proof.createSingleProof(allocator, &pool, root, gindex) catch continue; + defer single_proof.deinit(allocator); + + var pool2 = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 64, + }) catch continue; + defer pool2.deinit(); + + const rebuilt = proof.createNodeFromSingleProof( + &pool2, + gindex, + single_proof.leaf, + single_proof.witnesses, + ) catch continue; + defer pool2.unref(rebuilt); + + // A correct single proof rebuilds to the original root hash. + const rebuilt_root = rebuilt.getRoot(&pool2).*; + assert(std.mem.eql(u8, &original_root, &rebuilt_root)); + } +} diff --git a/test/fuzz/src/fuzz_ssz_opaque_roundtrip.zig b/test/fuzz/src/fuzz_ssz_opaque_roundtrip.zig new file mode 100644 index 000000000..c777fb8ab --- /dev/null +++ b/test/fuzz/src/fuzz_ssz_opaque_roundtrip.zig @@ -0,0 +1,201 @@ +// Round-trip fuzz for the opaque-node SSZ tree paths: +// chunked_leaf list/vector — tree.deserializeFromBytes / serializeIntoBytes / +// toValue / fromValue +// container_struct — the same four, plus tree.getValuePtr +// +// `fuzz_ssz_chunked_leaf_set` already covers the chunked_leaf TreeView ops +// (set/get/push/clone/commit/sliceTo); this target covers the byte- and +// value-level tree conversions that target never exercises. +// +// Input: [selector_byte][ssz_data...] +// selector % 4: 0 = chunked_leaf List(u64) +// 1 = chunked_leaf List(u32) +// 2 = StructContainerType (fixed 52-byte container) +// 3 = chunked_leaf Vector(u64) + +const std = @import("std"); +const assert = std.debug.assert; +const ssz = @import("ssz"); +const pmt = @import("persistent_merkle_tree"); +const Node = pmt.Node; +const ChunkedLeaf = pmt.ChunkedLeaf; + +const fuzz_buffer_size: u32 = 64 * 1024 * 1024; +var fuzz_buf: [fuzz_buffer_size]u8 = undefined; + +const Capacity: usize = 1 << 20; +const selector_count: u8 = 4; + +// All-fixed fields with no bool, so every 52-byte input deserializes and the +// whole round-trip past deserialize gets exercised. +const ContainerT = ssz.StructContainerType(struct { + x: ssz.UintType(64), + y: ssz.UintType(32), + z: ssz.UintType(64), + blob: ssz.ByteVectorType(32), +}); + +// Length 2*K*4 + 7: spans two chunked_leaves with an odd tail, so the last +// chunked_leaf is partial. +const VecChunkedLeaf = ssz.FixedVectorType(ssz.UintType(64), ChunkedLeaf.K * 4 * 2 + 7, .{ .chunked_leaf = true }); + +pub export fn zig_fuzz_init() callconv(.c) void {} + +pub export fn zig_fuzz_test(buf: [*]const u8, len: usize) callconv(.c) void { + if (len < 2) return; + + var fba = std.heap.FixedBufferAllocator.init(&fuzz_buf); + const allocator = fba.allocator(); + + const data = buf[1..len]; + switch (buf[0] % selector_count) { + 0 => fuzzListRoundtrip(ssz.FixedListType(ssz.UintType(64), Capacity, .{ .chunked_leaf = true }), allocator, data), + 1 => fuzzListRoundtrip(ssz.FixedListType(ssz.UintType(32), Capacity, .{ .chunked_leaf = true }), allocator, data), + 2 => fuzzContainerRoundtrip(allocator, data), + 3 => fuzzVectorRoundtrip(VecChunkedLeaf, allocator, data), + else => unreachable, + } +} + +fn fuzzListRoundtrip(comptime ListT: type, allocator: std.mem.Allocator, raw: []const u8) void { + // deserializeFromBytes wants a whole number of elements; trim the tail. + const elem_size = ListT.Element.fixed_size; + const data = raw[0 .. raw.len - raw.len % elem_size]; + + var pool = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 8192, + }) catch return; + defer pool.deinit(); + + // Pool baseline = pre-populated zero sentinels. Any tree id the round-trip + // fails to unref accumulates here and trips the assert at function exit. + const baseline_in_use = pool.getNodesInUse(); + var leak_check_armed = false; + defer { + if (leak_check_armed) { + assert(pool.getNodesInUse() == baseline_in_use); + } + } + + const node = ListT.tree.deserializeFromBytes(&pool, data) catch return; + defer pool.unref(node); + leak_check_armed = true; + + // tree -> bytes round-trips back to the input. + const size = ListT.tree.serializedSize(node, &pool) catch return; + assert(size == data.len); + const out = allocator.alloc(u8, size) catch return; + defer allocator.free(out); + const written = ListT.tree.serializeIntoBytes(node, &pool, out) catch return; + assert(written == size); + assert(std.mem.eql(u8, out, data)); + + // tree -> value -> bytes round-trips too. + var value: ListT.Type = .empty; + defer value.deinit(allocator); + ListT.tree.toValue(allocator, node, &pool, &value) catch return; + const value_size = ListT.serializedSize(&value); + assert(value_size == data.len); + const value_out = allocator.alloc(u8, value_size) catch return; + defer allocator.free(value_out); + const value_written = ListT.serializeIntoBytes(&value, value_out); + assert(value_written == value_size); + assert(std.mem.eql(u8, value_out, data)); + + // value -> tree rebuilds the same root. + const rebuilt = ListT.tree.fromValue(&pool, &value) catch return; + defer pool.unref(rebuilt); + assert(std.mem.eql(u8, node.getRoot(&pool), rebuilt.getRoot(&pool))); +} + +fn fuzzContainerRoundtrip(allocator: std.mem.Allocator, data: []const u8) void { + if (data.len != ContainerT.fixed_size) return; + + var pool = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 256, + }) catch return; + defer pool.deinit(); + + const baseline_in_use = pool.getNodesInUse(); + var leak_check_armed = false; + defer { + if (leak_check_armed) { + assert(pool.getNodesInUse() == baseline_in_use); + } + } + + const node = ContainerT.tree.deserializeFromBytes(&pool, data) catch return; + defer pool.unref(node); + leak_check_armed = true; + + // tree -> bytes round-trips back to the input. + var out: [ContainerT.fixed_size]u8 = undefined; + const written = ContainerT.tree.serializeIntoBytes(node, &pool, &out) catch return; + assert(written == ContainerT.fixed_size); + assert(std.mem.eql(u8, &out, data)); + + // tree -> value -> bytes round-trips too. + var value: ContainerT.Type = undefined; + ContainerT.tree.toValue(node, &pool, &value) catch return; + var value_out: [ContainerT.fixed_size]u8 = undefined; + const value_written = ContainerT.serializeIntoBytes(&value, &value_out); + assert(value_written == ContainerT.fixed_size); + assert(std.mem.eql(u8, &value_out, data)); + + // getValuePtr hands back the same struct toValue produced, with no copy. + const value_ptr = ContainerT.tree.getValuePtr(node, &pool) catch return; + assert(ContainerT.equals(value_ptr, &value)); + + // value -> tree rebuilds the same root. + const rebuilt = ContainerT.tree.fromValue(&pool, &value) catch return; + defer pool.unref(rebuilt); + assert(std.mem.eql(u8, node.getRoot(&pool), rebuilt.getRoot(&pool))); +} + +fn fuzzVectorRoundtrip(comptime VecT: type, allocator: std.mem.Allocator, raw: []const u8) void { + // A vector is fixed-size; take the leading fixed_size bytes. + if (raw.len < VecT.fixed_size) return; + const data = raw[0..VecT.fixed_size]; + + var pool = Node.Pool.init(.{ + .page_allocator = allocator, + .allocator = allocator, + .pool_size = 4096, + }) catch return; + defer pool.deinit(); + + const baseline_in_use = pool.getNodesInUse(); + var leak_check_armed = false; + defer { + if (leak_check_armed) { + assert(pool.getNodesInUse() == baseline_in_use); + } + } + + const node = VecT.tree.deserializeFromBytes(&pool, data) catch return; + defer pool.unref(node); + leak_check_armed = true; + + // tree -> bytes round-trips back to the input. + var out: [VecT.fixed_size]u8 = undefined; + const written = VecT.tree.serializeIntoBytes(node, &pool, &out) catch return; + assert(written == VecT.fixed_size); + assert(std.mem.eql(u8, &out, data)); + + // tree -> value -> bytes round-trips too. + var value: VecT.Type = undefined; + VecT.tree.toValue(node, &pool, &value) catch return; + var value_out: [VecT.fixed_size]u8 = undefined; + const value_written = VecT.serializeIntoBytes(&value, &value_out); + assert(value_written == VecT.fixed_size); + assert(std.mem.eql(u8, &value_out, data)); + + // value -> tree rebuilds the same root. + const rebuilt = VecT.tree.fromValue(&pool, &value) catch return; + defer pool.unref(rebuilt); + assert(std.mem.eql(u8, node.getRoot(&pool), rebuilt.getRoot(&pool))); +} diff --git a/test/spec/runner/epoch_processing.zig b/test/spec/runner/epoch_processing.zig index 4e7677000..62d8c0d31 100644 --- a/test/spec/runner/epoch_processing.zig +++ b/test/spec/runner/epoch_processing.zig @@ -46,7 +46,7 @@ pub fn TestCase(comptime fork: ForkSeq, comptime epoch_process_fn: EpochProcessi pub fn execute(allocator: std.mem.Allocator, dir: std.Io.Dir) !void { const pool_size = if (active_preset == .mainnet) 10_000_000 else 1_000_000; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var tc = try Self.init(allocator, &pool, dir); diff --git a/test/spec/runner/fork.zig b/test/spec/runner/fork.zig index 418e01fd2..752bd7d53 100644 --- a/test/spec/runner/fork.zig +++ b/test/spec/runner/fork.zig @@ -45,7 +45,7 @@ pub fn TestCase(comptime target_fork: ForkSeq) type { pub fn execute(allocator: Allocator, dir: std.Io.Dir) !void { const pool_size = if (active_preset == .mainnet) 10_000_000 else 1_000_000; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var tc = try Self.init(allocator, &pool, dir); diff --git a/test/spec/runner/merkle_proof.zig b/test/spec/runner/merkle_proof.zig index 8a34a9886..95c389f8d 100644 --- a/test/spec/runner/merkle_proof.zig +++ b/test/spec/runner/merkle_proof.zig @@ -95,7 +95,7 @@ pub fn TestCase(comptime fork: ForkSeq) type { try KzgCommitment.hashTreeRoot(&self.body.blob_kzg_commitments.items[0], &actual_leaf); } - var pool = try Node.Pool.init(self.allocator, 2048); + var pool = try Node.Pool.init(.{ .page_allocator = self.allocator, .allocator = self.allocator, .pool_size = 2048 }); defer pool.deinit(); const root_node = try BeaconBlockBody.tree.fromValue(&pool, &self.body); diff --git a/test/spec/runner/operations.zig b/test/spec/runner/operations.zig index bbb91b4d7..bc740c82f 100644 --- a/test/spec/runner/operations.zig +++ b/test/spec/runner/operations.zig @@ -86,7 +86,7 @@ pub fn TestCase(comptime fork: ForkSeq, comptime operation: Operation) type { pub fn execute(allocator: std.mem.Allocator, dir: std.Io.Dir) !void { const pool_size = if (active_preset == .mainnet) 10_000_000 else 1_000_000; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var tc = try Self.init(allocator, &pool, dir); diff --git a/test/spec/runner/rewards.zig b/test/spec/runner/rewards.zig index 49a35b538..2826693c9 100644 --- a/test/spec/runner/rewards.zig +++ b/test/spec/runner/rewards.zig @@ -25,7 +25,7 @@ pub const Handler = enum { }; pub fn TestCase(comptime fork: ForkSeq) type { - const Balances = ssz.FixedListType(ct.primitive.Gwei, preset.VALIDATOR_REGISTRY_LIMIT); + const Balances = ssz.FixedListType(ct.primitive.Gwei, preset.VALIDATOR_REGISTRY_LIMIT, .{}); const DeltasType = ssz.VariableVectorType(Balances, 2); const tc_utils = TestCaseUtils(fork); @@ -40,7 +40,7 @@ pub fn TestCase(comptime fork: ForkSeq) type { pub fn execute(allocator: std.mem.Allocator, dir: std.Io.Dir) !void { const pool_size = if (active_preset == .mainnet) 10_000_000 else 1_000_000; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var tc = try Self.init(allocator, &pool, dir); diff --git a/test/spec/runner/transition.zig b/test/spec/runner/transition.zig index d08486525..efb334713 100644 --- a/test/spec/runner/transition.zig +++ b/test/spec/runner/transition.zig @@ -23,7 +23,7 @@ pub fn Transition(comptime fork: ForkSeq) type { pub fn execute(allocator: std.mem.Allocator, dir: std.Io.Dir) !void { const pool_size = if (active_preset == .mainnet) 10_000_000 else 1_000_000; - var pool = try Node.Pool.init(allocator, pool_size); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }); defer pool.deinit(); var tc = try Self.init(allocator, &pool, dir); diff --git a/test/spec/ssz/generic_types.zig b/test/spec/ssz/generic_types.zig index 50afa00da..5a14b8366 100644 --- a/test/spec/ssz/generic_types.zig +++ b/test/spec/ssz/generic_types.zig @@ -4,76 +4,76 @@ const ssz = @import("ssz"); // (don't change the names) // basic_vector -pub const vec_bool_1 = ssz.FixedVectorType(ssz.BoolType(), 1); -pub const vec_bool_2 = ssz.FixedVectorType(ssz.BoolType(), 2); -pub const vec_bool_3 = ssz.FixedVectorType(ssz.BoolType(), 3); -pub const vec_bool_4 = ssz.FixedVectorType(ssz.BoolType(), 4); -pub const vec_bool_5 = ssz.FixedVectorType(ssz.BoolType(), 5); -pub const vec_bool_8 = ssz.FixedVectorType(ssz.BoolType(), 8); -pub const vec_bool_16 = ssz.FixedVectorType(ssz.BoolType(), 16); -pub const vec_bool_31 = ssz.FixedVectorType(ssz.BoolType(), 31); -pub const vec_bool_512 = ssz.FixedVectorType(ssz.BoolType(), 512); -pub const vec_bool_513 = ssz.FixedVectorType(ssz.BoolType(), 513); -pub const vec_uint8_1 = ssz.FixedVectorType(ssz.UintType(8), 1); -pub const vec_uint8_2 = ssz.FixedVectorType(ssz.UintType(8), 2); -pub const vec_uint8_3 = ssz.FixedVectorType(ssz.UintType(8), 3); -pub const vec_uint8_4 = ssz.FixedVectorType(ssz.UintType(8), 4); -pub const vec_uint8_5 = ssz.FixedVectorType(ssz.UintType(8), 5); -pub const vec_uint8_8 = ssz.FixedVectorType(ssz.UintType(8), 8); -pub const vec_uint8_16 = ssz.FixedVectorType(ssz.UintType(8), 16); -pub const vec_uint8_31 = ssz.FixedVectorType(ssz.UintType(8), 31); -pub const vec_uint8_512 = ssz.FixedVectorType(ssz.UintType(8), 512); -pub const vec_uint8_513 = ssz.FixedVectorType(ssz.UintType(8), 513); -pub const vec_uint16_1 = ssz.FixedVectorType(ssz.UintType(16), 1); -pub const vec_uint16_2 = ssz.FixedVectorType(ssz.UintType(16), 2); -pub const vec_uint16_3 = ssz.FixedVectorType(ssz.UintType(16), 3); -pub const vec_uint16_4 = ssz.FixedVectorType(ssz.UintType(16), 4); -pub const vec_uint16_5 = ssz.FixedVectorType(ssz.UintType(16), 5); -pub const vec_uint16_8 = ssz.FixedVectorType(ssz.UintType(16), 8); -pub const vec_uint16_16 = ssz.FixedVectorType(ssz.UintType(16), 16); -pub const vec_uint16_31 = ssz.FixedVectorType(ssz.UintType(16), 31); -pub const vec_uint16_512 = ssz.FixedVectorType(ssz.UintType(16), 512); -pub const vec_uint16_513 = ssz.FixedVectorType(ssz.UintType(16), 513); -pub const vec_uint32_1 = ssz.FixedVectorType(ssz.UintType(32), 1); -pub const vec_uint32_2 = ssz.FixedVectorType(ssz.UintType(32), 2); -pub const vec_uint32_3 = ssz.FixedVectorType(ssz.UintType(32), 3); -pub const vec_uint32_4 = ssz.FixedVectorType(ssz.UintType(32), 4); -pub const vec_uint32_5 = ssz.FixedVectorType(ssz.UintType(32), 5); -pub const vec_uint32_8 = ssz.FixedVectorType(ssz.UintType(32), 8); -pub const vec_uint32_16 = ssz.FixedVectorType(ssz.UintType(32), 16); -pub const vec_uint32_31 = ssz.FixedVectorType(ssz.UintType(32), 31); -pub const vec_uint32_512 = ssz.FixedVectorType(ssz.UintType(32), 512); -pub const vec_uint32_513 = ssz.FixedVectorType(ssz.UintType(32), 513); -pub const vec_uint64_1 = ssz.FixedVectorType(ssz.UintType(64), 1); -pub const vec_uint64_2 = ssz.FixedVectorType(ssz.UintType(64), 2); -pub const vec_uint64_3 = ssz.FixedVectorType(ssz.UintType(64), 3); -pub const vec_uint64_4 = ssz.FixedVectorType(ssz.UintType(64), 4); -pub const vec_uint64_5 = ssz.FixedVectorType(ssz.UintType(64), 5); -pub const vec_uint64_8 = ssz.FixedVectorType(ssz.UintType(64), 8); -pub const vec_uint64_16 = ssz.FixedVectorType(ssz.UintType(64), 16); -pub const vec_uint64_31 = ssz.FixedVectorType(ssz.UintType(64), 31); -pub const vec_uint64_512 = ssz.FixedVectorType(ssz.UintType(64), 512); -pub const vec_uint64_513 = ssz.FixedVectorType(ssz.UintType(64), 513); -pub const vec_uint128_1 = ssz.FixedVectorType(ssz.UintType(128), 1); -pub const vec_uint128_2 = ssz.FixedVectorType(ssz.UintType(128), 2); -pub const vec_uint128_3 = ssz.FixedVectorType(ssz.UintType(128), 3); -pub const vec_uint128_4 = ssz.FixedVectorType(ssz.UintType(128), 4); -pub const vec_uint128_5 = ssz.FixedVectorType(ssz.UintType(128), 5); -pub const vec_uint128_8 = ssz.FixedVectorType(ssz.UintType(128), 8); -pub const vec_uint128_16 = ssz.FixedVectorType(ssz.UintType(128), 16); -pub const vec_uint128_31 = ssz.FixedVectorType(ssz.UintType(128), 31); -pub const vec_uint128_512 = ssz.FixedVectorType(ssz.UintType(128), 512); -pub const vec_uint128_513 = ssz.FixedVectorType(ssz.UintType(128), 513); -pub const vec_uint256_1 = ssz.FixedVectorType(ssz.UintType(256), 1); -pub const vec_uint256_2 = ssz.FixedVectorType(ssz.UintType(256), 2); -pub const vec_uint256_3 = ssz.FixedVectorType(ssz.UintType(256), 3); -pub const vec_uint256_4 = ssz.FixedVectorType(ssz.UintType(256), 4); -pub const vec_uint256_5 = ssz.FixedVectorType(ssz.UintType(256), 5); -pub const vec_uint256_8 = ssz.FixedVectorType(ssz.UintType(256), 8); -pub const vec_uint256_16 = ssz.FixedVectorType(ssz.UintType(256), 16); -pub const vec_uint256_31 = ssz.FixedVectorType(ssz.UintType(256), 31); -pub const vec_uint256_512 = ssz.FixedVectorType(ssz.UintType(256), 512); -pub const vec_uint256_513 = ssz.FixedVectorType(ssz.UintType(256), 513); +pub const vec_bool_1 = ssz.FixedVectorType(ssz.BoolType(), 1, .{}); +pub const vec_bool_2 = ssz.FixedVectorType(ssz.BoolType(), 2, .{}); +pub const vec_bool_3 = ssz.FixedVectorType(ssz.BoolType(), 3, .{}); +pub const vec_bool_4 = ssz.FixedVectorType(ssz.BoolType(), 4, .{}); +pub const vec_bool_5 = ssz.FixedVectorType(ssz.BoolType(), 5, .{}); +pub const vec_bool_8 = ssz.FixedVectorType(ssz.BoolType(), 8, .{}); +pub const vec_bool_16 = ssz.FixedVectorType(ssz.BoolType(), 16, .{}); +pub const vec_bool_31 = ssz.FixedVectorType(ssz.BoolType(), 31, .{}); +pub const vec_bool_512 = ssz.FixedVectorType(ssz.BoolType(), 512, .{}); +pub const vec_bool_513 = ssz.FixedVectorType(ssz.BoolType(), 513, .{}); +pub const vec_uint8_1 = ssz.FixedVectorType(ssz.UintType(8), 1, .{}); +pub const vec_uint8_2 = ssz.FixedVectorType(ssz.UintType(8), 2, .{}); +pub const vec_uint8_3 = ssz.FixedVectorType(ssz.UintType(8), 3, .{}); +pub const vec_uint8_4 = ssz.FixedVectorType(ssz.UintType(8), 4, .{}); +pub const vec_uint8_5 = ssz.FixedVectorType(ssz.UintType(8), 5, .{}); +pub const vec_uint8_8 = ssz.FixedVectorType(ssz.UintType(8), 8, .{}); +pub const vec_uint8_16 = ssz.FixedVectorType(ssz.UintType(8), 16, .{}); +pub const vec_uint8_31 = ssz.FixedVectorType(ssz.UintType(8), 31, .{}); +pub const vec_uint8_512 = ssz.FixedVectorType(ssz.UintType(8), 512, .{}); +pub const vec_uint8_513 = ssz.FixedVectorType(ssz.UintType(8), 513, .{}); +pub const vec_uint16_1 = ssz.FixedVectorType(ssz.UintType(16), 1, .{}); +pub const vec_uint16_2 = ssz.FixedVectorType(ssz.UintType(16), 2, .{}); +pub const vec_uint16_3 = ssz.FixedVectorType(ssz.UintType(16), 3, .{}); +pub const vec_uint16_4 = ssz.FixedVectorType(ssz.UintType(16), 4, .{}); +pub const vec_uint16_5 = ssz.FixedVectorType(ssz.UintType(16), 5, .{}); +pub const vec_uint16_8 = ssz.FixedVectorType(ssz.UintType(16), 8, .{}); +pub const vec_uint16_16 = ssz.FixedVectorType(ssz.UintType(16), 16, .{}); +pub const vec_uint16_31 = ssz.FixedVectorType(ssz.UintType(16), 31, .{}); +pub const vec_uint16_512 = ssz.FixedVectorType(ssz.UintType(16), 512, .{}); +pub const vec_uint16_513 = ssz.FixedVectorType(ssz.UintType(16), 513, .{}); +pub const vec_uint32_1 = ssz.FixedVectorType(ssz.UintType(32), 1, .{}); +pub const vec_uint32_2 = ssz.FixedVectorType(ssz.UintType(32), 2, .{}); +pub const vec_uint32_3 = ssz.FixedVectorType(ssz.UintType(32), 3, .{}); +pub const vec_uint32_4 = ssz.FixedVectorType(ssz.UintType(32), 4, .{}); +pub const vec_uint32_5 = ssz.FixedVectorType(ssz.UintType(32), 5, .{}); +pub const vec_uint32_8 = ssz.FixedVectorType(ssz.UintType(32), 8, .{}); +pub const vec_uint32_16 = ssz.FixedVectorType(ssz.UintType(32), 16, .{}); +pub const vec_uint32_31 = ssz.FixedVectorType(ssz.UintType(32), 31, .{}); +pub const vec_uint32_512 = ssz.FixedVectorType(ssz.UintType(32), 512, .{}); +pub const vec_uint32_513 = ssz.FixedVectorType(ssz.UintType(32), 513, .{}); +pub const vec_uint64_1 = ssz.FixedVectorType(ssz.UintType(64), 1, .{}); +pub const vec_uint64_2 = ssz.FixedVectorType(ssz.UintType(64), 2, .{}); +pub const vec_uint64_3 = ssz.FixedVectorType(ssz.UintType(64), 3, .{}); +pub const vec_uint64_4 = ssz.FixedVectorType(ssz.UintType(64), 4, .{}); +pub const vec_uint64_5 = ssz.FixedVectorType(ssz.UintType(64), 5, .{}); +pub const vec_uint64_8 = ssz.FixedVectorType(ssz.UintType(64), 8, .{}); +pub const vec_uint64_16 = ssz.FixedVectorType(ssz.UintType(64), 16, .{}); +pub const vec_uint64_31 = ssz.FixedVectorType(ssz.UintType(64), 31, .{}); +pub const vec_uint64_512 = ssz.FixedVectorType(ssz.UintType(64), 512, .{}); +pub const vec_uint64_513 = ssz.FixedVectorType(ssz.UintType(64), 513, .{}); +pub const vec_uint128_1 = ssz.FixedVectorType(ssz.UintType(128), 1, .{}); +pub const vec_uint128_2 = ssz.FixedVectorType(ssz.UintType(128), 2, .{}); +pub const vec_uint128_3 = ssz.FixedVectorType(ssz.UintType(128), 3, .{}); +pub const vec_uint128_4 = ssz.FixedVectorType(ssz.UintType(128), 4, .{}); +pub const vec_uint128_5 = ssz.FixedVectorType(ssz.UintType(128), 5, .{}); +pub const vec_uint128_8 = ssz.FixedVectorType(ssz.UintType(128), 8, .{}); +pub const vec_uint128_16 = ssz.FixedVectorType(ssz.UintType(128), 16, .{}); +pub const vec_uint128_31 = ssz.FixedVectorType(ssz.UintType(128), 31, .{}); +pub const vec_uint128_512 = ssz.FixedVectorType(ssz.UintType(128), 512, .{}); +pub const vec_uint128_513 = ssz.FixedVectorType(ssz.UintType(128), 513, .{}); +pub const vec_uint256_1 = ssz.FixedVectorType(ssz.UintType(256), 1, .{}); +pub const vec_uint256_2 = ssz.FixedVectorType(ssz.UintType(256), 2, .{}); +pub const vec_uint256_3 = ssz.FixedVectorType(ssz.UintType(256), 3, .{}); +pub const vec_uint256_4 = ssz.FixedVectorType(ssz.UintType(256), 4, .{}); +pub const vec_uint256_5 = ssz.FixedVectorType(ssz.UintType(256), 5, .{}); +pub const vec_uint256_8 = ssz.FixedVectorType(ssz.UintType(256), 8, .{}); +pub const vec_uint256_16 = ssz.FixedVectorType(ssz.UintType(256), 16, .{}); +pub const vec_uint256_31 = ssz.FixedVectorType(ssz.UintType(256), 31, .{}); +pub const vec_uint256_512 = ssz.FixedVectorType(ssz.UintType(256), 512, .{}); +pub const vec_uint256_513 = ssz.FixedVectorType(ssz.UintType(256), 513, .{}); // bitlist pub const bitlist_1 = ssz.BitListType(1); @@ -134,16 +134,16 @@ pub const FixedTestStruct = ssz.FixedContainerType(struct { }); pub const VarTestStruct = ssz.VariableContainerType(struct { A: ssz.UintType(16), - B: ssz.FixedListType(ssz.UintType(16), 1024), + B: ssz.FixedListType(ssz.UintType(16), 1024, .{}), C: ssz.UintType(8), }); pub const ComplexTestStruct = ssz.VariableContainerType(struct { A: ssz.UintType(16), - B: ssz.FixedListType(ssz.UintType(16), 128), + B: ssz.FixedListType(ssz.UintType(16), 128, .{}), C: ssz.UintType(8), D: ssz.ByteListType(256), E: VarTestStruct, - F: ssz.FixedVectorType(FixedTestStruct, 4), + F: ssz.FixedVectorType(FixedTestStruct, 4, .{}), G: ssz.VariableVectorType(VarTestStruct, 2), }); pub const BitsStruct = ssz.VariableContainerType(struct { diff --git a/test/spec/ssz/test_case.zig b/test/spec/ssz/test_case.zig index 0ba46e9cb..5901d67f6 100644 --- a/test/spec/ssz/test_case.zig +++ b/test/spec/ssz/test_case.zig @@ -268,7 +268,7 @@ pub fn validTestCase(comptime ST: type, gpa: Allocator, path: std.Io.Dir, meta_f try Hasher.hash(&hash_scratch, value_expected, &root_actual); try std.testing.expectEqualSlices(u8, &root_expected, &root_actual); - var pool = try Node.Pool.init(gpa, 1_000_000); + var pool = try Node.Pool.init(.{ .page_allocator = gpa, .allocator = gpa, .pool_size = 1_000_000 }); defer pool.deinit(); // test conversion between tree and value diff --git a/test/spec/writer/finality.zig b/test/spec/writer/finality.zig index 9e5d75314..0cc65e609 100644 --- a/test/spec/writer/finality.zig +++ b/test/spec/writer/finality.zig @@ -32,7 +32,7 @@ pub const header = const test_template = \\test "{s} finality {s} {s}" {{ - \\ var pool = try Node.Pool.init(allocator, pool_size); + \\ var pool = try Node.Pool.init(.{{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }}); \\ defer pool.deinit(); \\ const test_dir_name = try std.fs.path.join(allocator, &[_][]const u8{{ \\ spec_test_options.spec_test_out_dir, diff --git a/test/spec/writer/random.zig b/test/spec/writer/random.zig index 36a28f3f2..d5776fefd 100644 --- a/test/spec/writer/random.zig +++ b/test/spec/writer/random.zig @@ -32,7 +32,7 @@ pub const header = const test_template = \\test "{s} random {s} {s}" {{ - \\ var pool = try Node.Pool.init(allocator, pool_size); + \\ var pool = try Node.Pool.init(.{{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }}); \\ defer pool.deinit(); \\ const test_dir_name = try std.fs.path.join(allocator, &[_][]const u8{{ \\ spec_test_options.spec_test_out_dir, diff --git a/test/spec/writer/sanity.zig b/test/spec/writer/sanity.zig index 943f36593..644787145 100644 --- a/test/spec/writer/sanity.zig +++ b/test/spec/writer/sanity.zig @@ -22,7 +22,7 @@ pub const header = const test_template = \\test "{s} sanity {s} {s}" {{ - \\ var pool = try Node.Pool.init(allocator, pool_size); + \\ var pool = try Node.Pool.init(.{{ .page_allocator = allocator, .allocator = allocator, .pool_size = pool_size }}); \\ defer pool.deinit(); \\ const test_dir_name = try std.fs.path.join(allocator, &[_][]const u8{{ \\ spec_test_options.spec_test_out_dir, From ddbf0846b41729eb2ad37bcdf68b60819807f324 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Tue, 9 Jun 2026 22:20:14 +0800 Subject: [PATCH 31/46] =?UTF-8?q?fix(state-transition):=20repair=20Pool.in?= =?UTF-8?q?it=20call=20broken=20by=20#346=C3=97#367=20merge=20skew=20(#394?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `main` CI is red: the `build & test` job fails to **compile** `test:state_transition`: \`\`\` src/state_transition/sync_committees_witness.zig:148:33: error: expected 1 argument(s), found 2 pub fn init(opts: InitOptions) Error!Pool { \`\`\` ## Root cause — a semantic merge conflict (merge skew) - **#346** (chunked-leaf) changed `Node.Pool.init(allocator, pool_size)` → `Node.Pool.init(opts: InitOptions)` (2 positional args → 1 options struct). - **#367** (`getSyncCommitteesWitness`) landed `sync_committees_witness.zig` in parallel, still calling the **old 2-arg** form: `Node.Pool.init(allocator, 500_000)`. Both PR branches were green because neither tree contained the *combination*: #346's branch didn't have `sync_committees_witness.zig` (it predates #367 and was never updated to the latest `main`), and #367's base still had the old `Pool.init`. Merging #346 into a `main` that already had #367 produced code git merged cleanly (different files, no textual conflict) but which no longer compiles. ## Fix One line — update the stale call site to the new `InitOptions` form (matching every other PMT test in the repo, both fields pinned to the testing allocator for leak tracking). `git grep` confirms this is the only remaining old-style call site. ## Verification `zig build test:state_transition` → **96/96 tests passed** (was: compile error). ## Prevention Consider enabling **"Require branches to be up to date before merging"** or a **GitHub merge queue** so PR CI runs against the real post-merge tree and catches this class of logical conflict that git can't see. --- src/state_transition/sync_committees_witness.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state_transition/sync_committees_witness.zig b/src/state_transition/sync_committees_witness.zig index 166a77b75..0b7ff23e6 100644 --- a/src/state_transition/sync_committees_witness.zig +++ b/src/state_transition/sync_committees_witness.zig @@ -145,7 +145,7 @@ const ProofFixture = struct { fn init(fork: ForkSeq) !ProofFixture { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(allocator, 500_000); + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); errdefer pool.deinit(); var state = switch (fork) { From 37181bd0265b4cbad2e034bd71d2054e91aa1627 Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 10 Jun 2026 22:19:11 +0800 Subject: [PATCH 32/46] refactor(bls): allocations around VMAS (#395) rework allocations around `verifyMultipleAggregateSignatures`. - we're batching on average about ~30 signature sets per batch on our highest load fleet (according to metrics), so a cap of about 32 makes sense for stack allocations. Anything beyond that, use heap allocations. - avoid copies for `msgs` which was unnecessary --- bindings/napi/blst.zig | 55 ++++++++++++++++++++++++++++++++--------- src/bls/ThreadPool.zig | 10 +++++--- src/bls/fast_verify.zig | 4 +-- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/bindings/napi/blst.zig b/bindings/napi/blst.zig index 092f5fe51..c48298e78 100644 --- a/bindings/napi/blst.zig +++ b/bindings/napi/blst.zig @@ -27,6 +27,14 @@ const ThreadPool = bls.ThreadPool; const DST = bls.DST; const MAX_AGGREGATE_PER_JOB = bls.MAX_AGGREGATE_PER_JOB; +/// In upstream lodestar we split batchable sets into chunks of minimum size 16. +/// Cost savings after ~16 are not significant. +/// In metrics, we can observe that sas fleet receives on average ~30 signature sets, +/// so a safe bound is about 32. +/// +/// See: packages/beacon-node/src/chain/bls/multithread/worker.ts +const BATCH_VERIFY_SIZE = 32; + /// Cached thread pool reference for parallel verification. /// Initialized lazily on first use, torn down via `deinitThreadPool`. var thread_pool: ?*ThreadPool = null; @@ -429,17 +437,40 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole const n_elems = try sets.length(); if (n_elems == 0) return js.Boolean.from(false); - const msgs = try allocator.alloc([32]u8, n_elems); - defer allocator.free(msgs); - - const pks = try allocator.alloc(*NativePublicKey, n_elems); - defer allocator.free(pks); - - const sigs = try allocator.alloc(*NativeSignature, n_elems); - defer allocator.free(sigs); - - const rands = try allocator.alloc([32]u8, n_elems); - defer allocator.free(rands); + var msgs_stack: [BATCH_VERIFY_SIZE][]const u8 = undefined; + var pks_stack: [BATCH_VERIFY_SIZE]*NativePublicKey = undefined; + var sigs_stack: [BATCH_VERIFY_SIZE]*NativeSignature = undefined; + var rands_stack: [BATCH_VERIFY_SIZE][32]u8 = undefined; + + var msgs_heap: ?[][]const u8 = null; + defer if (msgs_heap) |buf| allocator.free(buf); + var pks_heap: ?[]*NativePublicKey = null; + defer if (pks_heap) |buf| allocator.free(buf); + var sigs_heap: ?[]*NativeSignature = null; + defer if (sigs_heap) |buf| allocator.free(buf); + var rands_heap: ?[][32]u8 = null; + defer if (rands_heap) |buf| allocator.free(buf); + + const msgs = if (n_elems <= BATCH_VERIFY_SIZE) msgs_stack[0..n_elems] else blk: { + const buf = try allocator.alloc([]const u8, n_elems); + msgs_heap = buf; + break :blk buf; + }; + const pks = if (n_elems <= BATCH_VERIFY_SIZE) pks_stack[0..n_elems] else blk: { + const buf = try allocator.alloc(*NativePublicKey, n_elems); + pks_heap = buf; + break :blk buf; + }; + const sigs = if (n_elems <= BATCH_VERIFY_SIZE) sigs_stack[0..n_elems] else blk: { + const buf = try allocator.alloc(*NativeSignature, n_elems); + sigs_heap = buf; + break :blk buf; + }; + const rands = if (n_elems <= BATCH_VERIFY_SIZE) rands_stack[0..n_elems] else blk: { + const buf = try allocator.alloc([32]u8, n_elems); + rands_heap = buf; + break :blk buf; + }; var seed_bytes: [8]u8 = undefined; const io = napi_io.get(); @@ -454,7 +485,7 @@ pub fn verifyMultipleAggregateSignatures(sets: js.Array, pks_validate: ?js.Boole const msg_napi = try set.getNamedProperty("msg"); const msg_bytes = try uint8SliceFromValue(.{ .val = msg_napi }); if (msg_bytes.len != 32) return error.InvalidMessageLength; - @memcpy(&msgs[i], msg_bytes[0..32]); + msgs[i] = msg_bytes; const pk_napi = try set.getNamedProperty("pk"); const wrapped_pk = try e.unwrap(PublicKey, pk_napi); diff --git a/src/bls/ThreadPool.zig b/src/bls/ThreadPool.zig index f7850cdb3..df6d092cb 100644 --- a/src/bls/ThreadPool.zig +++ b/src/bls/ThreadPool.zig @@ -196,7 +196,7 @@ pub fn submitAndWait(pool: *ThreadPool, io: std.Io, items: []*WorkItem) (PoolErr const VerifyMultiJob = struct { pks: []const *PublicKey, sigs: []const *Signature, - msgs: []const [32]u8, + msgs: []const []const u8, rands: []const [32]u8, dst: []const u8, pks_validate: bool, @@ -231,7 +231,7 @@ const VerifyMultiWorkItem = struct { job.sigs_groupcheck, &job.rands[i], RAND_BITS, - &job.msgs[i], + job.msgs[i], ) catch { job.err_flag.store(true, .release); break; @@ -251,7 +251,7 @@ pub fn verifyMultipleAggregateSignatures( pool: *ThreadPool, io: std.Io, n_elems: usize, - msgs: []const [32]u8, + msgs: []const []const u8, dst: []const u8, pks: []const *PublicKey, pks_validate: bool, @@ -492,6 +492,7 @@ test "verifyMultipleAggregateSignatures multi-threaded" { const num_sigs = 16; var msgs: [num_sigs][32]u8 = undefined; + var msg_refs: [num_sigs][]const u8 = undefined; var pks: [num_sigs]PublicKey = undefined; var sigs: [num_sigs]Signature = undefined; var pk_ptrs: [num_sigs]*PublicKey = undefined; @@ -511,6 +512,7 @@ test "verifyMultipleAggregateSignatures multi-threaded" { const sk = try SecretKey.keyGen(&ikm_i, null); pks[i] = sk.toPublicKey(); sigs[i] = sk.sign(&msgs[i], blst.DST, null); + msg_refs[i] = &msgs[i]; pk_ptrs[i] = &pks[i]; sig_ptrs[i] = &sigs[i]; } @@ -521,7 +523,7 @@ test "verifyMultipleAggregateSignatures multi-threaded" { const result = try pool.verifyMultipleAggregateSignatures( std.testing.io, num_sigs, - &msgs, + &msg_refs, blst.DST, &pk_ptrs, true, diff --git a/src/bls/fast_verify.zig b/src/bls/fast_verify.zig index 210dcd8f0..5865f1c0c 100644 --- a/src/bls/fast_verify.zig +++ b/src/bls/fast_verify.zig @@ -12,7 +12,7 @@ const RAND_BITS = 8 * RAND_BYTES; pub fn verifyMultipleAggregateSignatures( pairing_buf: *align(Pairing.buf_align) [Pairing.sizeOf()]u8, n_elems: usize, - msgs: []const [32]u8, + msgs: []const []const u8, dst: []const u8, pks: []const *PublicKey, pks_validate: bool, @@ -38,7 +38,7 @@ pub fn verifyMultipleAggregateSignatures( sigs_groupcheck, &rands[i], RAND_BITS, - &msgs[i], + msgs[i], ); } From 3f3e3b7a894984c10242fe808e12bdc82e5e994d Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 11 Jun 2026 18:42:47 +0800 Subject: [PATCH 33/46] fix(bindings): size BLS thread pool by cgroup-aware CPU count (#386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `std.Thread.getCpuCount()` only reads the CPU affinity mask, so under a cgroup CPU quota (`docker --cpus=N` / k8s `limits.cpu`) it reports the host core count rather than the quota. That over-sizes the BLS verification thread pool at NAPI init, which can cause thread oversubscription and CFS throttling in CPU-limited containers. ## Changes - Add `src/cpu_count.zig` — `getNumCpus(gpa, io)` returns `min(cgroup quota, affinity)`, locating the cpu controller via `/proc/self/{cgroup,mountinfo}` and reading `cpu.max` / `cpu.cfs_quota_us` (cgroup v1 + v2). Parsing logic ported from the `num_cpus` crate. - The quota is the **minimum along the cgroup ancestor chain** (leaf up to the mount point), matching Rust `std::thread::available_parallelism`: the kernel enforces every level but each level's file reports only its own limit, so leaf-only reads miss LXC/Proxmox `cpulimit`, systemd `CPUQuota=` on a parent slice, and sub-cgroups inside a limited container. - Hardened beyond `num_cpus`: cgroup paths containing `:` (containerd's `…slice:cri-containerd:` cgroupfs naming) are kept intact, and `..` paths (process outside its cgroupns root, `cgroup_namespaces(7)`) fall back cleanly instead of erroring. - Use it at NAPI thread-pool init (`bindings/napi/root.zig`) instead of `std.Thread.getCpuCount()`. - Run `test:cpu_count` in CI (the build-test job enumerates module test steps explicitly). Notes: - `/proc` pseudo-files report size 0, so they are streamed to EOF (`readerStreaming` + `allocRemaining`), not read by stat size (`readFileAlloc` reads empty). - Error policy: `getNumCpus` itself is fail-fast — a genuinely **absent** quota (non-Linux, no cpu controller, no cgroup mount, unresolvable path, unlimited) is `null` → affinity fallback, while a **broken read** of an existing resource (unreadable `/proc` or quota file, unopenable cgroup dir, malformed content) is an error, so a readable quota is never silently masked. The NAPI call site catches detection errors, logs a warning, and sizes by the affinity count — a sizing probe must not prevent the module from loading. - `ceilDiv` is overflow-safe; the allocator is passed in by the caller (the module stays allocator-agnostic / libc-free). ## Testing - 22 unit tests covering the parse-and-resolve pipeline and the ancestor walk; test vectors adapted from `seanmonstar/num_cpus` (MIT). - Verified end-to-end in Linux containers (OrbStack): `--cpus=2` → 2, `--cpus=4` → 4, `--cpus=1.5` → 2 (ceil), unlimited → host count. The old `getCpuCount()` returned the host count in all cases. - Ancestor walk verified end-to-end: with `--cpus=2` and the process moved into an unconstrained child cgroup (`0::/child`, no `cpu.max` of its own), the walk finds the limit at the cgroupns root and returns 2 — leaf-only reads return the host count here. - Colon handling verified end-to-end: a process in `0::/x.slice:cri-containerd:y` resolves its cgroup dir and returns the quota. --- 🤖 This PR was developed with AI assistance (Claude Code). --- .github/workflows/CI.yml | 3 + bindings/napi/root.zig | 25 +- build.zig.zon | 5 + src/cpu_count.zig | 717 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 src/cpu_count.zig diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 96d271f80..68447bd1e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -60,6 +60,9 @@ jobs: - name: Run hex tests run: | zig build test:hex + - name: Run cpu_count tests + run: | + zig build test:cpu_count - name: Run config tests run: | zig build test:config diff --git a/bindings/napi/root.zig b/bindings/napi/root.zig index 6ceceb040..0e5f14294 100644 --- a/bindings/napi/root.zig +++ b/bindings/napi/root.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const js = @import("zapi:zapi").js; pub const pool = @import("./pool.zig"); pub const shuffle = @import("./shuffle.zig"); @@ -12,6 +13,9 @@ pub const pubkeys = @import("./pubkeys.zig"); const options = @import("bls_options"); const napi_io = @import("./io.zig"); +var gpa: std.heap.DebugAllocator(.{}) = .init; +const allocator = if (builtin.mode == .Debug) gpa.allocator() else std.heap.c_allocator; + fn init(old_ref_count: u32) !void { if (old_ref_count == 0) { // First environment — initialize shared state in your threadpool init. @@ -20,8 +24,11 @@ fn init(old_ref_count: u32) !void { var cpu_count: u64 = options.thread_count; if (options.thread_count == 0) { - cpu_count = @max((try std.Thread.getCpuCount()) - 1, 1); - std.debug.print("Note: no -Dthread-count set, will use runtime CPU count minus 1: {}\n", .{cpu_count}); + cpu_count = @max(try detectCpuCount(), 2) - 1; + std.debug.print( + "Note: no -Dthread-count set, using cgroup-aware CPU count minus 1: {}\n", + .{cpu_count}, + ); } const n_workers = @min(cpu_count, @import("bls").ThreadPool.MAX_WORKERS); @@ -32,6 +39,19 @@ fn init(old_ref_count: u32) !void { } } +/// cgroup-aware CPU count for sizing the BLS pool. A detection failure must +/// not prevent the module from loading: warn and fall back to the affinity +/// count (what `std.Thread.getCpuCount()` reports). +fn detectCpuCount() !usize { + return @import("cpu_count").getNumCpus(allocator, napi_io.get()) catch |err| { + std.debug.print( + "Warning: cgroup CPU detection failed ({s}), using affinity count\n", + .{@errorName(err)}, + ); + return std.Thread.getCpuCount(); + }; +} + fn cleanup(new_ref_count: u32) void { if (new_ref_count == 0) { // Last environment — tear down shared state. @@ -40,7 +60,6 @@ fn cleanup(new_ref_count: u32) void { pubkeys.state.deinit(); pool.state.deinit(); metrics.deinit(); - blst.deinitThreadPool(); napi_io.deinit(); } } diff --git a/build.zig.zon b/build.zig.zon index 2c60bd3a4..5a6aeaa5e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -136,6 +136,9 @@ .time = .{ .root_source_file = "src/time.zig", }, + .cpu_count = .{ + .root_source_file = "src/cpu_count.zig", + }, .testing_allocators = .{ .root_source_file = "src/testing_allocators.zig", }, @@ -334,6 +337,7 @@ .imports = .{ .bls, .bls_options, + .cpu_count, .hex, .persistent_merkle_tree, .ssz, @@ -355,6 +359,7 @@ // Per-module unit tests (mirror the old auto-generated `test:` steps). .constants = .{ .root_module = .constants }, .hex = .{ .root_module = .hex }, + .cpu_count = .{ .root_module = .cpu_count }, .hashing = .{ .root_module = .hashing }, .preset = .{ .root_module = .preset }, .persistent_merkle_tree = .{ .root_module = .persistent_merkle_tree }, diff --git a/src/cpu_count.zig b/src/cpu_count.zig new file mode 100644 index 000000000..ffa7ea8c5 --- /dev/null +++ b/src/cpu_count.zig @@ -0,0 +1,717 @@ +//! cgroup-aware logical CPU count for Linux. +//! +//! `std.Thread.getCpuCount()` only reads the CPU affinity mask and is blind to +//! the cgroup CFS quota (`cpu.max` / `cpu.cfs_quota_us`), so under +//! `docker --cpus=N` or k8s `limits.cpu` it reports the host core count. This +//! returns `min(quota, affinity)` instead, where the quota is the smallest one +//! along the cgroup ancestor chain — the kernel enforces every level, but each +//! level's quota file reports only its own limit. +//! +//! Consumed at NAPI thread-pool init (`bindings/napi/root.zig`) to size the BLS +//! verification thread pool (`src/bls/ThreadPool.zig`) in CPU-limited +//! (`docker --cpus` / k8s `limits.cpu`) deployments. + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const CgroupVersion = enum { v1, v2 }; + +/// cgroup-aware logical CPU count: `min(cgroup quota, affinity)`, or the affinity +/// count when no quota can be located. Drop-in for `std.Thread.getCpuCount()` +/// when sizing a thread pool. Falls back to the affinity count whenever no quota +/// is locatable (non-Linux, no cpu controller, no cgroup mount, unlimited); +/// errors only when detection genuinely breaks — an unreadable affinity, `/proc`, +/// or cgroup file, an unopenable cgroup dir, or malformed quota content. `gpa` is +/// used only for two short-lived `/proc` reads. +pub fn getNumCpus(gpa: Allocator, io: Io) !usize { + const logical = try std.Thread.getCpuCount(); + assert(logical >= 1); + + const quota = try cgroupsNumCpus(gpa, io); + const result = if (quota) |q| @min(q, logical) else logical; + + assert(result >= 1); + assert(result <= logical); + return result; +} + +/// The cgroup CPU quota as an effective core count (Linux only): locate the cpu +/// controller via `/proc/self/{cgroup,mountinfo}`, then read the quota at every +/// level from the process's cgroup up to the mount point, taking the minimum. +/// `null` when no quota can be located (non-Linux, no cpu controller, no cgroup +/// mount, unresolvable path, controller not enabled, unlimited). Errors only on a +/// broken read of an existing resource (unreadable `/proc` or quota file, +/// unopenable cgroup dir) or malformed content — so a *readable* quota is never +/// silently masked. +fn cgroupsNumCpus(gpa: Allocator, io: Io) !?usize { + if (builtin.os.tag != .linux) return null; + + const cwd = Io.Dir.cwd(); + + const cgroup = try readProcFile(gpa, io, cwd, "/proc/self/cgroup", .limited(1 << 20)); + defer gpa.free(cgroup); + + // Generous ceiling, not a tight bound: mountinfo scales with the mount count + // and can reach several MB under thousands of container mounts. + const mountinfo = try readProcFile(gpa, io, cwd, "/proc/self/mountinfo", .limited(8 << 20)); + defer gpa.free(mountinfo); + + const subsys = Subsys.load(cgroup) orelse return null; // no cpu controller + const mnt = MountInfo.load(mountinfo, subsys.version) orelse return null; // no cgroup mount + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const base = translate(mnt.root, mnt.mount_point, subsys.base, &path_buf) orelse return null; + + return try cpuQuotaChain(io, cwd, base, mnt.mount_point.len, subsys.version); +} + +/// Read a `/proc` pseudo-file whole; the caller owns the result. `/proc` reports +/// size 0, so stream to EOF rather than doing a size-based read. Errors (rather +/// than truncating) past `limit` via `error.StreamTooLong`. +fn readProcFile(gpa: Allocator, io: Io, dir: Io.Dir, sub_path: []const u8, limit: Io.Limit) ![]u8 { + assert(sub_path.len > 0); + + var file = try dir.openFile(io, sub_path, .{}); + defer file.close(io); + + var buf: [4096]u8 = undefined; + var reader = file.readerStreaming(io, &buf); + return try reader.interface.allocRemaining(gpa, limit); +} + +/// CPU quota from an open cgroup directory, as an effective core count. `null` +/// when the controller is not enabled here (quota file absent) or unlimited; +/// errors on an unreadable or malformed quota file. +fn cpuQuotaFromDir(io: Io, dir: Io.Dir, version: CgroupVersion) !?usize { + switch (version) { + .v2 => { + var buf: [128]u8 = undefined; + const content = (try readQuotaFile(io, dir, "cpu.max", &buf)) orelse return null; + return try parseCpuMaxV2(content); + }, + .v1 => { + var quota_buf: [64]u8 = undefined; + var period_buf: [64]u8 = undefined; + const quota = (try readQuotaFile(io, dir, "cpu.cfs_quota_us", "a_buf)) orelse + return null; + const period = (try readQuotaFile(io, dir, "cpu.cfs_period_us", &period_buf)) orelse + return null; + return try parseCpuV1(quota, period); + }, + } +} + +/// Quota-file content, or `null` when the file is absent (cpu controller not +/// enabled at this level); errors on any other read failure. +fn readQuotaFile(io: Io, dir: Io.Dir, sub_path: []const u8, buf: []u8) !?[]u8 { + return dir.readFile(io, sub_path, buf) catch |err| switch (err) { + error.FileNotFound => null, + else => err, + }; +} + +/// Minimum CPU quota along the cgroup chain from `leaf` up to and including the +/// mount point. The kernel enforces the smallest limit of the whole ancestor +/// chain, but a constrained child still reads `max` from its own file, so every +/// level must be read (LXC/Proxmox `cpulimit` and systemd `CPUQuota=` on a +/// parent slice live above the leaf). The leaf must open — its failure is a +/// detection error — while ancestors are best-effort: an unopenable ancestor +/// ends the walk. Bounded by the component count of `leaf`. +fn cpuQuotaChain( + io: Io, + root_dir: Io.Dir, + leaf: []const u8, + mount_point_len: usize, + version: CgroupVersion, +) !?usize { + assert(leaf.len >= mount_point_len); + + var result: ?usize = null; + { + var dir = try root_dir.openDir(io, leaf, .{}); + defer dir.close(io); + result = try cpuQuotaFromDir(io, dir, version); + } + + var path = leaf; + while (path.len > mount_point_len) { + const parent = std.fs.path.dirnamePosix(path) orelse break; + assert(parent.len < path.len); + assert(parent.len >= mount_point_len); + path = parent; + + var dir = root_dir.openDir(io, path, .{}) catch break; + defer dir.close(io); + if (try cpuQuotaFromDir(io, dir, version)) |quota| { + result = if (result) |r| @min(r, quota) else quota; + } + } + return result; +} + +/// Host path of the cgroup directory: strip the mount's internal `root` from +/// `subsys_base` by path component, then join the remainder onto `mount_point`, +/// writing into `out`. Returns null if `subsys_base` is not under `root` — +/// component boundaries are respected, so `/docker/abc-x` is not under +/// `/docker/abc`. +fn translate( + root: []const u8, + mount_point: []const u8, + subsys_base: []const u8, + out: []u8, +) ?[]const u8 { + var root_it = std.mem.tokenizeScalar(u8, root, '/'); + var base_it = std.mem.tokenizeScalar(u8, subsys_base, '/'); + + while (root_it.next()) |root_component| { + const base_component = base_it.next() orelse return null; // base shorter than root + if (!std.mem.eql(u8, root_component, base_component)) return null; // component mismatch + } + + if (mount_point.len > out.len) return null; + @memcpy(out[0..mount_point.len], mount_point); + var len: usize = mount_point.len; + + while (base_it.next()) |base_component| { + // A ".." component (process outside its cgroupns root, see + // cgroup_namespaces(7)) escapes the mount point: unresolvable. + if (std.mem.eql(u8, base_component, "..")) return null; + if (len + 1 + base_component.len > out.len) return null; + out[len] = '/'; + len += 1; + @memcpy(out[len..][0..base_component.len], base_component); + len += base_component.len; + } + + assert(len <= out.len); + return out[0..len]; +} + +/// The cpu controller entry from `/proc/self/cgroup`. +const Subsys = struct { + version: CgroupVersion, + base: []const u8, + + /// Parse one line, e.g. `11:cpu,cpuacct:/foo` (v1) or `0::/foo` (v2). + /// Null if the line is not the cpu controller. + fn parseLine(line: []const u8) ?Subsys { + var it = std.mem.splitScalar(u8, line, ':'); + _ = it.next() orelse return null; // hierarchy id + const subsystems = it.next() orelse return null; + // The path may itself contain ':' (containerd cgroupfs naming), so take + // the whole remainder rather than the next ':'-field. + const base = it.rest(); + if (base.len == 0) return null; + + const version: CgroupVersion = if (subsystems.len == 0) .v2 else .v1; + if (version == .v1 and !hasCsvItem(subsystems, "cpu")) return null; + return .{ .version = version, .base = base }; + } + + /// First cpu controller across all lines; a v1 entry takes precedence over v2. + fn load(content: []const u8) ?Subsys { + var result: ?Subsys = null; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + const s = Subsys.parseLine(line) orelse continue; + if (result != null and s.version == .v2) continue; // v2 never overwrites + result = s; + } + return result; + } +}; + +/// The cgroup mount entry from `/proc/self/mountinfo`. +const MountInfo = struct { + version: CgroupVersion, + root: []const u8, + mount_point: []const u8, + + /// Parse one line: `id pid dev root mount_point opts... - fstype src opts`. + /// v1 additionally requires the `cpu` super option. + fn parseLine(line: []const u8) ?MountInfo { + var it = std.mem.splitScalar(u8, line, ' '); + _ = it.next() orelse return null; // mount id + _ = it.next() orelse return null; // parent id + _ = it.next() orelse return null; // major:minor + const root = it.next() orelse return null; + const mount_point = it.next() orelse return null; + + // Skip the variable number of optional fields up to the "-" separator. + while (it.next()) |f| { + if (std.mem.eql(u8, f, "-")) break; + } else return null; + + const fstype = it.next() orelse return null; + const version: CgroupVersion = if (std.mem.eql(u8, fstype, "cgroup")) + .v1 + else if (std.mem.eql(u8, fstype, "cgroup2")) + .v2 + else + return null; + + if (version == .v1) { + _ = it.next() orelse return null; // mount source + const super_opts = it.next() orelse return null; + if (!hasCsvItem(super_opts, "cpu")) return null; + } + return .{ .version = version, .root = root, .mount_point = mount_point }; + } + + /// First mount of the requested version. + fn load(content: []const u8, version: CgroupVersion) ?MountInfo { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trimEnd(u8, raw, "\r"); + const m = MountInfo.parseLine(line) orelse continue; + if (m.version == version) return m; + } + return null; + } +}; + +/// Parse a v2 `cpu.max` value (` ` or `max `) to +/// `ceil(quota/period)` effective CPUs. `null` when unlimited (`max`); errors on +/// malformed content or a zero quota/period. +fn parseCpuMaxV2(content: []const u8) !?usize { + var it = std.mem.tokenizeAny(u8, content, " \t\r\n"); + const quota_s = it.next() orelse return error.Malformed; + if (std.mem.eql(u8, quota_s, "max")) return null; // unlimited + const period_s = it.next() orelse return error.Malformed; + + const quota = try std.fmt.parseUnsigned(u64, quota_s, 10); + const period = try std.fmt.parseUnsigned(u64, period_s, 10); + if (quota == 0 or period == 0) return error.Malformed; + return ceilDiv(quota, period); +} + +/// Parse v1 `cpu.cfs_quota_us` + `cpu.cfs_period_us` to `ceil(quota/period)`. +/// `null` when unlimited (quota `-1`); errors on malformed content or a zero +/// quota/period. +fn parseCpuV1(quota_text: []const u8, period_text: []const u8) !?usize { + const quota_s = std.mem.trim(u8, quota_text, " \t\r\n"); + const period_s = std.mem.trim(u8, period_text, " \t\r\n"); + + if (std.mem.eql(u8, quota_s, "-1")) return null; // unlimited + const quota = try std.fmt.parseUnsigned(u64, quota_s, 10); + const period = try std.fmt.parseUnsigned(u64, period_s, 10); + if (quota == 0 or period == 0) return error.Malformed; + return ceilDiv(quota, period); +} + +/// `ceil(n / d)` for `n, d > 0`. Uses `(n - 1) / d + 1` so the numerator cannot +/// overflow, and saturates the `usize` cast so an adversarial value can never +/// panic. Caller guarantees `n, d != 0`. +fn ceilDiv(n: u64, d: u64) usize { + assert(n != 0); + assert(d != 0); + + const q = (n - 1) / d + 1; + assert(q >= 1); + return std.math.cast(usize, q) orelse std.math.maxInt(usize); +} + +/// Whether `item` is one of the comma-separated entries in `csv`. +fn hasCsvItem(csv: []const u8, item: []const u8) bool { + var it = std.mem.splitScalar(u8, csv, ','); + while (it.next()) |x| { + if (std.mem.eql(u8, x, item)) return true; + } + return false; +} + +// Test fixtures: synthetic single lines + realistic full /proc samples. +const mnt_v1 = "7 5 0:6 / /sys/fs/cgroup/cpu,cpuacct rw,nosuid,nodev,noexec,relatime shared:7 " ++ + "- cgroup cgroup rw,cpu,cpuacct"; +const mnt_v1_zero_opt = "7 5 0:6 / /sys/fs/cgroup/cpu,cpuacct rw,nosuid,nodev,noexec,relatime " ++ + "- cgroup cgroup rw,cpu,cpuacct"; +const mnt_v1_multi_opt = "7 5 0:6 / /sys/fs/cgroup/cpu,cpuacct rw,nosuid,nodev,noexec,relatime " ++ + "shared:7 master:2 - cgroup cgroup rw,cpu,cpuacct"; +const mnt_v1_no_cpu = "8 5 0:7 / /sys/fs/cgroup/memory rw,nosuid shared:8 " ++ + "- cgroup cgroup rw,memory"; +const mnt_v2 = "30 25 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:4 " ++ + "- cgroup2 cgroup2 rw,nsdelegate"; +const cpu_mount = "/sys/fs/cgroup/cpu,cpuacct"; +// Realistic `/proc` samples (v1, v2, and the optional-field / good / ceil / +// zero-period variants), covering the full parse-and-resolve pipeline. +const sample_v1_cgroup = "12:perf_event:/\n11:cpu,cpuacct:/\n3:devices:/user.slice\n"; +const sample_mountinfo_prefix = + "1 0 8:1 / / rw,noatime shared:1 - ext4 /dev/sda1 rw,errors=remount-ro,data=reordered\n" ++ + "2 1 0:1 / /dev rw,relatime shared:2 " ++ + "- devtmpfs udev rw,size=10240k,nr_inodes=16487629,mode=755\n" ++ + "3 1 0:2 / /proc rw,nosuid,nodev,noexec,relatime shared:3 - proc proc rw\n" ++ + "4 1 0:3 / /sys rw,nosuid,nodev,noexec,relatime shared:4 - sysfs sysfs rw\n"; +const sample_v1_mountinfo_head = sample_mountinfo_prefix ++ + "5 4 0:4 / /sys/fs/cgroup ro,nosuid,nodev,noexec shared:5 - tmpfs tmpfs ro,mode=755\n" ++ + "6 5 0:5 / /sys/fs/cgroup/cpuset rw,nosuid,nodev,noexec,relatime shared:6 " ++ + "- cgroup cgroup rw,cpuset\n"; +const sample_v1_mountinfo_tail = + "8 5 0:7 / /sys/fs/cgroup/memory rw,nosuid,nodev,noexec,relatime shared:8 " ++ + "- cgroup cgroup rw,memory\n"; +const sample_v1_mountinfo = sample_v1_mountinfo_head ++ mnt_v1 ++ "\n" ++ sample_v1_mountinfo_tail; +const sample_v1_mountinfo_zero = + sample_v1_mountinfo_head ++ mnt_v1_zero_opt ++ "\n" ++ sample_v1_mountinfo_tail; +const sample_v1_mountinfo_multi = sample_v1_mountinfo_head ++ + "7 5 0:6 / /sys/fs/cgroup/cpu,cpuacct rw,nosuid,nodev,noexec,relatime " ++ + "shared:7 shared:8 shared:9 - cgroup cgroup rw,cpu,cpuacct\n" ++ + sample_v1_mountinfo_tail; +const sample_v2_cgroup = "12::/\n3::/user.slice\n"; +const sample_v2_cgroup_multi = "12::/\n11:cpu,cpuacct:/\n3::/user.slice\n"; +const sample_v2_mountinfo = sample_mountinfo_prefix ++ + "5 4 0:4 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime shared:5 " ++ + "- cgroup2 cgroup2 rw,nsdelegate,memory_recursiveprot\n"; +// Hybrid hierarchy: the unified cgroup2 mount ordered before the v1 cpu mount. +const sample_hybrid_mountinfo = mnt_v2 ++ "\n" ++ mnt_v1 ++ "\n"; +// Quota files keep their double trailing newline, to exercise the trimming. +const sample_v1_good_quota = "600000\n\n"; +const sample_v1_good_period = "100000\n\n"; +const sample_v1_ceil_quota = "150000\n\n"; +const sample_v1_ceil_period = "100000\n\n"; +const sample_v1_zero_quota = "600000\n"; +const sample_v1_zero_period = "0\n\n"; +const sample_v2_good_max = "600000 100000\n"; +const sample_v2_ceil_max = "150000 100000\n"; +const sample_v2_zero_max = "600000 0\n"; + +test "parseCpuMaxV2" { + const cases = .{ + .{ "200000 100000", @as(?usize, 2) }, + .{ "150000 100000", @as(?usize, 2) }, // rounds up + .{ "200000 100000\n", @as(?usize, 2) }, // trailing newline tolerated + .{ "600000 100000", @as(?usize, 6) }, + .{ "max 100000", @as(?usize, null) }, // unlimited + }; + inline for (cases) |c| { + try std.testing.expectEqual(c[1], try parseCpuMaxV2(c[0])); + } + try std.testing.expectError(error.Malformed, parseCpuMaxV2("100000 0")); // zero period + try std.testing.expectError(error.Malformed, parseCpuMaxV2("0 100000")); // zero quota + try std.testing.expectError(error.Malformed, parseCpuMaxV2("100000")); // missing period + try std.testing.expectError(error.InvalidCharacter, parseCpuMaxV2("abc 100000")); // garbage +} +test "parseCpuMaxV2: huge quota does not overflow" { + try std.testing.expect((try parseCpuMaxV2("18446744073709551615 2")) != null); +} + +test "parseCpuV1" { + const cases = .{ + .{ "200000", "100000", @as(?usize, 2) }, + .{ "150000\n", "100000\n", @as(?usize, 2) }, // rounds up + trims + .{ "600000", "100000", @as(?usize, 6) }, + .{ "-1", "100000", @as(?usize, null) }, // unlimited + }; + inline for (cases) |c| { + try std.testing.expectEqual(c[2], try parseCpuV1(c[0], c[1])); + } + try std.testing.expectError(error.Malformed, parseCpuV1("100000", "0")); // zero period + try std.testing.expectError(error.InvalidCharacter, parseCpuV1("abc", "100000")); // garbage +} + +fn expectSubsys(got: ?Subsys, version: ?CgroupVersion, base: []const u8) !void { + if (version) |v| { + try std.testing.expect(got != null); + try std.testing.expectEqual(v, got.?.version); + try std.testing.expectEqualStrings(base, got.?.base); + } else { + try std.testing.expect(got == null); + } +} + +test "Subsys.parseLine" { + try expectSubsys(Subsys.parseLine("11:cpu,cpuacct:/docker/01abcd"), .v1, "/docker/01abcd"); + try expectSubsys(Subsys.parseLine("0::/foo"), .v2, "/foo"); + try expectSubsys(Subsys.parseLine("5:memory:/foo"), null, ""); // no cpu controller + try expectSubsys(Subsys.parseLine("11:cpu,cpuacct:/a:b"), .v1, "/a:b"); // ':' in path + try expectSubsys( + Subsys.parseLine("0::/kubepods-besteffort-pod1.slice:cri-containerd:abc"), + .v2, + "/kubepods-besteffort-pod1.slice:cri-containerd:abc", + ); + try expectSubsys(Subsys.parseLine("11:cpu"), null, ""); // no path field +} +test "Subsys.load" { + // v1 trumps v2. + try expectSubsys(Subsys.load("0::/v2path\n11:cpu,cpuacct:/v1path"), .v1, "/v1path"); + try expectSubsys(Subsys.load("12:cpuset:/\n0::/unified"), .v2, "/unified"); + try expectSubsys(Subsys.load(sample_v1_cgroup), .v1, "/"); + try expectSubsys(Subsys.load(sample_v2_cgroup), .v2, "/"); + try expectSubsys(Subsys.load(sample_v2_cgroup_multi), .v1, "/"); // v1 wins +} + +fn expectMount( + got: ?MountInfo, + version: ?CgroupVersion, + root: []const u8, + mount_point: []const u8, +) !void { + if (version) |v| { + try std.testing.expect(got != null); + try std.testing.expectEqual(v, got.?.version); + try std.testing.expectEqualStrings(root, got.?.root); + try std.testing.expectEqualStrings(mount_point, got.?.mount_point); + } else { + try std.testing.expect(got == null); + } +} + +test "MountInfo.parseLine" { + try expectMount(MountInfo.parseLine(mnt_v1), .v1, "/", cpu_mount); + try expectMount(MountInfo.parseLine(mnt_v1_zero_opt), .v1, "/", cpu_mount); + try expectMount(MountInfo.parseLine(mnt_v1_multi_opt), .v1, "/", cpu_mount); + try expectMount(MountInfo.parseLine(mnt_v1_no_cpu), null, "", ""); // no cpu super-opt + try expectMount(MountInfo.parseLine(mnt_v2), .v2, "/", "/sys/fs/cgroup"); +} +test "MountInfo.load" { + try expectMount( + MountInfo.load(mnt_v1_no_cpu ++ "\n" ++ mnt_v2, .v2), + .v2, + "/", + "/sys/fs/cgroup", + ); + const samples = .{ sample_v1_mountinfo, sample_v1_mountinfo_zero, sample_v1_mountinfo_multi }; + inline for (samples) |content| { + try expectMount(MountInfo.load(content, .v1), .v1, "/", cpu_mount); + } + try expectMount(MountInfo.load(sample_v2_mountinfo, .v2), .v2, "/", "/sys/fs/cgroup"); + // The version filter must pick past the other version's mount in either direction. + try expectMount(MountInfo.load(sample_hybrid_mountinfo, .v1), .v1, "/", cpu_mount); + try expectMount(MountInfo.load(sample_hybrid_mountinfo, .v2), .v2, "/", "/sys/fs/cgroup"); +} +test "MountInfo.parseLine: structurally malformed lines" { + try expectMount(MountInfo.parseLine("1 2 0:3 / /mnt rw,relatime"), null, "", ""); // no "-" + try expectMount(MountInfo.parseLine("1 2 0:3"), null, "", ""); // truncated + try expectMount(MountInfo.parseLine("1 2 0:3 / /mnt rw -"), null, "", ""); // ends at "-" +} + +fn expectTranslate( + root: []const u8, + mount_point: []const u8, + base: []const u8, + expected: ?[]const u8, +) !void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const got = translate(root, mount_point, base, &buf); + if (expected) |exp| { + try std.testing.expect(got != null); + try std.testing.expectEqualStrings(exp, got.?); + } else { + try std.testing.expect(got == null); + } +} + +test "translate: mount path cases" { + try expectTranslate("/", "/sys/fs/cgroup/cpu", "/", "/sys/fs/cgroup/cpu"); + try expectTranslate( + "/docker/01abcd", + "/sys/fs/cgroup/cpu", + "/docker/01abcd", + "/sys/fs/cgroup/cpu", + ); + try expectTranslate( + "/docker/01abcd", + "/sys/fs/cgroup/cpu", + "/docker/01abcd/", + "/sys/fs/cgroup/cpu", + ); + try expectTranslate( + "/docker/01abcd", + "/sys/fs/cgroup/cpu", + "/docker/01abcd/large", + "/sys/fs/cgroup/cpu/large", + ); + try expectTranslate("/docker/01abcd", "/sys/fs/cgroup/cpu", "/", null); + try expectTranslate("/docker/01abcd", "/sys/fs/cgroup/cpu", "/docker", null); + try expectTranslate("/docker/01abcd", "/sys/fs/cgroup/cpu", "/elsewhere", null); + try expectTranslate("/docker/01abcd", "/sys/fs/cgroup/cpu", "/docker/01abcd-other-dir", null); + try expectTranslate("/", "/sys/fs/cgroup", "/../foo", null); // outside cgroupns root +} +test "translate: out buffer too small" { + var too_small_for_mount: [8]u8 = undefined; + try std.testing.expect(translate("/", "/sys/fs/cgroup", "/", &too_small_for_mount) == null); + + var too_small_for_component: [7]u8 = undefined; // "/sys" + "/abc" needs 8 + try std.testing.expect(translate("/", "/sys", "/abc", &too_small_for_component) == null); + + var exact_fit: [8]u8 = undefined; + try std.testing.expectEqualStrings("/sys/abc", translate("/", "/sys", "/abc", &exact_fit).?); +} + +test "cpuQuotaFromDir: v2" { + const cases = .{ + .{ "200000 100000\n", @as(?usize, 2) }, + .{ sample_v2_good_max, @as(?usize, 6) }, + .{ sample_v2_ceil_max, @as(?usize, 2) }, + }; + inline for (cases) |c| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cpu.max", .data = c[0] }); + try std.testing.expectEqual(c[1], try cpuQuotaFromDir(std.testing.io, tmp.dir, .v2)); + } + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cpu.max", .data = sample_v2_zero_max }); + try std.testing.expectError(error.Malformed, cpuQuotaFromDir(std.testing.io, tmp.dir, .v2)); +} +test "cpuQuotaFromDir: v1" { + const cases = .{ + .{ "300000\n", "100000\n", @as(?usize, 3) }, + .{ sample_v1_good_quota, sample_v1_good_period, @as(?usize, 6) }, + .{ sample_v1_ceil_quota, sample_v1_ceil_period, @as(?usize, 2) }, + }; + inline for (cases) |c| { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cpu.cfs_quota_us", .data = c[0] }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cpu.cfs_period_us", .data = c[1] }); + try std.testing.expectEqual(c[2], try cpuQuotaFromDir(std.testing.io, tmp.dir, .v1)); + } + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cpu.cfs_quota_us", + .data = sample_v1_zero_quota, + }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cpu.cfs_period_us", + .data = sample_v1_zero_period, + }); + try std.testing.expectError(error.Malformed, cpuQuotaFromDir(std.testing.io, tmp.dir, .v1)); +} +test "cpuQuotaFromDir: missing file is no quota" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std.testing.expectEqual( + @as(?usize, null), + try cpuQuotaFromDir(std.testing.io, tmp.dir, .v2), + ); +} + +test "cpuQuotaChain: ancestor limit wins" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(std.testing.io, "cg/mid/leaf"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cg/cpu.max", .data = "200000 100000\n" }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/mid/cpu.max", + .data = "max 100000\n", + }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/mid/leaf/cpu.max", + .data = "600000 100000\n", + }); + + try std.testing.expectEqual( + @as(?usize, 2), + try cpuQuotaChain(std.testing.io, tmp.dir, "cg/mid/leaf", "cg".len, .v2), + ); +} +test "cpuQuotaChain: leaf limit wins" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(std.testing.io, "cg/leaf"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "cg/cpu.max", .data = "600000 100000\n" }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/leaf/cpu.max", + .data = "200000 100000\n", + }); + + try std.testing.expectEqual( + @as(?usize, 2), + try cpuQuotaChain(std.testing.io, tmp.dir, "cg/leaf", "cg".len, .v2), + ); +} +test "cpuQuotaChain: v1 unlimited leaf, limited ancestor" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(std.testing.io, "cg/leaf"); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/cpu.cfs_quota_us", + .data = "300000\n", + }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/cpu.cfs_period_us", + .data = "100000\n", + }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/leaf/cpu.cfs_quota_us", + .data = "-1\n", + }); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "cg/leaf/cpu.cfs_period_us", + .data = "100000\n", + }); + + try std.testing.expectEqual( + @as(?usize, 3), + try cpuQuotaChain(std.testing.io, tmp.dir, "cg/leaf", "cg".len, .v1), + ); +} +test "cpuQuotaChain: no quota anywhere is null" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.createDirPath(std.testing.io, "cg/leaf"); + try std.testing.expectEqual( + @as(?usize, null), + try cpuQuotaChain(std.testing.io, tmp.dir, "cg/leaf", "cg".len, .v2), + ); +} +test "cpuQuotaChain: missing leaf dir errors" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std.testing.expectError( + error.FileNotFound, + cpuQuotaChain(std.testing.io, tmp.dir, "nope", "nope".len, .v2), + ); +} + +test "readProcFile: streams a size-unknown file fully" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "f", .data = "11:cpu:/\n" }); + + const got = + try readProcFile(std.testing.allocator, std.testing.io, tmp.dir, "f", .limited(1 << 20)); + defer std.testing.allocator.free(got); + + try std.testing.expectEqualStrings("11:cpu:/\n", got); +} +test "readProcFile: errors past limit" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "f", .data = "a" ** (100 * 1024) }); + try std.testing.expectError( + error.StreamTooLong, + readProcFile(std.testing.allocator, std.testing.io, tmp.dir, "f", .limited(64 * 1024)), + ); +} +test "readProcFile: missing file errors" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try std.testing.expectError( + error.FileNotFound, + readProcFile(std.testing.allocator, std.testing.io, tmp.dir, "nope", .limited(1 << 20)), + ); +} + +test "getNumCpus: at least 1" { + const n = try getNumCpus(std.testing.allocator, std.testing.io); + try std.testing.expect(n >= 1); +} From 20a41a5760e7c41d9ba03ec6b70cb59bb4ebe993 Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 12 Jun 2026 14:03:34 +0800 Subject: [PATCH 34/46] feat(bls): add small MSM for npoints < 32 (#393) we were missing the small MSM codepath previously, though this did not affect perf at all when deployed. Still, let's implement it for parity. --- bindings/test/blst.test.ts | 9 ++- src/bls/pippenger.zig | 120 +++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/bindings/test/blst.test.ts b/bindings/test/blst.test.ts index 9e74cd203..c98d2c310 100644 --- a/bindings/test/blst.test.ts +++ b/bindings/test/blst.test.ts @@ -382,13 +382,20 @@ describe("blst", () => { expect(result.sig).toBeInstanceOf(Signature); }); - it("should produce a valid aggregated signature", async () => { + it("should produce a valid aggregated signature - small MSM", async () => { const {msg, sets} = getTestSetsSameMessage(8); const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); const {pk, sig} = await asyncAggregateWithRandomness(input); expect(verify(msg, pk, sig, false, false)).toBe(true); }); + it("should produce a valid aggregated signature - tiled MSM", async () => { + const {msg, sets} = getTestSetsSameMessage(33); + const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); + const {pk, sig} = await asyncAggregateWithRandomness(input); + expect(verify(msg, pk, sig, false, false)).toBe(true); + }); + it("should work with a single set", async () => { const {msg, sets} = getTestSetsSameMessage(1); const input = sets.map((s) => ({pk: s.pk, sig: s.sig.toBytes()})); diff --git a/src/bls/pippenger.zig b/src/bls/pippenger.zig index d4c5bd0b0..398b4b251 100644 --- a/src/bls/pippenger.zig +++ b/src/bls/pippenger.zig @@ -20,6 +20,10 @@ //! single-threaded `Curve.mult_pippenger` directly since the overhead is not //! worth it. //! +//! For `2 <= npoints < 32` we match the Rust binding's small-MSM +//! branch: workers perform per-point scalar multiplication and reduce partial +//! projective results, avoiding tiled Pippenger setup for tiny batches. +//! //! Note: This is a direct port of blst's Rust binding `MultiPoint::mult` (pippenger.rs). const std = @import("std"); @@ -48,6 +52,8 @@ const CurveDescriptor = struct { scratch_sizeof: *const fn (npoints: usize) callconv(.c) usize, mult_pippenger: *const anyopaque, tile_pippenger: *const anyopaque, + from_affine: *const anyopaque, + mult: *const anyopaque, add_or_double: *const anyopaque, double: *const anyopaque, }; @@ -58,6 +64,8 @@ const G1: CurveDescriptor = .{ .scratch_sizeof = c.blst_p1s_mult_pippenger_scratch_sizeof, .mult_pippenger = @ptrCast(&c.blst_p1s_mult_pippenger), .tile_pippenger = @ptrCast(&c.blst_p1s_tile_pippenger), + .from_affine = @ptrCast(&c.blst_p1_from_affine), + .mult = @ptrCast(&c.blst_p1_mult), .add_or_double = @ptrCast(&c.blst_p1_add_or_double), .double = @ptrCast(&c.blst_p1_double), }; @@ -68,6 +76,8 @@ const G2: CurveDescriptor = .{ .scratch_sizeof = c.blst_p2s_mult_pippenger_scratch_sizeof, .mult_pippenger = @ptrCast(&c.blst_p2s_mult_pippenger), .tile_pippenger = @ptrCast(&c.blst_p2s_tile_pippenger), + .from_affine = @ptrCast(&c.blst_p2_from_affine), + .mult = @ptrCast(&c.blst_p2_mult), .add_or_double = @ptrCast(&c.blst_p2_add_or_double), .double = @ptrCast(&c.blst_p2_double), }; @@ -193,6 +203,16 @@ fn MultPippengerFn(comptime Curve: CurveDescriptor) type { ) callconv(.c) void; } +/// Typed function-pointer signature for `blst_p?_from_affine`. +fn FromAffineFn(comptime Curve: CurveDescriptor) type { + return *const fn (*Curve.Projective, *const Curve.Wrapper) callconv(.c) void; +} + +/// Typed function-pointer signature for `blst_p?_mult`. +fn MultFn(comptime Curve: CurveDescriptor) type { + return *const fn (*Curve.Projective, *const Curve.Projective, [*c]const u8, usize) callconv(.c) void; +} + /// Typed function-pointer signature for `blst_p?_add_or_double`. fn AddOrDoubleFn(comptime Curve: CurveDescriptor) type { return *const fn (*Curve.Projective, *const Curve.Projective, *const Curve.Projective) callconv(.c) void; @@ -203,6 +223,101 @@ fn DoubleFn(comptime Curve: CurveDescriptor) type { return *const fn (*Curve.Projective, *const Curve.Projective) callconv(.c) void; } +fn SmallMsmJob(comptime Curve: CurveDescriptor) type { + return struct { + points: []*const Curve.Wrapper, + scalars_refs: []*const u8, + nbits: usize, + counter: std.atomic.Value(usize), + }; +} + +fn SmallMsmWorkItem(comptime Curve: CurveDescriptor) type { + return struct { + const Self = @This(); + + base: WorkItem, + job: *SmallMsmJob(Curve), + result: Curve.Projective = undefined, + did_work: bool = false, + + fn exec(base_item: *WorkItem) void { + const self: *Self = @fieldParentPtr("base", base_item); + const job = self.job; + const from_affine: FromAffineFn(Curve) = @ptrCast(@alignCast(Curve.from_affine)); + const mult: MultFn(Curve) = @ptrCast(@alignCast(Curve.mult)); + const add_or_double: AddOrDoubleFn(Curve) = @ptrCast(@alignCast(Curve.add_or_double)); + + var acc: Curve.Projective = undefined; + var tmp: Curve.Projective = undefined; + var did_work = false; + + while (true) { + const i = job.counter.fetchAdd(1, .monotonic); + if (i >= job.points.len) break; + + from_affine(&tmp, job.points[i]); + if (!did_work) { + mult(&acc, &tmp, @ptrCast(job.scalars_refs[i]), job.nbits); + did_work = true; + } else { + mult(&tmp, &tmp, @ptrCast(job.scalars_refs[i]), job.nbits); + add_or_double(&acc, &acc, &tmp); + } + } + + self.did_work = did_work; + if (did_work) self.result = acc; + } + }; +} + +fn smallMSM( + comptime Curve: CurveDescriptor, + pool: *ThreadPool, + io: std.Io, + points: []*const Curve.Wrapper, + scalars_refs: []*const u8, + nbits: usize, + out: *Curve.Projective, +) (PoolError || std.Io.Cancelable)!void { + const n_active = @min(pool.n_workers, points.len); + const Job = SmallMsmJob(Curve); + const Item = SmallMsmWorkItem(Curve); + + var job = Job{ + .points = points, + .scalars_refs = scalars_refs, + .nbits = nbits, + .counter = std.atomic.Value(usize).init(0), + }; + + var work_items: [MAX_WORKERS]Item = undefined; + var item_ptrs: [MAX_WORKERS]*WorkItem = undefined; + for (0..n_active) |i| { + work_items[i] = .{ + .base = .{ .exec_fn = Item.exec }, + .job = &job, + }; + item_ptrs[i] = &work_items[i].base; + } + + try pool.submitAndWait(io, item_ptrs[0..n_active]); + + const add_or_double: AddOrDoubleFn(Curve) = @ptrCast(@alignCast(Curve.add_or_double)); + var have_result = false; + for (work_items[0..n_active]) |*item| { + if (!item.did_work) continue; + if (!have_result) { + out.* = item.result; + have_result = true; + } else { + add_or_double(out, out, &item.result); + } + } + std.debug.assert(have_result); +} + /// Shared state for a single `parallelMSM` invocation. Lives on the calling /// thread's stack; workers read it through their `TilePippengerWorkItem`. /// `scratch_buf` is split into `n_active` per-worker chunks of @@ -313,6 +428,11 @@ fn parallelMSM( return; } + if (ncpus >= 2 and npoints < 32) { + try smallMSM(Curve, pool, io, points, scalars_refs, nbits, out); + return; + } + const window = pippengerWindowSize(npoints); const bd = breakdownTiles(nbits, window, ncpus); const total = bd.nx * bd.ny; From eabd6fcb02020f0d135806e96e76e6453dbf131f Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 12 Jun 2026 14:03:41 +0800 Subject: [PATCH 35/46] feat(bindings): aggregate cached public keys by validator index (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of getting the pk from cache and then passing it back through napi, we just pass the already known indices into the native layer and let the cache do the work. On `feat3-super`, this halved pk aggregation time (8s to 4s): Screenshot 2026-06-09 at 5 26 16 PM Notably, sig set verification time per set went up (530us -> 600us), but I suspect this is due to it becoming the bottleneck now. Job wait time has gone down (37-38ms to about 31-32ms), and we're doing less verifications in batches (88% to 87% roughly) Screenshot 2026-06-09 at 5 27 09 PM block processing looks better with this: Screenshot 2026-06-09 at 5 29 44 PM verification metrics across the board trending down with this: Screenshot 2026-06-09 at 5 30 39 PM --- bindings/napi/pubkeys.zig | 41 +++++++++++++++++++++++++++++++++++ bindings/src/pubkeys.d.ts | 2 ++ bindings/src/pubkeys.js | 5 +++++ bindings/test/pubkeys.test.ts | 12 +++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/bindings/napi/pubkeys.zig b/bindings/napi/pubkeys.zig index c85344947..c458ea1f7 100644 --- a/bindings/napi/pubkeys.zig +++ b/bindings/napi/pubkeys.zig @@ -11,6 +11,7 @@ const napi_io = @import("./io.zig"); const allocator = std.heap.page_allocator; const default_initial_capacity: u32 = 0; +const max_stack_aggregate_pubkeys = 512; pub const State = struct { pubkey2index: PubkeyIndexMap = undefined, @@ -175,6 +176,46 @@ pub fn get(index: js.Number) !?blst_bindings.PublicKey { return .{ .raw = state.index2pubkey.items[@intCast(idx)] }; } +/// Aggregate multiple `PublicKey`s by the given +/// validator `indices` into one. +/// +/// Validation is not required here since it is done upon +/// processing validator deposits. +/// +/// JS: pubkeys.aggregate(indices) → PublicKey +pub fn aggregate(indices: js.Array) !blst_bindings.PublicKey { + if (!state.initialized) return error.PubkeyIndexNotInitialized; + + const len = try indices.length(); + if (len == 0) return error.EmptyPublicKeyArray; + + if (len == 1) { + const idx = try (try indices.getNumber(0)).toU32(); + if (idx >= state.index2pubkey.items.len) return error.PubkeyIndexNotFound; + return .{ .raw = state.index2pubkey.items[@intCast(idx)] }; + } + + var pks_stack: [max_stack_aggregate_pubkeys]bls.PublicKey = undefined; + const pks = if (len <= pks_stack.len) + pks_stack[0..len] + else blk: { + const buf = try allocator.alloc(bls.PublicKey, len); + break :blk buf; + }; + defer if (len > pks_stack.len) allocator.free(pks); + + for (0..len) |i| { + const idx = try (try indices.getNumber(@intCast(i))).toU32(); + if (idx >= state.index2pubkey.items.len) return error.PubkeyIndexNotFound; + pks[i] = state.index2pubkey.items[@intCast(idx)]; + } + + const agg_pk = bls.AggregatePublicKey.aggregate(pks, false) catch + return error.AggregationFailed; + + return .{ .raw = agg_pk.toPublicKey() }; +} + /// JS: pubkeys.set(index, pubkeyBytes) pub fn set(index: js.Number, pubkey: js.Uint8Array) !void { if (!state.initialized) return error.PubkeyIndexNotInitialized; diff --git a/bindings/src/pubkeys.d.ts b/bindings/src/pubkeys.d.ts index 27069a370..988e0eec1 100644 --- a/bindings/src/pubkeys.d.ts +++ b/bindings/src/pubkeys.d.ts @@ -5,6 +5,8 @@ export interface PubkeyCache { get(index: number): PublicKey | undefined; /** Same as get(), but throws if the index is not in the cache */ getOrThrow(index: number): PublicKey; + /** Aggregate cached public keys by validator index */ + aggregate(indices: number[]): PublicKey; /** Get validator index by pubkey bytes */ getIndex(pubkey: Uint8Array): number | null; /** Set both directions atomically — impl owns the PublicKey.fromBytes() deserialization */ diff --git a/bindings/src/pubkeys.js b/bindings/src/pubkeys.js index 572ae55e0..1887282f5 100644 --- a/bindings/src/pubkeys.js +++ b/bindings/src/pubkeys.js @@ -25,6 +25,11 @@ export const pubkeyCache = { return pk; }, + aggregate(indices) { + if (indices.length === 1) return pubkeyCache.getOrThrow(indices[0]); + return native.aggregate(indices); + }, + getIndex(pubkey) { return native.getIndex(pubkey); }, diff --git a/bindings/test/pubkeys.test.ts b/bindings/test/pubkeys.test.ts index c285921b9..940e81d49 100644 --- a/bindings/test/pubkeys.test.ts +++ b/bindings/test/pubkeys.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import {afterAll, beforeAll, describe, expect, it} from "vitest"; -import {SecretKey} from "../src/blst.js"; +import {SecretKey, aggregatePublicKeys} from "../src/blst.js"; import {pubkeyCache} from "../src/pubkeys.js"; // Generate deterministic valid BLS keypairs for testing @@ -43,6 +43,16 @@ describe("pubkeys", () => { expect(pk1).toBe(pk2); }); + it("aggregates cached pubkeys by index", () => { + const indices = [0, 1, 2]; + const expected = aggregatePublicKeys(indices.map((index) => pubkeyCache.getOrThrow(index))); + expect(pubkeyCache.aggregate(indices).toBytes()).toEqual(expected.toBytes()); + }); + + it("returns the cached pubkey for a single-key aggregate", () => { + expect(pubkeyCache.aggregate([1]).toBytes()).toEqual(pubkeyCache.getOrThrow(1).toBytes()); + }); + it("get returns undefined for out-of-range index", () => { expect(pubkeyCache.get(0xffffffff)).toBeUndefined(); }); From 4d02bac9891334e0d5b0dd762ddb1064f3b1ed8e Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 12 Jun 2026 14:17:41 +0800 Subject: [PATCH 36/46] chore(release): v0.1.2-rc.8 (#401) final release for BLS testing --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6c433e709..2fb996718 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.6", + "version": "0.1.2-rc.8", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From 08503f80b36d4569da262b609c1dec582a315572 Mon Sep 17 00:00:00 2001 From: bing Date: Fri, 12 Jun 2026 19:59:20 +0800 Subject: [PATCH 37/46] chore(deps): bump zbuild (#403) contains changes from chainsafe/zbuild#9 --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 5a6aeaa5e..605ed3348 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -16,8 +16,8 @@ .description = "Zig consensus modules for Lodestar (Ethereum beacon chain client)", .dependencies = .{ .zbuild = .{ - .url = "git+https://github.com/ChainSafe/zbuild?ref=refactor/comptime-library-rewrite#17c389be7e258dea422f43d5dad996f0f847bd66", - .hash = "zbuild-0.4.0-XJFavw5XAgBJ_8U6DHY0D8xHGUAx4Ak4r8pKe2-o_X8Q", + .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", + .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", }, .blst = .{ .url = "git+https://github.com/ChainSafe/blst.zig.git#5d9543be8a7a1a9942c62a097644286751f95dc7", From f0e6bc5ae3d2a228b45da3e240dbf7f73d8bc1a2 Mon Sep 17 00:00:00 2001 From: bing Date: Sat, 13 Jun 2026 07:26:41 +0800 Subject: [PATCH 38/46] chore(release): v0.1.2-rc.9 (#404) Contains zbuild update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2fb996718..03758864a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chainsafe/lodestar-z", - "version": "0.1.2-rc.8", + "version": "0.1.2-rc.9", "description": "Lodestar-z NAPI bindings", "files": [ "bindings/src/", From c5681b43613a7287ccc82be8a4defefbb46d31fd Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 14:47:19 +0800 Subject: [PATCH 39/46] refactor(bls): clean up bls (#398) while working on bls bindings and checking for usages here I noticed some inconsistencies, notably we pass `null` to decide if sigs/pks should be validated. Then we do some logic to decide defaults. Instead put these in a `BlsOpts` struct which we set defaults for, so we just pass empty structs for when we don't want validation. Also added doc comments. --- .../block/process_deposit.zig | 2 +- .../test_utils/interop_pubkeys.zig | 3 +- src/state_transition/utils/bls.zig | 64 +++++++++++++------ src/state_transition/utils/signature_sets.zig | 4 +- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/state_transition/block/process_deposit.zig b/src/state_transition/block/process_deposit.zig index 8cf58c320..272d5fa24 100644 --- a/src/state_transition/block/process_deposit.zig +++ b/src/state_transition/block/process_deposit.zig @@ -224,5 +224,5 @@ pub fn validateDepositSignature( try public_key.validate(); const signature = try bls.Signature.uncompress(&deposit_signature); try signature.validate(true); - try verify(&signing_root, &public_key, &signature, null, null); + try verify(&signing_root, &public_key, &signature, .{}); } diff --git a/src/state_transition/test_utils/interop_pubkeys.zig b/src/state_transition/test_utils/interop_pubkeys.zig index 5fbd44c26..edcfc010d 100644 --- a/src/state_transition/test_utils/interop_pubkeys.zig +++ b/src/state_transition/test_utils/interop_pubkeys.zig @@ -1,7 +1,6 @@ const std = @import("std"); const bls = @import("bls"); const types = @import("consensus_types"); -const bls_utils = @import("../utils/bls.zig"); const BLSPubkey = types.primitive.BLSPubkey.Type; const Secretkey = bls.SecretKey; @@ -28,5 +27,5 @@ pub fn interopSign(validator_index: usize, message: []const u8) !bls.Signature { const u64_slice = std.mem.bytesAsSlice(u64, ikm[0..8]); u64_slice[0] = @intCast(validator_index); const sk = try Secretkey.keyGen(&ikm, null); - return bls_utils.sign(sk, message); + return @import("../utils/bls.zig").sign(sk, message); } diff --git a/src/state_transition/utils/bls.zig b/src/state_transition/utils/bls.zig index c8fa721fe..83c95f5de 100644 --- a/src/state_transition/utils/bls.zig +++ b/src/state_transition/utils/bls.zig @@ -1,36 +1,60 @@ +//! Minimal abstraction around the `bls` module. +//! +//! Consumers should use bls within state transition without having to +//! deal with setting some common defaults for parameters, such as: +//! +//! 1) [Domain Separation Tag], or `dst`, which determines a unique hash-to-point +//! function. This is set to `bls.DST` by functions within the `bls` module. +//! +//! 2) [Augmentation], or `aug`, which decides if we sign pubkey || message instead of +//! just message. Since Ethereum uses proof-of-posession we do not use `aug`. +//! +//! [Domain Separation Tag]: https://www.ietf.org/archive/id/draft-irtf-cfrg-bls-signature-06.html#section-4.2.3-3 +//! [Augmentation]: https://www.ietf.org/archive/id/draft-irtf-cfrg-bls-signature-06.html#name-message-augmentation const std = @import("std"); const bls = @import("bls"); const PublicKey = bls.PublicKey; const Signature = bls.Signature; const SecretKey = bls.SecretKey; -/// See https://github.com/ethereum/consensus-specs/blob/v1.4.0/specs/phase0/beacon-chain.md#bls-signatures -const DST: []const u8 = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; +const BlsOpts = struct { + /// Decides whether the signature should be group checked. + sig_groupcheck: bool = false, + /// Decides if the public key will be infinity checked and group checked. + pk_validate: bool = false, +}; -pub fn sign(secret_key: SecretKey, msg: []const u8) Signature { - return secret_key.sign(msg, DST, null); +pub fn sign(sk: SecretKey, msg: []const u8) Signature { + return sk.sign(msg, bls.DST, null); } /// Verify a signature against a message and public key. -/// -/// If `pk_validate` is `true`, the public key will be infinity and group checked. -/// -/// If `sig_groupcheck` is `true`, the signature will be group checked. -pub fn verify(msg: []const u8, pk: *const PublicKey, sig: *const Signature, in_pk_validate: ?bool, in_sig_groupcheck: ?bool) bls.BlstError!void { - const sig_groupcheck = in_sig_groupcheck orelse false; - const pk_validate = in_pk_validate orelse false; - try sig.verify(sig_groupcheck, msg, DST, null, pk, pk_validate); +pub fn verify( + msg: []const u8, + pk: *const PublicKey, + sig: *const Signature, + opts: BlsOpts, +) bls.BlstError!void { + try sig.verify(opts.sig_groupcheck, msg, bls.DST, null, pk, opts.pk_validate); } -pub fn fastAggregateVerify(msg: []const u8, pks: []const PublicKey, sig: *const Signature, in_pk_validate: ?bool, in_sigs_group_check: ?bool) !bool { +pub fn fastAggregateVerify( + msg: []const u8, + pks: []const PublicKey, + sig: *const Signature, + opts: BlsOpts, +) !bool { var pairing_buf: [bls.Pairing.sizeOf()]u8 align(bls.Pairing.buf_align) = undefined; - - const sigs_groupcheck = in_sigs_group_check orelse false; - const pks_validate = in_pk_validate orelse false; - return sig.fastAggregateVerify(sigs_groupcheck, &pairing_buf, msg[0..32], DST, pks, pks_validate) catch return false; + return sig.fastAggregateVerify( + opts.sig_groupcheck, + &pairing_buf, + msg[0..32], + bls.DST, + pks, + opts.pk_validate, + ) catch return false; } -// TODO: unit tests test "bls - sanity" { const ikm: [32]u8 = [_]u8{ 0x93, 0xad, 0x7e, 0x65, 0xde, 0xad, 0x05, 0x2a, 0x08, 0x3a, @@ -42,10 +66,10 @@ test "bls - sanity" { const msg = [_]u8{1} ** 32; const sig = sign(sk, &msg); const pk = sk.toPublicKey(); - try verify(&msg, &pk, &sig, null, null); + try verify(&msg, &pk, &sig, .{}); var pks = [_]PublicKey{pk}; var pks_slice: []const PublicKey = pks[0..1]; - const result = try fastAggregateVerify(&msg, pks_slice[0..], &sig, null, null); + const result = try fastAggregateVerify(&msg, pks_slice[0..], &sig, .{}); try std.testing.expect(result); } diff --git a/src/state_transition/utils/signature_sets.zig b/src/state_transition/utils/signature_sets.zig index 53d6cff62..34e189212 100644 --- a/src/state_transition/utils/signature_sets.zig +++ b/src/state_transition/utils/signature_sets.zig @@ -26,7 +26,7 @@ pub const AggregatedSignatureSet = struct { pub fn verifySingleSignatureSet(set: *const SingleSignatureSet) !bool { // All signatures are not trusted and must be group checked (p2.subgroup_check) const signature = try Signature.uncompress(&set.signature); - if (verify(&set.signing_root, &set.pubkey, &signature, null, null)) { + if (verify(&set.signing_root, &set.pubkey, &signature, .{})) { return true; } else |_| { return false; @@ -36,7 +36,7 @@ pub fn verifySingleSignatureSet(set: *const SingleSignatureSet) !bool { pub fn verifyAggregatedSignatureSet(set: *const AggregatedSignatureSet) !bool { // All signatures are not trusted and must be group checked (p2.subgroup_check) const signature = try Signature.uncompress(&set.signature); - return fastAggregateVerify(&set.signing_root, set.pubkeys, &signature, null, null); + return fastAggregateVerify(&set.signing_root, set.pubkeys, &signature, .{}); } pub fn createSingleSignatureSetFromComponents(pubkey: *const PublicKey, signing_root: Root, signature: BLSSignature) SingleSignatureSet { From f9f8a89b951df6562ab86e49204495b5d2a00a5b Mon Sep 17 00:00:00 2001 From: NC <17676176+ensi321@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:47:54 -0700 Subject: [PATCH 40/46] chore: update gloas types to align with the latest specs (#431) Update gloas types to match v1.7.0-alpha.10 Co-authored-by: Claude Opus 4.7 --- src/config/ChainConfig.zig | 11 ++++++++ src/config/networks/gnosis.zig | 12 +++++++++ src/config/networks/mainnet.zig | 11 ++++++++ src/config/networks/minimal.zig | 11 ++++++++ src/consensus_types/gloas.zig | 48 ++++++++++++++++++++++++++++----- src/constants/root.zig | 15 ++++++++++- src/preset/preset.zig | 2 ++ 7 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/config/ChainConfig.zig b/src/config/ChainConfig.zig index 1bedb4e30..bd9354996 100644 --- a/src/config/ChainConfig.zig +++ b/src/config/ChainConfig.zig @@ -72,6 +72,17 @@ PROPOSER_REORG_CUTOFF_BPS: u64, ATTESTATION_DUE_BPS: u64, ATTESTATION_DUE_BPS_GLOAS: u64, +// Gloas (EIP-7732) +AGGREGATE_DUE_BPS_GLOAS: u64, +SYNC_MESSAGE_DUE_BPS_GLOAS: u64, +CONTRIBUTION_DUE_BPS_GLOAS: u64, +PAYLOAD_DUE_BPS: u64, +PAYLOAD_ATTESTATION_DUE_BPS: u64, +MIN_BUILDER_WITHDRAWABILITY_DELAY: u64, +CHURN_LIMIT_QUOTIENT_GLOAS: u64, +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: u64, +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: u64, + // Deposit contract DEPOSIT_CHAIN_ID: u64, DEPOSIT_NETWORK_ID: u64, diff --git a/src/config/networks/gnosis.zig b/src/config/networks/gnosis.zig index ee2377ffd..ebb2b719c 100644 --- a/src/config/networks/gnosis.zig +++ b/src/config/networks/gnosis.zig @@ -65,6 +65,18 @@ pub const chain_config = ChainConfig{ .ATTESTATION_DUE_BPS = 3333, .ATTESTATION_DUE_BPS_GLOAS = 2500, + // Gloas (EIP-7732) + // TODO: These are placeholders. Need to re-visit after gnosis values are out + .AGGREGATE_DUE_BPS_GLOAS = 5000, + .SYNC_MESSAGE_DUE_BPS_GLOAS = 2500, + .CONTRIBUTION_DUE_BPS_GLOAS = 5000, + .PAYLOAD_DUE_BPS = 7500, + .PAYLOAD_ATTESTATION_DUE_BPS = 7500, + .MIN_BUILDER_WITHDRAWABILITY_DELAY = 8192, + .CHURN_LIMIT_QUOTIENT_GLOAS = 32768, + .CONSOLIDATION_CHURN_LIMIT_QUOTIENT = 65536, + .MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS = 256000000000, + // Deposit contract .DEPOSIT_CHAIN_ID = 100, .DEPOSIT_NETWORK_ID = 100, diff --git a/src/config/networks/mainnet.zig b/src/config/networks/mainnet.zig index d20ef1460..2416d89aa 100644 --- a/src/config/networks/mainnet.zig +++ b/src/config/networks/mainnet.zig @@ -65,6 +65,17 @@ pub const chain_config = ChainConfig{ .ATTESTATION_DUE_BPS = 3333, .ATTESTATION_DUE_BPS_GLOAS = 2500, + // Gloas (EIP-7732) + .AGGREGATE_DUE_BPS_GLOAS = 5000, + .SYNC_MESSAGE_DUE_BPS_GLOAS = 2500, + .CONTRIBUTION_DUE_BPS_GLOAS = 5000, + .PAYLOAD_DUE_BPS = 7500, + .PAYLOAD_ATTESTATION_DUE_BPS = 7500, + .MIN_BUILDER_WITHDRAWABILITY_DELAY = 8192, + .CHURN_LIMIT_QUOTIENT_GLOAS = 32768, + .CONSOLIDATION_CHURN_LIMIT_QUOTIENT = 65536, + .MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS = 256000000000, + // Deposit contract .DEPOSIT_CHAIN_ID = 1, .DEPOSIT_NETWORK_ID = 1, diff --git a/src/config/networks/minimal.zig b/src/config/networks/minimal.zig index ae5c2b0d8..69b6af743 100644 --- a/src/config/networks/minimal.zig +++ b/src/config/networks/minimal.zig @@ -65,6 +65,17 @@ pub const chain_config = ChainConfig{ .ATTESTATION_DUE_BPS = 3333, .ATTESTATION_DUE_BPS_GLOAS = 2500, + // Gloas (EIP-7732) + .AGGREGATE_DUE_BPS_GLOAS = 5000, + .SYNC_MESSAGE_DUE_BPS_GLOAS = 2500, + .CONTRIBUTION_DUE_BPS_GLOAS = 5000, + .PAYLOAD_DUE_BPS = 7500, + .PAYLOAD_ATTESTATION_DUE_BPS = 7500, + .MIN_BUILDER_WITHDRAWABILITY_DELAY = 2, + .CHURN_LIMIT_QUOTIENT_GLOAS = 16, + .CONSOLIDATION_CHURN_LIMIT_QUOTIENT = 32, + .MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS = 128000000000, + // Deposit contract .DEPOSIT_CHAIN_ID = 5, .DEPOSIT_NETWORK_ID = 5, diff --git a/src/consensus_types/gloas.zig b/src/consensus_types/gloas.zig index 393b4bf6d..40c3af009 100644 --- a/src/consensus_types/gloas.zig +++ b/src/consensus_types/gloas.zig @@ -77,10 +77,35 @@ pub const AggregateAndProof = electra.AggregateAndProof; pub const SignedAggregateAndProof = electra.SignedAggregateAndProof; pub const SignedBeaconBlockHeader = electra.SignedBeaconBlockHeader; -// Execution payload types remain for envelope usage -pub const ExecutionPayload = electra.ExecutionPayload; +// ExecutionPayloadHeader retained for light client usage pub const ExecutionPayloadHeader = electra.ExecutionPayloadHeader; +// RLP-encoded block access list (EIP-7928) +pub const BlockAccessList = ssz.ByteListType(preset.MAX_BYTES_PER_TRANSACTION); + +// Gloas ExecutionPayload adds block_access_list (EIP-7928) and slot_number (EIP-7843) +pub const ExecutionPayload = ssz.VariableContainerType(struct { + parent_hash: p.Bytes32, + fee_recipient: p.Bytes20, + state_root: p.Bytes32, + receipts_root: p.Bytes32, + logs_bloom: bellatrix.LogsBloom, + prev_randao: p.Bytes32, + block_number: p.Uint64, + gas_limit: p.Uint64, + gas_used: p.Uint64, + timestamp: p.Uint64, + extra_data: bellatrix.ExtraData, + base_fee_per_gas: p.Uint256, + block_hash: p.Bytes32, + transactions: bellatrix.Transactions, + withdrawals: capella.Withdrawals, + blob_gas_used: p.Uint64, + excess_blob_gas: p.Uint64, + block_access_list: BlockAccessList, + slot_number: p.Uint64, +}); + // Reuse Fulu DAS types pub const RowIndex = fulu.RowIndex; pub const ColumnIndex = fulu.ColumnIndex; @@ -89,6 +114,13 @@ pub const Cell = fulu.Cell; pub const MatrixEntry = fulu.MatrixEntry; pub const ProposerLookahead = fulu.ProposerLookahead; +// Cached payload-timeliness committees for the prev/current epoch window (EIP-7732) +pub const PtcWindow = ssz.FixedVectorType( + ssz.FixedVectorType(p.ValidatorIndex, preset.PTC_SIZE, .{}), + (2 + preset.MIN_SEED_LOOKAHEAD) * preset.SLOTS_PER_EPOCH, + .{}, +); + // Light client types pub const LightClientHeader = electra.LightClientHeader; pub const LightClientBootstrap = electra.LightClientBootstrap; @@ -170,6 +202,7 @@ pub const ExecutionPayloadBid = ssz.VariableContainerType(struct { value: p.Uint64, execution_payment: p.Uint64, blob_kzg_commitments: ssz.FixedListType(p.KZGCommitment, preset.MAX_BLOB_COMMITMENTS_PER_BLOCK, .{}), + execution_requests_root: p.Root, }); pub const SignedExecutionPayloadBid = ssz.VariableContainerType(struct { @@ -182,8 +215,7 @@ pub const ExecutionPayloadEnvelope = ssz.VariableContainerType(struct { execution_requests: ExecutionRequests, builder_index: BuilderIndex, beacon_block_root: p.Root, - slot: p.Slot, - state_root: p.Root, + parent_beacon_block_root: p.Root, }); pub const SignedExecutionPayloadEnvelope = ssz.VariableContainerType(struct { @@ -209,6 +241,7 @@ pub const BeaconBlockBody = ssz.VariableContainerType(struct { // executionRequests removed in Gloas (EIP-7732) signed_execution_payload_bid: SignedExecutionPayloadBid, payload_attestations: ssz.FixedListType(PayloadAttestation, preset.MAX_PAYLOAD_ATTESTATIONS, .{}), + parent_execution_requests: ExecutionRequests, }); pub const BeaconBlock = ssz.VariableContainerType(struct { @@ -260,8 +293,8 @@ pub const BeaconState = ssz.VariableContainerType(struct { inactivity_scores: altair.InactivityScores, current_sync_committee: SyncCommittee, next_sync_committee: SyncCommittee, - // latestExecutionPayloadHeader removed in Gloas (EIP-7732) - latest_execution_payload_bid: ExecutionPayloadBid, + // latestExecutionPayloadHeader replaced by latest_block_hash in Gloas (EIP-7732) + latest_block_hash: p.Bytes32, next_withdrawal_index: p.WithdrawalIndex, next_withdrawal_validator_index: p.ValidatorIndex, historical_summaries: ssz.FixedListType(HistoricalSummary, preset.HISTORICAL_ROOTS_LIMIT, .{}), @@ -281,8 +314,9 @@ pub const BeaconState = ssz.VariableContainerType(struct { execution_payload_availability: ssz.BitVectorType(preset.SLOTS_PER_HISTORICAL_ROOT), builder_pending_payments: ssz.FixedVectorType(BuilderPendingPayment, 2 * preset.SLOTS_PER_EPOCH, .{}), builder_pending_withdrawals: ssz.FixedListType(BuilderPendingWithdrawal, preset.BUILDER_PENDING_WITHDRAWALS_LIMIT, .{}), - latest_block_hash: p.Bytes32, + latest_execution_payload_bid: ExecutionPayloadBid, payload_expected_withdrawals: Withdrawals, + ptc_window: PtcWindow, }); pub const BlobSidecar = electra.BlobSidecar; diff --git a/src/constants/root.zig b/src/constants/root.zig index 0038e7bf6..5aed64870 100644 --- a/src/constants/root.zig +++ b/src/constants/root.zig @@ -12,6 +12,7 @@ pub const GENESIS_SLOT = 0; pub const BLS_WITHDRAWAL_PREFIX = 0; pub const ETH1_ADDRESS_WITHDRAWAL_PREFIX = 1; pub const COMPOUNDING_WITHDRAWAL_PREFIX = 2; +pub const BUILDER_WITHDRAWAL_PREFIX = 3; // Gloas (EIP-7732) // Domain types @@ -27,13 +28,25 @@ pub const DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF = [_]u8{ 8, 0, 0, 0 }; pub const DOMAIN_CONTRIBUTION_AND_PROOF = [_]u8{ 9, 0, 0, 0 }; pub const DOMAIN_BLS_TO_EXECUTION_CHANGE = [_]u8{ 10, 0, 0, 0 }; +// Gloas (EIP-7732) domains +pub const DOMAIN_BEACON_BUILDER = [_]u8{ 11, 0, 0, 0 }; // 0x0B000000 +pub const DOMAIN_PTC_ATTESTER = [_]u8{ 12, 0, 0, 0 }; // 0x0C000000 +pub const DOMAIN_PROPOSER_PREFERENCES = [_]u8{ 13, 0, 0, 0 }; // 0x0D000000 + // Application specific domains pub const DOMAIN_APPLICATION_MASK = [_]u8{ 0, 0, 0, 1 }; pub const DOMAIN_APPLICATION_BUILDER = [_]u8{ 0, 0, 0, 1 }; // need to be updated when new domain is added -pub const ALL_DOMAINS = [_][4]u8{ DOMAIN_BEACON_PROPOSER, DOMAIN_BEACON_ATTESTER, DOMAIN_RANDAO, DOMAIN_DEPOSIT, DOMAIN_VOLUNTARY_EXIT, DOMAIN_SELECTION_PROOF, DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_BLS_TO_EXECUTION_CHANGE, DOMAIN_APPLICATION_MASK, DOMAIN_APPLICATION_BUILDER }; +pub const ALL_DOMAINS = [_][4]u8{ DOMAIN_BEACON_PROPOSER, DOMAIN_BEACON_ATTESTER, DOMAIN_RANDAO, DOMAIN_DEPOSIT, DOMAIN_VOLUNTARY_EXIT, DOMAIN_SELECTION_PROOF, DOMAIN_AGGREGATE_AND_PROOF, DOMAIN_SYNC_COMMITTEE, DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF, DOMAIN_CONTRIBUTION_AND_PROOF, DOMAIN_BLS_TO_EXECUTION_CHANGE, DOMAIN_BEACON_BUILDER, DOMAIN_PTC_ATTESTER, DOMAIN_PROPOSER_PREFERENCES, DOMAIN_APPLICATION_MASK, DOMAIN_APPLICATION_BUILDER }; + +// Gloas (EIP-7732) misc + +pub const BUILDER_INDEX_FLAG: u64 = 1 << 40; // 2**40 +pub const BUILDER_INDEX_SELF_BUILD: u64 = std.math.maxInt(u64); +pub const BUILDER_PAYMENT_THRESHOLD_NUMERATOR = 6; +pub const BUILDER_PAYMENT_THRESHOLD_DENOMINATOR = 10; // Participation flag indices diff --git a/src/preset/preset.zig b/src/preset/preset.zig index eb5bafe2c..dd85b491a 100644 --- a/src/preset/preset.zig +++ b/src/preset/preset.zig @@ -87,6 +87,7 @@ const PresetMainnet = struct { pub const MAX_PAYLOAD_ATTESTATIONS = 4; pub const BUILDER_REGISTRY_LIMIT = 1_099_511_627_776; pub const BUILDER_PENDING_WITHDRAWALS_LIMIT = 1_048_576; + pub const MAX_BUILDERS_PER_WITHDRAWALS_SWEEP = 16384; }; const PresetMinimal = struct { @@ -164,6 +165,7 @@ const PresetMinimal = struct { pub const MAX_PAYLOAD_ATTESTATIONS = 4; pub const BUILDER_REGISTRY_LIMIT = 1_099_511_627_776; pub const BUILDER_PENDING_WITHDRAWALS_LIMIT = 1_048_576; + pub const MAX_BUILDERS_PER_WITHDRAWALS_SWEEP = 16; }; const preset_str = @import("build_options").preset; From e73c6d49c020818f835f31cb3fb6aa59a466f208 Mon Sep 17 00:00:00 2001 From: bing Date: Tue, 16 Jun 2026 20:45:48 +0800 Subject: [PATCH 41/46] fix: no-op when syncPubkeys run on a pk cache with shrinking validator set (#432) also add a test --- 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; From c5753a5b4889c706687b1783ac9182473a94d5a2 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 17 Jun 2026 01:14:10 +0800 Subject: [PATCH 42/46] fix(pmt,ssz): harden chunked-leaf and zero-copy tree-view memory safety (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation A review of the chunked-leaf packing and zero-copy tree-view paths surfaced a handful of memory-safety issues. ## Description - **Composite `set`/`push`/`setValue` ownership.** Make them caller-retains-on-failure (the std/Ghostty model): `chunks.set` no longer deinits the passed view on its own reservation OOM, and `setValue`/`pushValue` carry an `errdefer` over the view they build. Fixes a double-free in `load_state` (`applyModifiedValidators` / `appendNewValidators`), where the caller's `errdefer` and `set`'s self-free both ran on the `ensureUnusedCapacity` OOM path. - **ChunkedLeaf root recompute.** `getRoot`'s `.chunked_leaf` arm uses a reused Pool scratch field + `computeRoot` instead of `computeRootAllocating`, removing the only `@panic("OOM")` in `src/` (aborted the Node.js host on OOM) and the per-recompute malloc/free on the hashTreeRoot path. A Pool field rather than a stack buffer because `getRoot` recurses to tree depth (~47 on a mainnet validators path), and chunked_leaf is a recursion leaf so one shared scratch is always safe. - **`sumTargetUnslashedBalanceIncrements`.** Assert `participations.len == validators.len`; the zero-copy validator pointer slice turns a cross-list length mismatch into a garbage-pointer dereference. - **`ContainerTreeView.deserialize`.** Add the `errdefer pool.unref(root)` its two siblings already carry, so an `init` OOM no longer strands the deserialized subtree. - **Delete dead `fillToLength` / `fillToDepth`.** Pool-corrupting on first use, zero callers, superseded by `fillWithContents`. - **`ChunkedLeaf.computeRoot` trailing-zero assert.** Assert chunks past `len` are zero — a violated invariant would silently hash stale data into a wrong (consensus-divergent) root. - **`getChunkedLeafPtr` exclusive-ownership assert.** Assert `refCount() == 0` before handing out a mutable blob pointer; in-place mutation of a shared node corrupts every tree referencing it. - **List `setLength` → `growTo`, grow-only.** New positions read as zero (the data subtree is already the virtual zero subtree), so growing is O(1) and correct by construction; a bare length *cut* only rewrites the length mix-in, leaving stale chunk data in the merkleized root — a silent wrong hashTreeRoot. Now asserted (`new_length >= _len`) and documented: shrinking must go through `sliceTo`. All production callers grow (upgrade-to-altair); the one shrink user (the loadState trim test generator) now truncates a value-level state, keeping the test fixture independent of `sliceTo`, which loadState itself uses to trim. - **`ContainerTreeView.getFieldRoot` per-call pool-node leak.** On a dirty basic field it built a temporary node from the cached value and never unref'd it — one orphaned pool slot per call, invisible to leak detectors (`Pool.deinit` frees every in-use slot on teardown). Mirrors the fix its `StructContainerTreeView` sibling already carries: copy the hash into a per-field backing store, unref the node, return a pointer into the store. Pinned by a `getNodesInUse`-baseline test (10 calls leaked 10 slots before; baseline-stable after). - **Cloning a dirty tree view — two latent bugs.** A transfer-clone deliberately *drops* uncommitted writes (the rc-0 staged nodes are exclusively owned and can't be shared in the refcount model). The composite path handles this correctly; the basic-list path had two gaps. (1) **Leak:** `TreeViewState.clone` dropped the staged `children_nodes` entries *without* `unref`, orphaning a pool slot (and any chunked_leaf blob) per dropped write — invisible to leak detectors because `Pool.deinit` frees every in-use slot on teardown; now caught by a `getNodesInUse` baseline. (2) **`_len` skew:** the clone kept the uncommitted `_len`, so a dropped push left length N+1 over an N-element tree → wrong root on commit; the clone now reflects the committed length. Both latent (callers commit before cloning). - **`StructContainerTreeView.clone` semantics.** It committed the source first, so uncommitted writes survived into both views and `clone()` mutated the source's root — the opposite of every other view's drop semantics. It now clones the committed state and drops uncommitted writes (from the source too on transfer). - **`ProofFixture` dangling Pool (sync-committee witness tests).** The fixture returned its `Pool` by value after handing `&pool` to the views, leaving them pointing at a dead stack frame; the tests passed only by stack-layout luck. The fixture now initializes in place. - **Allocator-lane routing.** Two transient buffers (the chunked-leaf serialize Id scratch, the compact-multiproof arena) allocated from the page-allocator lane reserved for the pool's node columns; they now use the general allocator lane. --- src/persistent_merkle_tree/ChunkedLeaf.zig | 14 +- src/persistent_merkle_tree/Node.zig | 93 +---------- src/persistent_merkle_tree/node_test.zig | 17 ++ src/persistent_merkle_tree/proof.zig | 2 +- src/ssz/tree_view/array_basic.zig | 40 +++++ src/ssz/tree_view/chunks.zig | 23 ++- src/ssz/tree_view/container.zig | 149 ++++++++++++++++-- src/ssz/tree_view/list_basic.zig | 102 +++++++++++- src/ssz/tree_view/list_composite.zig | 95 +++++++++-- src/ssz/tree_view/utils/tree_view_state.zig | 5 +- src/ssz/type/list.zig | 4 +- src/state_transition/load_state.zig | 34 ++-- .../slot/upgrade_state_to_altair.zig | 6 +- .../sync_committees_witness.zig | 46 +++--- .../utils/target_unslashed_balance.zig | 2 + 15 files changed, 444 insertions(+), 188 deletions(-) diff --git a/src/persistent_merkle_tree/ChunkedLeaf.zig b/src/persistent_merkle_tree/ChunkedLeaf.zig index c1703a964..9c5fe6679 100644 --- a/src/persistent_merkle_tree/ChunkedLeaf.zig +++ b/src/persistent_merkle_tree/ChunkedLeaf.zig @@ -4,7 +4,6 @@ //! array + length) referenced by one `.chunked_leaf` Node. Self-contained, //! ref-counted via the Pool's Node ref count, copy-on-write on mutation. const std = @import("std"); -const Allocator = std.mem.Allocator; const hashing = @import("hashing"); const hash = hashing.hash; @@ -32,13 +31,14 @@ len: u16, /// padding. Each reduction is one batched `hash()` call so hashtree's /// SIMD lanes stay saturated. /// -/// `scratch` is a caller-supplied K/2-element buffer. `computeRootAllocating` -/// wraps this with a per-call `allocator.alignedAlloc` + free. +/// `scratch` is a caller-supplied K/2-element buffer, so root computation is allocation-free +/// and infallible. /// /// First round reads `chunks` directly into `scratch` (avoids the /// in-place mutation that `hashing.merkleize` would require on `*const /// chunks`). Later rounds halve in-place on `scratch`. pub fn computeRoot(self: *const ChunkedLeaf, scratch: *align(64) [K / 2][32]u8, out: *[32]u8) void { + for (self.chunks[self.len..]) |*chunk| std.debug.assert(std.mem.allEqual(u8, chunk, 0)); hash(scratch[0..], self.chunks[0..]) catch unreachable; var width: usize = K / 2; @@ -49,14 +49,6 @@ pub fn computeRoot(self: *const ChunkedLeaf, scratch: *align(64) [K / 2][32]u8, out.* = scratch[0]; } -/// `computeRoot` wrapper that owns the scratch via `allocator`. -pub fn computeRootAllocating(self: *const ChunkedLeaf, allocator: Allocator, out: *[32]u8) void { - const scratch_slice = allocator.alignedAlloc([32]u8, .@"64", K / 2) catch @panic("OOM"); - defer allocator.free(scratch_slice); - const scratch_arr: *align(64) [K / 2][32]u8 = @ptrCast(scratch_slice.ptr); - self.computeRoot(scratch_arr, out); -} - const Node = @import("Node.zig"); test "computeRoot for all-zero chunked_leaf equals getZeroHash(k_log2)" { diff --git a/src/persistent_merkle_tree/Node.zig b/src/persistent_merkle_tree/Node.zig index bdee1fbd4..e517f0f83 100644 --- a/src/persistent_merkle_tree/Node.zig +++ b/src/persistent_merkle_tree/Node.zig @@ -261,6 +261,9 @@ pub const Pool = struct { allocator: Allocator, nodes: std.MultiArrayList(Node).Slice, next_free_node: Id, + // Reused scratch for chunked_leaf root recompute: single-threaded, and chunked_leaf is a leaf + // of getRoot's recursion, so at most one computeRoot uses it at a time. + chunked_leaf_scratch: [ChunkedLeaf.K / 2][32]u8 align(64), pub const InitOptions = struct { page_allocator: Allocator = std.heap.page_allocator, @@ -278,6 +281,7 @@ pub const Pool = struct { .allocator = opts.allocator, .nodes = undefined, .next_free_node = @enumFromInt(max_depth), + .chunked_leaf_scratch = undefined, }; var list = std.MultiArrayList(Node).empty; @@ -776,7 +780,7 @@ pub const Id = enum(u32) { } const storage = chunkedLeafPtr(pool.nodes.items(.payload), idx); var hash: [32]u8 = undefined; - storage.computeRootAllocating(pool.allocator, &hash); + storage.computeRoot(&pool.chunked_leaf_scratch, &hash); roots[idx] = hash; return &roots[idx]; }, @@ -822,6 +826,7 @@ pub const Id = enum(u32) { pub fn getChunkedLeafPtr(node_id: Id, pool: *Pool) Error!*ChunkedLeaf { const idx = @intFromEnum(node_id); if (pool.nodes.items(.state)[idx].kind() != .chunked_leaf) return Error.InvalidNode; + std.debug.assert(pool.nodes.items(.state)[idx].refCount() == 0); return chunkedLeafPtr(pool.nodes.items(.payload), idx); } @@ -1530,92 +1535,6 @@ pub const Id = enum(u32) { } }; -/// Fill a view to the specified depth, returning the new root node id. -pub fn fillToDepth(pool: *Pool, bottom: Id, depth: Depth) Error!Id { - var d = depth; - var node = bottom; - while (d > 0) : (d -= 1) { - node = try pool.createBranch(node, node); - } - - return node; -} - -/// Fill a view to the specified length and depth, returning the new root node id. -pub fn fillToLength(pool: *Pool, leaf: Id, depth: Depth, length: usize) Error!Id { - const max_length = @as(Gindex.Uint, 1) << depth; - if (length > max_length) { - return Error.InvalidLength; - } - - // fill a full view to the specified depth - var node_id = try fillToDepth(pool, leaf, depth); - - // if the requested length is the same as the max length, return the node - if (length == max_length) { - return node_id; - } - - // otherwise, traverse down to the specified length - const gindex: Gindex = @enumFromInt(max_length | length); - const path_len = gindex.pathLen(); - var path = gindex.toPath(); - - var parents_buf: [max_depth]Id = undefined; - var lefts_buf: [max_depth]Id = undefined; - var rights_buf: [max_depth]Id = undefined; - - const path_parents = parents_buf[0..path_len]; - const path_lefts = lefts_buf[0..path_len]; - const path_rights = rights_buf[0..path_len]; - - const states = pool.nodes.items(.state); - const payloads = pool.nodes.items(.payload); - - for (0..path_len - 1) |i| { - const idx = @intFromEnum(node_id); - const k = states[idx].kind(); - if (noChildKind(node_id, k)) { - return Error.InvalidNode; - } - const c = childrenOf(node_id, k, payloads); - if (path.left()) { - path_lefts[i] = path_parents[i + 1]; - path_rights[i] = c.right; - node_id = c.left; - } else { - path_lefts[i] = c.left; - path_rights[i] = path_parents[i + 1]; - node_id = c.right; - } - path.next(); - } - - // and rebind with zero(0) - { - const idx = @intFromEnum(node_id); - const k = states[idx].kind(); - if (noChildKind(node_id, k)) return Error.InvalidNode; - const c = childrenOf(node_id, k, payloads); - if (path.left()) { - path_lefts[path_len - 1] = @enumFromInt(0); - path_rights[path_len - 1] = c.right; - } else { - path_lefts[path_len - 1] = c.left; - path_rights[path_len - 1] = @enumFromInt(0); - } - } - - // and rebind with zero(0) - try pool.rebind( - path_parents, - path_lefts, - path_rights, - ); - - return path_parents[0]; -} - /// Fill a view with the specified contents, returning the new root node id. /// /// Note: contents is mutated. diff --git a/src/persistent_merkle_tree/node_test.zig b/src/persistent_merkle_tree/node_test.zig index 7eeefe706..eb6ae6fb0 100644 --- a/src/persistent_merkle_tree/node_test.zig +++ b/src/persistent_merkle_tree/node_test.zig @@ -5,6 +5,7 @@ const Depth = @import("hashing").Depth; const Node = @import("Node.zig"); const Gindex = @import("gindex.zig").Gindex; +const ChunkedLeaf = @import("ChunkedLeaf.zig"); // Allocate until the pool is full, so the next request has to grow (and fail). Returns the filler. fn drainPoolToFull(pool: *Node.Pool, out: *std.ArrayList(Node.Id)) !void { @@ -125,6 +126,22 @@ test "Node.State predicates" { _ = free_state.nextFree(); } +test "chunked_leaf getRoot recomputes without touching the pool allocator" { + var counter = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = std.math.maxInt(usize) }); + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = counter.allocator(), .pool_size = 16 }); + defer pool.deinit(); + + var chunks: [ChunkedLeaf.K][32]u8 align(64) = undefined; + for (&chunks, 0..) |*c, i| c.* = [_]u8{@intCast(i & 0xff)} ** 32; + + const node = try pool.createChunkedLeaf(&chunks, ChunkedLeaf.K); + defer pool.unref(node); + + const allocs_before = counter.alloc_index; + _ = node.getRoot(&pool); // root starts lazy → this recomputes + try std.testing.expectEqual(allocs_before, counter.alloc_index); +} + test "Pool" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 10 }); diff --git a/src/persistent_merkle_tree/proof.zig b/src/persistent_merkle_tree/proof.zig index ce74e7031..076eaae06 100644 --- a/src/persistent_merkle_tree/proof.zig +++ b/src/persistent_merkle_tree/proof.zig @@ -564,7 +564,7 @@ pub fn createNodeFromCompactMultiProof( leaves: [][32]u8, descriptor: []const u8, ) (Node.Error || Error)!Node.Id { - var arena = std.heap.ArenaAllocator.init(pool.page_allocator); + var arena = std.heap.ArenaAllocator.init(pool.allocator); defer arena.deinit(); const temp_allocator = arena.allocator(); diff --git a/src/ssz/tree_view/array_basic.zig b/src/ssz/tree_view/array_basic.zig index 2692f89bd..c51c20e78 100644 --- a/src/ssz/tree_view/array_basic.zig +++ b/src/ssz/tree_view/array_basic.zig @@ -174,6 +174,46 @@ test "TreeView vector element roundtrip" { try std.testing.expectEqualSlices(u64, &expected, &roundtrip); } +test "TreeView vector chunked_leaf roundtrip across the chunked_leaf boundary" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const Uint64 = UintType(64); + const VectorType = FixedVectorType(Uint64, 276, .{ .chunked_leaf = true }); + + var original: VectorType.Type = undefined; + for (&original, 0..) |*e, i| e.* = i; + + const root_node = try VectorType.tree.fromValue(&pool, &original); + var view = try VectorType.TreeView.init(allocator, &pool, root_node); + defer view.deinit(); + + try std.testing.expectEqual(@as(u64, 0), try view.get(0)); + try std.testing.expectEqual(@as(u64, 255), try view.get(255)); + try std.testing.expectEqual(@as(u64, 256), try view.get(256)); + try std.testing.expectEqual(@as(u64, 275), try view.get(275)); + + try view.set(255, 999); + try view.set(256, 1000); + try view.commit(); + + var expected = original; + expected[255] = 999; + expected[256] = 1000; + + var expected_root: [32]u8 = undefined; + try VectorType.hashTreeRoot(&expected, &expected_root); + + var actual_root: [32]u8 = undefined; + try view.hashTreeRootInto(&actual_root); + try std.testing.expectEqualSlices(u8, &expected_root, &actual_root); + + var roundtrip: VectorType.Type = undefined; + try VectorType.tree.toValue(view.getRoot(), &pool, &roundtrip); + try std.testing.expectEqualSlices(u64, &expected, &roundtrip); +} + test "TreeView vector getAll fills provided buffer" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); diff --git a/src/ssz/tree_view/chunks.zig b/src/ssz/tree_view/chunks.zig index 121674f0b..6dc8292e9 100644 --- a/src/ssz/tree_view/chunks.zig +++ b/src/ssz/tree_view/chunks.zig @@ -442,20 +442,16 @@ pub fn CompositeChunks( return child_ptr; } - /// Takes ownership of `value` (and deinits it if a reservation fails). Deinits whatever - /// child was cached for `index`, so any earlier get()/getReadonly() of it is now invalid. - /// Pass a view you own — never a get()/getReadonly() pointer for this same index, or a - /// failed set would deinit a view the cache still holds (double-free). + /// Takes ownership of `value` only on success; on any error `value` is left untouched for + /// the caller to free. Deinits whatever child was cached for `index`, so any earlier + /// get()/getReadonly() of it is now invalid. Pass a view you own — never a + /// get()/getReadonly() pointer for this same index, or the displaced-old deinit would free a + /// view the cache still holds (double-free). pub fn set(self: *Self, index: usize, value: ElementPtr) !void { const gindex = Gindex.fromDepth(chunk_depth, index); - // Reserve before storing so neither store can fail. A failure mid-store would drop - // `value` (we own it now, the caller won't free it) or leave `changed` and - // `children_data` out of sync. - { - errdefer value.deinit(); - try self.state.changed.ensureUnusedCapacity(self.state.allocator, 1); - try self.children_data.ensureUnusedCapacity(self.state.allocator, 1); - } + // Reserve first so the commit below cannot fail. + try self.state.changed.ensureUnusedCapacity(self.state.allocator, 1); + try self.children_data.ensureUnusedCapacity(self.state.allocator, 1); self.state.changed.putAssumeCapacity(gindex, {}); const opt_old_data = self.children_data.fetchPutAssumeCapacity(gindex, value); if (opt_old_data) |old_data_value| { @@ -505,12 +501,11 @@ pub fn CompositeChunks( /// Set a child from an SSZ value type. pub fn setValue(self: *Self, index: usize, value: *const Value) !void { const root = try ST.Element.tree.fromValue(self.state.pool, value); - // Free `root` only if init fails. Once init succeeds, `set` owns `child_view` on every - // path, so we must not deinit it here; that would double-free if set later fails. const child_view = Element.init(self.state.allocator, self.state.pool, root) catch |err| { self.state.pool.unref(root); return err; }; + errdefer child_view.deinit(); try self.set(index, child_view); } diff --git a/src/ssz/tree_view/container.zig b/src/ssz/tree_view/container.zig index 612e52e81..5b7e1b8e0 100644 --- a/src/ssz/tree_view/container.zig +++ b/src/ssz/tree_view/container.zig @@ -36,6 +36,9 @@ pub fn ContainerTreeView(comptime ST: type) type { /// whether the corresponding child node/data has changed since the last update of the root changed: std.StaticBitSet(ST.chunk_count), original_nodes: [ST.chunk_count]?Node.Id, + /// Stable backing store for `getFieldRoot` return pointers on dirty basic fields, so the + /// temporary PMT node can be unref'd instead of leaking a Pool slot per call. + field_root_cache: [ST.chunk_count][32]u8, pub const SszType = ST; const Self = @This(); @@ -54,6 +57,7 @@ pub fn ContainerTreeView(comptime ST: type) type { .original_nodes = .{null} ** ST.chunk_count, .root = root, .changed = std.StaticBitSet(ST.chunk_count).initEmpty(), + .field_root_cache = undefined, }; return ptr; } @@ -290,6 +294,7 @@ pub fn ContainerTreeView(comptime ST: type) type { pub fn deserialize(allocator: Allocator, pool: *Node.Pool, bytes: []const u8) !*Self { const root = try ST.tree.deserializeFromBytes(pool, bytes); + errdefer pool.unref(root); return try Self.init(allocator, pool, root); } @@ -331,12 +336,13 @@ pub fn ContainerTreeView(comptime ST: type) type { const field_index = comptime ST.getFieldIndex(field_name); const ChildST = ST.getFieldType(field_name); if (comptime isBasicType(ChildST)) { - // For basic types, get the node at the field's position and return its root - const node = if (self.child_data[field_index]) |child_value| blk: { - break :blk try ChildST.tree.fromValue(self.pool, &child_value); - } else blk: { - break :blk try self.root.getNodeAtDepth(self.pool, ST.chunk_depth, field_index); - }; + if (self.child_data[field_index]) |child_value| { + const node = try ChildST.tree.fromValue(self.pool, &child_value); + defer self.pool.unref(node); + self.field_root_cache[field_index] = node.getRoot(self.pool).*; + return &self.field_root_cache[field_index]; + } + const node = try self.root.getNodeAtDepth(self.pool, ST.chunk_depth, field_index); return node.getRoot(self.pool); } else { // For composite types, if we have a cached view, commit it and return its root @@ -465,9 +471,9 @@ pub fn StructContainerTreeView(comptime ST: type) type { return ptr; } + /// Clones the committed state; uncommitted writes are dropped (from the source too on + /// transfer), matching the other tree views. pub fn clone(self: *Self, opts: CloneOpts) !*Self { - try self.commit(); - try self.pool.ref(self.root); errdefer self.pool.unref(self.root); @@ -479,11 +485,15 @@ pub fn StructContainerTreeView(comptime ST: type) type { ptr.root = self.root; ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty(); - if (opts.transfer_cache) { + if (opts.transfer_cache and self.changed.count() == 0) { ptr.value = self.value; } else { try ST.tree.toValue(self.root, self.pool, &ptr.value); } + if (opts.transfer_cache and self.changed.count() != 0) { + self.value = ptr.value; + self.changed = std.StaticBitSet(ST.chunk_count).initEmpty(); + } return ptr; } @@ -800,6 +810,127 @@ test "TreeView container field roundtrip" { ); } +test "StructContainerTreeView clone drops uncommitted changes" { + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 256 }); + defer pool.deinit(); + + const StructCheckpoint = StructContainerType(struct { + epoch: UintType(64), + root: ByteVectorType(32), + }); + const value: StructCheckpoint.Type = .{ .epoch = 1, .root = [_]u8{1} ** 32 }; + var view = try StructCheckpoint.TreeView.fromValue(std.testing.allocator, &pool, &value); + defer view.deinit(); + const committed_root = view.getRoot(); + + try view.set("epoch", 9); + var cloned = try view.clone(.{}); + defer cloned.deinit(); + + try std.testing.expectEqual(@as(u64, 1), try cloned.get("epoch")); + try std.testing.expectEqual(@as(u64, 1), try view.get("epoch")); + try std.testing.expectEqual(committed_root, cloned.getRoot()); + try std.testing.expectEqual(committed_root, view.getRoot()); + + try view.set("epoch", 5); + var kept = try view.clone(.{ .transfer_cache = false }); + defer kept.deinit(); + try std.testing.expectEqual(@as(u64, 1), try kept.get("epoch")); + try std.testing.expectEqual(@as(u64, 5), try view.get("epoch")); +} + +const DoubleFreeDetectAllocator = @import("testing_allocators").DoubleFreeDetectAllocator; + +test "TreeView container setValue/commit - OOM does not double-free" { + const new_root_bytes: [32]u8 = [_]u8{0xee} ** 32; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; + defer pool.deinit(); + + const checkpoint: Checkpoint.Type = .{ .epoch = 1, .root = [_]u8{1} ** 32 }; + const root_node = Checkpoint.tree.fromValue(&pool, &checkpoint) catch continue; + var view = Checkpoint.TreeView.init(alloc, &pool, root_node) catch { + pool.unref(root_node); + continue; + }; + defer view.deinit(); + + view.setValue("root", &new_root_bytes) catch { + try std.testing.expect(!oom.double_free); + continue; + }; + view.commit() catch {}; + try std.testing.expect(!oom.double_free); + } +} + +test "TreeView container fromValue - OOM leaves no orphan pool nodes" { + const checkpoint: Checkpoint.Type = .{ .epoch = 7, .root = [_]u8{7} ** 32 }; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); + var pool = Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 0 }) catch continue; + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + const view = Checkpoint.TreeView.fromValue(failing.allocator(), &pool, &checkpoint) catch { + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + continue; + }; + view.deinit(); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + +test "TreeView container getFieldRoot on a dirty basic field leaves no orphan pool nodes" { + var pool = try Node.Pool.init(.{ .page_allocator = std.testing.allocator, .allocator = std.testing.allocator, .pool_size = 256 }); + defer pool.deinit(); + + const checkpoint: Checkpoint.Type = .{ .epoch = 1, .root = [_]u8{1} ** 32 }; + const root_node = try Checkpoint.tree.fromValue(&pool, &checkpoint); + var view = try Checkpoint.TreeView.init(std.testing.allocator, &pool, root_node); + defer view.deinit(); + + try view.set("epoch", 99); + const baseline = pool.getNodesInUse(); + + var expected = [_]u8{0} ** 32; + std.mem.writeInt(u64, expected[0..8], 99, .little); + for (0..10) |_| { + const field_root = try view.getFieldRoot("epoch"); + try std.testing.expectEqualSlices(u8, &expected, field_root); + } + try std.testing.expectEqual(baseline, pool.getNodesInUse()); +} + +test "TreeView container deserialize - OOM leaves no orphan pool nodes" { + const value: Checkpoint.Type = .{ .epoch = 7, .root = [_]u8{0x5a} ** 32 }; + var bytes: [Checkpoint.fixed_size]u8 = undefined; + _ = Checkpoint.serializeIntoBytes(&value, &bytes); + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_at, .resize_fail_index = 0 }); + var pool = Node.Pool.init(.{ .page_allocator = failing.allocator(), .allocator = failing.allocator(), .pool_size = 0 }) catch continue; + defer pool.deinit(); + + const baseline = pool.getNodesInUse(); + const view = Checkpoint.TreeView.deserialize(failing.allocator(), &pool, &bytes) catch { + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + continue; + }; + view.deinit(); + try std.testing.expectEqual(baseline, pool.getNodesInUse()); + } +} + test "TreeView container nested types set/get/commit" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 2048 }); diff --git a/src/ssz/tree_view/list_basic.zig b/src/ssz/tree_view/list_basic.zig index 771a0c2e8..cbae59bd0 100644 --- a/src/ssz/tree_view/list_basic.zig +++ b/src/ssz/tree_view/list_basic.zig @@ -73,7 +73,9 @@ pub fn ListBasicTreeView(comptime ST: type) type { try self.chunks.clone(opts, &ptr.chunks); ptr.allocator = self.allocator; ptr._orig_len = self._orig_len; - ptr._len = self._len; + // Uncommitted writes are dropped (from the source too on transfer), so length = committed. + ptr._len = self._orig_len; + if (opts.transfer_cache) self._len = self._orig_len; return ptr; } @@ -88,6 +90,13 @@ pub fn ListBasicTreeView(comptime ST: type) type { self._orig_len = self._len; } + /// Grows the list; new positions read as zero. Shrinking must go through sliceTo — + /// a bare length cut would leave stale chunk data in the merkleized root. + pub fn growTo(self: *Self, new_length: usize) !void { + std.debug.assert(new_length >= self._len); + self._len = new_length; + } + pub fn clearCache(self: *Self) void { self.chunks.clearCache(); } @@ -113,10 +122,6 @@ pub fn ListBasicTreeView(comptime ST: type) type { try ST.tree.toValue(allocator, self.chunks.state.root, self.chunks.state.pool, out); } - pub fn setLength(self: *Self, new_length: usize) !void { - self._len = new_length; - } - /// Read-only iterator over committed elements. Pending `set`/`push` /// writes are not visible — call `commit()` first if they matter. pub fn iteratorReadonly(self: *const Self, start_index: usize) ReadonlyIterator { @@ -809,6 +814,54 @@ test "TreeView list basic clone(true) transfers cache and clears source" { try std.testing.expect(cloned.chunks.state.children_nodes.count() > 0); } +test "TreeView list basic clone(transfer_cache) on a dirty view leaves no orphan pool nodes" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 64 }); + defer pool.deinit(); + + const ListType = FixedListType(UintType(32), 16, .{}); + var list: ListType.Type = .empty; + defer list.deinit(allocator); + for (0..4) |i| try list.append(allocator, @as(u32, @intCast(i + 1))); + + const baseline = pool.getNodesInUse(); + { + const root = try ListType.tree.fromValue(&pool, &list); + var view = try ListType.TreeView.init(allocator, &pool, root); + try view.set(0, @as(u32, 42)); + var cloned = try view.clone(.{}); + cloned.deinit(); + view.deinit(); + } + try std.testing.expectEqual(baseline, pool.getNodesInUse()); +} + +test "TreeView list basic clone(transfer_cache) drops an uncommitted push without _len skew" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 256 }); + defer pool.deinit(); + + const ListType = FixedListType(UintType(32), 16, .{}); + var list: ListType.Type = .empty; + defer list.deinit(allocator); + for (0..4) |i| try list.append(allocator, @as(u32, @intCast(i + 1))); + + const root = try ListType.tree.fromValue(&pool, &list); + var view = try ListType.TreeView.init(allocator, &pool, root); + defer view.deinit(); + + try view.push(@as(u32, 99)); + + var cloned = try view.clone(.{}); + defer cloned.deinit(); + try cloned.commit(); + + var roundtrip: ListType.Type = .empty; + defer roundtrip.deinit(allocator); + try ListType.tree.toValue(allocator, cloned.getRoot(), &pool, &roundtrip); + try std.testing.expectEqual(@as(usize, 4), roundtrip.items.len); +} + // Refer to https://github.com/ChainSafe/ssz/blob/7f5580c2ea69f9307300ddb6010a8bc7ce2fc471/packages/ssz/test/unit/byType/listBasic/tree.test.ts#L180-L203 test "TreeView basic list getAll reflects pushes" { const allocator = std.testing.allocator; @@ -1310,6 +1363,45 @@ test "ListBasicTreeView chunked_leaf: push keeps ChunkedLeaf.len in sync" { } } +test "ListBasicTreeView chunked_leaf: u8 element width round-trips" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); + defer pool.deinit(); + + const ListT = FixedListType(UintType(8), 1 << 20, .{ .chunked_leaf = true }); + const K: usize = ChunkedLeafType.K; + const items_per_chunk: usize = 32; + const cl_depth: Depth = ListT.chunk_depth + 1 - ChunkedLeafType.k_log2; + + const item_count: usize = K * items_per_chunk + 2 * items_per_chunk + 5; + + var src: ListT.Type = .empty; + defer src.deinit(allocator); + const root0 = try ListT.tree.fromValue(&pool, &src); + var view = try ListT.TreeView.init(allocator, &pool, root0); + defer view.deinit(); + + for (0..item_count) |i| try view.push(@as(u8, @intCast(i % 256))); + try view.commit(); + + const total_chunks = (item_count + items_per_chunk - 1) / items_per_chunk; + const chunked_leaf_count = (total_chunks + K - 1) / K; + const zero_chunk = [_]u8{0} ** 32; + for (0..chunked_leaf_count) |cl_idx| { + const cl = try view.chunks.state.root.getNodeAtDepth(&pool, cl_depth, cl_idx); + const expected_len: usize = @min(K, total_chunks - cl_idx * K); + try std.testing.expectEqual(@as(u16, @intCast(expected_len)), try cl.getChunkedLeafLen(&pool)); + const chunks = try cl.getChunkedLeafChunks(&pool); + for (expected_len..K) |c| try std.testing.expectEqualSlices(u8, &zero_chunk, &chunks[c]); + } + + var roundtrip: ListT.Type = .empty; + defer roundtrip.deinit(allocator); + try ListT.tree.toValue(allocator, view.getRoot(), &pool, &roundtrip); + try std.testing.expectEqual(item_count, roundtrip.items.len); + for (0..item_count) |i| try std.testing.expectEqual(@as(u8, @intCast(i % 256)), roundtrip.items[i]); +} + test "ListBasicTreeView chunked_leaf: iteratorReadonly with start_index mid-chunked_leaf" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 4096 }); diff --git a/src/ssz/tree_view/list_composite.zig b/src/ssz/tree_view/list_composite.zig index 4c6f99b3f..31a924277 100644 --- a/src/ssz/tree_view/list_composite.zig +++ b/src/ssz/tree_view/list_composite.zig @@ -68,7 +68,9 @@ pub fn ListCompositeTreeView(comptime ST: type) type { try self.chunks.clone(opts, &ptr.chunks); ptr.allocator = self.allocator; ptr._orig_len = self._orig_len; - ptr._len = self._len; + // Uncommitted writes are dropped (from the source too on transfer), so length = committed. + ptr._len = self._orig_len; + if (opts.transfer_cache) self._len = self._orig_len; return ptr; } @@ -108,7 +110,10 @@ pub fn ListCompositeTreeView(comptime ST: type) type { try ST.tree.toValue(allocator, self.chunks.state.root, self.chunks.state.pool, out); } - pub fn setLength(self: *Self, new_length: usize) !void { + /// Grows the list; new positions read as zero. Shrinking must go through sliceTo — + /// a bare length cut would leave stale chunk data in the merkleized root. + pub fn growTo(self: *Self, new_length: usize) !void { + std.debug.assert(new_length >= self._len); self._len = new_length; } @@ -156,8 +161,8 @@ pub fn ListCompositeTreeView(comptime ST: type) type { } /// On success takes ownership of `value` and deinits the element cached for `index`, so any - /// earlier get()/getReadonly() of it is now invalid. On error.IndexOutOfBounds the caller - /// keeps `value` (ownership only transfers once it reaches the backing chunks). + /// earlier get()/getReadonly() of it is now invalid. On any error (IndexOutOfBounds or a + /// backing-store OOM) the caller keeps `value` and must free it. pub fn set(self: *Self, index: usize, value: Element) !void { const list_length = try self.length(); if (index >= list_length) return error.IndexOutOfBounds; @@ -176,9 +181,9 @@ pub fn ListCompositeTreeView(comptime ST: type) type { /// Appends an element to the end of the list. /// - /// Ownership of the `value` TreeView is transferred to the list view. - /// The caller must not deinitialize or otherwise use `value` after calling this method, - /// as it is now owned by the list. + /// Ownership of the `value` TreeView transfers to the list view on success. After a + /// successful call the caller must not deinit or use `value`; on any error the caller keeps + /// `value` and must free it. pub fn push(self: *Self, value: Element) !void { const list_length = try self.length(); if (list_length >= ST.limit) { @@ -193,8 +198,6 @@ pub fn ListCompositeTreeView(comptime ST: type) type { /// Push an SSZ value type, creating a TreeView internally. pub fn pushValue(self: *Self, value: *const ST.Element.Type) !void { - // Check the limit first. After this, push always takes the view (set frees it on its - // own OOM), so adding a cleanup errdefer here would double-free. if ((try self.length()) >= ST.limit) return error.LengthOverLimit; const root = try ST.Element.tree.fromValue(self.chunks.state.pool, value); @@ -202,6 +205,7 @@ pub fn ListCompositeTreeView(comptime ST: type) type { self.chunks.state.pool.unref(root); return err; }; + errdefer child_view.deinit(); try self.push(child_view); } @@ -384,6 +388,7 @@ pub fn ListCompositeTreeView(comptime ST: type) type { const FixedContainerType = @import("../type/container.zig").FixedContainerType; const VariableContainerType = @import("../type/container.zig").VariableContainerType; +const StructContainerType = @import("../type/container.zig").StructContainerType; const UintType = @import("../type/uint.zig").UintType; const ByteVectorType = @import("../type/byte_vector.zig").ByteVectorType; const ByteListType = @import("../type/byte_list.zig").ByteListType; @@ -396,6 +401,37 @@ const Checkpoint = FixedContainerType(struct { root: ByteVectorType(32), }); +test "TreeView composite list iteratorReadonly nextValuePtr walks struct elements" { + const allocator = std.testing.allocator; + var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); + defer pool.deinit(); + + const StructCheckpoint = StructContainerType(struct { + epoch: UintType(64), + root: ByteVectorType(32), + }); + const ListType = FixedListType(StructCheckpoint, 16, .{}); + + var list: ListType.Type = .empty; + defer list.deinit(allocator); + for (0..5) |i| try list.append(allocator, .{ .epoch = @intCast(i + 1), .root = [_]u8{@intCast(i)} ** 32 }); + + const root = try ListType.tree.fromValue(&pool, &list); + var view = try ListType.TreeView.init(allocator, &pool, root); + defer view.deinit(); + + var it = view.iteratorReadonly(0); + for (0..5) |i| { + const ptr = try it.nextValuePtr(); + try std.testing.expectEqual(@as(u64, @intCast(i + 1)), ptr.epoch); + try std.testing.expectEqual(@as(u8, @intCast(i)), ptr.root[0]); + } + + var it_offset = view.iteratorReadonly(3); + try std.testing.expectEqual(@as(u64, 4), (try it_offset.nextValuePtr()).epoch); + try std.testing.expectEqual(@as(u64, 5), (try it_offset.nextValuePtr()).epoch); +} + test "TreeView composite list sliceTo truncates elements" { const allocator = std.testing.allocator; var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 512 }); @@ -458,7 +494,7 @@ test "TreeView composite list sliceTo does not leak pool nodes" { const DoubleFreeDetectAllocator = @import("testing_allocators").DoubleFreeDetectAllocator; -// set takes ownership of the view, so setValue must not deinit it too. Sweep every OOM point. +// set takes ownership only on success; setValue/push errdefer the view for the OOM path. test "TreeView composite list setValue - OOM does not double-free the element view" { const ListType = FixedListType(Checkpoint, 16, .{}); @@ -518,6 +554,45 @@ test "TreeView composite list push - OOM does not double-free" { } } +test "TreeView composite list set(index, ownedView) - failed set leaves the element view to the caller" { + const ListType = FixedListType(Checkpoint, 16, .{}); + + var list: ListType.Type = .empty; + defer list.deinit(std.testing.allocator); + for (0..3) |i| try list.append(std.testing.allocator, .{ .epoch = @intCast(i), .root = [_]u8{@intCast(i)} ** 32 }); + const newval: Checkpoint.Type = .{ .epoch = 99, .root = [_]u8{0xee} ** 32 }; + + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var oom = DoubleFreeDetectAllocator.init(std.testing.allocator, fail_at); + defer oom.deinit(); + const alloc = oom.allocator(); + + var pool = Node.Pool.init(.{ .page_allocator = alloc, .allocator = alloc, .pool_size = 0 }) catch continue; + defer pool.deinit(); + + const root = ListType.tree.fromValue(&pool, &list) catch continue; + var view = ListType.TreeView.init(alloc, &pool, root) catch { + pool.unref(root); + continue; + }; + defer view.deinit(); + + const elem_node = Checkpoint.tree.fromValue(&pool, &newval) catch continue; + var elem_view = Checkpoint.TreeView.init(alloc, &pool, elem_node) catch { + pool.unref(elem_node); + continue; + }; + const elem_addr = @intFromPtr(elem_view); + + if (view.set(0, elem_view)) |_| {} else |_| { + try std.testing.expect(oom.live.contains(elem_addr)); + elem_view.deinit(); + } + try std.testing.expect(!oom.double_free); + } +} + test "TreeView composite list clone(transfer_cache) - OOM does not double-free cached children" { const ListType = FixedListType(Checkpoint, 16, .{}); diff --git a/src/ssz/tree_view/utils/tree_view_state.zig b/src/ssz/tree_view/utils/tree_view_state.zig index b589c1310..bf1ef67da 100644 --- a/src/ssz/tree_view/utils/tree_view_state.zig +++ b/src/ssz/tree_view/utils/tree_view_state.zig @@ -131,7 +131,10 @@ pub const TreeViewState = struct { out.children_nodes = self.children_nodes; for (self.changed.keys()) |gindex| { - _ = out.children_nodes.remove(gindex); + if (out.children_nodes.fetchRemove(gindex)) |entry| { + const state = entry.value.getState(self.pool); + if (!state.isFree() and state.refCount() == 0) self.pool.unref(entry.value); + } } self.children_nodes = .empty; diff --git a/src/ssz/type/list.zig b/src/ssz/type/list.zig index 90f942084..8c6ccecc6 100644 --- a/src/ssz/type/list.zig +++ b/src/ssz/type/list.zig @@ -591,8 +591,8 @@ pub fn FixedListType(comptime ST: type, comptime _limit: comptime_int, comptime const content_root = try node.getLeft(pool); const chunked_leaf_count = (chunk_count + ChunkedLeaf.K - 1) / ChunkedLeaf.K; - const chunked_leaf_ids_buf = try pool.page_allocator.alloc(Node.Id, chunked_leaf_count); - defer pool.page_allocator.free(chunked_leaf_ids_buf); + const chunked_leaf_ids_buf = try pool.allocator.alloc(Node.Id, chunked_leaf_count); + defer pool.allocator.free(chunked_leaf_ids_buf); try content_root.getNodesAtDepth(pool, chunked_leaf_depth, 0, chunked_leaf_ids_buf); const state_col = pool.nodes.items(.state); diff --git a/src/state_transition/load_state.zig b/src/state_transition/load_state.zig index 011468f78..ff6f8b103 100644 --- a/src/state_transition/load_state.zig +++ b/src/state_transition/load_state.zig @@ -644,27 +644,23 @@ test "loadState scenarios" { break :blk try state_ptr.serialize(allocator); }, .trim_struct => |m| { - var validators = try state_ptr.validators(); - try validators.setLength(m.new_len); - - var balances = try state_ptr.balances(); - try balances.setLength(m.new_len); - - var scores = try state_ptr.inactivityScores(); - try scores.setLength(m.new_len); - - var previous_epoch_participation = try state_ptr.previousEpochParticipation(); - try previous_epoch_participation.setLength(m.new_len); - - var current_epoch_participation = try state_ptr.currentEpochParticipation(); - try current_epoch_participation.setLength(m.new_len); - + var trimmed = types.electra.BeaconState.default_value; + try types.electra.BeaconState.deserializeFromBytes(allocator, seed_bytes, &trimmed); + defer types.electra.BeaconState.deinit(allocator, &trimmed); + + trimmed.validators.shrinkRetainingCapacity(m.new_len); + trimmed.balances.shrinkRetainingCapacity(m.new_len); + trimmed.inactivity_scores.shrinkRetainingCapacity(m.new_len); + trimmed.previous_epoch_participation.shrinkRetainingCapacity(m.new_len); + trimmed.current_epoch_participation.shrinkRetainingCapacity(m.new_len); if (m.new_len == 0) { - var eth1_data = try state_ptr.eth1Data(); - try eth1_data.set("deposit_count", 0); - try state_ptr.setEth1DepositIndex(0); + trimmed.eth1_data.deposit_count = 0; + trimmed.eth1_deposit_index = 0; } - break :blk try state_ptr.serialize(allocator); + + const out_bytes = try allocator.alloc(u8, types.electra.BeaconState.serializedSize(&trimmed)); + _ = types.electra.BeaconState.serializeIntoBytes(&trimmed, out_bytes); + break :blk out_bytes; }, } }; diff --git a/src/state_transition/slot/upgrade_state_to_altair.zig b/src/state_transition/slot/upgrade_state_to_altair.zig index 2a0415049..89bea9d9d 100644 --- a/src/state_transition/slot/upgrade_state_to_altair.zig +++ b/src/state_transition/slot/upgrade_state_to_altair.zig @@ -31,13 +31,13 @@ pub fn upgradeStateToAltair( const validators_count = try altair_state.validatorsCount(); var previous_epoch_participations = try altair_state.previousEpochParticipation(); - try previous_epoch_participations.setLength(validators_count); + try previous_epoch_participations.growTo(validators_count); var current_epoch_participations = try altair_state.currentEpochParticipation(); - try current_epoch_participations.setLength(validators_count); + try current_epoch_participations.growTo(validators_count); var inactivity_scores = try altair_state.inactivityScores(); - try inactivity_scores.setLength(validators_count); + try inactivity_scores.growTo(validators_count); const active_indices = epoch_cache.next_shuffling.get().active_indices; diff --git a/src/state_transition/sync_committees_witness.zig b/src/state_transition/sync_committees_witness.zig index 0b7ff23e6..8d0400cf7 100644 --- a/src/state_transition/sync_committees_witness.zig +++ b/src/state_transition/sync_committees_witness.zig @@ -143,37 +143,28 @@ const ProofFixture = struct { current_sync_committee: ct.altair.SyncCommittee.Type, next_sync_committee: ct.altair.SyncCommittee.Type, - fn init(fork: ForkSeq) !ProofFixture { + fn init(self: *ProofFixture, fork: ForkSeq) !void { const allocator = std.testing.allocator; - var pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); - errdefer pool.deinit(); + self.pool = try Node.Pool.init(.{ .page_allocator = allocator, .allocator = allocator, .pool_size = 500_000 }); + errdefer self.pool.deinit(); - var state = switch (fork) { - .altair => try AnyBeaconState.fromValue(allocator, &pool, .altair, &ct.altair.BeaconState.default_value), - .electra => try AnyBeaconState.fromValue(allocator, &pool, .electra, &ct.electra.BeaconState.default_value), + self.state = switch (fork) { + .altair => try AnyBeaconState.fromValue(allocator, &self.pool, .altair, &ct.altair.BeaconState.default_value), + .electra => try AnyBeaconState.fromValue(allocator, &self.pool, .electra, &ct.electra.BeaconState.default_value), else => return error.UnsupportedFork, }; - errdefer state.deinit(); + errdefer self.state.deinit(); - const current_sync_committee = fillSyncCommittee(0xbb); - const next_sync_committee = fillSyncCommittee(0xcc); - try state.setCurrentSyncCommittee(¤t_sync_committee); - try state.setNextSyncCommittee(&next_sync_committee); + self.current_sync_committee = fillSyncCommittee(0xbb); + self.next_sync_committee = fillSyncCommittee(0xcc); + try self.state.setCurrentSyncCommittee(&self.current_sync_committee); + try self.state.setNextSyncCommittee(&self.next_sync_committee); - try state.commit(); - const state_root = (try state.hashTreeRoot()).*; - const root_node = switch (state) { + try self.state.commit(); + self.state_root = (try self.state.hashTreeRoot()).*; + self.root_node = switch (self.state) { inline else => |view| view.root, }; - - return .{ - .pool = pool, - .state = state, - .state_root = state_root, - .root_node = root_node, - .current_sync_committee = current_sync_committee, - .next_sync_committee = next_sync_committee, - }; } fn deinit(self: *ProofFixture) void { @@ -203,7 +194,8 @@ test "getSyncCommitteesWitness: SyncCommittees proof" { }; for (test_cases) |tc| { - var fixture = try ProofFixture.init(tc.fork_seq); + var fixture: ProofFixture = undefined; + try fixture.init(tc.fork_seq); defer fixture.deinit(); var witness_data: SyncCommitteeWitness = undefined; @@ -241,7 +233,8 @@ test "getSyncCommitteesWitness: currentSyncCommittee proof" { }; inline for (test_cases) |tc| { - var fixture = try ProofFixture.init(tc.fork_seq); + var fixture: ProofFixture = undefined; + try fixture.init(tc.fork_seq); defer fixture.deinit(); var witness_data: SyncCommitteeWitness = undefined; @@ -284,7 +277,8 @@ test "getSyncCommitteesWitness: nextSyncCommittee proof" { }; inline for (test_cases) |tc| { - var fixture = try ProofFixture.init(tc.fork_seq); + var fixture: ProofFixture = undefined; + try fixture.init(tc.fork_seq); defer fixture.deinit(); var witness_data: SyncCommitteeWitness = undefined; diff --git a/src/state_transition/utils/target_unslashed_balance.zig b/src/state_transition/utils/target_unslashed_balance.zig index 154189ecd..94423a118 100644 --- a/src/state_transition/utils/target_unslashed_balance.zig +++ b/src/state_transition/utils/target_unslashed_balance.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const types = @import("consensus_types"); const Validator = types.phase0.Validator.Type; const Epoch = types.primitive.Epoch.Type; @@ -8,6 +9,7 @@ const isActiveValidator = @import("./validator.zig").isActiveValidator; const TIMELY_TARGET = 1 << c.TIMELY_TARGET_FLAG_INDEX; pub fn sumTargetUnslashedBalanceIncrements(participations: []const u8, epoch: Epoch, validators: []const *const Validator) u64 { + std.debug.assert(participations.len == validators.len); var total: u64 = 0; for (participations, 0..) |participation, i| { if ((participation & TIMELY_TARGET) == TIMELY_TARGET) { From d8b795b44f9bfc0b500008ee94546070b839d90a Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 17 Jun 2026 13:46:02 +0800 Subject: [PATCH 43/46] feat(bindings): align `BeaconStateView` with `IBeaconStateView` (#347) This PR aligns the bindings to `BeaconStateView` and its native implementation with the requirements of the typescript interface found at [`IBeaconStateView`](https://github.com/ChainSafe/lodestar/blob/374360e50a5de058b777a94d041089f9999d0726/packages/state-transition/src/stateView/interface.ts#L56). This should be ready for a look. This PR mostly aligns `BeaconStateView` to a 'good enough' state to be consumed by `lodestar` for state transition. This PR mainly adds missing functions and fixes the function signatures of already implemented methods (which did not align with `IBeaconStateView` Note that this does not include the following implementations, which are throw stubs for now: - gloas related functions (we do pre-gloas STF for now) - rewards API Other related work that I broke into smaller PRs for reviewability: --- bindings/napi/BeaconStateView.zig | 667 +++++++++++++++++++++++--- bindings/napi/config.zig | 185 +++---- bindings/napi/js_types.zig | 6 +- bindings/napi/pubkeys.zig | 12 + bindings/napi/root.zig | 1 - bindings/napi/stateTransition.zig | 127 ----- bindings/napi/to_napi_value.zig | 20 +- bindings/napi/transition_opts.zig | 73 +++ bindings/src/index.d.ts | 193 ++++++-- bindings/src/pubkeys.d.ts | 2 + bindings/src/pubkeys.js | 5 + bindings/src/state-transition.d.ts | 16 + bindings/src/state-transition.js | 6 + bindings/test/beaconStateView.test.ts | 117 ++++- bindings/test/config.test.ts | 16 + bindings/test/demo.ts | 8 +- bindings/test/pubkeys.test.ts | 12 + build.zig.zon | 1 + package.json | 4 + src/state_transition/root.zig | 1 + 20 files changed, 1133 insertions(+), 339 deletions(-) delete mode 100644 bindings/napi/stateTransition.zig create mode 100644 bindings/napi/transition_opts.zig create mode 100644 bindings/src/state-transition.d.ts create mode 100644 bindings/src/state-transition.js create mode 100644 bindings/test/config.test.ts diff --git a/bindings/napi/BeaconStateView.zig b/bindings/napi/BeaconStateView.zig index 97d38ed33..76001cec4 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,16 @@ 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 }), + .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 }), .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, @@ -136,6 +142,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(); @@ -215,6 +226,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: 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"); + }; + 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: 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"); + }; + return js.Number.from(flag); +} + pub fn latestExecutionPayloadHeader(self: *const BeaconStateView) !js.Value { const env = js.env(); const cached_state = try self.requireState(); @@ -230,11 +261,42 @@ 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); + + return js.Number.from(header.blockNumber()); +} + // ------------------------- // 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 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_), + }; + 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 slot_value: u64 = @intCast(try slot_arg.toI64()); @@ -254,6 +316,27 @@ pub fn getBlockRoot(self: *const BeaconStateView, slot_arg: js.Number) !js.Uint8 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 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_), + }; + 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)); +} + pub fn getRandaoMix(self: *const BeaconStateView, epoch_arg: js.Number) !js.Uint8Array { const env = js.env(); const cached_state = try self.requireState(); @@ -269,6 +352,20 @@ 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 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)); +} + /// Get the historical summaries from the state (Capella+). /// Returns: array of {blockSummaryRoot: Uint8Array, stateSummaryRoot: Uint8Array} pub fn historicalSummaries(self: *const BeaconStateView) !js.Array { @@ -375,10 +472,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; @@ -408,7 +501,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); @@ -449,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(); @@ -535,6 +636,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 = @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"); + }; + + 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(); @@ -550,12 +669,12 @@ 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(); const balance = try balances.get(index_value); - return js.BigInt.from(balance); + return js.Number.from(balance); } /// Get a validator by index. @@ -588,6 +707,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 = @intCast(try current_epoch_arg.toI64()); + + 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(); @@ -608,40 +784,43 @@ 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 Lodestar-shaped block object. +/// +/// 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); - 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); + 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); - const bytes = try signed_block_bytes.toSlice(); - const signed_block = try AnySignedBeaconBlock.deserialize( - allocator, - .full, - fork_seq, - bytes, - ); - defer signed_block.deinit(allocator); + if (fork_seq != .bellatrix) return js.Boolean.from(false); - if (signed_block.forkSeq() != cached_state.state.forkSeq()) { - return throwNullAs(js.Boolean, "FORK_MISMATCH", "Fork of signed block does not match state fork"); - } + // 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; - 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)) or (bt == .blinded and f.gte(.gloas))) { - return error.InvalidBlockTypeForFork; - } else st.isExecutionEnabled( - f, - cached_state.state.castToFork(f), - bt, - signed_block.beaconBlock().castToFork(bt, f), - ), - }, - }; - return js.Boolean.from(result); + const body = try (try block_raw.getNamedProperty("body")).coerceToObject(); + + // 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. @@ -653,8 +832,6 @@ pub fn isMergeTransitionComplete(self: *const BeaconStateView) !js.Boolean { return js.Boolean.from(result); } -// pub fn BeaconStateView_getExpectedWithdrawals - /// Get the proposer rewards for the state. pub fn proposerRewards(self: *const BeaconStateView) !js_types.ProposerRewards { const env = js.env(); @@ -662,30 +839,37 @@ 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 - -// pub fn BeaconStateView_computeAttestationRewards +/// 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()); -// pub fn BeaconStateView_computeSyncCommitteeRewards - -// pub fn BeaconStateView_getLatestWeakSubjectivityCheckpointEpoch + 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); +} -/// 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 { +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()) { @@ -705,15 +889,13 @@ pub fn getVoluntaryExitValidity(self: *const BeaconStateView, signed_exit_bytes: return .{ .val = try env.createStringUtf8(@tagName(validity)) }; } -/// 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 { +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()) { @@ -802,8 +984,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 { @@ -886,10 +1066,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 - /// Bench-only: run loadState end-to-end and tear down. Mirrors what TS's /// loadState measures (no CachedBeaconState wrap, no EpochCache build) so /// native vs TS comparisons isolate the SSZ tree-rebuild cost. @@ -914,6 +1090,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(); @@ -930,11 +1169,23 @@ 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(); - const off = try offset.toU32(); +/// +/// 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: usize = @intCast(try offset.toI64()); if (off > output_slice.len) return error.InvalidOffset; const cached_state = try self.requireState(); @@ -962,9 +1213,13 @@ 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(); - const off = try offset.toU32(); +/// 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: usize = @intCast(try offset.toI64()); if (off > output_slice.len) return error.InvalidOffset; const cached_state = try self.requireState(); @@ -980,8 +1235,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: @@ -1004,6 +1257,202 @@ pub fn processSlots(self: *const BeaconStateView, slot_arg: js.Number, options: }; } +/// 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): 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 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, + .pool_rc = pool.state.poolRc().ref(), + }; +} + +/// 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); +} + +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 = @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"); + }; + 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"); +} + +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 { + 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(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 { + // Native state is always fully populated — return true. + return js.Boolean.from(true); +} + +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)); + }, + } +} + /// Compute expected withdrawals for the next payload (capella+). /// Returns: { expectedWithdrawals: Withdrawal[], processedPartialWithdrawalsCount, processedValidatorSweepCount, /// processedBuilderWithdrawalsCount, processedBuildersSweepCount } @@ -1091,3 +1540,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/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/napi/js_types.zig b/bindings/napi/js_types.zig index 25abeb005..9bd474b5d 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/pubkeys.zig b/bindings/napi/pubkeys.zig index c458ea1f7..e65813520 100644 --- a/bindings/napi/pubkeys.zig +++ b/bindings/napi/pubkeys.zig @@ -32,6 +32,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 = .{}; @@ -152,6 +159,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/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"); diff --git a/bindings/napi/stateTransition.zig b/bindings/napi/stateTransition.zig deleted file mode 100644 index 0e761c887..000000000 --- a/bindings/napi/stateTransition.zig +++ /dev/null @@ -1,127 +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; - -/// 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" -/// - 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 = .{}; - 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 - 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; -} - -/// 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 }; -} 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/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; +} diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index a13426e09..bc45006e9 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; @@ -11,6 +12,16 @@ interface Checkpoint { root: Uint8Array; } +export interface VoluntaryExit { + epoch: number; + validatorIndex: number; +} + +export interface SignedVoluntaryExit { + message: VoluntaryExit; + signature: Uint8Array; +} + interface Eth1Data { depositRoot: Uint8Array; depositCount: number; @@ -37,24 +48,64 @@ 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; epoch: number; } +export enum ForkName { + phase0 = "phase0", + altair = "altair", + bellatrix = "bellatrix", + capella = "capella", + deneb = "deneb", + electra = "electra", + fulu = "fulu", + gloas = "gloas", +} + interface SyncCommittee { pubkeys: Uint8Array; aggregatePubkey: Uint8Array; } -interface ProcessSlotsOpts { +export interface ProcessSlotsOpts { /** Default: false (cache is transferred). Set to true to opt out of cache transfer. */ dontTransferCache?: boolean; } interface CompactMultiProof { - type: "compactMulti"; + // 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; } @@ -65,7 +116,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. */ @@ -74,20 +125,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; @@ -120,7 +178,7 @@ type ValidatorStatus = | "withdrawal_possible" | "withdrawal_done"; -type VoluntaryExitValidity = +export type VoluntaryExitValidity = | "valid" | "inactive" | "already_exited" @@ -129,11 +187,12 @@ type VoluntaryExitValidity = | "pending_withdrawals" | "invalid_signature"; -declare class BeaconStateView { +export declare class BeaconStateView { static createFromBytes(bytes: Uint8Array): BeaconStateView; slot: number; fork: Fork; + forkName: ForkName; epoch: number; genesisTime: number; genesisValidatorsRoot: Uint8Array; @@ -142,11 +201,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,26 +222,57 @@ 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[]; + // TODO(bing): Implement when we support gloas + // biome-ignore lint/suspicious/noExplicitAny: gloas stub + withParentPayloadApplied(executionRequests: any): BeaconStateView; + + 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[]; getBeaconProposer(slot: number): number; + getBeaconProposerOrNull(slot: number): number | null; currentSyncCommittee: SyncCommittee; nextSyncCommittee: SyncCommittee; 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,19 +280,32 @@ declare class BeaconStateView { isExecutionStateType: boolean; isMergeTransitionComplete: boolean; - // TODO remove - isExecutionEnabled(fork: string, signedBlockBytes: Uint8Array): boolean; - - // getExpectedWithdrawals(): ExpectedWithdrawals; + /** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */ + isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean; + /** + * Check whether execution is enabled for the given block at this state. + * + * 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(block: BeaconBlockLike): boolean; 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; + getVoluntaryExitValidity(signedVoluntaryExit: SignedVoluntaryExit, verifySignature: boolean): VoluntaryExitValidity; + isValidVoluntaryExit(signedVoluntaryExit: SignedVoluntaryExit, verifySignature: boolean): boolean; getFinalizedRootProof(): Uint8Array[]; getSyncCommitteesWitness(): { @@ -204,6 +313,7 @@ declare class BeaconStateView { currentSyncCommitteeRoot: Uint8Array; nextSyncCommitteeRoot: Uint8Array; }; + getSingleProof(gindex: bigint): Uint8Array[]; /** * Compute expected withdrawals for the next payload (capella+). * @@ -222,30 +332,41 @@ declare class BeaconStateView { processedBuildersSweepCount: number; processedValidatorSweepCount: number; }; - // 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; + loadOtherState( + stateBytes: Uint8Array, + seedValidatorsBytes?: Uint8Array, + opts?: {preloadValidatorsAndBalances?: boolean} + ): BeaconStateView; loadOtherStateBench(stateBytes: Uint8Array, seedValidatorsBytes?: Uint8Array): void; + // 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; 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; + serializeValidatorsToBytes(output: {uint8Array: Uint8Array; dataView: DataView}, offset: number): number; hashTreeRoot(): Uint8Array; createMultiProof(descriptor: Uint8Array): CompactMultiProof; - // stateTransition(signedBlockBytes: Uint8Array): BeaconStateView; processSlots(slot: number, options?: ProcessSlotsOpts): BeaconStateView; } diff --git a/bindings/src/pubkeys.d.ts b/bindings/src/pubkeys.d.ts index 988e0eec1..ad94b9eec 100644 --- a/bindings/src/pubkeys.d.ts +++ b/bindings/src/pubkeys.d.ts @@ -15,6 +15,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 1887282f5..0590d6544 100644 --- a/bindings/src/pubkeys.js +++ b/bindings/src/pubkeys.js @@ -49,6 +49,11 @@ export const pubkeyCache = { native.load(filepath); }, + reset() { + pkCache.clear(); + native.reset(); + }, + save(filepath) { native.save(filepath); }, 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/bindings/test/beaconStateView.test.ts b/bindings/test/beaconStateView.test.ts index aeb1f4a61..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"; @@ -6,6 +7,43 @@ import {beforeAll, describe, expect, it} from "vitest"; import bindings from "../src/index.js"; import {getFirstEraFilePath} from "./eraFiles.ts"; +// 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 SYNC_COMMITTEE_SIZE = 512; +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 { + 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), + }; + }); +} + describe("BeaconStateView", () => { let state: InstanceType; let stateBytes: Uint8Array; @@ -280,13 +318,62 @@ 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 = { + aggregatePubkey: validators[0].pubkey, + pubkeys: syncCommitteePubkeys, + }; + + 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(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", () => { @@ -363,7 +450,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 +529,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 +550,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); @@ -514,15 +603,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 = [ @@ -556,9 +649,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"); }); }); 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(); + }); + } +}); 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])) ); diff --git a/bindings/test/pubkeys.test.ts b/bindings/test/pubkeys.test.ts index 940e81d49..b24ad8989 100644 --- a/bindings/test/pubkeys.test.ts +++ b/bindings/test/pubkeys.test.ts @@ -90,4 +90,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(); + }); }); diff --git a/build.zig.zon b/build.zig.zon index 605ed3348..547fbdd3e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -347,6 +347,7 @@ .config, .fork_types, .state_transition, + .hex, "zapi:zapi", }, }, diff --git a/package.json b/package.json index 03758864a..20ec384f0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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": { diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index 6681f94e4..06e61b890 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -90,6 +90,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 2276bc59f643db7edd94b23eede9984187a070ab Mon Sep 17 00:00:00 2001 From: bing Date: Wed, 17 Jun 2026 13:47:45 +0800 Subject: [PATCH 44/46] fix: various fixes around config (#433) `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 From eb609db27c5afd7ad7bfbdef4d3faccab383de89 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 17 Jun 2026 15:20:52 +0200 Subject: [PATCH 45/46] fix: re-apply merge code removal in a new file --- bindings/napi/transition_opts.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bindings/napi/transition_opts.zig b/bindings/napi/transition_opts.zig index 873fce4b6..3beec4e16 100644 --- a/bindings/napi/transition_opts.zig +++ b/bindings/napi/transition_opts.zig @@ -14,7 +14,7 @@ const st = @import("state_transition"); /// Recognized fields: /// - `verifyStateRoot`, `verifyProposer`, `verifySignatures`: bool /// - `dontTransferCache`: bool (negated to set `transfer_cache`) -/// - `executionPayloadStatus`: "valid" | "invalid" | "preMerge" +/// - `executionPayloadStatus`: "valid" | "invalid" /// - `dataAvailabilityStatus`: "Available" | "PreData" | "OutOfRange" /// /// Throws `error.InvalidExecutionPayloadStatus` / `error.InvalidDataAvailabilityStatus` @@ -47,8 +47,6 @@ pub fn parseOptions(options: ?js.Value) !st.TransitionOpts { .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; } From 04a869a9c3071e01609516f96aebfd27c0324c45 Mon Sep 17 00:00:00 2001 From: markolazic01 Date: Wed, 17 Jun 2026 15:26:02 +0200 Subject: [PATCH 46/46] fix: remove isMergeTransitionBlock --- bindings/src/index.d.ts | 2 -- src/state_transition/root.zig | 1 - 2 files changed, 3 deletions(-) diff --git a/bindings/src/index.d.ts b/bindings/src/index.d.ts index bc45006e9..bb45a9ac8 100644 --- a/bindings/src/index.d.ts +++ b/bindings/src/index.d.ts @@ -280,8 +280,6 @@ export declare class BeaconStateView { isExecutionStateType: 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; /** * Check whether execution is enabled for the given block at this state. * diff --git a/src/state_transition/root.zig b/src/state_transition/root.zig index 06e61b890..6681f94e4 100644 --- a/src/state_transition/root.zig +++ b/src/state_transition/root.zig @@ -90,7 +90,6 @@ 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;