@lodestar/era 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,cAAc,EAAC,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAC,sBAAsB,EAAE,YAAY,EAAC,MAAM,yBAAyB,CAAC;AAE7E,4DAA4D;AAC5D,MAAM,UAAU,SAAS,CAAC,KAAiB,EAAE,MAAc;IACzD,OAAO,MAAM,CAAC,SAAS,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,UAAU,CAAC,KAAiB,EAAE,MAAc;IAC1D,OAAO,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;AAC5D,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,UAAU,CAAC,KAAiB,EAAE,MAAc;IAC1D,OAAO,MAAM,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAC3D,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,UAAU,CAAC,KAAiB,EAAE,MAAc;IAC1D,OAAO,MAAM,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAC3D,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,UAAU,CAAC,MAAkB,EAAE,MAAc,EAAE,CAAS;IACtE,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CAAC,MAAkB,EAAE,MAAc,EAAE,CAAS;IACvE,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC;AACzD,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CAAC,MAAkB,EAAE,MAAc,EAAE,CAAS;IACvE,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC;AACzD,CAAC;AAED,qCAAqC;AACrC,MAAM,UAAU,gBAAgB,CAAC,cAA0B;IACzD,MAAM,YAAY,GAAG,IAAI,sBAAsB,EAAE,CAAC;IAElD,MAAM,KAAK,GAAG,IAAI,cAAc,CAAC,cAAc,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,YAAY,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;IAE9C,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;QACpB,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAC;IACpE,CAAC;IAED,OAAO,MAAM,CAAC,QAAQ,EAAE,CAAC;AAC3B,CAAC;AAED,yCAAyC;AACzC,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAgB;IACnD,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC;QACnG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAChC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@lodestar/era",
3
+ "description": "Era file handling module for Lodestar",
4
+ "license": "Apache-2.0",
5
+ "author": "ChainSafe Systems",
6
+ "homepage": "https://github.com/ChainSafe/lodestar#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ChainSafe/lodestar.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/ChainSafe/lodestar/issues"
13
+ },
14
+ "version": "1.36.0",
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "bun": "./src/index.ts",
19
+ "types": "./lib/index.d.ts",
20
+ "import": "./lib/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "src",
25
+ "lib",
26
+ "!**/*.tsbuildinfo"
27
+ ],
28
+ "scripts": {
29
+ "clean": "rm -rf lib && rm -f *.tsbuildinfo",
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "build:watch": "yarn run build --watch",
32
+ "build:release": "yarn clean && yarn run build",
33
+ "check-build": "node -e \"(async function() { await import('./lib/index.js') })()\"",
34
+ "check-types": "tsc",
35
+ "lint": "biome check src/ test/",
36
+ "lint:fix": "yarn run lint --write",
37
+ "test": "yarn test:unit",
38
+ "test:unit": "vitest run --project unit --project unit-minimal",
39
+ "check-readme": "typescript-docs-verifier"
40
+ },
41
+ "dependencies": {
42
+ "@chainsafe/blst": "^2.2.0",
43
+ "@lodestar/config": "^1.36.0",
44
+ "@lodestar/params": "^1.36.0",
45
+ "@lodestar/reqresp": "^1.36.0",
46
+ "@lodestar/types": "^1.36.0",
47
+ "uint8arraylist": "^2.4.7"
48
+ }
49
+ }
package/src/e2s.ts ADDED
@@ -0,0 +1,178 @@
1
+ import type {FileHandle} from "node:fs/promises";
2
+ import {Slot} from "@lodestar/types";
3
+ import {readInt48, readUint16, readUint32, writeInt48, writeUint16, writeUint32} from "./util.ts";
4
+
5
+ /**
6
+ * Known entry types in an E2Store (.e2s) file along with their exact 2-byte codes.
7
+ */
8
+ export enum EntryType {
9
+ Empty = 0,
10
+ CompressedSignedBeaconBlock = 1,
11
+ CompressedBeaconState = 2,
12
+ Version = 0x65 | (0x32 << 8), // "e2" in ASCII
13
+ SlotIndex = 0x69 | (0x32 << 8),
14
+ }
15
+ /**
16
+ * Logical, parsed entry from an E2Store file.
17
+ */
18
+ export interface Entry {
19
+ type: EntryType;
20
+ data: Uint8Array;
21
+ }
22
+
23
+ /**
24
+ * Maps slots to file positions in an era file.
25
+ * - Block index: count = SLOTS_PER_HISTORICAL_ROOT, maps slots to blocks
26
+ * - State index: count = 1, points to the era state
27
+ * - Zero offset = empty slot (no block)
28
+ */
29
+ export interface SlotIndex {
30
+ type: EntryType.SlotIndex;
31
+ /** First slot covered by this index (era * SLOTS_PER_HISTORICAL_ROOT) */
32
+ startSlot: Slot;
33
+ /** File positions where data can be found. Length varies by index type. */
34
+ offsets: number[];
35
+ /** File position where this index record starts */
36
+ recordStart: number;
37
+ }
38
+
39
+ /**
40
+ * The complete version record (8 bytes total).
41
+ */
42
+ export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
43
+
44
+ /**
45
+ * E2Store header size in bytes
46
+ */
47
+ export const E2STORE_HEADER_SIZE = 8;
48
+
49
+ /**
50
+ * Helper to read entry at a specific offset from an open file handle.
51
+ * Reads header first to determine data length, then reads the complete entry.
52
+ */
53
+ export async function readEntry(fh: FileHandle, offset: number): Promise<Entry> {
54
+ // Read header (8 bytes)
55
+ const header = new Uint8Array(E2STORE_HEADER_SIZE);
56
+ await fh.read(header, 0, E2STORE_HEADER_SIZE, offset);
57
+ const {type, length} = parseEntryHeader(header);
58
+
59
+ // Read entry payload/data
60
+ const data = new Uint8Array(length);
61
+ await fh.read(data, 0, data.length, offset + E2STORE_HEADER_SIZE);
62
+
63
+ return {type, data};
64
+ }
65
+
66
+ /**
67
+ * Read an e2Store entry (header + data)
68
+ * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0)
69
+ */
70
+ export function parseEntryHeader(header: Uint8Array): {type: EntryType; length: number} {
71
+ if (header.length < E2STORE_HEADER_SIZE) {
72
+ throw new Error(`Buffer too small for E2Store header: need ${E2STORE_HEADER_SIZE} bytes, got ${header.length}`);
73
+ }
74
+
75
+ // validate entry type from first 2 bytes
76
+ const typeCode = readUint16(header, 0);
77
+ if (!(typeCode in EntryType)) {
78
+ throw new Error(`Unknown E2Store entry type: 0x${typeCode.toString(16)}`);
79
+ }
80
+ const type = typeCode as EntryType;
81
+
82
+ // Parse data length from next 4 bytes (offset 2, little endian)
83
+ const length = readUint32(header, 2);
84
+
85
+ // Validate reserved bytes are zero (offset 6-7)
86
+ const reserved = readUint16(header, 6);
87
+ if (reserved !== 0) {
88
+ throw new Error(`E2Store reserved bytes must be zero, got: ${reserved}`);
89
+ }
90
+
91
+ return {type, length};
92
+ }
93
+
94
+ export async function readVersion(fh: FileHandle, offset: number): Promise<void> {
95
+ const versionHeader = new Uint8Array(E2STORE_HEADER_SIZE);
96
+ await fh.read(versionHeader, 0, E2STORE_HEADER_SIZE, offset);
97
+ if (Buffer.compare(versionHeader, VERSION_RECORD_BYTES) !== 0) {
98
+ throw new Error("Invalid E2Store version record");
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Read a SlotIndex from a file handle.
104
+ */
105
+ export async function readSlotIndex(fh: FileHandle, offset: number): Promise<SlotIndex> {
106
+ const recordEnd = offset;
107
+ const countBuffer = new Uint8Array(8);
108
+ await fh.read(countBuffer, 0, 8, recordEnd - 8);
109
+ const count = readInt48(countBuffer, 0);
110
+
111
+ const recordStart = recordEnd - (8 * count + 24);
112
+
113
+ // Validate index position is within file bounds
114
+ if (recordStart < 0) {
115
+ throw new Error(`SlotIndex position ${recordStart} is invalid - file too small for count=${count}`);
116
+ }
117
+
118
+ // Read and validate the slot index entry
119
+ const entry = await readEntry(fh, recordStart);
120
+ if (entry.type !== EntryType.SlotIndex) {
121
+ throw new Error(`Expected SlotIndex entry, got ${entry.type}`);
122
+ }
123
+
124
+ // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16
125
+ const expectedSize = 8 * count + 16;
126
+ if (entry.data.length !== expectedSize) {
127
+ throw new Error(`SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}`);
128
+ }
129
+
130
+ // Parse start slot from payload
131
+ const startSlot = readInt48(entry.data, 0);
132
+
133
+ const offsets: number[] = [];
134
+ for (let i = 0; i < count; i++) {
135
+ offsets.push(readInt48(entry.data, 8 * i + 8));
136
+ }
137
+
138
+ return {
139
+ type: EntryType.SlotIndex,
140
+ startSlot,
141
+ offsets,
142
+ recordStart,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Write a single E2Store TLV entry (header + payload)
148
+ * Header layout: type[2] | length u32 LE | reserved u16(=0)
149
+ */
150
+ export async function writeEntry(fh: FileHandle, offset: number, type: EntryType, payload: Uint8Array): Promise<void> {
151
+ const header = new Uint8Array(E2STORE_HEADER_SIZE);
152
+ writeUint16(header, 0, type); // type (2 bytes)
153
+ writeUint32(header, 2, payload.length); // length (4 bytes)
154
+ // reserved bytes (6-7) remain 0
155
+ await fh.writev([header, payload], offset);
156
+ }
157
+
158
+ export async function writeVersion(fh: FileHandle, offset: number): Promise<void> {
159
+ await fh.write(VERSION_RECORD_BYTES, 0, VERSION_RECORD_BYTES.length, offset);
160
+ }
161
+
162
+ export function serializeSlotIndex(slotIndex: SlotIndex): Uint8Array {
163
+ const count = slotIndex.offsets.length;
164
+ const payload = new Uint8Array(count * 8 + 16);
165
+
166
+ // startSlot
167
+ writeInt48(payload, 0, slotIndex.startSlot);
168
+
169
+ // offsets
170
+ let off = 8;
171
+ for (let i = 0; i < count; i++, off += 8) {
172
+ writeInt48(payload, off, slotIndex.offsets[i]);
173
+ }
174
+
175
+ // trailing count
176
+ writeInt48(payload, 8 + count * 8, count);
177
+ return payload;
178
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./reader.js";
2
+ export * from "./util.js";
3
+ export * from "./writer.js";
@@ -0,0 +1,196 @@
1
+ import {type FileHandle, open} from "node:fs/promises";
2
+ import {basename} from "node:path";
3
+ import {PublicKey, Signature, verify} from "@chainsafe/blst";
4
+ import {ChainForkConfig, createCachedGenesis} from "@lodestar/config";
5
+ import {DOMAIN_BEACON_PROPOSER, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params";
6
+ import {BeaconState, SignedBeaconBlock, Slot, ssz} from "@lodestar/types";
7
+ import {E2STORE_HEADER_SIZE, EntryType, readEntry, readVersion} from "../e2s.ts";
8
+ import {snappyUncompress} from "../util.ts";
9
+ import {
10
+ EraIndices,
11
+ computeEraNumberFromBlockSlot,
12
+ parseEraName,
13
+ readAllEraIndices,
14
+ readSlotFromBeaconStateBytes,
15
+ } from "./util.ts";
16
+
17
+ /**
18
+ * EraReader is responsible for reading and validating ERA files.
19
+ *
20
+ * See https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md
21
+ */
22
+ export class EraReader {
23
+ readonly config: ChainForkConfig;
24
+ /** The underlying file handle */
25
+ readonly fh: FileHandle;
26
+ /** The era number retrieved from the file name */
27
+ readonly eraNumber: number;
28
+ /** The short historical root retrieved from the file name */
29
+ readonly shortHistoricalRoot: string;
30
+ /** An array of state and block indices, one per group */
31
+ readonly groups: EraIndices[];
32
+
33
+ constructor(
34
+ config: ChainForkConfig,
35
+ fh: FileHandle,
36
+ eraNumber: number,
37
+ shortHistoricalRoot: string,
38
+ indices: EraIndices[]
39
+ ) {
40
+ this.config = config;
41
+ this.fh = fh;
42
+ this.eraNumber = eraNumber;
43
+ this.shortHistoricalRoot = shortHistoricalRoot;
44
+ this.groups = indices;
45
+ }
46
+
47
+ static async open(config: ChainForkConfig, path: string): Promise<EraReader> {
48
+ const fh = await open(path, "r");
49
+ const name = basename(path);
50
+ const {configName, eraNumber, shortHistoricalRoot} = parseEraName(name);
51
+ if (config.CONFIG_NAME !== configName) {
52
+ throw new Error(`Config name mismatch: expected ${config.CONFIG_NAME}, got ${configName}`);
53
+ }
54
+ const indices = await readAllEraIndices(fh);
55
+ return new EraReader(config, fh, eraNumber, shortHistoricalRoot, indices);
56
+ }
57
+
58
+ /**
59
+ * Close the underlying file descriptor
60
+ *
61
+ * No further actions can be taken after this operation
62
+ */
63
+ async close(): Promise<void> {
64
+ await this.fh.close();
65
+ }
66
+
67
+ async readCompressedState(eraNumber?: number): Promise<Uint8Array> {
68
+ eraNumber = eraNumber ?? this.eraNumber;
69
+ const index = this.groups.at(eraNumber - this.eraNumber);
70
+ if (!index) {
71
+ throw new Error(`No index found for era number ${eraNumber}`);
72
+ }
73
+ const entry = await readEntry(this.fh, index.stateIndex.recordStart + index.stateIndex.offsets[0]);
74
+
75
+ if (entry.type !== EntryType.CompressedBeaconState) {
76
+ throw new Error(`Expected CompressedBeaconState, got ${entry.type}`);
77
+ }
78
+
79
+ return entry.data;
80
+ }
81
+
82
+ async readSerializedState(eraNumber?: number): Promise<Uint8Array> {
83
+ const compressed = await this.readCompressedState(eraNumber);
84
+ return snappyUncompress(compressed);
85
+ }
86
+
87
+ async readState(eraNumber?: number): Promise<BeaconState> {
88
+ const serialized = await this.readSerializedState(eraNumber);
89
+ const stateSlot = readSlotFromBeaconStateBytes(serialized);
90
+ return this.config.getForkTypes(stateSlot).BeaconState.deserialize(serialized);
91
+ }
92
+
93
+ async readCompressedBlock(slot: Slot): Promise<Uint8Array | null> {
94
+ const slotEra = computeEraNumberFromBlockSlot(slot);
95
+ const index = this.groups.at(slotEra - this.eraNumber);
96
+ if (!index) {
97
+ throw new Error(`Slot ${slot} is out of range`);
98
+ }
99
+ if (!index.blocksIndex) {
100
+ throw new Error(`No block index found for era number ${slotEra}`);
101
+ }
102
+ // Calculate offset within the index
103
+ const indexOffset = slot - index.blocksIndex.startSlot;
104
+ const offset = index.blocksIndex.recordStart + index.blocksIndex.offsets[indexOffset];
105
+ if (offset === 0) {
106
+ return null; // Empty slot
107
+ }
108
+
109
+ const entry = await readEntry(this.fh, offset);
110
+ if (entry.type !== EntryType.CompressedSignedBeaconBlock) {
111
+ throw new Error(`Expected CompressedSignedBeaconBlock, got ${EntryType[entry.type] ?? "unknown"}`);
112
+ }
113
+ return entry.data;
114
+ }
115
+
116
+ async readSerializedBlock(slot: Slot): Promise<Uint8Array | null> {
117
+ const compressed = await this.readCompressedBlock(slot);
118
+ if (compressed === null) return null;
119
+ return snappyUncompress(compressed);
120
+ }
121
+
122
+ async readBlock(slot: Slot): Promise<SignedBeaconBlock | null> {
123
+ const serialized = await this.readSerializedBlock(slot);
124
+ if (serialized === null) return null;
125
+ return this.config.getForkTypes(slot).SignedBeaconBlock.deserialize(serialized);
126
+ }
127
+
128
+ /**
129
+ * Validate the era file.
130
+ * - e2s format correctness
131
+ * - era range correctness
132
+ * - network correctness for state and blocks
133
+ * - block root and signature matches
134
+ */
135
+ async validate(): Promise<void> {
136
+ for (let groupIndex = 0; groupIndex < this.groups.length; groupIndex++) {
137
+ const eraNumber = this.eraNumber + groupIndex;
138
+ const index = this.groups[groupIndex];
139
+
140
+ // validate version entry
141
+ const start = index.blocksIndex
142
+ ? index.blocksIndex.recordStart + index.blocksIndex.offsets[0] - E2STORE_HEADER_SIZE
143
+ : index.stateIndex.recordStart + index.stateIndex.offsets[0] - E2STORE_HEADER_SIZE;
144
+ await readVersion(this.fh, start);
145
+
146
+ // validate state
147
+ // the state is loadable and consistent with the given runtime configuration
148
+ const state = await this.readState(eraNumber);
149
+ const cachedGenesis = createCachedGenesis(this.config, state.genesisValidatorsRoot);
150
+
151
+ if (eraNumber === 0 && index.blocksIndex) {
152
+ throw new Error("Genesis era (era 0) should not have blocks index");
153
+ }
154
+ if (eraNumber !== 0) {
155
+ if (!index.blocksIndex) {
156
+ throw new Error(`Era ${eraNumber} is missing blocks index`);
157
+ }
158
+
159
+ // validate blocks
160
+ for (
161
+ let slot = index.blocksIndex.startSlot;
162
+ slot < index.blocksIndex.startSlot + index.blocksIndex.offsets.length;
163
+ slot++
164
+ ) {
165
+ const block = await this.readBlock(slot);
166
+ if (block === null) {
167
+ if (slot === index.blocksIndex.startSlot) continue; // first slot in the era can't be easily validated
168
+ if (
169
+ Buffer.compare(
170
+ state.blockRoots[(slot - 1) % SLOTS_PER_HISTORICAL_ROOT],
171
+ state.blockRoots[slot % SLOTS_PER_HISTORICAL_ROOT]
172
+ ) !== 0
173
+ ) {
174
+ throw new Error(`Block root mismatch at slot ${slot} for empty slot`);
175
+ }
176
+ continue;
177
+ }
178
+
179
+ const blockRoot = this.config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message);
180
+ if (Buffer.compare(blockRoot, state.blockRoots[slot % SLOTS_PER_HISTORICAL_ROOT]) !== 0) {
181
+ throw new Error(`Block root mismatch at slot ${slot}`);
182
+ }
183
+ const msg = ssz.phase0.SigningData.hashTreeRoot({
184
+ objectRoot: blockRoot,
185
+ domain: cachedGenesis.getDomain(slot, DOMAIN_BEACON_PROPOSER),
186
+ });
187
+ const pk = PublicKey.fromBytes(state.validators[block.message.proposerIndex].pubkey);
188
+ const sig = Signature.fromBytes(block.signature);
189
+ if (!verify(msg, pk, sig, true, true)) {
190
+ throw new Error(`Block signature verification failed at slot ${slot}`);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,134 @@
1
+ import type {FileHandle} from "node:fs/promises";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_HISTORICAL_ROOT, isForkPostCapella} from "@lodestar/params";
4
+ import {BeaconState, Slot, capella, ssz} from "@lodestar/types";
5
+ import {E2STORE_HEADER_SIZE, SlotIndex, readSlotIndex} from "../e2s.ts";
6
+ import {readUint48} from "../util.ts";
7
+
8
+ /**
9
+ * Parsed components of an .era file name.
10
+ * Format: <config-name>-<era-number>-<short-historical-root>.era
11
+ */
12
+ export interface EraFileName {
13
+ /** CONFIG_NAME field of runtime config (mainnet, sepolia, holesky, etc.) */
14
+ configName: string;
15
+ /** Number of the first era stored in file, 5-digit zero-padded (00000, 00001, etc.) */
16
+ eraNumber: number;
17
+ /** First 4 bytes of last historical root, lower-case hex-encoded (8 chars) */
18
+ shortHistoricalRoot: string;
19
+ }
20
+
21
+ export interface EraIndices {
22
+ stateIndex: SlotIndex;
23
+ blocksIndex?: SlotIndex;
24
+ }
25
+
26
+ /** Return true if `slot` is within the era range */
27
+ export function isSlotInRange(slot: Slot, eraNumber: number): boolean {
28
+ return computeEraNumberFromBlockSlot(slot) === eraNumber;
29
+ }
30
+
31
+ export function isValidEraStateSlot(slot: Slot, eraNumber: number): boolean {
32
+ return slot % SLOTS_PER_HISTORICAL_ROOT === 0 && slot / SLOTS_PER_HISTORICAL_ROOT === eraNumber;
33
+ }
34
+
35
+ export function computeEraNumberFromBlockSlot(slot: Slot): number {
36
+ return Math.floor(slot / SLOTS_PER_HISTORICAL_ROOT) + 1;
37
+ }
38
+
39
+ export function computeStartBlockSlotFromEraNumber(eraNumber: number): Slot {
40
+ if (eraNumber === 0) {
41
+ throw new Error("Genesis era (era 0) does not contain blocks");
42
+ }
43
+ return (eraNumber - 1) * SLOTS_PER_HISTORICAL_ROOT;
44
+ }
45
+
46
+ /**
47
+ * Parse era filename.
48
+ *
49
+ * Format: `<config-name>-<era-number>-<short-historical-root>.era`
50
+ */
51
+ export function parseEraName(filename: string): {configName: string; eraNumber: number; shortHistoricalRoot: string} {
52
+ const match = filename.match(/^(.*)-(\d{5})-([0-9a-f]{8})\.era$/);
53
+ if (!match) {
54
+ throw new Error(`Invalid era filename format: ${filename}`);
55
+ }
56
+ return {
57
+ configName: match[1],
58
+ eraNumber: parseInt(match[2], 10),
59
+ shortHistoricalRoot: match[3],
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Read all indices from an era file.
65
+ */
66
+ export async function readAllEraIndices(fh: FileHandle): Promise<EraIndices[]> {
67
+ let end = (await fh.stat()).size;
68
+
69
+ const indices: EraIndices[] = [];
70
+ while (end > E2STORE_HEADER_SIZE) {
71
+ const index = await readEraIndexes(fh, end);
72
+ indices.push(index);
73
+ end = index.blocksIndex
74
+ ? index.blocksIndex.recordStart + index.blocksIndex.offsets[0] - E2STORE_HEADER_SIZE
75
+ : index.stateIndex.recordStart + index.stateIndex.offsets[0] - E2STORE_HEADER_SIZE;
76
+ }
77
+ return indices;
78
+ }
79
+
80
+ /**
81
+ * Read state and block SlotIndex entries from an era file and validate alignment.
82
+ */
83
+ export async function readEraIndexes(fh: FileHandle, end: number): Promise<EraIndices> {
84
+ const stateIndex = await readSlotIndex(fh, end);
85
+ if (stateIndex.offsets.length !== 1) {
86
+ throw new Error(`State SlotIndex must have exactly one offset, got ${stateIndex.offsets.length}`);
87
+ }
88
+
89
+ // Read block index if not genesis era (era 0)
90
+ let blocksIndex: SlotIndex | undefined;
91
+ if (stateIndex.startSlot > 0) {
92
+ blocksIndex = await readSlotIndex(fh, stateIndex.recordStart);
93
+ if (blocksIndex.offsets.length !== SLOTS_PER_HISTORICAL_ROOT) {
94
+ throw new Error(
95
+ `Block SlotIndex must have exactly ${SLOTS_PER_HISTORICAL_ROOT} offsets, got ${blocksIndex.offsets.length}`
96
+ );
97
+ }
98
+
99
+ // Validate block and state indices are properly aligned
100
+ const expectedBlockStartSlot = stateIndex.startSlot - SLOTS_PER_HISTORICAL_ROOT;
101
+ if (blocksIndex.startSlot !== expectedBlockStartSlot) {
102
+ throw new Error(
103
+ `Block index alignment error: expected startSlot=${expectedBlockStartSlot}, ` +
104
+ `got startSlot=${blocksIndex.startSlot} (should be exactly one era before state)`
105
+ );
106
+ }
107
+ }
108
+
109
+ return {stateIndex, blocksIndex};
110
+ }
111
+
112
+ export function readSlotFromBeaconStateBytes(beaconStateBytes: Uint8Array): Slot {
113
+ // not technically a Uint48, but for practical purposes fits within 6 bytes
114
+ return readUint48(
115
+ beaconStateBytes,
116
+ // slot is at offset 40: 8 (genesisTime) + 32 (genesisValidatorsRoot)
117
+ 40
118
+ );
119
+ }
120
+
121
+ export function getShortHistoricalRoot(config: ChainForkConfig, state: BeaconState): string {
122
+ return Buffer.from(
123
+ state.slot === 0
124
+ ? state.genesisValidatorsRoot
125
+ : // Post-Capella, historical_roots is replaced by historical_summaries
126
+ isForkPostCapella(config.getForkName(state.slot))
127
+ ? ssz.capella.HistoricalSummary.hashTreeRoot(
128
+ (state as capella.BeaconState).historicalSummaries.at(-1) as capella.BeaconState["historicalSummaries"][0]
129
+ )
130
+ : (state.historicalRoots.at(-1) as Uint8Array)
131
+ )
132
+ .subarray(0, 4)
133
+ .toString("hex");
134
+ }