From bc85d5f4802bd30f3966e9b0fa8378c77f361949 Mon Sep 17 00:00:00 2001 From: guha-rahul <69rahul16@gmail.com> Date: Sun, 6 Jul 2025 05:08:07 +0530 Subject: [PATCH 01/34] init --- packages/era/LICENSE | 201 +++++++++++++++++++++++++++++++ packages/era/README.md | 12 ++ packages/era/package.json | 41 +++++++ packages/era/src/constants.ts | 23 ++++ packages/era/src/types.ts | 121 +++++++++++++++++++ packages/era/tsconfig.build.json | 7 ++ packages/era/tsconfig.json | 7 ++ 7 files changed, 412 insertions(+) create mode 100644 packages/era/LICENSE create mode 100644 packages/era/README.md create mode 100644 packages/era/package.json create mode 100644 packages/era/src/constants.ts create mode 100644 packages/era/src/types.ts create mode 100644 packages/era/tsconfig.build.json create mode 100644 packages/era/tsconfig.json diff --git a/packages/era/LICENSE b/packages/era/LICENSE new file mode 100644 index 000000000000..f49a4e16e68b --- /dev/null +++ b/packages/era/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/era/README.md b/packages/era/README.md new file mode 100644 index 000000000000..c5b15925eab5 --- /dev/null +++ b/packages/era/README.md @@ -0,0 +1,12 @@ +# `@lodestar/db` + +> This package is part of [ChainSafe's Lodestar](https://lodestar.chainsafe.io) project + +## Usage + +See usage in the `lodestar` package here: +https://github.com/ChainSafe/lodestar/blob/0cf18e3bedcbf402e46917d402eb92938dafd49c/packages/lodestar/src/db/api/beacon/beacon.ts#L27 + +## License + +Apache-2.0 [ChainSafe Systems](https://chainsafe.io) diff --git a/packages/era/package.json b/packages/era/package.json new file mode 100644 index 000000000000..8f6638041d84 --- /dev/null +++ b/packages/era/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lodestar/era", + "version": "1.31.0", + "description": "DB modules of Lodestar", + "author": "ChainSafe Systems", + "homepage": "https://github.com/ChainSafe/lodestar#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ChainSafe/lodestar.git" + }, + "bugs": { + "url": "https://github.com/ChainSafe/lodestar/issues" + }, + "type": "module", + "exports": "./lib/index.js", + "types": "./lib/index.d.ts", + "files": [ + "lib/**/*.d.ts", + "lib/**/*.js", + "lib/**/*.js.map", + "*.d.ts", + "*.js" + ], + "scripts": { + "clean": "rm -rf lib && rm -f *.tsbuildinfo", + "build": "tsc -p tsconfig.build.json", + "build:watch": "yarn run build --watch", + "build:release": "yarn clean && yarn run build", + "check-build": "node -e \"(async function() { await import('./lib/index.js') })()\"", + "check-types": "tsc", + "lint": "biome check src/ test/", + "lint:fix": "yarn run lint --write", + "test": "yarn test:unit", + "test:unit": "vitest run --project unit --project unit-minimal", + "check-readme": "typescript-docs-verifier" + }, + "dependencies": { + }, + "devDependencies": { + } +} diff --git a/packages/era/src/constants.ts b/packages/era/src/constants.ts new file mode 100644 index 000000000000..02ea8572085a --- /dev/null +++ b/packages/era/src/constants.ts @@ -0,0 +1,23 @@ +/** + * Known entry types in an E2Store (.e2s) file. + * Values are the exact 2-byte type codes as defined in the specification. + */ +export const E2StoreEntryType = { + Empty: new Uint8Array([0x00, 0x00]), + CompressedSignedBeaconBlock: new Uint8Array([0x01, 0x00]), + CompressedBeaconState: new Uint8Array([0x02, 0x00]), + Version: new Uint8Array([0x65, 0x32]), // "e2" in ASCII + SlotIndex: new Uint8Array([0x69, 0x32]), // "i2" in ASCII +} as const; + +/** + * The complete version record (8 bytes total). + */ +export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + +/** + * Binary format constants. + */ +export const E2STORE_HEADER_SIZE = 8; +export const SLOT_INDEX_ENTRY_SIZE = 8; +export const MIN_SLOT_INDEX_SIZE = 32; // header(8) + startSlot(8) + 1 offset(8) + count(8) diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts new file mode 100644 index 000000000000..ba836132a01b --- /dev/null +++ b/packages/era/src/types.ts @@ -0,0 +1,121 @@ +import {Slot} from "@lodestar/types"; +import {E2StoreEntryType} from "./constants.js"; + +/** + * Known entry types in an E2Store (.e2s) fil+ Snappy framing format. + * Encoding: snappyFramed(ssz(SignedBeaconBlock)) + */ +export interface CompressedSignedBeaconBlock { + type: typeof E2StoreEntryType.CompressedSignedBeaconBlock; + data: Uint8Array; +} + +/** + * A compressed BeaconState using SSZ + Snappy framing format. + * Encoding: snappyFramed(ssz(BeaconState)) + */ +export interface CompressedBeaconState { + type: typeof E2StoreEntryType.CompressedBeaconState; + data: Uint8Array; +} + +/** + * Parsed components of an .era file name. + * Format: ---.era + */ +export interface EraFileName { + /** CONFIG_NAME field of runtime config (mainnet, sepolia, holesky, etc.) */ + configName: string; + /** Number of the first era stored in file, 5-digit zero-padded (00000, 00001, etc.) */ + eraNumber: number; + /** Number of eras stored in file, 5-digit zero-padded (00000, 00001, etc.) */ + eraCount: number; + /** First 4 bytes of last historical root, lower-case hex-encoded (8 chars) */ + shortHistoricalRoot: string; +} + +/** + * Complete era file with potentially multiple groups. + * Era files with multiple eras use the era number of the lowest era stored. + */ +export interface EraFile { + groups: EraGroup[]; + fileName?: string; + fileNameInfo?: EraFileName; +} + +/** + * Structured content of a single era file group. + * High-level representation after parsing the raw era file. + * Era files can contain multiple groups - groups can freely be split and combined. + */ +export interface EraGroup { + eraNumber: number; + version: VersionRecord; + blocks: {slot: Slot; block: CompressedSignedBeaconBlock}[]; + state: CompressedBeaconState; + blockIndex?: SlotIndex; // Optional for genesis era (era 0) + stateIndex: SlotIndex; + otherEntries?: E2StoreEntry[]; // Extension point for future record types +} + +/** + * Logical, parsed entry from an E2Store file. + */ +export interface E2StoreEntry { + type: (typeof E2StoreEntryType)[keyof typeof E2StoreEntryType]; + data: Uint8Array; +} + +/** + * Version record data. Always empty but indicates e2store format version. + * The first 8 bytes of an e2s file are always [0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + */ +export interface VersionRecord { + type: typeof E2StoreEntryType.Version; + data: Uint8Array; // (length 0) +} + +/** + * Maps slots to file positions in an era file. + * - Block index: count = SLOTS_PER_HISTORICAL_ROOT, maps slots to blocks + * - State index: count = 1, points to the era state + * - Zero offset = empty slot (no block) + */ +export interface SlotIndex { + /** First slot covered by this index (era * SLOTS_PER_HISTORICAL_ROOT) */ + startSlot: Slot; + /** File positions where data can be found. Length varies by index type. */ + offsets: bigint[]; + /** Number of offsets in the index (stored at end of record for backward reading) */ + count: bigint; +} + +/** + * 8-byte header for every entry in an E2Store file. + * Format: type (2 bytes) | length (4 bytes) | reserved (2 bytes) + */ +export interface E2StoreHeader { + type: (typeof E2StoreEntryType)[keyof typeof E2StoreEntryType]; + length: number; // uint32 little-endian + reserved: number; // uint16 little-endian, must be 0 +} + +/** + * Standalone .e2i index file. + * + * SlotIndex records can appear in standalone files ending with .e2i + * + * Key differences from embedded indices: + * - File name ends with .e2i by convention + * - Offsets are negative and counted from the end of the data file + * - Can be appended to data file without changing contents + */ +export interface StandaloneIndexFile { + /** File name, should end with .e2i */ + fileName: string; + /** The slot index data */ + slotIndex: SlotIndex; + /** Always true to indicate this is a standalone index with negative offsets */ + isStandalone: true; +} diff --git a/packages/era/tsconfig.build.json b/packages/era/tsconfig.build.json new file mode 100644 index 000000000000..92235557ba5d --- /dev/null +++ b/packages/era/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib" + } +} diff --git a/packages/era/tsconfig.json b/packages/era/tsconfig.json new file mode 100644 index 000000000000..a0f4f2a31e93 --- /dev/null +++ b/packages/era/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "test"], + "compilerOptions": { + "outDir": "lib" + } +} From 438f2d3313ab6d2915dd64f557b035ffc02acbaf Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sat, 16 Aug 2025 19:49:12 +0530 Subject: [PATCH 02/34] chore: update docs --- packages/era/README.md | 4 ++-- packages/era/package.json | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/era/README.md b/packages/era/README.md index c5b15925eab5..1fa0fa17e35a 100644 --- a/packages/era/README.md +++ b/packages/era/README.md @@ -1,11 +1,11 @@ -# `@lodestar/db` +# `@lodestar/era` > This package is part of [ChainSafe's Lodestar](https://lodestar.chainsafe.io) project ## Usage See usage in the `lodestar` package here: -https://github.com/ChainSafe/lodestar/blob/0cf18e3bedcbf402e46917d402eb92938dafd49c/packages/lodestar/src/db/api/beacon/beacon.ts#L27 + ## License diff --git a/packages/era/package.json b/packages/era/package.json index 8f6638041d84..5b939e38ab82 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -1,7 +1,7 @@ { "name": "@lodestar/era", - "version": "1.31.0", - "description": "DB modules of Lodestar", + "version": "1.33.0", + "description": "Era file handling module for Lodestar", "author": "ChainSafe Systems", "homepage": "https://github.com/ChainSafe/lodestar#readme", "repository": { @@ -35,6 +35,8 @@ "check-readme": "typescript-docs-verifier" }, "dependencies": { + "@lodestar/types": "^1.33.0", + "@lodestar/utils": "^1.33.0" }, "devDependencies": { } From d0534570dbc90f66373874303e2b58c31a485d0f Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sat, 16 Aug 2025 19:49:55 +0530 Subject: [PATCH 03/34] fix types --- packages/era/src/constants.ts | 26 ++++++++++++++++++-------- packages/era/src/helpers.ts | 0 packages/era/src/types.ts | 30 +++++++++++++----------------- 3 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 packages/era/src/helpers.ts diff --git a/packages/era/src/constants.ts b/packages/era/src/constants.ts index 02ea8572085a..7da3eb5f7dcf 100644 --- a/packages/era/src/constants.ts +++ b/packages/era/src/constants.ts @@ -1,14 +1,24 @@ /** * Known entry types in an E2Store (.e2s) file. - * Values are the exact 2-byte type codes as defined in the specification. */ -export const E2StoreEntryType = { - Empty: new Uint8Array([0x00, 0x00]), - CompressedSignedBeaconBlock: new Uint8Array([0x01, 0x00]), - CompressedBeaconState: new Uint8Array([0x02, 0x00]), - Version: new Uint8Array([0x65, 0x32]), // "e2" in ASCII - SlotIndex: new Uint8Array([0x69, 0x32]), // "i2" in ASCII -} as const; +export enum E2StoreEntryType { + Empty = "Empty", + CompressedSignedBeaconBlock = "CompressedSignedBeaconBlock", + CompressedBeaconState = "CompressedBeaconState", + Version = "Version", + SlotIndex = "SlotIndex", +} + +/** + * The exact 2-byte type codes for E2StoreEntryType as defined in the specification. + */ +export const EraTypes = { + [E2StoreEntryType.Empty]: new Uint8Array([0x00, 0x00]), + [E2StoreEntryType.CompressedSignedBeaconBlock]: new Uint8Array([0x01, 0x00]), + [E2StoreEntryType.CompressedBeaconState]: new Uint8Array([0x02, 0x00]), + [E2StoreEntryType.Version]: new Uint8Array([0x65, 0x32]), // "e2" in ASCII + [E2StoreEntryType.SlotIndex]: new Uint8Array([0x69, 0x32]), // "i2" in ASCII +}; /** * The complete version record (8 bytes total). diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index ba836132a01b..1f418120862b 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -2,11 +2,11 @@ import {Slot} from "@lodestar/types"; import {E2StoreEntryType} from "./constants.js"; /** - * Known entry types in an E2Store (.e2s) fil+ Snappy framing format. + * entry types in an E2Store (.e2s) fil+ Snappy framing format. * Encoding: snappyFramed(ssz(SignedBeaconBlock)) */ export interface CompressedSignedBeaconBlock { - type: typeof E2StoreEntryType.CompressedSignedBeaconBlock; + type: E2StoreEntryType.CompressedSignedBeaconBlock; data: Uint8Array; } @@ -15,7 +15,7 @@ export interface CompressedSignedBeaconBlock { * Encoding: snappyFramed(ssz(BeaconState)) */ export interface CompressedBeaconState { - type: typeof E2StoreEntryType.CompressedBeaconState; + type: E2StoreEntryType.CompressedBeaconState; data: Uint8Array; } @@ -40,8 +40,8 @@ export interface EraFileName { */ export interface EraFile { groups: EraGroup[]; - fileName?: string; - fileNameInfo?: EraFileName; + fileName: string; + fileNameInfo: EraFileName; } /** @@ -49,21 +49,16 @@ export interface EraFile { * High-level representation after parsing the raw era file. * Era files can contain multiple groups - groups can freely be split and combined. */ -export interface EraGroup { - eraNumber: number; - version: VersionRecord; - blocks: {slot: Slot; block: CompressedSignedBeaconBlock}[]; - state: CompressedBeaconState; +export interface EraGroup { // iterate after implement the types blockIndex?: SlotIndex; // Optional for genesis era (era 0) stateIndex: SlotIndex; - otherEntries?: E2StoreEntry[]; // Extension point for future record types -} +} /** * Logical, parsed entry from an E2Store file. */ export interface E2StoreEntry { - type: (typeof E2StoreEntryType)[keyof typeof E2StoreEntryType]; + type: E2StoreEntryType; data: Uint8Array; } @@ -72,7 +67,7 @@ export interface E2StoreEntry { * The first 8 bytes of an e2s file are always [0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] */ export interface VersionRecord { - type: typeof E2StoreEntryType.Version; + type: E2StoreEntryType.Version; data: Uint8Array; // (length 0) } @@ -83,12 +78,13 @@ export interface VersionRecord { * - Zero offset = empty slot (no block) */ export interface SlotIndex { + type: E2StoreEntryType.SlotIndex; /** First slot covered by this index (era * SLOTS_PER_HISTORICAL_ROOT) */ startSlot: Slot; /** File positions where data can be found. Length varies by index type. */ - offsets: bigint[]; + offsets: number[]; /** Number of offsets in the index (stored at end of record for backward reading) */ - count: bigint; + count: number; } /** @@ -96,7 +92,7 @@ export interface SlotIndex { * Format: type (2 bytes) | length (4 bytes) | reserved (2 bytes) */ export interface E2StoreHeader { - type: (typeof E2StoreEntryType)[keyof typeof E2StoreEntryType]; + type: E2StoreEntryType; length: number; // uint32 little-endian reserved: number; // uint16 little-endian, must be 0 } From 0823b507818431c8b91a19108ae48be3ddf24c63 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Mon, 18 Aug 2025 04:48:39 +0530 Subject: [PATCH 04/34] export Snappy decompress --- packages/era/package.json | 6 +++++- packages/reqresp/src/encodingStrategies/sszSnappy/index.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/era/package.json b/packages/era/package.json index 5b939e38ab82..9ce61ddae737 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -36,7 +36,11 @@ }, "dependencies": { "@lodestar/types": "^1.33.0", - "@lodestar/utils": "^1.33.0" + "@lodestar/utils": "^1.33.0", + "@lodestar/config": "^1.33.0", + "@lodestar/params": "^1.33.0", + "@lodestar/reqresp": "^1.33.0", + "uint8arraylist": "^2.4.7" }, "devDependencies": { } diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts index ad5e13018ffa..925cb7fc1656 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts @@ -1,3 +1,4 @@ export * from "./encode.js"; export * from "./decode.js"; export * from "./errors.js"; +export {SnappyFramesUncompress} from "./snappyFrames/uncompress.js"; From d44951ff49ec5b62c55044104f33001a433cda67 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Mon, 18 Aug 2025 04:49:45 +0530 Subject: [PATCH 05/34] remove non-reader types --- packages/era/src/constants.ts | 6 +-- packages/era/src/types.ts | 78 +---------------------------------- 2 files changed, 4 insertions(+), 80 deletions(-) diff --git a/packages/era/src/constants.ts b/packages/era/src/constants.ts index 7da3eb5f7dcf..952427735667 100644 --- a/packages/era/src/constants.ts +++ b/packages/era/src/constants.ts @@ -26,8 +26,6 @@ export const EraTypes = { export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); /** - * Binary format constants. + * E2Store header size in bytes */ -export const E2STORE_HEADER_SIZE = 8; -export const SLOT_INDEX_ENTRY_SIZE = 8; -export const MIN_SLOT_INDEX_SIZE = 32; // header(8) + startSlot(8) + 1 offset(8) + count(8) +export const E2STORE_HEADER_SIZE = 8; \ No newline at end of file diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index 1f418120862b..3d97f484de81 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -1,24 +1,6 @@ import {Slot} from "@lodestar/types"; import {E2StoreEntryType} from "./constants.js"; -/** - * entry types in an E2Store (.e2s) fil+ Snappy framing format. - * Encoding: snappyFramed(ssz(SignedBeaconBlock)) - */ -export interface CompressedSignedBeaconBlock { - type: E2StoreEntryType.CompressedSignedBeaconBlock; - data: Uint8Array; -} - -/** - * A compressed BeaconState using SSZ + Snappy framing format. - * Encoding: snappyFramed(ssz(BeaconState)) - */ -export interface CompressedBeaconState { - type: E2StoreEntryType.CompressedBeaconState; - data: Uint8Array; -} - /** * Parsed components of an .era file name. * Format: ---.era @@ -34,25 +16,6 @@ export interface EraFileName { shortHistoricalRoot: string; } -/** - * Complete era file with potentially multiple groups. - * Era files with multiple eras use the era number of the lowest era stored. - */ -export interface EraFile { - groups: EraGroup[]; - fileName: string; - fileNameInfo: EraFileName; -} - -/** - * Structured content of a single era file group. - * High-level representation after parsing the raw era file. - * Era files can contain multiple groups - groups can freely be split and combined. - */ -export interface EraGroup { // iterate after implement the types - blockIndex?: SlotIndex; // Optional for genesis era (era 0) - stateIndex: SlotIndex; -} /** * Logical, parsed entry from an E2Store file. @@ -62,14 +25,6 @@ export interface E2StoreEntry { data: Uint8Array; } -/** - * Version record data. Always empty but indicates e2store format version. - * The first 8 bytes of an e2s file are always [0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] - */ -export interface VersionRecord { - type: E2StoreEntryType.Version; - data: Uint8Array; // (length 0) -} /** * Maps slots to file positions in an era file. @@ -83,35 +38,6 @@ export interface SlotIndex { startSlot: Slot; /** File positions where data can be found. Length varies by index type. */ offsets: number[]; - /** Number of offsets in the index (stored at end of record for backward reading) */ - count: number; -} - -/** - * 8-byte header for every entry in an E2Store file. - * Format: type (2 bytes) | length (4 bytes) | reserved (2 bytes) - */ -export interface E2StoreHeader { - type: E2StoreEntryType; - length: number; // uint32 little-endian - reserved: number; // uint16 little-endian, must be 0 -} - -/** - * Standalone .e2i index file. - * - * SlotIndex records can appear in standalone files ending with .e2i - * - * Key differences from embedded indices: - * - File name ends with .e2i by convention - * - Offsets are negative and counted from the end of the data file - * - Can be appended to data file without changing contents - */ -export interface StandaloneIndexFile { - /** File name, should end with .e2i */ - fileName: string; - /** The slot index data */ - slotIndex: SlotIndex; - /** Always true to indicate this is a standalone index with negative offsets */ - isStandalone: true; + /** File position where this index record starts */ + recordStart: number; } From 10c4e9807719f1e8bc759bde0de7256d0f8ac61d Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Mon, 18 Aug 2025 04:50:09 +0530 Subject: [PATCH 06/34] add reading of era --- packages/era/src/helpers.ts | 250 ++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index e69de29bb2d1..7da671663f08 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -0,0 +1,250 @@ +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {SnappyFramesUncompress} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; +import {Uint8ArrayList} from "uint8arraylist"; +import { + EraTypes, + E2StoreEntryType, + E2STORE_HEADER_SIZE +} from "./constants.js"; +import type {SlotIndex, E2StoreEntry} from "./types.js"; + +/** + * Read an e2Store entry (header + data) + * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0) + */ +export function readEntry(bytes: Uint8Array): E2StoreEntry { + + if (bytes.length < E2STORE_HEADER_SIZE) { + throw new Error(`Buffer too small for E2Store header: need ${E2STORE_HEADER_SIZE} bytes, got ${bytes.length}`); + } + + // validate entry type from first 2 bytes + const typeBytes = bytes.slice(0, 2); + const typeEntry = Object.entries(EraTypes).find(([, expectedBytes]) => + typeBytes[0] === expectedBytes[0] && typeBytes[1] === expectedBytes[1] + ); + if (!typeEntry) { + const typeHex = Array.from(typeBytes) + .map(b => `0x${b.toString(16).padStart(2, '0')}`) + .join(', '); + throw new Error(`Unknown E2Store entry type: [${typeHex}]`); + } + const type = typeEntry[0] as E2StoreEntryType; + + // Parse data length from next 4 bytes (offset 2, little endian) + const lengthView = new DataView( + bytes.buffer, + bytes.byteOffset + 2, + 4 + ); + const length = lengthView.getUint32(0, true); + + // Validate reserved bytes are zero (offset 6-7) + const reserved = bytes[6] | (bytes[7] << 8); + if (reserved !== 0) { + throw new Error(`E2Store reserved bytes must be zero, got: ${reserved}`); + } + + // Validate data length fits within buffer + const availableDataLength = bytes.length - E2STORE_HEADER_SIZE; + if (length > availableDataLength) { + throw new Error( + `E2Store data length ${length} exceeds available buffer space ${availableDataLength}` + ); + } + + const dataStartOffset = E2STORE_HEADER_SIZE; + const data = bytes.slice(dataStartOffset, dataStartOffset + length); + + return { type, data }; +} + +/** + * Read 64-bit little-endian integer + */ +function readInt64(bytes: Uint8Array, offset: number): bigint { + const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8); + return view.getBigInt64(0, true); +} + +/** + * Read slot index from end of era file with validation + */ +export function readSlotIndex(bytes: Uint8Array, expectedType: 'state' | 'block'): SlotIndex { + + const countOffset = bytes.length - 8; + const eofCount = Number(readInt64(bytes, countOffset)); + + // Validate count matches expected type requirements + if (expectedType === 'state' && eofCount !== 1) { + throw new Error(`State index must have count=1, got ${eofCount}`); + } + if (expectedType === 'block' && eofCount !== SLOTS_PER_HISTORICAL_ROOT) { + throw new Error(`Block index must have count=${SLOTS_PER_HISTORICAL_ROOT}, got ${eofCount}`); + } + + // Calculate where slot index starts in buffer + // Structure: header(8) + startSlot(8) + offsets(count*8) + count(8) + const indexSize = E2STORE_HEADER_SIZE + 16 + (eofCount * 8); + const indexStart = bytes.length - indexSize; + + // Validate index position is within file bounds + if (indexStart < 0) { + throw new Error( + `SlotIndex position ${indexStart} is invalid - file too small for count=${eofCount}` + ); + } + + // Read and validate the slot index entry + const entry = readEntry(bytes.slice(indexStart)); + if (entry.type !== E2StoreEntryType.SlotIndex) { + throw new Error(`Expected SlotIndex entry, got ${entry.type}`); + } + + // Validate payload size matches specification + // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16 + const expectedSize = (eofCount * 8) + 16; + if (entry.data.length !== expectedSize) { + throw new Error( + `SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}` + ); + } + + // Parse start slot from payload + const startSlot = Number(readInt64(entry.data, 0)); + + // Parse slot offsets with relative→absolute conversion + const offsets: number[] = []; + for (let i = 0; i < eofCount; i++) { + // Offset field position: after startSlot(8) + i * 8 + const offsetFieldOffset = 8 + (i * 8); + const relativeOffset = readInt64(entry.data, offsetFieldOffset); + + if (relativeOffset === 0n) { + offsets.push(0); + } else { + // Convert relative offset to absolute position with bounds validation + const indexHeaderStart = BigInt(indexStart); + const absoluteOffset = indexHeaderStart + relativeOffset; + if (absoluteOffset < 0n || absoluteOffset >= BigInt(bytes.length)) { + throw new Error( + `Invalid absolute offset: ${absoluteOffset} (relative: ${relativeOffset}, ` + + `indexStart: ${indexStart}, fileSize: ${bytes.length})` + ); + } + offsets.push(Number(absoluteOffset)); + } + } + + // Validate trailing count matches EOF count + // Trailing count position: after startSlot(8) + offsets(count*8) + const trailingCountOffset = 8 + (eofCount * 8); + const trailingCount = Number(readInt64(entry.data, trailingCountOffset)); + if (trailingCount !== eofCount) { + throw new Error( + `SlotIndex trailing count mismatch: expected ${eofCount}, got ${trailingCount}` + ); + } + + return { + type: E2StoreEntryType.SlotIndex, + startSlot, + offsets, + recordStart: indexStart + }; +} + +/** + * Gets both slot indices from era file with validation and alignment checks + */ +export function getEraIndexes( + eraBytes: Uint8Array, + expectedEra?: number +): {stateSlotIndex: SlotIndex; blockSlotIndex?: SlotIndex} { + const stateSlotIndex = readSlotIndex(eraBytes, 'state'); + + // Validate state index aligns with expected era boundary + if (expectedEra !== undefined) { + const expectedStateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; + if (stateSlotIndex.startSlot !== expectedStateStartSlot) { + throw new Error( + `State index era alignment error: expected startSlot=${expectedStateStartSlot} ` + + `(era ${expectedEra}), got startSlot=${stateSlotIndex.startSlot}` + ); + } + } + + // Read block index if not genesis era (era 0) + let blockSlotIndex: SlotIndex | undefined; + if (stateSlotIndex.startSlot > 0) { + const blockIndexBytes = eraBytes.slice(0, stateSlotIndex.recordStart); + blockSlotIndex = readSlotIndex(blockIndexBytes, 'block'); + + // Validate block and state indices are properly aligned + const expectedBlockStartSlot = stateSlotIndex.startSlot - SLOTS_PER_HISTORICAL_ROOT; + if (blockSlotIndex.startSlot !== expectedBlockStartSlot) { + throw new Error( + `Block index alignment error: expected startSlot=${expectedBlockStartSlot}, ` + + `got startSlot=${blockSlotIndex.startSlot} (should be exactly one era before state)` + ); + } + } + + return {stateSlotIndex, blockSlotIndex}; +} + +/** + * Decompresses snappy-framed data using Lodestar's spec-compliant decompressor + */ +function decompressFrames(compressedData: Uint8Array): Uint8Array { + const decompressor = new SnappyFramesUncompress(); + + const input = new Uint8ArrayList(compressedData); + const result = decompressor.uncompress(input); + + if (result === null) { + throw new Error("Snappy decompression failed - no data returned"); + } + + return result.subarray(); +} + +/** + * Decompresses and deserializes a beacon state using the correct fork for the era + */ +export function decompressBeaconState( + compressedData: Uint8Array, + era: number, + config: ChainForkConfig +) { + const uncompressed = decompressFrames(compressedData); + + const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; + const forkTypes = config.getForkTypes(stateSlot); + + try { + return forkTypes.BeaconState.deserialize(uncompressed); + } catch (error) { + throw new Error(`Failed to deserialize BeaconState for era ${era}, slot ${stateSlot}: ${error}`); + } +} + +/** + * Decompresses and deserializes a signed beacon block using the correct fork for the block slot + */ +export function decompressSignedBeaconBlock( + compressedData: Uint8Array, + blockSlot: number, + config: ChainForkConfig +) { + const uncompressed = decompressFrames(compressedData); + + const forkTypes = config.getForkTypes(blockSlot); + + try { + return forkTypes.SignedBeaconBlock.deserialize(uncompressed); + } catch (error) { + throw new Error(`Failed to deserialize SignedBeaconBlock for slot ${blockSlot}: ${error}`); + } +} \ No newline at end of file From 21b8081d4be40f2039d1e664485794ae19df23a0 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Tue, 19 Aug 2025 22:40:05 +0530 Subject: [PATCH 07/34] fix build --- packages/era/src/helpers.ts | 2 +- packages/era/src/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/era/src/index.ts diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index 7da671663f08..baab62d31df0 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {SnappyFramesUncompress} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; +import {SnappyFramesUncompress} from "../../reqresp/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; import {Uint8ArrayList} from "uint8arraylist"; import { EraTypes, diff --git a/packages/era/src/index.ts b/packages/era/src/index.ts new file mode 100644 index 000000000000..98d2e3e791d3 --- /dev/null +++ b/packages/era/src/index.ts @@ -0,0 +1,5 @@ +export * from "./constants.js"; +export * from "./types.js"; +export * from "./helpers.js"; + + From b3de442ea774f415ead3b64bae6cbb64543546f4 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Wed, 20 Aug 2025 01:34:34 +0530 Subject: [PATCH 08/34] lint --- packages/era/src/constants.ts | 2 +- packages/era/src/helpers.ts | 132 ++++++++++++++-------------------- packages/era/src/index.ts | 2 - packages/era/src/types.ts | 2 - 4 files changed, 54 insertions(+), 84 deletions(-) diff --git a/packages/era/src/constants.ts b/packages/era/src/constants.ts index 952427735667..344784dce8f5 100644 --- a/packages/era/src/constants.ts +++ b/packages/era/src/constants.ts @@ -28,4 +28,4 @@ export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00 /** * E2Store header size in bytes */ -export const E2STORE_HEADER_SIZE = 8; \ No newline at end of file +export const E2STORE_HEADER_SIZE = 8; diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index baab62d31df0..236f0aec17cf 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -1,43 +1,34 @@ import {ChainForkConfig} from "@lodestar/config"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {SnappyFramesUncompress} from "../../reqresp/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; import {Uint8ArrayList} from "uint8arraylist"; -import { - EraTypes, - E2StoreEntryType, - E2STORE_HEADER_SIZE -} from "./constants.js"; -import type {SlotIndex, E2StoreEntry} from "./types.js"; +import {SnappyFramesUncompress} from "../../reqresp/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; +import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes} from "./constants.js"; +import type {E2StoreEntry, SlotIndex} from "./types.js"; /** * Read an e2Store entry (header + data) * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0) */ export function readEntry(bytes: Uint8Array): E2StoreEntry { - if (bytes.length < E2STORE_HEADER_SIZE) { throw new Error(`Buffer too small for E2Store header: need ${E2STORE_HEADER_SIZE} bytes, got ${bytes.length}`); } // validate entry type from first 2 bytes const typeBytes = bytes.slice(0, 2); - const typeEntry = Object.entries(EraTypes).find(([, expectedBytes]) => - typeBytes[0] === expectedBytes[0] && typeBytes[1] === expectedBytes[1] + const typeEntry = Object.entries(EraTypes).find( + ([, expectedBytes]) => typeBytes[0] === expectedBytes[0] && typeBytes[1] === expectedBytes[1] ); if (!typeEntry) { const typeHex = Array.from(typeBytes) - .map(b => `0x${b.toString(16).padStart(2, '0')}`) - .join(', '); + .map((b) => `0x${b.toString(16).padStart(2, "0")}`) + .join(", "); throw new Error(`Unknown E2Store entry type: [${typeHex}]`); } const type = typeEntry[0] as E2StoreEntryType; // Parse data length from next 4 bytes (offset 2, little endian) - const lengthView = new DataView( - bytes.buffer, - bytes.byteOffset + 2, - 4 - ); + const lengthView = new DataView(bytes.buffer, bytes.byteOffset + 2, 4); const length = lengthView.getUint32(0, true); // Validate reserved bytes are zero (offset 6-7) @@ -49,15 +40,13 @@ export function readEntry(bytes: Uint8Array): E2StoreEntry { // Validate data length fits within buffer const availableDataLength = bytes.length - E2STORE_HEADER_SIZE; if (length > availableDataLength) { - throw new Error( - `E2Store data length ${length} exceeds available buffer space ${availableDataLength}` - ); + throw new Error(`E2Store data length ${length} exceeds available buffer space ${availableDataLength}`); } - + const dataStartOffset = E2STORE_HEADER_SIZE; const data = bytes.slice(dataStartOffset, dataStartOffset + length); - - return { type, data }; + + return {type, data}; } /** @@ -71,56 +60,51 @@ function readInt64(bytes: Uint8Array, offset: number): bigint { /** * Read slot index from end of era file with validation */ -export function readSlotIndex(bytes: Uint8Array, expectedType: 'state' | 'block'): SlotIndex { - +export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { const countOffset = bytes.length - 8; const eofCount = Number(readInt64(bytes, countOffset)); - + // Validate count matches expected type requirements - if (expectedType === 'state' && eofCount !== 1) { + if (expectedType === "state" && eofCount !== 1) { throw new Error(`State index must have count=1, got ${eofCount}`); } - if (expectedType === 'block' && eofCount !== SLOTS_PER_HISTORICAL_ROOT) { + if (expectedType === "block" && eofCount !== SLOTS_PER_HISTORICAL_ROOT) { throw new Error(`Block index must have count=${SLOTS_PER_HISTORICAL_ROOT}, got ${eofCount}`); } - + // Calculate where slot index starts in buffer // Structure: header(8) + startSlot(8) + offsets(count*8) + count(8) - const indexSize = E2STORE_HEADER_SIZE + 16 + (eofCount * 8); + const indexSize = E2STORE_HEADER_SIZE + 16 + eofCount * 8; const indexStart = bytes.length - indexSize; - + // Validate index position is within file bounds if (indexStart < 0) { - throw new Error( - `SlotIndex position ${indexStart} is invalid - file too small for count=${eofCount}` - ); + throw new Error(`SlotIndex position ${indexStart} is invalid - file too small for count=${eofCount}`); } - + // Read and validate the slot index entry const entry = readEntry(bytes.slice(indexStart)); if (entry.type !== E2StoreEntryType.SlotIndex) { throw new Error(`Expected SlotIndex entry, got ${entry.type}`); } - + // Validate payload size matches specification // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16 - const expectedSize = (eofCount * 8) + 16; + const expectedSize = eofCount * 8 + 16; if (entry.data.length !== expectedSize) { - throw new Error( - `SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}` - ); + throw new Error(`SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}`); } - + // Parse start slot from payload const startSlot = Number(readInt64(entry.data, 0)); - + // Parse slot offsets with relative→absolute conversion const offsets: number[] = []; for (let i = 0; i < eofCount; i++) { // Offset field position: after startSlot(8) + i * 8 - const offsetFieldOffset = 8 + (i * 8); + const offsetFieldOffset = 8 + i * 8; const relativeOffset = readInt64(entry.data, offsetFieldOffset); - + if (relativeOffset === 0n) { offsets.push(0); } else { @@ -130,28 +114,26 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: 'state' | 'block' if (absoluteOffset < 0n || absoluteOffset >= BigInt(bytes.length)) { throw new Error( `Invalid absolute offset: ${absoluteOffset} (relative: ${relativeOffset}, ` + - `indexStart: ${indexStart}, fileSize: ${bytes.length})` + `indexStart: ${indexStart}, fileSize: ${bytes.length})` ); } offsets.push(Number(absoluteOffset)); } } - + // Validate trailing count matches EOF count // Trailing count position: after startSlot(8) + offsets(count*8) - const trailingCountOffset = 8 + (eofCount * 8); + const trailingCountOffset = 8 + eofCount * 8; const trailingCount = Number(readInt64(entry.data, trailingCountOffset)); if (trailingCount !== eofCount) { - throw new Error( - `SlotIndex trailing count mismatch: expected ${eofCount}, got ${trailingCount}` - ); + throw new Error(`SlotIndex trailing count mismatch: expected ${eofCount}, got ${trailingCount}`); } - + return { type: E2StoreEntryType.SlotIndex, startSlot, offsets, - recordStart: indexStart + recordStart: indexStart, }; } @@ -159,38 +141,38 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: 'state' | 'block' * Gets both slot indices from era file with validation and alignment checks */ export function getEraIndexes( - eraBytes: Uint8Array, + eraBytes: Uint8Array, expectedEra?: number ): {stateSlotIndex: SlotIndex; blockSlotIndex?: SlotIndex} { - const stateSlotIndex = readSlotIndex(eraBytes, 'state'); - + const stateSlotIndex = readSlotIndex(eraBytes, "state"); + // Validate state index aligns with expected era boundary if (expectedEra !== undefined) { const expectedStateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; if (stateSlotIndex.startSlot !== expectedStateStartSlot) { throw new Error( `State index era alignment error: expected startSlot=${expectedStateStartSlot} ` + - `(era ${expectedEra}), got startSlot=${stateSlotIndex.startSlot}` + `(era ${expectedEra}), got startSlot=${stateSlotIndex.startSlot}` ); } } - + // Read block index if not genesis era (era 0) let blockSlotIndex: SlotIndex | undefined; if (stateSlotIndex.startSlot > 0) { const blockIndexBytes = eraBytes.slice(0, stateSlotIndex.recordStart); - blockSlotIndex = readSlotIndex(blockIndexBytes, 'block'); - + blockSlotIndex = readSlotIndex(blockIndexBytes, "block"); + // Validate block and state indices are properly aligned const expectedBlockStartSlot = stateSlotIndex.startSlot - SLOTS_PER_HISTORICAL_ROOT; if (blockSlotIndex.startSlot !== expectedBlockStartSlot) { throw new Error( `Block index alignment error: expected startSlot=${expectedBlockStartSlot}, ` + - `got startSlot=${blockSlotIndex.startSlot} (should be exactly one era before state)` + `got startSlot=${blockSlotIndex.startSlot} (should be exactly one era before state)` ); } } - + return {stateSlotIndex, blockSlotIndex}; } @@ -199,30 +181,26 @@ export function getEraIndexes( */ function decompressFrames(compressedData: Uint8Array): Uint8Array { const decompressor = new SnappyFramesUncompress(); - + const input = new Uint8ArrayList(compressedData); const result = decompressor.uncompress(input); - + if (result === null) { throw new Error("Snappy decompression failed - no data returned"); } - + return result.subarray(); } /** * Decompresses and deserializes a beacon state using the correct fork for the era */ -export function decompressBeaconState( - compressedData: Uint8Array, - era: number, - config: ChainForkConfig -) { +export function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); - + const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; const forkTypes = config.getForkTypes(stateSlot); - + try { return forkTypes.BeaconState.deserialize(uncompressed); } catch (error) { @@ -233,18 +211,14 @@ export function decompressBeaconState( /** * Decompresses and deserializes a signed beacon block using the correct fork for the block slot */ -export function decompressSignedBeaconBlock( - compressedData: Uint8Array, - blockSlot: number, - config: ChainForkConfig -) { +export function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); - + const forkTypes = config.getForkTypes(blockSlot); - + try { return forkTypes.SignedBeaconBlock.deserialize(uncompressed); } catch (error) { throw new Error(`Failed to deserialize SignedBeaconBlock for slot ${blockSlot}: ${error}`); } -} \ No newline at end of file +} diff --git a/packages/era/src/index.ts b/packages/era/src/index.ts index 98d2e3e791d3..61cc15edad52 100644 --- a/packages/era/src/index.ts +++ b/packages/era/src/index.ts @@ -1,5 +1,3 @@ export * from "./constants.js"; export * from "./types.js"; export * from "./helpers.js"; - - diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index 3d97f484de81..7a610a883c48 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -16,7 +16,6 @@ export interface EraFileName { shortHistoricalRoot: string; } - /** * Logical, parsed entry from an E2Store file. */ @@ -25,7 +24,6 @@ export interface E2StoreEntry { data: Uint8Array; } - /** * Maps slots to file positions in an era file. * - Block index: count = SLOTS_PER_HISTORICAL_ROOT, maps slots to blocks From a2f0b4bbaaeb3e9de5ebe0b1210dba8b5e1db14b Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Wed, 20 Aug 2025 02:55:54 +0530 Subject: [PATCH 09/34] lint --- packages/era/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/era/README.md b/packages/era/README.md index 1fa0fa17e35a..cb3b8c1e8273 100644 --- a/packages/era/README.md +++ b/packages/era/README.md @@ -6,7 +6,6 @@ See usage in the `lodestar` package here: - ## License Apache-2.0 [ChainSafe Systems](https://chainsafe.io) From 91e747076f652af10bb027e6d947db959da3041a Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sun, 24 Aug 2025 22:10:34 +0530 Subject: [PATCH 10/34] add tests and era downloader script --- packages/era/test/era_downloader.sh | 45 ++++++++++++++ .../era/test/unit/era.integration.test.ts | 58 +++++++++++++++++++ packages/era/test/unit/era.unit.test.ts | 58 +++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100755 packages/era/test/era_downloader.sh create mode 100644 packages/era/test/unit/era.integration.test.ts create mode 100644 packages/era/test/unit/era.unit.test.ts diff --git a/packages/era/test/era_downloader.sh b/packages/era/test/era_downloader.sh new file mode 100755 index 000000000000..1d8962c13896 --- /dev/null +++ b/packages/era/test/era_downloader.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Copyright (c) 2025 Status Research & Development GmbH. Licensed under +# either of: +# - Apache License, version 2.0 +# - MIT license +# at your option. This file may not be copied, modified, or distributed except +# according to those terms. + +# Usage: +# - chmod +x era_downloader.sh +# - ./era_downloader.sh # downloads mainnet-01506-4781865b.era into this test directory +# - ./era_downloader.sh # downloads the provided file into this test directory +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DOWNLOAD_DIR="$SCRIPT_DIR" + +if [ $# -eq 0 ]; then + DOWNLOAD_URL="https://mainnet.era.nimbus.team/mainnet-01506-4781865b.era" +elif [ $# -eq 1 ]; then + DOWNLOAD_URL="$1" +else + echo "Usage: $0 [file_url]" + exit 1 +fi + +if ! command -v aria2c > /dev/null 2>&1; then + echo "❌ aria2c is not installed. Install via: brew install aria2 (macOS) or sudo apt install aria2 (Linux)" + exit 1 +fi + +mkdir -p "$DOWNLOAD_DIR" + +FILE_NAME=$(basename "$DOWNLOAD_URL") + +echo "📥 Downloading $FILE_NAME to $DOWNLOAD_DIR ..." +aria2c -x 8 -c -o "$FILE_NAME" \ + --dir="$DOWNLOAD_DIR" \ + --console-log-level=warn \ + --quiet=true \ + --summary-interval=0 \ + "$DOWNLOAD_URL" + +echo "✅ Downloaded: $DOWNLOAD_DIR/$FILE_NAME" diff --git a/packages/era/test/unit/era.integration.test.ts b/packages/era/test/unit/era.integration.test.ts new file mode 100644 index 000000000000..8fa8a21186ac --- /dev/null +++ b/packages/era/test/unit/era.integration.test.ts @@ -0,0 +1,58 @@ +import {readFileSync} from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {assert, beforeAll, describe, it} from "vitest"; + +import {E2STORE_HEADER_SIZE, E2StoreEntryType, getEraIndexes, readEntry, readSlotIndex} from "../../lib/index.js"; + +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe.runIf(!process.env.CI)("era file (integration)", () => { + let data: Uint8Array; + const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); + const expectedEra = 1506; + const stateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; + const blockStartSlot = stateStartSlot - SLOTS_PER_HISTORICAL_ROOT; + + beforeAll(() => { + try { + data = new Uint8Array(readFileSync(eraPath)); + } catch { + throw new Error(". Run the downloader script first:\n ./packages/era/test/era_downloader.sh\n"); + } + }); + + it("first entry is Version", () => { + const entry = readEntry(data); + assert.equal(entry.type, E2StoreEntryType.Version); + assert.equal(entry.data.length, 0); + }); + + it("second and third entries are blocks", () => { + let p = 0; + const e1 = readEntry(data); + p += E2STORE_HEADER_SIZE + e1.data.length; + const e2 = readEntry(data.slice(p)); + p += E2STORE_HEADER_SIZE + e2.data.length; + const e3 = readEntry(data.slice(p)); + + assert.equal(e2.type, E2StoreEntryType.CompressedSignedBeaconBlock); + assert.equal(e3.type, E2StoreEntryType.CompressedSignedBeaconBlock); + }); + + it("reads the state slotIndex (count=1)", () => { + const stateIndex = readSlotIndex(data, "state"); + assert.equal(stateIndex.offsets.length, 1); + assert.equal(Number(stateIndex.startSlot), stateStartSlot); + }); + + it("getEraIndexes(): returns aligned block+state indices", () => { + const idx = getEraIndexes(data, expectedEra); + assert.equal(Number(idx.stateSlotIndex.startSlot), stateStartSlot); + if (!idx.blockSlotIndex) throw new Error("blockSlotIndex is undefined"); + assert.equal(Number(idx.blockSlotIndex.startSlot), blockStartSlot); + }); +}); diff --git a/packages/era/test/unit/era.unit.test.ts b/packages/era/test/unit/era.unit.test.ts new file mode 100644 index 000000000000..2e1b73928f80 --- /dev/null +++ b/packages/era/test/unit/era.unit.test.ts @@ -0,0 +1,58 @@ +import {assert, describe, it} from "vitest"; +import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, readEntry} from "../../src/index.js"; + +function header(typeBytes: Uint8Array, dataLen: number): Uint8Array { + const h = new Uint8Array(8); + h[0] = typeBytes[0]; + h[1] = typeBytes[1]; + // 4-byte LE length + h[2] = dataLen & 0xff; + h[3] = (dataLen >> 8) & 0xff; + h[4] = (dataLen >> 16) & 0xff; + h[5] = (dataLen >> 24) & 0xff; + // reserved = 0x0000 + h[6] = 0x00; + h[7] = 0x00; + return h; +} + +describe("e2Store utilities (unit)", () => { + it("should read the type and data correctly", () => { + const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const bytes = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], payload.length), ...payload]); + + const entry = readEntry(bytes); + assert.equal(entry.type, E2StoreEntryType.Version); + assert.deepEqual(entry.data, payload); + }); + + it("should throw on entry with invalid length", () => { + const bad = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], 25), 0x01, 0x02, 0x03, 0x04]); + + try { + readEntry(bad); + assert.fail("should have thrown on invalid data length"); + } catch (err) { + assert.instanceOf(err, Error); + } + }); + + it("should iterate and read multiple entries ", () => { + const firstPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const first = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], firstPayload.length), ...firstPayload]); + const second = header(EraTypes[E2StoreEntryType.Empty], 0); + const bytes = new Uint8Array([...first, ...second]); + + const entries: Array> = []; + let p = 0; + while (p + E2STORE_HEADER_SIZE <= bytes.length) { + const e = readEntry(bytes.slice(p)); + entries.push(e); + p += E2STORE_HEADER_SIZE + e.data.length; + } + + assert.equal(entries.length, 2); + assert.equal(entries[0].type, E2StoreEntryType.Version); + assert.equal(entries[1].type, E2StoreEntryType.Empty); + }); +}); From f710bcf88233f22968e71e05ce772649a34f3799 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Mon, 1 Sep 2025 21:25:25 +0530 Subject: [PATCH 11/34] add block reading --- packages/era/src/helpers.ts | 68 ++++++++++++++++++ .../era/test/unit/era.integration.test.ts | 69 ++++++++++++------- 2 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index 236f0aec17cf..016d9f184c2c 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -61,6 +61,9 @@ function readInt64(bytes: Uint8Array, offset: number): bigint { * Read slot index from end of era file with validation */ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { + if (bytes.length < 8) { + throw new Error("Buffer too small for SlotIndex count"); + } const countOffset = bytes.length - 8; const eofCount = Number(readInt64(bytes, countOffset)); @@ -222,3 +225,68 @@ export function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlo throw new Error(`Failed to deserialize SignedBeaconBlock for slot ${blockSlot}: ${error}`); } } + +/** + * Read & decode the single era BeaconState from an .era file. + */ +export function readBeaconStateFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { + const {stateSlotIndex} = getEraIndexes(eraBytes, expectedEra); + + const offset = stateSlotIndex.offsets[0]; + if (!offset) throw new Error("No BeaconState in this era (stateSlotIndex offset is 0)"); + + const entry = readEntry(eraBytes.slice(offset)); + if (entry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState at 0x${offset.toString(16)}, got ${entry.type}`); + } + + const era = expectedEra ?? Math.floor(stateSlotIndex.startSlot / SLOTS_PER_HISTORICAL_ROOT); + return decompressBeaconState(entry.data, era, config); +} + +/** + * Read & decode a SignedBeaconBlock at a given offset inside the era’s block index. + */ +export function readBeaconBlockFromEra( + eraBytes: Uint8Array, + blockOffset: number, + config: ChainForkConfig, + expectedEra?: number +) { + if (blockOffset < 0 || blockOffset >= SLOTS_PER_HISTORICAL_ROOT) { + throw new RangeError(`blockOffset out of range: ${blockOffset}`); + } + + const {blockSlotIndex} = getEraIndexes(eraBytes, expectedEra); + if (!blockSlotIndex) throw new Error("No block SlotIndex present in this era file"); + + const abs = blockSlotIndex.offsets[blockOffset]; + if (!abs) throw new Error(`No block at offset ${blockOffset} (empty slot)`); + + const entry = readEntry(eraBytes.slice(abs)); + if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock at 0x${abs.toString(16)}, got ${entry.type}`); + } + + const slot = blockSlotIndex.startSlot + blockOffset; + return decompressSignedBeaconBlock(entry.data, slot, config); +} + +/** + * Iterate all SignedBeaconBlocks in an era (skips empty slots). + */ +export function* readBlocksFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { + const {blockSlotIndex} = getEraIndexes(eraBytes, expectedEra); + if (!blockSlotIndex) return; + + for (let i = 0; i < blockSlotIndex.offsets.length; i++) { + const abs = blockSlotIndex.offsets[i]; + if (!abs) continue; + + const entry = readEntry(eraBytes.slice(abs)); + if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) continue; + + const slot = blockSlotIndex.startSlot + i; + yield decompressSignedBeaconBlock(entry.data, slot, config); + } +} diff --git a/packages/era/test/unit/era.integration.test.ts b/packages/era/test/unit/era.integration.test.ts index 8fa8a21186ac..67d7e9ce0fc4 100644 --- a/packages/era/test/unit/era.integration.test.ts +++ b/packages/era/test/unit/era.integration.test.ts @@ -3,7 +3,9 @@ import path from "node:path"; import {fileURLToPath} from "node:url"; import {assert, beforeAll, describe, it} from "vitest"; -import {E2STORE_HEADER_SIZE, E2StoreEntryType, getEraIndexes, readEntry, readSlotIndex} from "../../lib/index.js"; +import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; +import {config as defaultConfig} from "@lodestar/config/default"; +import {getEraIndexes, readBeaconBlockFromEra, readBeaconStateFromEra, readBlocksFromEra} from "../../src/index.js"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; @@ -12,6 +14,7 @@ const __dirname = path.dirname(__filename); describe.runIf(!process.env.CI)("era file (integration)", () => { let data: Uint8Array; + let cfg: ChainForkConfig; const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); const expectedEra = 1506; const stateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; @@ -23,36 +26,56 @@ describe.runIf(!process.env.CI)("era file (integration)", () => { } catch { throw new Error(". Run the downloader script first:\n ./packages/era/test/era_downloader.sh\n"); } + cfg = createChainForkConfig(defaultConfig); }); - it("first entry is Version", () => { - const entry = readEntry(data); - assert.equal(entry.type, E2StoreEntryType.Version); - assert.equal(entry.data.length, 0); + // Low-level entry and raw index checks are covered in unit tests + + it("getEraIndexes returns aligned block and state indices", () => { + const idx = getEraIndexes(data, expectedEra); + assert.equal(Number(idx.stateSlotIndex.startSlot), stateStartSlot); + if (!idx.blockSlotIndex) throw new Error("blockSlotIndex is undefined"); + assert.equal(Number(idx.blockSlotIndex.startSlot), blockStartSlot); }); - it("second and third entries are blocks", () => { - let p = 0; - const e1 = readEntry(data); - p += E2STORE_HEADER_SIZE + e1.data.length; - const e2 = readEntry(data.slice(p)); - p += E2STORE_HEADER_SIZE + e2.data.length; - const e3 = readEntry(data.slice(p)); + it("readBeaconStateFromEra decodes state for the expected era", () => { + const state = readBeaconStateFromEra(data, cfg, expectedEra); + assert.equal(Number(state.slot), stateStartSlot); + }); - assert.equal(e2.type, E2StoreEntryType.CompressedSignedBeaconBlock); - assert.equal(e3.type, E2StoreEntryType.CompressedSignedBeaconBlock); + it("readBeaconStateFromEra infers the era when not provided", () => { + const state = readBeaconStateFromEra(data, cfg); + assert.equal(Number(state.slot), stateStartSlot); }); - it("reads the state slotIndex (count=1)", () => { - const stateIndex = readSlotIndex(data, "state"); - assert.equal(stateIndex.offsets.length, 1); - assert.equal(Number(stateIndex.startSlot), stateStartSlot); + it("readBeaconBlockFromEra decodes a non-empty block by offset", () => { + const {blockSlotIndex} = getEraIndexes(data, expectedEra); + if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); + const idx = blockSlotIndex.offsets.findIndex((o) => o !== 0); + if (idx === -1) throw new Error("no non-empty block slots found in this era"); + const block = readBeaconBlockFromEra(data, idx, cfg, expectedEra); + assert.equal(Number(block.message.slot), blockSlotIndex.startSlot + idx); }); - it("getEraIndexes(): returns aligned block+state indices", () => { - const idx = getEraIndexes(data, expectedEra); - assert.equal(Number(idx.stateSlotIndex.startSlot), stateStartSlot); - if (!idx.blockSlotIndex) throw new Error("blockSlotIndex is undefined"); - assert.equal(Number(idx.blockSlotIndex.startSlot), blockStartSlot); + it("readBeaconBlockFromEra works without expectedEra", () => { + const {blockSlotIndex} = getEraIndexes(data, expectedEra); + if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); + const idx = blockSlotIndex.offsets.findIndex((o) => o !== 0); + if (idx === -1) throw new Error("no non-empty block slots found in this era"); + const block = readBeaconBlockFromEra(data, idx, cfg); + assert.equal(Number(block.message.slot), blockSlotIndex.startSlot + idx); + }); + + it("readBlocksFromEra yields at least one block in order", () => { + const {blockSlotIndex} = getEraIndexes(data, expectedEra); + if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); + const firstIdx = blockSlotIndex.offsets.findIndex((o) => o !== 0); + if (firstIdx === -1) throw new Error("no non-empty block slots found in this era"); + const it = readBlocksFromEra(data, cfg, expectedEra); + const first = it.next(); + assert.equal(first.done, false); + if (!first.done) { + assert.equal(Number(first.value.message.slot), blockSlotIndex.startSlot + firstIdx); + } }); }); From bd9a39bff8c0ac59aa1ef11fa26a590f1f109ca2 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sun, 7 Sep 2025 03:29:33 +0530 Subject: [PATCH 12/34] add era writing and chores --- packages/era/src/helpers.ts | 289 +++++++++++++++--- packages/era/src/types.ts | 4 +- .../unit/era.readwrite.integration.test.ts | 250 +++++++++++++++ packages/era/test/unit/era.unit.test.ts | 18 +- 4 files changed, 506 insertions(+), 55 deletions(-) create mode 100644 packages/era/test/unit/era.readwrite.integration.test.ts diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index 016d9f184c2c..aa6e4c790ac8 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -1,10 +1,31 @@ import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {Uint8ArrayList} from "uint8arraylist"; import {SnappyFramesUncompress} from "../../reqresp/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; -import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes} from "./constants.js"; +import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, VERSION_RECORD_BYTES} from "./constants.js"; import type {E2StoreEntry, SlotIndex} from "./types.js"; +/** + * Cache fork types by epoch. Fork transitions occur at epoch boundaries, + * so caching by epoch is safe and efficient. + */ +type ForkTypes = ReturnType; +const forkTypesByEpoch = new Map(); +function getForkTypesCached(config: ChainForkConfig, slot: number): ForkTypes { + const epoch = Math.floor(slot / SLOTS_PER_EPOCH); + let types = forkTypesByEpoch.get(epoch); + if (!types) { + types = config.getForkTypes(slot); + forkTypesByEpoch.set(epoch, types); + } + return types; +} + +/** Shared 8-byte scratch and DataView to avoid per-call allocations for i64 read/write */ +const scratch64 = new ArrayBuffer(8); +const scratch64View = new DataView(scratch64); +const scratch64Bytes = new Uint8Array(scratch64); + /** * Read an e2Store entry (header + data) * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0) @@ -15,7 +36,7 @@ export function readEntry(bytes: Uint8Array): E2StoreEntry { } // validate entry type from first 2 bytes - const typeBytes = bytes.slice(0, 2); + const typeBytes = bytes.subarray(0, 2); const typeEntry = Object.entries(EraTypes).find( ([, expectedBytes]) => typeBytes[0] === expectedBytes[0] && typeBytes[1] === expectedBytes[1] ); @@ -44,21 +65,22 @@ export function readEntry(bytes: Uint8Array): E2StoreEntry { } const dataStartOffset = E2STORE_HEADER_SIZE; - const data = bytes.slice(dataStartOffset, dataStartOffset + length); + const data = bytes.subarray(dataStartOffset, dataStartOffset + length); return {type, data}; } -/** - * Read 64-bit little-endian integer - */ +/** Read 64-bit signed integer (little-endian) at offset. */ function readInt64(bytes: Uint8Array, offset: number): bigint { - const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8); - return view.getBigInt64(0, true); + // Copy 8 bytes into shared scratch, then read via shared DataView + scratch64Bytes.set(bytes.subarray(offset, offset + 8)); + return scratch64View.getBigInt64(0, true); } /** - * Read slot index from end of era file with validation + * Read a SlotIndex from the end of the buffer with validation. + * Validates expected count, entry type and payload size, offset bounds, + * and trailing count. */ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { if (bytes.length < 8) { @@ -86,7 +108,7 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" } // Read and validate the slot index entry - const entry = readEntry(bytes.slice(indexStart)); + const entry = readEntry(bytes.subarray(indexStart)); if (entry.type !== E2StoreEntryType.SlotIndex) { throw new Error(`Expected SlotIndex entry, got ${entry.type}`); } @@ -111,16 +133,16 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" if (relativeOffset === 0n) { offsets.push(0); } else { - // Convert relative offset to absolute position with bounds validation + // Convert relative offset to absolute header position with bounds validation const indexHeaderStart = BigInt(indexStart); - const absoluteOffset = indexHeaderStart + relativeOffset; - if (absoluteOffset < 0n || absoluteOffset >= BigInt(bytes.length)) { + const absoluteHeaderOffset = indexHeaderStart + relativeOffset; + if (absoluteHeaderOffset < 0n || absoluteHeaderOffset >= BigInt(bytes.length)) { throw new Error( - `Invalid absolute offset: ${absoluteOffset} (relative: ${relativeOffset}, ` + + `Invalid absolute offset: ${absoluteHeaderOffset} (relative: ${relativeOffset}, ` + `indexStart: ${indexStart}, fileSize: ${bytes.length})` ); } - offsets.push(Number(absoluteOffset)); + offsets.push(Number(absoluteHeaderOffset)); } } @@ -141,7 +163,7 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" } /** - * Gets both slot indices from era file with validation and alignment checks + * Read state and block SlotIndex entries from an era file and validate alignment. */ export function getEraIndexes( eraBytes: Uint8Array, @@ -163,7 +185,7 @@ export function getEraIndexes( // Read block index if not genesis era (era 0) let blockSlotIndex: SlotIndex | undefined; if (stateSlotIndex.startSlot > 0) { - const blockIndexBytes = eraBytes.slice(0, stateSlotIndex.recordStart); + const blockIndexBytes = eraBytes.subarray(0, stateSlotIndex.recordStart); blockSlotIndex = readSlotIndex(blockIndexBytes, "block"); // Validate block and state indices are properly aligned @@ -179,9 +201,7 @@ export function getEraIndexes( return {stateSlotIndex, blockSlotIndex}; } -/** - * Decompresses snappy-framed data using Lodestar's spec-compliant decompressor - */ +/** Decompress snappy-framed data using Lodestar's spec-compliant decompressor. */ function decompressFrames(compressedData: Uint8Array): Uint8Array { const decompressor = new SnappyFramesUncompress(); @@ -195,58 +215,235 @@ function decompressFrames(compressedData: Uint8Array): Uint8Array { return result.subarray(); } -/** - * Decompresses and deserializes a beacon state using the correct fork for the era - */ -export function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { +/** Decompress and deserialize a BeaconState using the appropriate fork for the era. */ +export function decompressBeaconState( + compressedData: Uint8Array, + era: number, + config: ChainForkConfig, + forkTypes?: ForkTypes +) { const uncompressed = decompressFrames(compressedData); const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; - const forkTypes = config.getForkTypes(stateSlot); + const types = forkTypes ?? getForkTypesCached(config, stateSlot); try { - return forkTypes.BeaconState.deserialize(uncompressed); + return types.BeaconState.deserialize(uncompressed); } catch (error) { throw new Error(`Failed to deserialize BeaconState for era ${era}, slot ${stateSlot}: ${error}`); } } -/** - * Decompresses and deserializes a signed beacon block using the correct fork for the block slot - */ -export function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { +/** Decompress and deserialize a SignedBeaconBlock using the fork for the given slot. */ +export function decompressSignedBeaconBlock( + compressedData: Uint8Array, + blockSlot: number, + config: ChainForkConfig, + forkTypes?: ForkTypes +) { const uncompressed = decompressFrames(compressedData); - const forkTypes = config.getForkTypes(blockSlot); + const types = forkTypes ?? getForkTypesCached(config, blockSlot); try { - return forkTypes.SignedBeaconBlock.deserialize(uncompressed); + return types.SignedBeaconBlock.deserialize(uncompressed); } catch (error) { throw new Error(`Failed to deserialize SignedBeaconBlock for slot ${blockSlot}: ${error}`); } } +export type SnappyFramedCompress = (ssz: Uint8Array) => Uint8Array; + /** - * Read & decode the single era BeaconState from an .era file. + * Write a single E2Store TLV entry (header + payload) + * Header layout: type[2] | length u32 LE | reserved u16(=0) */ +export function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { + if (type2.length !== 2) throw new Error("type must be 2 bytes"); + const out = new Uint8Array(E2STORE_HEADER_SIZE + payload.length); + // type + out[0] = type2[0]; + out[1] = type2[1]; + // length u32 LE + out[2] = payload.length & 0xff; + out[3] = (payload.length >>> 8) & 0xff; + out[4] = (payload.length >>> 16) & 0xff; + out[5] = (payload.length >>> 24) & 0xff; + // reserved u16 = 0 at [6..7] + out.set(payload, E2STORE_HEADER_SIZE); + return out; +} + +/** Encode a 64-bit signed integer (little-endian) into a new Uint8Array. */ +export function writeI64LE(v: bigint): Uint8Array { + // Allocates a single 8-byte output but reuses the shared DataView for encoding + scratch64View.setBigInt64(0, v, true); + const out = new Uint8Array(8); + out.set(scratch64Bytes); + return out; +} + +/** In-place encode of a 64-bit signed integer (little-endian) into target at offset. */ +function writeI64LEInto(target: Uint8Array, offset: number, v: bigint): void { + scratch64View.setBigInt64(0, v, true); + target.set(scratch64Bytes, offset); +} +export function readI64LE(buf: Uint8Array, off: number): bigint { + const dv = new DataView(buf.buffer, buf.byteOffset + off, 8); + return dv.getBigInt64(0, true); +} +/** + * Build SlotIndex payload: startSlot | offsets[count] | count. + * Offsets are i64 relative to the index record start (0 = missing). + * Payload size = count*8 + 16 (header not included). + */ +export function buildSlotIndexData( + startSlot: number, + offsetsAbs: readonly number[], + indexRecordStart: number +): Uint8Array { + const count = offsetsAbs.length; + const payload = new Uint8Array(count * 8 + 16); + + // startSlot + writeI64LEInto(payload, 0, BigInt(startSlot)); + + // offsets (relative to beginning of index record) + let off = 8; + for (let i = 0; i < count; i++, off += 8) { + const abs = offsetsAbs[i]; + const rel = abs === 0 ? 0n : BigInt(abs - indexRecordStart); + writeI64LEInto(payload, off, rel); + } + + // trailing count + writeI64LEInto(payload, 8 + count * 8, BigInt(count)); + return payload; +} + +/** Compressed record helpers (snappy framed) */ +export function writeCompressedBlock(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { + const framed = snappyFramed(ssz); + return writeEntry(EraTypes[E2StoreEntryType.CompressedSignedBeaconBlock], framed); +} + +export function writeCompressedState(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { + const framed = snappyFramed(ssz); + return writeEntry(EraTypes[E2StoreEntryType.CompressedBeaconState], framed); +} + +/** Concatenate an array of Uint8Array into a single Uint8Array. */ +function concat(chunks: Uint8Array[]): Uint8Array { + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let p = 0; + for (const c of chunks) { + out.set(c, p); + p += c.length; + } + return out; +} + +/** + * Write a single era group to bytes. + * Layout: Version | block* | era-state | SlotIndex(block)? | SlotIndex(state) + * Genesis (era 0): omit block index; always include state index (count=1). + */ +export function writeEraGroup(params: { + era: number; + slotsPerHistoricalRoot: number; + snappyFramed: SnappyFramedCompress; + blocksBySlot: Map; + stateSlot: number; + stateSSZ: Uint8Array; +}): Uint8Array { + const {era, slotsPerHistoricalRoot: SPR, snappyFramed, blocksBySlot, stateSlot, stateSSZ} = params; + + if (stateSlot !== era * SPR) throw new Error(`stateSlot must be era*SPR (${era * SPR}), got ${stateSlot}`); + + const chunks: Uint8Array[] = []; + let cursor = 0; + const push = (b: Uint8Array) => { + chunks.push(b); + cursor += b.length; + }; + + // 1) Version (begin group) + push(VERSION_RECORD_BYTES); + + // 2) Blocks window + const firstBlockSlot = era === 0 ? 0 : stateSlot - SPR; + const blockOffsetsAbs = new Array(era === 0 ? 0 : SPR).fill(0); + if (era > 0) { + for (let slot = firstBlockSlot; slot < stateSlot; slot++) { + const ssz = blocksBySlot.get(slot); + if (!ssz) continue; // empty slot + const rec = writeCompressedBlock(ssz, snappyFramed); + const headerPos = cursor; + push(rec); + // Store header-start offsets (legacy/header-start semantics) + blockOffsetsAbs[slot - firstBlockSlot] = headerPos; + } + } + + // 3) State (exactly one) + const stateRec = writeCompressedState(stateSSZ, snappyFramed); + const stateHeaderPos = cursor; + push(stateRec); + + // 4) Block index (omit for genesis) + if (era > 0) { + const idxHeaderStart = cursor; // beginning of SlotIndex entry + const data = buildSlotIndexData(firstBlockSlot, blockOffsetsAbs, idxHeaderStart); + push(writeEntry(EraTypes[E2StoreEntryType.SlotIndex], data)); + } + + // 5) State index (count=1; startSlot = stateSlot) + { + const idxHeaderStart = cursor; + const data = buildSlotIndexData(stateSlot, [stateHeaderPos], idxHeaderStart); + push(writeEntry(EraTypes[E2StoreEntryType.SlotIndex], data)); + } + + return concat(chunks); +} + +/** Write an ERA file from one or more groups (concatenated). */ +export function writeEraFile( + groups: Array<{ + era: number; + slotsPerHistoricalRoot: number; + snappyFramed: SnappyFramedCompress; + blocksBySlot: Map; + stateSlot: number; + stateSSZ: Uint8Array; + }> +): Uint8Array { + const chunks: Uint8Array[] = []; + for (const g of groups) { + chunks.push(writeEraGroup(g)); + } + return concat(chunks); +} + +/** Read and decode the BeaconState from an .era file (single era). */ export function readBeaconStateFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { const {stateSlotIndex} = getEraIndexes(eraBytes, expectedEra); const offset = stateSlotIndex.offsets[0]; if (!offset) throw new Error("No BeaconState in this era (stateSlotIndex offset is 0)"); - const entry = readEntry(eraBytes.slice(offset)); + const entry = readEntry(eraBytes.subarray(offset)); if (entry.type !== E2StoreEntryType.CompressedBeaconState) { throw new Error(`Expected CompressedBeaconState at 0x${offset.toString(16)}, got ${entry.type}`); } const era = expectedEra ?? Math.floor(stateSlotIndex.startSlot / SLOTS_PER_HISTORICAL_ROOT); - return decompressBeaconState(entry.data, era, config); + const types = getForkTypesCached(config, stateSlotIndex.startSlot); + return decompressBeaconState(entry.data, era, config, types); } -/** - * Read & decode a SignedBeaconBlock at a given offset inside the era’s block index. - */ +/** Read and decode a SignedBeaconBlock at the given offset in the block index. */ export function readBeaconBlockFromEra( eraBytes: Uint8Array, blockOffset: number, @@ -263,18 +460,17 @@ export function readBeaconBlockFromEra( const abs = blockSlotIndex.offsets[blockOffset]; if (!abs) throw new Error(`No block at offset ${blockOffset} (empty slot)`); - const entry = readEntry(eraBytes.slice(abs)); + const entry = readEntry(eraBytes.subarray(abs)); if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { throw new Error(`Expected CompressedSignedBeaconBlock at 0x${abs.toString(16)}, got ${entry.type}`); } const slot = blockSlotIndex.startSlot + blockOffset; - return decompressSignedBeaconBlock(entry.data, slot, config); + const types = getForkTypesCached(config, slot); + return decompressSignedBeaconBlock(entry.data, slot, config, types); } -/** - * Iterate all SignedBeaconBlocks in an era (skips empty slots). - */ +/** Iterate all SignedBeaconBlocks in an era (skips empty slots). */ export function* readBlocksFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { const {blockSlotIndex} = getEraIndexes(eraBytes, expectedEra); if (!blockSlotIndex) return; @@ -283,10 +479,11 @@ export function* readBlocksFromEra(eraBytes: Uint8Array, config: ChainForkConfig const abs = blockSlotIndex.offsets[i]; if (!abs) continue; - const entry = readEntry(eraBytes.slice(abs)); + const entry = readEntry(eraBytes.subarray(abs)); if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) continue; const slot = blockSlotIndex.startSlot + i; - yield decompressSignedBeaconBlock(entry.data, slot, config); + const types = getForkTypesCached(config, slot); + yield decompressSignedBeaconBlock(entry.data, slot, config, types); } } diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index 7a610a883c48..a5ac6107eab7 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -3,15 +3,13 @@ import {E2StoreEntryType} from "./constants.js"; /** * Parsed components of an .era file name. - * Format: ---.era + * Format: --.era */ export interface EraFileName { /** CONFIG_NAME field of runtime config (mainnet, sepolia, holesky, etc.) */ configName: string; /** Number of the first era stored in file, 5-digit zero-padded (00000, 00001, etc.) */ eraNumber: number; - /** Number of eras stored in file, 5-digit zero-padded (00000, 00001, etc.) */ - eraCount: number; /** First 4 bytes of last historical root, lower-case hex-encoded (8 chars) */ shortHistoricalRoot: string; } diff --git a/packages/era/test/unit/era.readwrite.integration.test.ts b/packages/era/test/unit/era.readwrite.integration.test.ts new file mode 100644 index 000000000000..7601a77496fc --- /dev/null +++ b/packages/era/test/unit/era.readwrite.integration.test.ts @@ -0,0 +1,250 @@ +import {existsSync, mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {assert, beforeAll, describe, it} from "vitest"; + +import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; +import {config as defaultConfig} from "@lodestar/config/default"; +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {encodeSnappy} from "../../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.js"; + +import { + E2StoreEntryType, + decompressBeaconState, + decompressSignedBeaconBlock, + getEraIndexes, + readEntry, + readI64LE, + writeEraGroup, +} from "../../src/index.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe.runIf(!process.env.CI)("read original era and re-write our own era file", () => { + let data: Uint8Array; + let cfg: ChainForkConfig; + const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); + const expectedEra = 1506; + + beforeAll(() => { + try { + data = new Uint8Array(readFileSync(eraPath)); + } catch { + throw new Error(". Run the downloader script first:\n ./packages/era/test/era_downloader.sh\n"); + } + cfg = createChainForkConfig(defaultConfig); + }); + + let snappyTimeMs = 0; + async function encodeSnappyToUint8Array(data: Uint8Array): Promise { + const t0 = Date.now(); + const buffers: Buffer[] = []; + for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { + buffers.push(chunk); + } + snappyTimeMs += Date.now() - t0; + const total = buffers.reduce((n, b) => n + b.length, 0); + const out = new Uint8Array(total); + let p = 0; + for (const b of buffers) { + out.set(b, p); + p += b.length; + } + return out; + } + const framedBySSZ = new WeakMap(); + function snappyFramed(data: Uint8Array): Uint8Array { + const out = framedBySSZ.get(data); + if (!out) throw new Error("missing precomputed snappy frame for provided SSZ bytes"); + return out; + } + + it("reads an existing era group and writes a full group that round-trips", async () => { + console.log("stage: read+parse indexes"); + const {stateSlotIndex, blockSlotIndex} = getEraIndexes(data, expectedEra); + + // Build uncompressed SSZ for state from original file + const stateOffset = stateSlotIndex.offsets[0]; + assert.ok(stateOffset > 0, "original state must exist"); + const stateEntry = readEntry(data.subarray(stateOffset)); + assert.equal(stateEntry.type, E2StoreEntryType.CompressedBeaconState); + let scanDeserializeMs = 0; + let serializeMs = 0; + let tlvWriteMs = 0; + + console.log("stage: state decompress+serialize"); + // Decompress + deserialize state + const stateValue = (() => { + const t0 = Date.now(); + const v = decompressBeaconState(stateEntry.data, expectedEra, cfg); + scanDeserializeMs += Date.now() - t0; + return v; + })(); + const stateSlot = stateSlotIndex.startSlot; + const stateSSZ = (() => { + const t0 = Date.now(); + const ssz = cfg.getForkTypes(stateSlot).BeaconState.serialize(stateValue); + serializeMs += Date.now() - t0; + return ssz; + })(); + + console.log("stage: scan+serialize blocks window"); + // Build all available blocks in the window as SSZ + const SPR = SLOTS_PER_HISTORICAL_ROOT; + const blocksBySlot = new Map(); + let originalNonEmpty = 0; + let scanned = 0; + if (blockSlotIndex) { + for (let i = 0; i < blockSlotIndex.offsets.length; i++) { + const abs = blockSlotIndex.offsets[i]; + scanned++; + if (!abs) continue; + originalNonEmpty++; + const entry = readEntry(data.subarray(abs)); + if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) continue; + const slot = blockSlotIndex.startSlot + i; + const blockValue = (() => { + const t0 = Date.now(); + const v = decompressSignedBeaconBlock(entry.data, slot, cfg); + scanDeserializeMs += Date.now() - t0; + return v; + })(); + const blockSSZ = (() => { + const t0 = Date.now(); + const ssz = cfg.getForkTypes(slot).SignedBeaconBlock.serialize(blockValue); + serializeMs += Date.now() - t0; + return ssz; + })(); + blocksBySlot.set(slot, blockSSZ); + + if ((scanned & 0x1ff) === 0) { + console.log(`progress: scanned ${scanned}/${SPR} slots, non-empty ${originalNonEmpty}`); + } + } + } + console.log("stage: precompute snappy frames (original encoder)"); + // Precompute snappy frames + framedBySSZ.set(stateSSZ, await encodeSnappyToUint8Array(stateSSZ)); + for (const ssz of blocksBySlot.values()) { + framedBySSZ.set(ssz, await encodeSnappyToUint8Array(ssz)); + } + console.log(`stage: blocks prepared (non-empty=${originalNonEmpty})`); + + // Write a new era group and then a full era file with that single group + console.log("stage: write group (snappy+TLV+index)"); + const groupBytes = (() => { + const t0 = Date.now(); + const bytes = writeEraGroup({ + era: expectedEra, + slotsPerHistoricalRoot: SPR, + snappyFramed, + blocksBySlot, + stateSlot, + stateSSZ, + }); + tlvWriteMs += Date.now() - t0; + return bytes; + })(); + console.log(`stage: validate new group (bytes=${groupBytes.length})`); + + // Validate group round-trip + const newIdx = getEraIndexes(groupBytes, expectedEra); + assert.equal(newIdx.stateSlotIndex.startSlot, stateSlot); + assert.equal(stateSlot, expectedEra * SPR); + if (blockSlotIndex) { + const bsi = newIdx.blockSlotIndex; + assert.ok(bsi); + assert.equal(bsi.startSlot, blockSlotIndex.startSlot); + assert.equal(bsi.offsets.length, SPR); + assert.equal(bsi.startSlot, stateSlot - SPR); + const newNonEmpty = bsi.offsets.filter((o) => o !== 0).length; + assert.equal(newNonEmpty, originalNonEmpty); + } else { + assert.equal(newIdx.blockSlotIndex, undefined); + } + + // Validate state decodes from new file + const newStateOffset = newIdx.stateSlotIndex.offsets[0]; + const newStateEntry = readEntry(groupBytes.subarray(newStateOffset)); + assert.equal(newStateEntry.type, E2StoreEntryType.CompressedBeaconState); + const newState = decompressBeaconState(newStateEntry.data, expectedEra, cfg); + assert.equal(Number(newState.slot), stateSlot); + + // State index: count=1, relative = headerStart - indexHeaderStart (header-start semantics) + { + const ssi = newIdx.stateSlotIndex; + const ssiEntry = readEntry(groupBytes.subarray(ssi.recordStart)); + // startSlot(8) + offsets(8) + count(8) + const recordedRel = readI64LE(ssiEntry.data, 8); + const expectedRel = BigInt(newStateOffset - ssi.recordStart); + assert.equal(recordedRel, expectedRel); + } + + // Block index (if present): each non-zero offset obeys the same relation + if (newIdx.blockSlotIndex) { + const bsi = newIdx.blockSlotIndex; + const bsiEntry = readEntry(groupBytes.subarray(bsi.recordStart)); + for (let i = 0; i < bsi.offsets.length; i++) { + const headerStart = bsi.offsets[i]; + const rel = readI64LE(bsiEntry.data, 8 + i * 8); + if (headerStart === 0) { + assert.equal(rel, 0n); + } else { + const expectedRel = BigInt(headerStart - bsi.recordStart); + assert.equal(rel, expectedRel); + } + } + } + + // Validate first and last non-empty blocks decode from new file + if (newIdx.blockSlotIndex) { + const offsets = newIdx.blockSlotIndex.offsets; + const firstIdx = offsets.findIndex((o) => o !== 0); + let lastIdx = -1; + for (let i = offsets.length - 1; i >= 0; i--) { + if (offsets[i] !== 0) { + lastIdx = i; + break; + } + } + if (firstIdx !== -1) { + const off = offsets[firstIdx]; + const be = readEntry(groupBytes.subarray(off)); + assert.equal(be.type, E2StoreEntryType.CompressedSignedBeaconBlock); + const slot = newIdx.blockSlotIndex.startSlot + firstIdx; + const blk = decompressSignedBeaconBlock(be.data, slot, cfg); + assert.equal(Number(blk.message.slot), slot); + } + if (lastIdx !== -1 && lastIdx !== firstIdx) { + const off2 = offsets[lastIdx]; + const be2 = readEntry(groupBytes.subarray(off2)); + assert.equal(be2.type, E2StoreEntryType.CompressedSignedBeaconBlock); + const slot2 = newIdx.blockSlotIndex.startSlot + lastIdx; + const blk2 = decompressSignedBeaconBlock(be2.data, slot2, cfg); + assert.equal(Number(blk2.message.slot), slot2); + } + + // For remaining non-empty blocks, validate TLV type/length without decoding SSZ + for (let i = 0; i < offsets.length; i++) { + if (i === firstIdx || i === lastIdx) continue; + const off = offsets[i]; + if (!off) continue; + const e = readEntry(groupBytes.subarray(off)); + assert.equal(e.type, E2StoreEntryType.CompressedSignedBeaconBlock); + assert.ok(e.data.length >= 0); + } + } + + // Write the produced ERA file + console.log("stage: write to disk"); + const outDir = path.resolve(__dirname, "../out"); + if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); + const outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-rewrite.era`); + writeFileSync(outFile, groupBytes); + + console.log( + `timings(ms): scan+deserialize=${scanDeserializeMs} serialize=${serializeMs} snappy=${snappyTimeMs} tlvWrite+index=${tlvWriteMs}` + ); + }, 120000); +}); diff --git a/packages/era/test/unit/era.unit.test.ts b/packages/era/test/unit/era.unit.test.ts index 2e1b73928f80..51fb4f452cd1 100644 --- a/packages/era/test/unit/era.unit.test.ts +++ b/packages/era/test/unit/era.unit.test.ts @@ -19,10 +19,12 @@ function header(typeBytes: Uint8Array, dataLen: number): Uint8Array { describe("e2Store utilities (unit)", () => { it("should read the type and data correctly", () => { const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const bytes = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], payload.length), ...payload]); + const ver = header(EraTypes[E2StoreEntryType.Version], 0); + const bytes = new Uint8Array([...ver, ...header(EraTypes[E2StoreEntryType.Empty], payload.length), ...payload]); - const entry = readEntry(bytes); - assert.equal(entry.type, E2StoreEntryType.Version); + // Read the second entry (Empty with payload) + const entry = readEntry(bytes.slice(E2STORE_HEADER_SIZE)); + assert.equal(entry.type, E2StoreEntryType.Empty); assert.deepEqual(entry.data, payload); }); @@ -39,9 +41,10 @@ describe("e2Store utilities (unit)", () => { it("should iterate and read multiple entries ", () => { const firstPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const first = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], firstPayload.length), ...firstPayload]); + const ver = header(EraTypes[E2StoreEntryType.Version], 0); + const first = new Uint8Array([...header(EraTypes[E2StoreEntryType.Empty], firstPayload.length), ...firstPayload]); const second = header(EraTypes[E2StoreEntryType.Empty], 0); - const bytes = new Uint8Array([...first, ...second]); + const bytes = new Uint8Array([...ver, ...first, ...second]); const entries: Array> = []; let p = 0; @@ -51,8 +54,11 @@ describe("e2Store utilities (unit)", () => { p += E2STORE_HEADER_SIZE + e.data.length; } - assert.equal(entries.length, 2); + assert.equal(entries.length, 3); assert.equal(entries[0].type, E2StoreEntryType.Version); + assert.equal(entries[0].data.length, 0); assert.equal(entries[1].type, E2StoreEntryType.Empty); + assert.deepEqual(entries[1].data, firstPayload); + assert.equal(entries[2].type, E2StoreEntryType.Empty); }); }); From 1c211d144f144063a7c420b400b0710875445559 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sat, 4 Oct 2025 17:12:43 +0530 Subject: [PATCH 13/34] refactor --- packages/era/src/helpers.ts | 618 ++++++++++++++---- packages/era/src/index.ts | 2 +- packages/era/src/types.ts | 35 + .../era/test/unit/era.integration.test.ts | 81 --- .../unit/era.readwrite.integration.test.ts | 351 ++++------ 5 files changed, 671 insertions(+), 416 deletions(-) delete mode 100644 packages/era/test/unit/era.integration.test.ts diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index aa6e4c790ac8..2953017542e4 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -1,25 +1,14 @@ -import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import type {FileHandle} from "node:fs/promises"; +import {open, readFile, writeFile} from "node:fs/promises"; +import {basename} from "node:path"; import {Uint8ArrayList} from "uint8arraylist"; -import {SnappyFramesUncompress} from "../../reqresp/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; +import {encodeSnappy} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.js"; +import {SnappyFramesUncompress} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, VERSION_RECORD_BYTES} from "./constants.js"; -import type {E2StoreEntry, SlotIndex} from "./types.js"; - -/** - * Cache fork types by epoch. Fork transitions occur at epoch boundaries, - * so caching by epoch is safe and efficient. - */ -type ForkTypes = ReturnType; -const forkTypesByEpoch = new Map(); -function getForkTypesCached(config: ChainForkConfig, slot: number): ForkTypes { - const epoch = Math.floor(slot / SLOTS_PER_EPOCH); - let types = forkTypesByEpoch.get(epoch); - if (!types) { - types = config.getForkTypes(slot); - forkTypesByEpoch.set(epoch, types); - } - return types; -} +import type {E2StoreEntry, EraFile, EraIndex, SlotIndex} from "./types.js"; /** Shared 8-byte scratch and DataView to avoid per-call allocations for i64 read/write */ const scratch64 = new ArrayBuffer(8); @@ -82,7 +71,7 @@ function readInt64(bytes: Uint8Array, offset: number): bigint { * Validates expected count, entry type and payload size, offset bounds, * and trailing count. */ -export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { +function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { if (bytes.length < 8) { throw new Error("Buffer too small for SlotIndex count"); } @@ -113,7 +102,6 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" throw new Error(`Expected SlotIndex entry, got ${entry.type}`); } - // Validate payload size matches specification // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16 const expectedSize = eofCount * 8 + 16; if (entry.data.length !== expectedSize) { @@ -146,7 +134,6 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" } } - // Validate trailing count matches EOF count // Trailing count position: after startSlot(8) + offsets(count*8) const trailingCountOffset = 8 + eofCount * 8; const trailingCount = Number(readInt64(entry.data, trailingCountOffset)); @@ -165,7 +152,7 @@ export function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block" /** * Read state and block SlotIndex entries from an era file and validate alignment. */ -export function getEraIndexes( +function getEraIndexes( eraBytes: Uint8Array, expectedEra?: number ): {stateSlotIndex: SlotIndex; blockSlotIndex?: SlotIndex} { @@ -201,7 +188,7 @@ export function getEraIndexes( return {stateSlotIndex, blockSlotIndex}; } -/** Decompress snappy-framed data using Lodestar's spec-compliant decompressor. */ +/** Decompress snappy-framed data */ function decompressFrames(compressedData: Uint8Array): Uint8Array { const decompressor = new SnappyFramesUncompress(); @@ -215,17 +202,12 @@ function decompressFrames(compressedData: Uint8Array): Uint8Array { return result.subarray(); } -/** Decompress and deserialize a BeaconState using the appropriate fork for the era. */ -export function decompressBeaconState( - compressedData: Uint8Array, - era: number, - config: ChainForkConfig, - forkTypes?: ForkTypes -) { +/** Decompress and deserialize a BeaconState using the apt fork for the era. */ +function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; - const types = forkTypes ?? getForkTypesCached(config, stateSlot); + const types = config.getForkTypes(stateSlot); try { return types.BeaconState.deserialize(uncompressed); @@ -235,15 +217,10 @@ export function decompressBeaconState( } /** Decompress and deserialize a SignedBeaconBlock using the fork for the given slot. */ -export function decompressSignedBeaconBlock( - compressedData: Uint8Array, - blockSlot: number, - config: ChainForkConfig, - forkTypes?: ForkTypes -) { +function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); - const types = forkTypes ?? getForkTypesCached(config, blockSlot); + const types = config.getForkTypes(blockSlot); try { return types.SignedBeaconBlock.deserialize(uncompressed); @@ -252,13 +229,13 @@ export function decompressSignedBeaconBlock( } } -export type SnappyFramedCompress = (ssz: Uint8Array) => Uint8Array; +type SnappyFramedCompress = (ssz: Uint8Array) => Uint8Array; /** * Write a single E2Store TLV entry (header + payload) * Header layout: type[2] | length u32 LE | reserved u16(=0) */ -export function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { +function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { if (type2.length !== 2) throw new Error("type must be 2 bytes"); const out = new Uint8Array(E2STORE_HEADER_SIZE + payload.length); // type @@ -274,21 +251,12 @@ export function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { return out; } -/** Encode a 64-bit signed integer (little-endian) into a new Uint8Array. */ -export function writeI64LE(v: bigint): Uint8Array { - // Allocates a single 8-byte output but reuses the shared DataView for encoding - scratch64View.setBigInt64(0, v, true); - const out = new Uint8Array(8); - out.set(scratch64Bytes); - return out; -} - /** In-place encode of a 64-bit signed integer (little-endian) into target at offset. */ function writeI64LEInto(target: Uint8Array, offset: number, v: bigint): void { scratch64View.setBigInt64(0, v, true); target.set(scratch64Bytes, offset); } -export function readI64LE(buf: Uint8Array, off: number): bigint { +function readI64LE(buf: Uint8Array, off: number): bigint { const dv = new DataView(buf.buffer, buf.byteOffset + off, 8); return dv.getBigInt64(0, true); } @@ -297,11 +265,7 @@ export function readI64LE(buf: Uint8Array, off: number): bigint { * Offsets are i64 relative to the index record start (0 = missing). * Payload size = count*8 + 16 (header not included). */ -export function buildSlotIndexData( - startSlot: number, - offsetsAbs: readonly number[], - indexRecordStart: number -): Uint8Array { +function buildSlotIndexData(startSlot: number, offsetsAbs: readonly number[], indexRecordStart: number): Uint8Array { const count = offsetsAbs.length; const payload = new Uint8Array(count * 8 + 16); @@ -322,12 +286,12 @@ export function buildSlotIndexData( } /** Compressed record helpers (snappy framed) */ -export function writeCompressedBlock(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { +function writeCompressedBlock(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { const framed = snappyFramed(ssz); return writeEntry(EraTypes[E2StoreEntryType.CompressedSignedBeaconBlock], framed); } -export function writeCompressedState(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { +function writeCompressedState(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { const framed = snappyFramed(ssz); return writeEntry(EraTypes[E2StoreEntryType.CompressedBeaconState], framed); } @@ -349,7 +313,7 @@ function concat(chunks: Uint8Array[]): Uint8Array { * Layout: Version | block* | era-state | SlotIndex(block)? | SlotIndex(state) * Genesis (era 0): omit block index; always include state index (count=1). */ -export function writeEraGroup(params: { +function writeEraGroup(params: { era: number; slotsPerHistoricalRoot: number; snappyFramed: SnappyFramedCompress; @@ -408,82 +372,504 @@ export function writeEraGroup(params: { return concat(chunks); } -/** Write an ERA file from one or more groups (concatenated). */ -export function writeEraFile( - groups: Array<{ - era: number; - slotsPerHistoricalRoot: number; - snappyFramed: SnappyFramedCompress; - blocksBySlot: Map; - stateSlot: number; - stateSSZ: Uint8Array; - }> -): Uint8Array { - const chunks: Uint8Array[] = []; - for (const g of groups) { - chunks.push(writeEraGroup(g)); +/** + * Read an era index file from disk. + * Format: startSlot (i64 LE) | count (i64 LE) | indices[count] (i64 LE each) + */ +export async function readEraIndexFile(path: string): Promise { + const buffer = await readFile(path); + + if (buffer.length < 16) { + throw new Error(`Index file too small: need at least 16 bytes, got ${buffer.length}`); } - return concat(chunks); + + const startSlot = Number(readI64LE(buffer, 0)); + const count = Number(readI64LE(buffer, 8)); + + const expectedSize = 16 + count * 8; + if (buffer.length !== expectedSize) { + throw new Error(`Index file size mismatch: expected ${expectedSize} bytes, got ${buffer.length}`); + } + + const indices: number[] = []; + for (let i = 0; i < count; i++) { + indices.push(Number(readI64LE(buffer, 16 + i * 8))); + } + + return {startSlot, indices}; +} + +/** + * Write an era index file to disk. + * Format: startSlot (i64 LE) | count (i64 LE) | indices[count] (i64 LE each) + */ +export async function writeEraIndexFile(path: string, index: EraIndex): Promise { + const count = index.indices.length; + const buffer = new Uint8Array(16 + count * 8); + + // Write startSlot + writeI64LEInto(buffer, 0, BigInt(index.startSlot)); + + // Write count + writeI64LEInto(buffer, 8, BigInt(count)); + + // Write indices + for (let i = 0; i < count; i++) { + writeI64LEInto(buffer, 16 + i * 8, BigInt(index.indices[i])); + } + + await writeFile(path, buffer); +} + +/** Return true if `slot` is within the era range */ +export function isSlotInRange(slot: Slot, eraNumber: number): boolean { + const eraStartSlot = eraNumber * SLOTS_PER_HISTORICAL_ROOT; + const eraEndSlot = eraStartSlot + SLOTS_PER_HISTORICAL_ROOT; + return slot >= eraStartSlot && slot < eraEndSlot; +} + +/** + * Parse era number from era filename. + * Format: --.era + */ +function parseEraNumber(filename: string): number { + const match = filename.match(/-(\d{5})-/); + if (!match) { + throw new Error(`Invalid era filename format: ${filename}`); + } + return parseInt(match[1], 10); +} + +/** Create a new era file for writing */ +export async function createEraFile(path: string, eraNumber: number): Promise { + const fh = await open(path, "w+"); + const name = basename(path); + + // Register the file handle + fileHandles.set(fh.fd, fh); + + const eraFile: EraFile = { + fd: fh.fd, + name, + eraNumber, + + async close() { + fileHandles.delete(fh.fd); + await fh.close(); + }, + + async validate(config: ChainForkConfig): Promise { + await validateEraFile(fh.fd, eraNumber, config); + }, + + async createIndex(): Promise { + // Read the entire file to extract the slot index + const stats = await fh.stat(); + const buffer = new Uint8Array(stats.size); + await fh.read(buffer, 0, stats.size, 0); + + // Get the block slot index from the era file + const {blockSlotIndex} = getEraIndexes(buffer, eraNumber); + + if (!blockSlotIndex) { + // Genesis era (era 0) has no block index + return { + startSlot: 0, + indices: [], + }; + } + + return { + startSlot: blockSlotIndex.startSlot, + indices: blockSlotIndex.offsets, + }; + }, + }; + + return eraFile; +} + +/** Open an era file and return an EraFile handle */ +export async function openEraFile(path: string): Promise { + const fh = await open(path, "r"); + const name = basename(path); + const eraNumber = parseEraNumber(name); + + // Register the file handle + fileHandles.set(fh.fd, fh); + + const eraFile: EraFile = { + fd: fh.fd, + name, + eraNumber, + + async close() { + fileHandles.delete(fh.fd); + await fh.close(); + }, + + async validate(config: ChainForkConfig): Promise { + await validateEraFile(fh.fd, eraNumber, config); + }, + + async createIndex(): Promise { + // Read the entire file to extract the slot index + const stats = await fh.stat(); + const buffer = new Uint8Array(stats.size); + await fh.read(buffer, 0, stats.size, 0); + + // Get the block slot index from the era file + const {blockSlotIndex} = getEraIndexes(buffer, eraNumber); + + if (!blockSlotIndex) { + // Genesis era (era 0) has no block index + return { + startSlot: 0, + indices: [], + }; + } + + return { + startSlot: blockSlotIndex.startSlot, + indices: blockSlotIndex.offsets, + }; + }, + }; + + return eraFile; +} + +/** + * Helper to read entry at a specific offset from an open file handle. + * Reads header first to determine data length, then reads the complete entry. + */ +async function readEntryFromFile(fh: FileHandle, offset: number): Promise { + // Read header (8 bytes) + const header = new Uint8Array(E2STORE_HEADER_SIZE); + await fh.read(header, 0, E2STORE_HEADER_SIZE, offset); + + // Parse length from header + const lengthView = new DataView(header.buffer, header.byteOffset + 2, 4); + const dataLength = lengthView.getUint32(0, true); + + // Read complete entry (header + data) + const fullEntry = new Uint8Array(E2STORE_HEADER_SIZE + dataLength); + await fh.read(fullEntry, 0, fullEntry.length, offset); + + return readEntry(fullEntry); +} + +/** Compress data using snappy framing */ +async function compressSnappyFramed(data: Uint8Array): Promise { + const buffers: Buffer[] = []; + for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { + buffers.push(chunk); + } + const total = buffers.reduce((n, b) => n + b.length, 0); + const out = new Uint8Array(total); + let p = 0; + for (const b of buffers) { + out.set(b, p); + p += b.length; + } + return out; +} + +// WeakMap to track FileHandle for each EraFile by fd +const fileHandles = new Map(); + +/** Helper to get file handle from fd */ +function getFileHandle(fd: number): FileHandle { + const fh = fileHandles.get(fd); + if (!fh) { + throw new Error(`No FileHandle found for fd ${fd}`); + } + return fh; } -/** Read and decode the BeaconState from an .era file (single era). */ -export function readBeaconStateFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { - const {stateSlotIndex} = getEraIndexes(eraBytes, expectedEra); +/** + * Get the state offset from the era file. + * Reads only the necessary parts of the file to locate the state index. + */ +async function getStateOffset(fh: FileHandle, eraNumber: number): Promise { + // For now, read entire file to get indexes + const stats = await fh.stat(); + const buffer = new Uint8Array(stats.size); + await fh.read(buffer, 0, stats.size, 0); + const {stateSlotIndex} = getEraIndexes(buffer, eraNumber); const offset = stateSlotIndex.offsets[0]; - if (!offset) throw new Error("No BeaconState in this era (stateSlotIndex offset is 0)"); + if (!offset) throw new Error("No BeaconState in this era"); + + return offset; +} + +/** + * Validate an era file for format correctness, era range, network correctness, and signatures. + */ +async function validateEraFile(fd: number, eraNumber: number, config: ChainForkConfig): Promise { + const fh = getFileHandle(fd); + const stats = await fh.stat(); + const buffer = new Uint8Array(stats.size); + await fh.read(buffer, 0, stats.size, 0); + + // Validate e2s format and era range + const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, eraNumber); + + // Validate state + const stateOffset = stateSlotIndex.offsets[0]; + if (!stateOffset) throw new Error("No BeaconState in era file"); + + const stateEntry = readEntry(buffer.subarray(stateOffset)); + if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); + } - const entry = readEntry(eraBytes.subarray(offset)); - if (entry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState at 0x${offset.toString(16)}, got ${entry.type}`); + const state = decompressBeaconState(stateEntry.data, eraNumber, config); + const expectedStateSlot = eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (state.slot !== expectedStateSlot) { + throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); } - const era = expectedEra ?? Math.floor(stateSlotIndex.startSlot / SLOTS_PER_HISTORICAL_ROOT); - const types = getForkTypesCached(config, stateSlotIndex.startSlot); - return decompressBeaconState(entry.data, era, config, types); + // Validate blocks if not genesis + if (blockSlotIndex) { + for (let i = 0; i < blockSlotIndex.offsets.length; i++) { + const offset = blockSlotIndex.offsets[i]; + if (!offset) continue; // Empty slot + + const blockEntry = readEntry(buffer.subarray(offset)); + if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); + } + + const slot = blockSlotIndex.startSlot + i; + const block = decompressSignedBeaconBlock(blockEntry.data, slot, config); + + // Validate block slot matches + if (block.message.slot !== slot) { + throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); + } + + // Validate block signature exists (basic check) + if (block.signature.length === 0) { + throw new Error(`Block at slot ${slot} has empty signature`); + } + } + } } -/** Read and decode a SignedBeaconBlock at the given offset in the block index. */ -export function readBeaconBlockFromEra( - eraBytes: Uint8Array, - blockOffset: number, - config: ChainForkConfig, - expectedEra?: number -) { - if (blockOffset < 0 || blockOffset >= SLOTS_PER_HISTORICAL_ROOT) { - throw new RangeError(`blockOffset out of range: ${blockOffset}`); +/** EraFileReader implementation */ +export class EraFileReader { + readonly era: EraFile; + readonly index: EraIndex; + private readonly config: ChainForkConfig; + + constructor(era: EraFile, index: EraIndex, config: ChainForkConfig) { + this.era = era; + this.index = index; + this.config = config; + } + + async readCompressedCanonicalState(): Promise { + const fh = getFileHandle(this.era.fd); + const offset = await getStateOffset(fh, this.era.eraNumber); + const entry = await readEntryFromFile(fh, offset); + + if (entry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${entry.type}`); + } + + return entry.data; + } + + async readCanonicalState(): Promise { + const compressed = await this.readCompressedCanonicalState(); + return decompressBeaconState(compressed, this.era.eraNumber, this.config); } - const {blockSlotIndex} = getEraIndexes(eraBytes, expectedEra); - if (!blockSlotIndex) throw new Error("No block SlotIndex present in this era file"); + async readCompressedBlock(slot: Slot): Promise { + // Calculate offset within the index + const indexOffset = slot - this.index.startSlot; + if (indexOffset < 0 || indexOffset >= this.index.indices.length) { + throw new Error( + `Slot ${slot} is out of range for this era file (valid range: ${this.index.startSlot} to ${this.index.startSlot + this.index.indices.length - 1})` + ); + } + + const fileOffset = this.index.indices[indexOffset]; + if (fileOffset === 0) { + return null; // Empty slot + } - const abs = blockSlotIndex.offsets[blockOffset]; - if (!abs) throw new Error(`No block at offset ${blockOffset} (empty slot)`); + const fh = getFileHandle(this.era.fd); + const entry = await readEntryFromFile(fh, fileOffset); + if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock, got ${entry.type}`); + } + return entry.data; + } - const entry = readEntry(eraBytes.subarray(abs)); - if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock at 0x${abs.toString(16)}, got ${entry.type}`); + async readBlock(slot: Slot): Promise { + const compressed = await this.readCompressedBlock(slot); + if (compressed === null) return null; + return decompressSignedBeaconBlock(compressed, slot, this.config); } - const slot = blockSlotIndex.startSlot + blockOffset; - const types = getForkTypesCached(config, slot); - return decompressSignedBeaconBlock(entry.data, slot, config, types); + async validate(): Promise { + // Read entire file for validation + const fh = getFileHandle(this.era.fd); + const stats = await fh.stat(); + const buffer = new Uint8Array(stats.size); + await fh.read(buffer, 0, stats.size, 0); + + // Validate e2s format and era range + const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, this.era.eraNumber); + + // Validate state + const stateOffset = stateSlotIndex.offsets[0]; + if (!stateOffset) throw new Error("No BeaconState in era file"); + + const stateEntry = readEntry(buffer.subarray(stateOffset)); + if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); + } + + const state = decompressBeaconState(stateEntry.data, this.era.eraNumber, this.config); + const expectedStateSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (state.slot !== expectedStateSlot) { + throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); + } + + // Validate blocks if not genesis + if (blockSlotIndex) { + for (let i = 0; i < blockSlotIndex.offsets.length; i++) { + const offset = blockSlotIndex.offsets[i]; + if (!offset) continue; // Empty slot + + const blockEntry = readEntry(buffer.subarray(offset)); + if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); + } + + const slot = blockSlotIndex.startSlot + i; + const block = decompressSignedBeaconBlock(blockEntry.data, slot, this.config); + + // Validate block slot matches + if (block.message.slot !== slot) { + throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); + } + + // Validate block signature exists (basic check) + if (block.signature.length === 0) { + throw new Error(`Block at slot ${slot} has empty signature`); + } + } + } + } } -/** Iterate all SignedBeaconBlocks in an era (skips empty slots). */ -export function* readBlocksFromEra(eraBytes: Uint8Array, config: ChainForkConfig, expectedEra?: number) { - const {blockSlotIndex} = getEraIndexes(eraBytes, expectedEra); - if (!blockSlotIndex) return; +/** EraFileWriter implementation */ +export class EraFileWriter { + readonly era: EraFile; + private readonly config: ChainForkConfig; + private stateWritten = false; + private stateSlot: Slot | undefined; + private stateData: Uint8Array | undefined; + private blocksBySlot = new Map(); + + constructor(era: EraFile, config: ChainForkConfig) { + this.era = era; + this.config = config; + } - for (let i = 0; i < blockSlotIndex.offsets.length; i++) { - const abs = blockSlotIndex.offsets[i]; - if (!abs) continue; + async writeCompressedCanonicalState(slot: Slot, data: Uint8Array): Promise { + if (this.stateWritten) { + throw new Error("Canonical state has already been written"); + } + const expectedSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (slot !== expectedSlot) { + throw new Error(`State slot must be ${expectedSlot} for era ${this.era.eraNumber}, got ${slot}`); + } + this.stateSlot = slot; + this.stateData = data; + this.stateWritten = true; + } - const entry = readEntry(eraBytes.subarray(abs)); - if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) continue; + async writeCanonicalState(state: BeaconState): Promise { + const slot = state.slot; + const types = this.config.getForkTypes(slot); + const ssz = types.BeaconState.serialize(state); + const compressed = await compressSnappyFramed(ssz); + await this.writeCompressedCanonicalState(slot, compressed); + } + + async writeCompressedBlock(slot: Slot, data: Uint8Array): Promise { + // Blocks in era N file are from era N-1 + if (this.era.eraNumber === 0) { + throw new Error("Genesis era (era 0) does not contain blocks"); + } + + const blockEra = this.era.eraNumber - 1; + if (!isSlotInRange(slot, blockEra)) { + const expectedStartSlot = blockEra * SLOTS_PER_HISTORICAL_ROOT; + const expectedEndSlot = expectedStartSlot + SLOTS_PER_HISTORICAL_ROOT; + throw new Error( + `Slot ${slot} is not in valid block range for era ${this.era.eraNumber} file (valid range: ${expectedStartSlot} to ${expectedEndSlot - 1})` + ); + } + this.blocksBySlot.set(slot, data); + } + + async writeBlock(block: SignedBeaconBlock): Promise { + const slot = block.message.slot; + const types = this.config.getForkTypes(slot); + const ssz = types.SignedBeaconBlock.serialize(block); + const compressed = await compressSnappyFramed(ssz); + await this.writeCompressedBlock(slot, compressed); + } + + async finish(): Promise { + if (!this.stateWritten || !this.stateData || this.stateSlot === undefined) { + throw new Error("Must write canonical state before finishing"); + } + + // Helper to convert compressed data to snappy framed format (already compressed) + const snappyFramed = (data: Uint8Array) => data; + + // Prepare blocks map with SSZ data (already compressed) + const blocksBySlotSSZ = new Map(); + for (const [slot, compressed] of this.blocksBySlot) { + blocksBySlotSSZ.set(slot, compressed); + } + + // Write the era group + const eraBytes = writeEraGroup({ + era: this.era.eraNumber, + slotsPerHistoricalRoot: SLOTS_PER_HISTORICAL_ROOT, + snappyFramed, + blocksBySlot: blocksBySlotSSZ, + stateSlot: this.stateSlot, + stateSSZ: this.stateData, + }); + + // Write to file + const fh = getFileHandle(this.era.fd); + await fh.write(eraBytes, 0, eraBytes.length, 0); + + // Create and return index + const {blockSlotIndex} = getEraIndexes(eraBytes, this.era.eraNumber); + + if (!blockSlotIndex) { + // Genesis era + return { + startSlot: 0, + indices: [], + }; + } - const slot = blockSlotIndex.startSlot + i; - const types = getForkTypesCached(config, slot); - yield decompressSignedBeaconBlock(entry.data, slot, config, types); + return { + startSlot: blockSlotIndex.startSlot, + indices: blockSlotIndex.offsets, + }; } } diff --git a/packages/era/src/index.ts b/packages/era/src/index.ts index 61cc15edad52..3248dc70f12b 100644 --- a/packages/era/src/index.ts +++ b/packages/era/src/index.ts @@ -1,3 +1,3 @@ export * from "./constants.js"; -export * from "./types.js"; export * from "./helpers.js"; +export * from "./types.js"; diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index a5ac6107eab7..1170baf0d6d2 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -1,3 +1,4 @@ +import type {ChainForkConfig} from "@lodestar/config"; import {Slot} from "@lodestar/types"; import {E2StoreEntryType} from "./constants.js"; @@ -37,3 +38,37 @@ export interface SlotIndex { /** File position where this index record starts */ recordStart: number; } + +/** Data read from a slot index file */ +export interface EraIndex { + startSlot: number; + indices: number[]; +} + +/** An open Era file */ +export interface EraFile { + /** file descriptor */ + fd: number; + name: string; + eraNumber: number; + + /** + * Convenience method to close the underlying file descriptor. + * No further actions can be taken after this operation. + */ + close(): Promise; + + /** + * Fully validate the era file for: + * - e2s format correctness + * - era range correctness + * - network correctness for state and blocks + * - block root and signature matches + */ + validate(config: ChainForkConfig): Promise; + + /** + * Create an Era index from the contents of this file. + */ + createIndex(): Promise; +} diff --git a/packages/era/test/unit/era.integration.test.ts b/packages/era/test/unit/era.integration.test.ts deleted file mode 100644 index 67d7e9ce0fc4..000000000000 --- a/packages/era/test/unit/era.integration.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {readFileSync} from "node:fs"; -import path from "node:path"; -import {fileURLToPath} from "node:url"; -import {assert, beforeAll, describe, it} from "vitest"; - -import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; -import {config as defaultConfig} from "@lodestar/config/default"; -import {getEraIndexes, readBeaconBlockFromEra, readBeaconStateFromEra, readBlocksFromEra} from "../../src/index.js"; - -import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe.runIf(!process.env.CI)("era file (integration)", () => { - let data: Uint8Array; - let cfg: ChainForkConfig; - const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); - const expectedEra = 1506; - const stateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; - const blockStartSlot = stateStartSlot - SLOTS_PER_HISTORICAL_ROOT; - - beforeAll(() => { - try { - data = new Uint8Array(readFileSync(eraPath)); - } catch { - throw new Error(". Run the downloader script first:\n ./packages/era/test/era_downloader.sh\n"); - } - cfg = createChainForkConfig(defaultConfig); - }); - - // Low-level entry and raw index checks are covered in unit tests - - it("getEraIndexes returns aligned block and state indices", () => { - const idx = getEraIndexes(data, expectedEra); - assert.equal(Number(idx.stateSlotIndex.startSlot), stateStartSlot); - if (!idx.blockSlotIndex) throw new Error("blockSlotIndex is undefined"); - assert.equal(Number(idx.blockSlotIndex.startSlot), blockStartSlot); - }); - - it("readBeaconStateFromEra decodes state for the expected era", () => { - const state = readBeaconStateFromEra(data, cfg, expectedEra); - assert.equal(Number(state.slot), stateStartSlot); - }); - - it("readBeaconStateFromEra infers the era when not provided", () => { - const state = readBeaconStateFromEra(data, cfg); - assert.equal(Number(state.slot), stateStartSlot); - }); - - it("readBeaconBlockFromEra decodes a non-empty block by offset", () => { - const {blockSlotIndex} = getEraIndexes(data, expectedEra); - if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); - const idx = blockSlotIndex.offsets.findIndex((o) => o !== 0); - if (idx === -1) throw new Error("no non-empty block slots found in this era"); - const block = readBeaconBlockFromEra(data, idx, cfg, expectedEra); - assert.equal(Number(block.message.slot), blockSlotIndex.startSlot + idx); - }); - - it("readBeaconBlockFromEra works without expectedEra", () => { - const {blockSlotIndex} = getEraIndexes(data, expectedEra); - if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); - const idx = blockSlotIndex.offsets.findIndex((o) => o !== 0); - if (idx === -1) throw new Error("no non-empty block slots found in this era"); - const block = readBeaconBlockFromEra(data, idx, cfg); - assert.equal(Number(block.message.slot), blockSlotIndex.startSlot + idx); - }); - - it("readBlocksFromEra yields at least one block in order", () => { - const {blockSlotIndex} = getEraIndexes(data, expectedEra); - if (!blockSlotIndex) throw new Error("blockSlotIndex is undefined"); - const firstIdx = blockSlotIndex.offsets.findIndex((o) => o !== 0); - if (firstIdx === -1) throw new Error("no non-empty block slots found in this era"); - const it = readBlocksFromEra(data, cfg, expectedEra); - const first = it.next(); - assert.equal(first.done, false); - if (!first.done) { - assert.equal(Number(first.value.message.slot), blockSlotIndex.startSlot + firstIdx); - } - }); -}); diff --git a/packages/era/test/unit/era.readwrite.integration.test.ts b/packages/era/test/unit/era.readwrite.integration.test.ts index 7601a77496fc..36402d611a89 100644 --- a/packages/era/test/unit/era.readwrite.integration.test.ts +++ b/packages/era/test/unit/era.readwrite.integration.test.ts @@ -1,250 +1,165 @@ -import {existsSync, mkdirSync, readFileSync, writeFileSync} from "node:fs"; +import {existsSync, mkdirSync} from "node:fs"; import path from "node:path"; import {fileURLToPath} from "node:url"; import {assert, beforeAll, describe, it} from "vitest"; - import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; import {config as defaultConfig} from "@lodestar/config/default"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {encodeSnappy} from "../../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.js"; - -import { - E2StoreEntryType, - decompressBeaconState, - decompressSignedBeaconBlock, - getEraIndexes, - readEntry, - readI64LE, - writeEraGroup, -} from "../../src/index.js"; +import {EraFileReader, EraFileWriter, createEraFile, openEraFile} from "../../src/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe.runIf(!process.env.CI)("read original era and re-write our own era file", () => { - let data: Uint8Array; let cfg: ChainForkConfig; const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); const expectedEra = 1506; beforeAll(() => { - try { - data = new Uint8Array(readFileSync(eraPath)); - } catch { - throw new Error(". Run the downloader script first:\n ./packages/era/test/era_downloader.sh\n"); - } cfg = createChainForkConfig(defaultConfig); }); - let snappyTimeMs = 0; - async function encodeSnappyToUint8Array(data: Uint8Array): Promise { - const t0 = Date.now(); - const buffers: Buffer[] = []; - for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { - buffers.push(chunk); - } - snappyTimeMs += Date.now() - t0; - const total = buffers.reduce((n, b) => n + b.length, 0); - const out = new Uint8Array(total); - let p = 0; - for (const b of buffers) { - out.set(b, p); - p += b.length; - } - return out; - } - const framedBySSZ = new WeakMap(); - function snappyFramed(data: Uint8Array): Uint8Array { - const out = framedBySSZ.get(data); - if (!out) throw new Error("missing precomputed snappy frame for provided SSZ bytes"); - return out; - } - - it("reads an existing era group and writes a full group that round-trips", async () => { - console.log("stage: read+parse indexes"); - const {stateSlotIndex, blockSlotIndex} = getEraIndexes(data, expectedEra); - - // Build uncompressed SSZ for state from original file - const stateOffset = stateSlotIndex.offsets[0]; - assert.ok(stateOffset > 0, "original state must exist"); - const stateEntry = readEntry(data.subarray(stateOffset)); - assert.equal(stateEntry.type, E2StoreEntryType.CompressedBeaconState); - let scanDeserializeMs = 0; - let serializeMs = 0; - let tlvWriteMs = 0; - - console.log("stage: state decompress+serialize"); - // Decompress + deserialize state - const stateValue = (() => { - const t0 = Date.now(); - const v = decompressBeaconState(stateEntry.data, expectedEra, cfg); - scanDeserializeMs += Date.now() - t0; - return v; - })(); - const stateSlot = stateSlotIndex.startSlot; - const stateSSZ = (() => { - const t0 = Date.now(); - const ssz = cfg.getForkTypes(stateSlot).BeaconState.serialize(stateValue); - serializeMs += Date.now() - t0; - return ssz; - })(); - - console.log("stage: scan+serialize blocks window"); - // Build all available blocks in the window as SSZ + it("reads an existing era file and writes a new era file that round-trips", async () => { const SPR = SLOTS_PER_HISTORICAL_ROOT; - const blocksBySlot = new Map(); - let originalNonEmpty = 0; - let scanned = 0; - if (blockSlotIndex) { - for (let i = 0; i < blockSlotIndex.offsets.length; i++) { - const abs = blockSlotIndex.offsets[i]; - scanned++; - if (!abs) continue; - originalNonEmpty++; - const entry = readEntry(data.subarray(abs)); - if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) continue; - const slot = blockSlotIndex.startSlot + i; - const blockValue = (() => { - const t0 = Date.now(); - const v = decompressSignedBeaconBlock(entry.data, slot, cfg); - scanDeserializeMs += Date.now() - t0; - return v; - })(); - const blockSSZ = (() => { - const t0 = Date.now(); - const ssz = cfg.getForkTypes(slot).SignedBeaconBlock.serialize(blockValue); - serializeMs += Date.now() - t0; - return ssz; - })(); - blocksBySlot.set(slot, blockSSZ); - - if ((scanned & 0x1ff) === 0) { - console.log(`progress: scanned ${scanned}/${SPR} slots, non-empty ${originalNonEmpty}`); - } + const stateSlot = expectedEra * SPR; + const blockStartSlot = stateSlot - SPR; + + const startTime = Date.now(); + let t0 = startTime; + + console.log("stage: open and read original era file"); + const originalEraFile = await openEraFile(eraPath); + const originalIndex = await originalEraFile.createIndex(); + const reader = new EraFileReader(originalEraFile, originalIndex, cfg); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + console.log("stage: read state from original file"); + const originalState = await reader.readCanonicalState(); + assert.equal(Number(originalState.slot), stateSlot); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + console.log("stage: read blocks from original file"); + const blocks: {slot: number; block: any}[] = []; + for (let i = 0; i < originalIndex.indices.length; i++) { + if (originalIndex.indices[i] === 0) continue; + const slot = blockStartSlot + i; + const block = await reader.readBlock(slot); + assert.ok(block !== null, `Expected block at slot ${slot} but got null`); + blocks.push({slot, block}); + if ((i & 0x1ff) === 0) { + console.log(`progress: scanned ${i}/${SPR} slots, non-empty ${blocks.length}`); } } - console.log("stage: precompute snappy frames (original encoder)"); - // Precompute snappy frames - framedBySSZ.set(stateSSZ, await encodeSnappyToUint8Array(stateSSZ)); - for (const ssz of blocksBySlot.values()) { - framedBySSZ.set(ssz, await encodeSnappyToUint8Array(ssz)); - } - console.log(`stage: blocks prepared (non-empty=${originalNonEmpty})`); - - // Write a new era group and then a full era file with that single group - console.log("stage: write group (snappy+TLV+index)"); - const groupBytes = (() => { - const t0 = Date.now(); - const bytes = writeEraGroup({ - era: expectedEra, - slotsPerHistoricalRoot: SPR, - snappyFramed, - blocksBySlot, - stateSlot, - stateSSZ, - }); - tlvWriteMs += Date.now() - t0; - return bytes; - })(); - console.log(`stage: validate new group (bytes=${groupBytes.length})`); - - // Validate group round-trip - const newIdx = getEraIndexes(groupBytes, expectedEra); - assert.equal(newIdx.stateSlotIndex.startSlot, stateSlot); - assert.equal(stateSlot, expectedEra * SPR); - if (blockSlotIndex) { - const bsi = newIdx.blockSlotIndex; - assert.ok(bsi); - assert.equal(bsi.startSlot, blockSlotIndex.startSlot); - assert.equal(bsi.offsets.length, SPR); - assert.equal(bsi.startSlot, stateSlot - SPR); - const newNonEmpty = bsi.offsets.filter((o) => o !== 0).length; - assert.equal(newNonEmpty, originalNonEmpty); - } else { - assert.equal(newIdx.blockSlotIndex, undefined); - } + console.log(`stage: read complete (non-empty blocks=${blocks.length})`); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); - // Validate state decodes from new file - const newStateOffset = newIdx.stateSlotIndex.offsets[0]; - const newStateEntry = readEntry(groupBytes.subarray(newStateOffset)); - assert.equal(newStateEntry.type, E2StoreEntryType.CompressedBeaconState); - const newState = decompressBeaconState(newStateEntry.data, expectedEra, cfg); - assert.equal(Number(newState.slot), stateSlot); - - // State index: count=1, relative = headerStart - indexHeaderStart (header-start semantics) - { - const ssi = newIdx.stateSlotIndex; - const ssiEntry = readEntry(groupBytes.subarray(ssi.recordStart)); - // startSlot(8) + offsets(8) + count(8) - const recordedRel = readI64LE(ssiEntry.data, 8); - const expectedRel = BigInt(newStateOffset - ssi.recordStart); - assert.equal(recordedRel, expectedRel); - } + await originalEraFile.close(); - // Block index (if present): each non-zero offset obeys the same relation - if (newIdx.blockSlotIndex) { - const bsi = newIdx.blockSlotIndex; - const bsiEntry = readEntry(groupBytes.subarray(bsi.recordStart)); - for (let i = 0; i < bsi.offsets.length; i++) { - const headerStart = bsi.offsets[i]; - const rel = readI64LE(bsiEntry.data, 8 + i * 8); - if (headerStart === 0) { - assert.equal(rel, 0n); - } else { - const expectedRel = BigInt(headerStart - bsi.recordStart); - assert.equal(rel, expectedRel); - } - } - } + console.log("stage: create and write new era file"); + console.log("stage: create and write new era file"); + const outDir = path.resolve(__dirname, "../out"); + if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); + const outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-rewrite.era`); - // Validate first and last non-empty blocks decode from new file - if (newIdx.blockSlotIndex) { - const offsets = newIdx.blockSlotIndex.offsets; - const firstIdx = offsets.findIndex((o) => o !== 0); - let lastIdx = -1; - for (let i = offsets.length - 1; i >= 0; i--) { - if (offsets[i] !== 0) { - lastIdx = i; - break; - } - } - if (firstIdx !== -1) { - const off = offsets[firstIdx]; - const be = readEntry(groupBytes.subarray(off)); - assert.equal(be.type, E2StoreEntryType.CompressedSignedBeaconBlock); - const slot = newIdx.blockSlotIndex.startSlot + firstIdx; - const blk = decompressSignedBeaconBlock(be.data, slot, cfg); - assert.equal(Number(blk.message.slot), slot); - } - if (lastIdx !== -1 && lastIdx !== firstIdx) { - const off2 = offsets[lastIdx]; - const be2 = readEntry(groupBytes.subarray(off2)); - assert.equal(be2.type, E2StoreEntryType.CompressedSignedBeaconBlock); - const slot2 = newIdx.blockSlotIndex.startSlot + lastIdx; - const blk2 = decompressSignedBeaconBlock(be2.data, slot2, cfg); - assert.equal(Number(blk2.message.slot), slot2); - } + const newEraFile = await createEraFile(outFile, expectedEra); + const writer = new EraFileWriter(newEraFile, cfg); - // For remaining non-empty blocks, validate TLV type/length without decoding SSZ - for (let i = 0; i < offsets.length; i++) { - if (i === firstIdx || i === lastIdx) continue; - const off = offsets[i]; - if (!off) continue; - const e = readEntry(groupBytes.subarray(off)); - assert.equal(e.type, E2StoreEntryType.CompressedSignedBeaconBlock); - assert.ok(e.data.length >= 0); + // Write state + console.log("stage: write state"); + await writer.writeCanonicalState(originalState); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Write all blocks + console.log("stage: write blocks"); + for (const {block} of blocks) { + await writer.writeBlock(block); + } + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Finish writing + console.log("stage: finish writing"); + await writer.finish(); + await newEraFile.close(); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + console.log("stage: validate new era file"); + // Open and validate the new file + const validationEraFile = await openEraFile(outFile); + const validationIndex = await validationEraFile.createIndex(); + const validationReader = new EraFileReader(validationEraFile, validationIndex, cfg); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Validate entire era file format and correctness + console.log("stage: validate era file format"); + await validationEraFile.validate(cfg); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Validate index matches + assert.equal(validationIndex.startSlot, blockStartSlot); + assert.equal(validationIndex.indices.length, SPR); + const newNonEmpty = validationIndex.indices.filter((o) => o !== 0).length; + assert.equal(newNonEmpty, blocks.length); + + // Validate empty/non-empty slot pattern matches original + for (let i = 0; i < SPR; i++) { + const originalIsEmpty = originalIndex.indices[i] === 0; + const newIsEmpty = validationIndex.indices[i] === 0; + assert.equal(newIsEmpty, originalIsEmpty, `Slot ${blockStartSlot + i} empty/non-empty status should match`); + } + + // Validate state matches (full comparison via SSZ serialization) + console.log("stage: validate state"); + const validatedState = await validationReader.readCanonicalState(); + + // Serialize both states and compare bytes - this proves they're 100% identical + const originalTypes = cfg.getForkTypes(originalState.slot); + const validatedTypes = cfg.getForkTypes(validatedState.slot); + const originalSSZ = originalTypes.BeaconState.serialize(originalState); + const validatedSSZ = validatedTypes.BeaconState.serialize(validatedState); + + assert.deepEqual(validatedSSZ, originalSSZ, "State SSZ bytes should match exactly"); + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Validate ALL blocks match exactly (full comparison via SSZ serialization) + console.log("stage: validate all blocks"); + for (const {slot, block} of blocks) { + const validatedBlock = await validationReader.readBlock(slot); + assert.ok(validatedBlock !== null, `Block at slot ${slot} should not be null`); + + // Serialize both blocks and compare bytes - this proves they're 100% identical + const types = cfg.getForkTypes(slot); + const originalSSZ = types.SignedBeaconBlock.serialize(block); + const validatedSSZ = types.SignedBeaconBlock.serialize(validatedBlock); + assert.deepEqual(validatedSSZ, originalSSZ, `Block at slot ${slot} SSZ bytes should match exactly`); + } + console.log(` time: ${Date.now() - t0}ms`); + t0 = Date.now(); + + // Validate empty slots remain empty + console.log("stage: validate empty slots"); + for (let i = 0; i < originalIndex.indices.length; i++) { + if (originalIndex.indices[i] === 0) { + const slot = blockStartSlot + i; + const validatedBlock = await validationReader.readBlock(slot); + assert.equal(validatedBlock, null, `Slot ${slot} should be empty`); } } + console.log(` time: ${Date.now() - t0}ms`); - // Write the produced ERA file - console.log("stage: write to disk"); - const outDir = path.resolve(__dirname, "../out"); - if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); - const outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-rewrite.era`); - writeFileSync(outFile, groupBytes); + // Cleanup + await validationEraFile.close(); - console.log( - `timings(ms): scan+deserialize=${scanDeserializeMs} serialize=${serializeMs} snappy=${snappyTimeMs} tlvWrite+index=${tlvWriteMs}` - ); + const totalTime = Date.now() - startTime; + console.log(`Round-trip test passed: ${blocks.length} blocks written and fully validated`); + console.log(` Total time: ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`); }, 120000); }); From 515939987f4eef078ae4097b60f683ce53135b36 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Sat, 4 Oct 2025 19:14:21 +0530 Subject: [PATCH 14/34] export encodeSnappy --- packages/era/src/helpers.ts | 3 +-- packages/reqresp/package.json | 4 ++++ packages/reqresp/src/encodingStrategies/sszSnappy/index.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index 2953017542e4..9207a7995b85 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -4,9 +4,8 @@ import {basename} from "node:path"; import {Uint8ArrayList} from "uint8arraylist"; import {ChainForkConfig} from "@lodestar/config"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/encodingStrategies/sszSnappy"; import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; -import {encodeSnappy} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.js"; -import {SnappyFramesUncompress} from "../../reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, VERSION_RECORD_BYTES} from "./constants.js"; import type {E2StoreEntry, EraFile, EraIndex, SlotIndex} from "./types.js"; diff --git a/packages/reqresp/package.json b/packages/reqresp/package.json index 6564006711b9..af5c27eedd6e 100644 --- a/packages/reqresp/package.json +++ b/packages/reqresp/package.json @@ -21,6 +21,10 @@ "./utils": { "bun": "./src/utils/index.ts", "import": "./lib/utils/index.js" + }, + "./encodingStrategies/sszSnappy": { + "bun": "./src/encodingStrategies/sszSnappy/index.ts", + "import": "./lib/encodingStrategies/sszSnappy/index.js" } }, "typesVersions": { diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts index c27445d4611f..8f1869f78412 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts @@ -1,4 +1,5 @@ export * from "./decode.js"; export * from "./encode.js"; export * from "./errors.js"; +export {encodeSnappy} from "./snappyFrames/compress.js"; export {SnappyFramesUncompress} from "./snappyFrames/uncompress.js"; From fd825bf486c77a0c064f0539059f5764df6b1945 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Mon, 6 Oct 2025 20:12:43 +0530 Subject: [PATCH 15/34] move test to e2e --- packages/era/test/{unit => e2e}/era.readwrite.integration.test.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/era/test/{unit => e2e}/era.readwrite.integration.test.ts (100%) diff --git a/packages/era/test/unit/era.readwrite.integration.test.ts b/packages/era/test/e2e/era.readwrite.integration.test.ts similarity index 100% rename from packages/era/test/unit/era.readwrite.integration.test.ts rename to packages/era/test/e2e/era.readwrite.integration.test.ts From a910d40efdba0e2d17f3a4628198affbddb4c512 Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Tue, 7 Oct 2025 21:04:51 +0530 Subject: [PATCH 16/34] refactor class --- packages/era/src/eraFile.ts | 299 ++++++++++++ packages/era/src/helpers.ts | 462 ++++-------------- packages/era/src/index.ts | 1 + packages/era/src/types.ts | 29 -- .../era.readwrite.integration.test.ts | 12 +- 5 files changed, 388 insertions(+), 415 deletions(-) create mode 100644 packages/era/src/eraFile.ts rename packages/era/test/{e2e => e2e-mainnet}/era.readwrite.integration.test.ts (94%) diff --git a/packages/era/src/eraFile.ts b/packages/era/src/eraFile.ts new file mode 100644 index 000000000000..623d81777fed --- /dev/null +++ b/packages/era/src/eraFile.ts @@ -0,0 +1,299 @@ +import type {FileHandle} from "node:fs/promises"; +import {open} from "node:fs/promises"; +import {basename} from "node:path"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; +import {E2StoreEntryType} from "./constants.js"; +import { + compressSnappyFramed, + decompressBeaconState, + decompressSignedBeaconBlock, + getEraIndexes, + getStateOffset, + isSlotInRange, + readBlockSlotIndexFromFile, + readEntry, + readEntryFromFile, + validateEraFile, + writeEraGroup, +} from "./helpers.js"; +import type {EraIndex} from "./types.js"; + +/** + * Parse era number from era filename. + * Format: --.era + */ +function parseEraNumber(filename: string): number { + const match = filename.match(/-(\d{5})-/); + if (!match) { + throw new Error(`Invalid era filename format: ${filename}`); + } + return parseInt(match[1], 10); +} + +/** An open Era file */ +export class EraFile { + readonly fh: FileHandle; + readonly name: string; + readonly eraNumber: number; + + private constructor(fh: FileHandle, name: string, eraNumber: number) { + this.fh = fh; + this.name = name; + this.eraNumber = eraNumber; + } + + /** Create a new era file for writing */ + static async create(path: string, eraNumber: number): Promise { + const fh = await open(path, "w+"); + const name = basename(path); + return new EraFile(fh, name, eraNumber); + } + + /** Open an existing era file for reading */ + static async open(path: string): Promise { + const fh = await open(path, "r"); + const name = basename(path); + const eraNumber = parseEraNumber(name); + return new EraFile(fh, name, eraNumber); + } + + /** + * Close the underlying file descriptor. + * No further actions can be taken after this operation. + */ + async close(): Promise { + await this.fh.close(); + } + + /** + * Fully validate the era file for: + * - e2s format correctness + * - era range correctness + * - network correctness for state and blocks + * - block root and signature matches + */ + async validate(config: ChainForkConfig): Promise { + await validateEraFile(this.fh, this.eraNumber, config); + } + + /** + * Create an Era index from the contents of this file. + */ + async createIndex(): Promise { + return readBlockSlotIndexFromFile(this.fh); + } +} + +/** EraFileReader implementation */ +export class EraFileReader { + readonly era: EraFile; + readonly index: EraIndex; + private readonly config: ChainForkConfig; + + constructor(era: EraFile, index: EraIndex, config: ChainForkConfig) { + this.era = era; + this.index = index; + this.config = config; + } + + async readCompressedCanonicalState(): Promise { + const offset = await getStateOffset(this.era.fh, this.era.eraNumber); + const entry = await readEntryFromFile(this.era.fh, offset); + + if (entry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${entry.type}`); + } + + return entry.data; + } + + async readCanonicalState(): Promise { + const compressed = await this.readCompressedCanonicalState(); + return decompressBeaconState(compressed, this.era.eraNumber, this.config); + } + + async readCompressedBlock(slot: Slot): Promise { + // Calculate offset within the index + const indexOffset = slot - this.index.startSlot; + if (indexOffset < 0 || indexOffset >= this.index.indices.length) { + throw new Error( + `Slot ${slot} is out of range for this era file (valid range: ${this.index.startSlot} to ${this.index.startSlot + this.index.indices.length - 1})` + ); + } + + const fileOffset = this.index.indices[indexOffset]; + if (fileOffset === 0) { + return null; // Empty slot + } + + const entry = await readEntryFromFile(this.era.fh, fileOffset); + if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock, got ${entry.type}`); + } + return entry.data; + } + + async readBlock(slot: Slot): Promise { + const compressed = await this.readCompressedBlock(slot); + if (compressed === null) return null; + return decompressSignedBeaconBlock(compressed, slot, this.config); + } + + async validate(): Promise { + // Read entire file for validation + const stats = await this.era.fh.stat(); + const buffer = new Uint8Array(stats.size); + await this.era.fh.read(buffer, 0, stats.size, 0); + + // Validate e2s format and era range + const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, this.era.eraNumber); + + // Validate state + const stateOffset = stateSlotIndex.offsets[0]; + if (!stateOffset) throw new Error("No BeaconState in era file"); + + const stateEntry = readEntry(buffer.subarray(stateOffset)); + if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); + } + + const state = decompressBeaconState(stateEntry.data, this.era.eraNumber, this.config); + const expectedStateSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (state.slot !== expectedStateSlot) { + throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); + } + + // Validate blocks if not genesis + if (blockSlotIndex) { + for (let i = 0; i < blockSlotIndex.offsets.length; i++) { + const offset = blockSlotIndex.offsets[i]; + if (!offset) continue; // Empty slot + + const blockEntry = readEntry(buffer.subarray(offset)); + if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); + } + + const slot = blockSlotIndex.startSlot + i; + const block = decompressSignedBeaconBlock(blockEntry.data, slot, this.config); + + // Validate block slot matches + if (block.message.slot !== slot) { + throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); + } + + // Validate block signature exists (basic check) + if (block.signature.length === 0) { + throw new Error(`Block at slot ${slot} has empty signature`); + } + } + } + } +} + +/** EraFileWriter implementation */ +export class EraFileWriter { + readonly era: EraFile; + private readonly config: ChainForkConfig; + private stateWritten = false; + private stateSlot: Slot | undefined; + private stateData: Uint8Array | undefined; + private blocksBySlot = new Map(); + + constructor(era: EraFile, config: ChainForkConfig) { + this.era = era; + this.config = config; + } + + async writeCompressedCanonicalState(slot: Slot, data: Uint8Array): Promise { + if (this.stateWritten) { + throw new Error("Canonical state has already been written"); + } + const expectedSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (slot !== expectedSlot) { + throw new Error(`State slot must be ${expectedSlot} for era ${this.era.eraNumber}, got ${slot}`); + } + this.stateSlot = slot; + this.stateData = data; + this.stateWritten = true; + } + + async writeCanonicalState(state: BeaconState): Promise { + const slot = state.slot; + const types = this.config.getForkTypes(slot); + const ssz = types.BeaconState.serialize(state); + const compressed = await compressSnappyFramed(ssz); + await this.writeCompressedCanonicalState(slot, compressed); + } + + async writeCompressedBlock(slot: Slot, data: Uint8Array): Promise { + // Blocks in era N file are from era N-1 + if (this.era.eraNumber === 0) { + throw new Error("Genesis era (era 0) does not contain blocks"); + } + + const blockEra = this.era.eraNumber - 1; + if (!isSlotInRange(slot, blockEra)) { + const expectedStartSlot = blockEra * SLOTS_PER_HISTORICAL_ROOT; + const expectedEndSlot = expectedStartSlot + SLOTS_PER_HISTORICAL_ROOT; + throw new Error( + `Slot ${slot} is not in valid block range for era ${this.era.eraNumber} file (valid range: ${expectedStartSlot} to ${expectedEndSlot - 1})` + ); + } + this.blocksBySlot.set(slot, data); + } + + async writeBlock(block: SignedBeaconBlock): Promise { + const slot = block.message.slot; + const types = this.config.getForkTypes(slot); + const ssz = types.SignedBeaconBlock.serialize(block); + const compressed = await compressSnappyFramed(ssz); + await this.writeCompressedBlock(slot, compressed); + } + + async finish(): Promise { + if (!this.stateWritten || !this.stateData || this.stateSlot === undefined) { + throw new Error("Must write canonical state before finishing"); + } + + // Helper to convert compressed data to snappy framed format (already compressed) + const snappyFramed = (data: Uint8Array) => data; + + // Prepare blocks map with SSZ data (already compressed) + const blocksBySlotSSZ = new Map(); + for (const [slot, compressed] of this.blocksBySlot) { + blocksBySlotSSZ.set(slot, compressed); + } + + // Write the era group + const eraBytes = writeEraGroup({ + era: this.era.eraNumber, + slotsPerHistoricalRoot: SLOTS_PER_HISTORICAL_ROOT, + snappyFramed, + blocksBySlot: blocksBySlotSSZ, + stateSlot: this.stateSlot, + stateSSZ: this.stateData, + }); + + // Write to file + await this.era.fh.write(eraBytes, 0, eraBytes.length, 0); + + // Create and return index + const {blockSlotIndex} = getEraIndexes(eraBytes, this.era.eraNumber); + + if (!blockSlotIndex) { + // Genesis era + return { + startSlot: 0, + indices: [], + }; + } + + return { + startSlot: blockSlotIndex.startSlot, + indices: blockSlotIndex.offsets, + }; + } +} diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts index 9207a7995b85..3bb143ed8de9 100644 --- a/packages/era/src/helpers.ts +++ b/packages/era/src/helpers.ts @@ -1,18 +1,12 @@ import type {FileHandle} from "node:fs/promises"; -import {open, readFile, writeFile} from "node:fs/promises"; -import {basename} from "node:path"; +import {readFile, writeFile} from "node:fs/promises"; import {Uint8ArrayList} from "uint8arraylist"; import {ChainForkConfig} from "@lodestar/config"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/encodingStrategies/sszSnappy"; -import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; +import {Slot} from "@lodestar/types"; import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, VERSION_RECORD_BYTES} from "./constants.js"; -import type {E2StoreEntry, EraFile, EraIndex, SlotIndex} from "./types.js"; - -/** Shared 8-byte scratch and DataView to avoid per-call allocations for i64 read/write */ -const scratch64 = new ArrayBuffer(8); -const scratch64View = new DataView(scratch64); -const scratch64Bytes = new Uint8Array(scratch64); +import type {E2StoreEntry, EraIndex, SlotIndex} from "./types.js"; /** * Read an e2Store entry (header + data) @@ -58,11 +52,9 @@ export function readEntry(bytes: Uint8Array): E2StoreEntry { return {type, data}; } -/** Read 64-bit signed integer (little-endian) at offset. */ -function readInt64(bytes: Uint8Array, offset: number): bigint { - // Copy 8 bytes into shared scratch, then read via shared DataView - scratch64Bytes.set(bytes.subarray(offset, offset + 8)); - return scratch64View.getBigInt64(0, true); +/** Read 48-bit signed integer (little-endian) at offset. */ +function readInt64(bytes: Uint8Array, offset: number): number { + return Buffer.prototype.readIntLE.call(bytes, offset, 6); } /** @@ -117,19 +109,18 @@ function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): Slot const offsetFieldOffset = 8 + i * 8; const relativeOffset = readInt64(entry.data, offsetFieldOffset); - if (relativeOffset === 0n) { + if (relativeOffset === 0) { offsets.push(0); } else { // Convert relative offset to absolute header position with bounds validation - const indexHeaderStart = BigInt(indexStart); - const absoluteHeaderOffset = indexHeaderStart + relativeOffset; - if (absoluteHeaderOffset < 0n || absoluteHeaderOffset >= BigInt(bytes.length)) { + const absoluteHeaderOffset = indexStart + relativeOffset; + if (absoluteHeaderOffset < 0 || absoluteHeaderOffset >= bytes.length) { throw new Error( `Invalid absolute offset: ${absoluteHeaderOffset} (relative: ${relativeOffset}, ` + `indexStart: ${indexStart}, fileSize: ${bytes.length})` ); } - offsets.push(Number(absoluteHeaderOffset)); + offsets.push(absoluteHeaderOffset); } } @@ -151,7 +142,7 @@ function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): Slot /** * Read state and block SlotIndex entries from an era file and validate alignment. */ -function getEraIndexes( +export function getEraIndexes( eraBytes: Uint8Array, expectedEra?: number ): {stateSlotIndex: SlotIndex; blockSlotIndex?: SlotIndex} { @@ -202,7 +193,7 @@ function decompressFrames(compressedData: Uint8Array): Uint8Array { } /** Decompress and deserialize a BeaconState using the apt fork for the era. */ -function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { +export function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; @@ -216,7 +207,7 @@ function decompressBeaconState(compressedData: Uint8Array, era: number, config: } /** Decompress and deserialize a SignedBeaconBlock using the fork for the given slot. */ -function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { +export function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { const uncompressed = decompressFrames(compressedData); const types = config.getForkTypes(blockSlot); @@ -250,15 +241,66 @@ function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { return out; } -/** In-place encode of a 64-bit signed integer (little-endian) into target at offset. */ -function writeI64LEInto(target: Uint8Array, offset: number, v: bigint): void { - scratch64View.setBigInt64(0, v, true); - target.set(scratch64Bytes, offset); +/** In-place encode of a 48-bit signed integer (little-endian) into target at offset. */ +function writeI64LEInto(target: Uint8Array, offset: number, v: number): void { + Buffer.prototype.writeIntLE.call(target, v, offset, 6); +} +function readI64LE(buf: Uint8Array, off: number): number { + return Buffer.prototype.readIntLE.call(buf, off, 6); } -function readI64LE(buf: Uint8Array, off: number): bigint { - const dv = new DataView(buf.buffer, buf.byteOffset + off, 8); - return dv.getBigInt64(0, true); + +/** + * Read block slot index from an era file without loading the entire file into memory. + * Only reads the necessary index data from the end of the file. + */ +export async function readBlockSlotIndexFromFile(fh: FileHandle): Promise { + const stats = await fh.stat(); + const fileSize = stats.size; + + const stateIndexSize = E2STORE_HEADER_SIZE + 24; + const stateIndexStart = fileSize - stateIndexSize; + + // Read state index to get startSlot + const stateIndexBytes = new Uint8Array(stateIndexSize); + await fh.read(stateIndexBytes, 0, stateIndexSize, stateIndexStart); + const stateEntry = readEntry(stateIndexBytes); + const stateStartSlot = readI64LE(stateEntry.data, 0); + + // Genesis era (era 0) has no block index + if (stateStartSlot === 0) { + return {startSlot: 0, indices: []}; + } + + // Read trailing count from block index (8 bytes before state index) + const blockCountBytes = new Uint8Array(8); + await fh.read(blockCountBytes, 0, 8, stateIndexStart - 8); + const blockCount = readI64LE(blockCountBytes, 0); + + // Calculate block index size and read it + const blockIndexSize = E2STORE_HEADER_SIZE + 16 + blockCount * 8; + const blockIndexStart = stateIndexStart - blockIndexSize; + + const blockIndexBytes = new Uint8Array(blockIndexSize); + await fh.read(blockIndexBytes, 0, blockIndexSize, blockIndexStart); + const blockEntry = readEntry(blockIndexBytes); + + // Parse block index + const blockStartSlot = readI64LE(blockEntry.data, 0); + const offsets: number[] = []; + for (let i = 0; i < blockCount; i++) { + const offsetFieldOffset = 8 + i * 8; + const relativeOffset = readI64LE(blockEntry.data, offsetFieldOffset); + if (relativeOffset === 0) { + offsets.push(0); + } else { + const absoluteOffset = blockIndexStart + relativeOffset; + offsets.push(absoluteOffset); + } + } + + return {startSlot: blockStartSlot, indices: offsets}; } + /** * Build SlotIndex payload: startSlot | offsets[count] | count. * Offsets are i64 relative to the index record start (0 = missing). @@ -269,18 +311,18 @@ function buildSlotIndexData(startSlot: number, offsetsAbs: readonly number[], in const payload = new Uint8Array(count * 8 + 16); // startSlot - writeI64LEInto(payload, 0, BigInt(startSlot)); + writeI64LEInto(payload, 0, startSlot); // offsets (relative to beginning of index record) let off = 8; for (let i = 0; i < count; i++, off += 8) { const abs = offsetsAbs[i]; - const rel = abs === 0 ? 0n : BigInt(abs - indexRecordStart); + const rel = abs === 0 ? 0 : abs - indexRecordStart; writeI64LEInto(payload, off, rel); } // trailing count - writeI64LEInto(payload, 8 + count * 8, BigInt(count)); + writeI64LEInto(payload, 8 + count * 8, count); return payload; } @@ -312,7 +354,7 @@ function concat(chunks: Uint8Array[]): Uint8Array { * Layout: Version | block* | era-state | SlotIndex(block)? | SlotIndex(state) * Genesis (era 0): omit block index; always include state index (count=1). */ -function writeEraGroup(params: { +export function writeEraGroup(params: { era: number; slotsPerHistoricalRoot: number; snappyFramed: SnappyFramedCompress; @@ -407,14 +449,14 @@ export async function writeEraIndexFile(path: string, index: EraIndex): Promise< const buffer = new Uint8Array(16 + count * 8); // Write startSlot - writeI64LEInto(buffer, 0, BigInt(index.startSlot)); + writeI64LEInto(buffer, 0, index.startSlot); // Write count - writeI64LEInto(buffer, 8, BigInt(count)); + writeI64LEInto(buffer, 8, count); // Write indices for (let i = 0; i < count; i++) { - writeI64LEInto(buffer, 16 + i * 8, BigInt(index.indices[i])); + writeI64LEInto(buffer, 16 + i * 8, index.indices[i]); } await writeFile(path, buffer); @@ -427,122 +469,11 @@ export function isSlotInRange(slot: Slot, eraNumber: number): boolean { return slot >= eraStartSlot && slot < eraEndSlot; } -/** - * Parse era number from era filename. - * Format: --.era - */ -function parseEraNumber(filename: string): number { - const match = filename.match(/-(\d{5})-/); - if (!match) { - throw new Error(`Invalid era filename format: ${filename}`); - } - return parseInt(match[1], 10); -} - -/** Create a new era file for writing */ -export async function createEraFile(path: string, eraNumber: number): Promise { - const fh = await open(path, "w+"); - const name = basename(path); - - // Register the file handle - fileHandles.set(fh.fd, fh); - - const eraFile: EraFile = { - fd: fh.fd, - name, - eraNumber, - - async close() { - fileHandles.delete(fh.fd); - await fh.close(); - }, - - async validate(config: ChainForkConfig): Promise { - await validateEraFile(fh.fd, eraNumber, config); - }, - - async createIndex(): Promise { - // Read the entire file to extract the slot index - const stats = await fh.stat(); - const buffer = new Uint8Array(stats.size); - await fh.read(buffer, 0, stats.size, 0); - - // Get the block slot index from the era file - const {blockSlotIndex} = getEraIndexes(buffer, eraNumber); - - if (!blockSlotIndex) { - // Genesis era (era 0) has no block index - return { - startSlot: 0, - indices: [], - }; - } - - return { - startSlot: blockSlotIndex.startSlot, - indices: blockSlotIndex.offsets, - }; - }, - }; - - return eraFile; -} - -/** Open an era file and return an EraFile handle */ -export async function openEraFile(path: string): Promise { - const fh = await open(path, "r"); - const name = basename(path); - const eraNumber = parseEraNumber(name); - - // Register the file handle - fileHandles.set(fh.fd, fh); - - const eraFile: EraFile = { - fd: fh.fd, - name, - eraNumber, - - async close() { - fileHandles.delete(fh.fd); - await fh.close(); - }, - - async validate(config: ChainForkConfig): Promise { - await validateEraFile(fh.fd, eraNumber, config); - }, - - async createIndex(): Promise { - // Read the entire file to extract the slot index - const stats = await fh.stat(); - const buffer = new Uint8Array(stats.size); - await fh.read(buffer, 0, stats.size, 0); - - // Get the block slot index from the era file - const {blockSlotIndex} = getEraIndexes(buffer, eraNumber); - - if (!blockSlotIndex) { - // Genesis era (era 0) has no block index - return { - startSlot: 0, - indices: [], - }; - } - - return { - startSlot: blockSlotIndex.startSlot, - indices: blockSlotIndex.offsets, - }; - }, - }; - - return eraFile; -} - /** * Helper to read entry at a specific offset from an open file handle. * Reads header first to determine data length, then reads the complete entry. */ -async function readEntryFromFile(fh: FileHandle, offset: number): Promise { +export async function readEntryFromFile(fh: FileHandle, offset: number): Promise { // Read header (8 bytes) const header = new Uint8Array(E2STORE_HEADER_SIZE); await fh.read(header, 0, E2STORE_HEADER_SIZE, offset); @@ -559,7 +490,7 @@ async function readEntryFromFile(fh: FileHandle, offset: number): Promise { +export async function compressSnappyFramed(data: Uint8Array): Promise { const buffers: Buffer[] = []; for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { buffers.push(chunk); @@ -574,23 +505,11 @@ async function compressSnappyFramed(data: Uint8Array): Promise { return out; } -// WeakMap to track FileHandle for each EraFile by fd -const fileHandles = new Map(); - -/** Helper to get file handle from fd */ -function getFileHandle(fd: number): FileHandle { - const fh = fileHandles.get(fd); - if (!fh) { - throw new Error(`No FileHandle found for fd ${fd}`); - } - return fh; -} - /** * Get the state offset from the era file. * Reads only the necessary parts of the file to locate the state index. */ -async function getStateOffset(fh: FileHandle, eraNumber: number): Promise { +export async function getStateOffset(fh: FileHandle, eraNumber: number): Promise { // For now, read entire file to get indexes const stats = await fh.stat(); const buffer = new Uint8Array(stats.size); @@ -606,8 +525,7 @@ async function getStateOffset(fh: FileHandle, eraNumber: number): Promise { - const fh = getFileHandle(fd); +export async function validateEraFile(fh: FileHandle, eraNumber: number, config: ChainForkConfig): Promise { const stats = await fh.stat(); const buffer = new Uint8Array(stats.size); await fh.read(buffer, 0, stats.size, 0); @@ -656,219 +574,3 @@ async function validateEraFile(fd: number, eraNumber: number, config: ChainForkC } } } - -/** EraFileReader implementation */ -export class EraFileReader { - readonly era: EraFile; - readonly index: EraIndex; - private readonly config: ChainForkConfig; - - constructor(era: EraFile, index: EraIndex, config: ChainForkConfig) { - this.era = era; - this.index = index; - this.config = config; - } - - async readCompressedCanonicalState(): Promise { - const fh = getFileHandle(this.era.fd); - const offset = await getStateOffset(fh, this.era.eraNumber); - const entry = await readEntryFromFile(fh, offset); - - if (entry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState, got ${entry.type}`); - } - - return entry.data; - } - - async readCanonicalState(): Promise { - const compressed = await this.readCompressedCanonicalState(); - return decompressBeaconState(compressed, this.era.eraNumber, this.config); - } - - async readCompressedBlock(slot: Slot): Promise { - // Calculate offset within the index - const indexOffset = slot - this.index.startSlot; - if (indexOffset < 0 || indexOffset >= this.index.indices.length) { - throw new Error( - `Slot ${slot} is out of range for this era file (valid range: ${this.index.startSlot} to ${this.index.startSlot + this.index.indices.length - 1})` - ); - } - - const fileOffset = this.index.indices[indexOffset]; - if (fileOffset === 0) { - return null; // Empty slot - } - - const fh = getFileHandle(this.era.fd); - const entry = await readEntryFromFile(fh, fileOffset); - if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock, got ${entry.type}`); - } - return entry.data; - } - - async readBlock(slot: Slot): Promise { - const compressed = await this.readCompressedBlock(slot); - if (compressed === null) return null; - return decompressSignedBeaconBlock(compressed, slot, this.config); - } - - async validate(): Promise { - // Read entire file for validation - const fh = getFileHandle(this.era.fd); - const stats = await fh.stat(); - const buffer = new Uint8Array(stats.size); - await fh.read(buffer, 0, stats.size, 0); - - // Validate e2s format and era range - const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, this.era.eraNumber); - - // Validate state - const stateOffset = stateSlotIndex.offsets[0]; - if (!stateOffset) throw new Error("No BeaconState in era file"); - - const stateEntry = readEntry(buffer.subarray(stateOffset)); - if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); - } - - const state = decompressBeaconState(stateEntry.data, this.era.eraNumber, this.config); - const expectedStateSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; - if (state.slot !== expectedStateSlot) { - throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); - } - - // Validate blocks if not genesis - if (blockSlotIndex) { - for (let i = 0; i < blockSlotIndex.offsets.length; i++) { - const offset = blockSlotIndex.offsets[i]; - if (!offset) continue; // Empty slot - - const blockEntry = readEntry(buffer.subarray(offset)); - if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); - } - - const slot = blockSlotIndex.startSlot + i; - const block = decompressSignedBeaconBlock(blockEntry.data, slot, this.config); - - // Validate block slot matches - if (block.message.slot !== slot) { - throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); - } - - // Validate block signature exists (basic check) - if (block.signature.length === 0) { - throw new Error(`Block at slot ${slot} has empty signature`); - } - } - } - } -} - -/** EraFileWriter implementation */ -export class EraFileWriter { - readonly era: EraFile; - private readonly config: ChainForkConfig; - private stateWritten = false; - private stateSlot: Slot | undefined; - private stateData: Uint8Array | undefined; - private blocksBySlot = new Map(); - - constructor(era: EraFile, config: ChainForkConfig) { - this.era = era; - this.config = config; - } - - async writeCompressedCanonicalState(slot: Slot, data: Uint8Array): Promise { - if (this.stateWritten) { - throw new Error("Canonical state has already been written"); - } - const expectedSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; - if (slot !== expectedSlot) { - throw new Error(`State slot must be ${expectedSlot} for era ${this.era.eraNumber}, got ${slot}`); - } - this.stateSlot = slot; - this.stateData = data; - this.stateWritten = true; - } - - async writeCanonicalState(state: BeaconState): Promise { - const slot = state.slot; - const types = this.config.getForkTypes(slot); - const ssz = types.BeaconState.serialize(state); - const compressed = await compressSnappyFramed(ssz); - await this.writeCompressedCanonicalState(slot, compressed); - } - - async writeCompressedBlock(slot: Slot, data: Uint8Array): Promise { - // Blocks in era N file are from era N-1 - if (this.era.eraNumber === 0) { - throw new Error("Genesis era (era 0) does not contain blocks"); - } - - const blockEra = this.era.eraNumber - 1; - if (!isSlotInRange(slot, blockEra)) { - const expectedStartSlot = blockEra * SLOTS_PER_HISTORICAL_ROOT; - const expectedEndSlot = expectedStartSlot + SLOTS_PER_HISTORICAL_ROOT; - throw new Error( - `Slot ${slot} is not in valid block range for era ${this.era.eraNumber} file (valid range: ${expectedStartSlot} to ${expectedEndSlot - 1})` - ); - } - this.blocksBySlot.set(slot, data); - } - - async writeBlock(block: SignedBeaconBlock): Promise { - const slot = block.message.slot; - const types = this.config.getForkTypes(slot); - const ssz = types.SignedBeaconBlock.serialize(block); - const compressed = await compressSnappyFramed(ssz); - await this.writeCompressedBlock(slot, compressed); - } - - async finish(): Promise { - if (!this.stateWritten || !this.stateData || this.stateSlot === undefined) { - throw new Error("Must write canonical state before finishing"); - } - - // Helper to convert compressed data to snappy framed format (already compressed) - const snappyFramed = (data: Uint8Array) => data; - - // Prepare blocks map with SSZ data (already compressed) - const blocksBySlotSSZ = new Map(); - for (const [slot, compressed] of this.blocksBySlot) { - blocksBySlotSSZ.set(slot, compressed); - } - - // Write the era group - const eraBytes = writeEraGroup({ - era: this.era.eraNumber, - slotsPerHistoricalRoot: SLOTS_PER_HISTORICAL_ROOT, - snappyFramed, - blocksBySlot: blocksBySlotSSZ, - stateSlot: this.stateSlot, - stateSSZ: this.stateData, - }); - - // Write to file - const fh = getFileHandle(this.era.fd); - await fh.write(eraBytes, 0, eraBytes.length, 0); - - // Create and return index - const {blockSlotIndex} = getEraIndexes(eraBytes, this.era.eraNumber); - - if (!blockSlotIndex) { - // Genesis era - return { - startSlot: 0, - indices: [], - }; - } - - return { - startSlot: blockSlotIndex.startSlot, - indices: blockSlotIndex.offsets, - }; - } -} diff --git a/packages/era/src/index.ts b/packages/era/src/index.ts index 3248dc70f12b..025d975922cc 100644 --- a/packages/era/src/index.ts +++ b/packages/era/src/index.ts @@ -1,3 +1,4 @@ export * from "./constants.js"; +export * from "./eraFile.js"; export * from "./helpers.js"; export * from "./types.js"; diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts index 1170baf0d6d2..c43a30c153d0 100644 --- a/packages/era/src/types.ts +++ b/packages/era/src/types.ts @@ -1,4 +1,3 @@ -import type {ChainForkConfig} from "@lodestar/config"; import {Slot} from "@lodestar/types"; import {E2StoreEntryType} from "./constants.js"; @@ -44,31 +43,3 @@ export interface EraIndex { startSlot: number; indices: number[]; } - -/** An open Era file */ -export interface EraFile { - /** file descriptor */ - fd: number; - name: string; - eraNumber: number; - - /** - * Convenience method to close the underlying file descriptor. - * No further actions can be taken after this operation. - */ - close(): Promise; - - /** - * Fully validate the era file for: - * - e2s format correctness - * - era range correctness - * - network correctness for state and blocks - * - block root and signature matches - */ - validate(config: ChainForkConfig): Promise; - - /** - * Create an Era index from the contents of this file. - */ - createIndex(): Promise; -} diff --git a/packages/era/test/e2e/era.readwrite.integration.test.ts b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts similarity index 94% rename from packages/era/test/e2e/era.readwrite.integration.test.ts rename to packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts index 36402d611a89..b48ae02882d3 100644 --- a/packages/era/test/e2e/era.readwrite.integration.test.ts +++ b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts @@ -3,9 +3,9 @@ import path from "node:path"; import {fileURLToPath} from "node:url"; import {assert, beforeAll, describe, it} from "vitest"; import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; -import {config as defaultConfig} from "@lodestar/config/default"; +import {mainnetChainConfig} from "@lodestar/config/networks"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {EraFileReader, EraFileWriter, createEraFile, openEraFile} from "../../src/index.js"; +import {EraFile, EraFileReader, EraFileWriter} from "../../src/index.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -16,7 +16,7 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file const expectedEra = 1506; beforeAll(() => { - cfg = createChainForkConfig(defaultConfig); + cfg = createChainForkConfig(mainnetChainConfig); }); it("reads an existing era file and writes a new era file that round-trips", async () => { @@ -28,7 +28,7 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file let t0 = startTime; console.log("stage: open and read original era file"); - const originalEraFile = await openEraFile(eraPath); + const originalEraFile = await EraFile.open(eraPath); const originalIndex = await originalEraFile.createIndex(); const reader = new EraFileReader(originalEraFile, originalIndex, cfg); console.log(` time: ${Date.now() - t0}ms`); @@ -64,7 +64,7 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); const outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-rewrite.era`); - const newEraFile = await createEraFile(outFile, expectedEra); + const newEraFile = await EraFile.create(outFile, expectedEra); const writer = new EraFileWriter(newEraFile, cfg); // Write state @@ -90,7 +90,7 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file console.log("stage: validate new era file"); // Open and validate the new file - const validationEraFile = await openEraFile(outFile); + const validationEraFile = await EraFile.open(outFile); const validationIndex = await validationEraFile.createIndex(); const validationReader = new EraFileReader(validationEraFile, validationIndex, cfg); console.log(` time: ${Date.now() - t0}ms`); From ee62c050d217c0296c3d5e4b754f16620a3aa73b Mon Sep 17 00:00:00 2001 From: Rahul Guha <19rahul2003@gmail.com> Date: Wed, 22 Oct 2025 04:20:58 +0530 Subject: [PATCH 17/34] update package.json --- packages/era/package.json | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/era/package.json b/packages/era/package.json index 9ce61ddae737..74f0c9dfcc33 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -1,7 +1,7 @@ { "name": "@lodestar/era", - "version": "1.33.0", "description": "Era file handling module for Lodestar", + "license": "Apache-2.0", "author": "ChainSafe Systems", "homepage": "https://github.com/ChainSafe/lodestar#readme", "repository": { @@ -11,15 +11,18 @@ "bugs": { "url": "https://github.com/ChainSafe/lodestar/issues" }, + "version": "1.34.1", "type": "module", - "exports": "./lib/index.js", - "types": "./lib/index.d.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "import": "./lib/index.js" + } + }, "files": [ - "lib/**/*.d.ts", - "lib/**/*.js", - "lib/**/*.js.map", - "*.d.ts", - "*.js" + "src", + "lib", + "!**/*.tsbuildinfo" ], "scripts": { "clean": "rm -rf lib && rm -f *.tsbuildinfo", @@ -35,11 +38,11 @@ "check-readme": "typescript-docs-verifier" }, "dependencies": { - "@lodestar/types": "^1.33.0", - "@lodestar/utils": "^1.33.0", - "@lodestar/config": "^1.33.0", - "@lodestar/params": "^1.33.0", - "@lodestar/reqresp": "^1.33.0", + "@lodestar/types": "^1.34.1", + "@lodestar/utils": "^1.34.1", + "@lodestar/config": "^1.34.1", + "@lodestar/params": "^1.34.1", + "@lodestar/reqresp": "^1.34.1", "uint8arraylist": "^2.4.7" }, "devDependencies": { From 4e21a0fa85f84390dfebe6a99e86d53043d23833 Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 30 Oct 2025 18:46:08 -0400 Subject: [PATCH 18/34] chore: pr review --- packages/era/.gitignore | 1 + packages/era/package.json | 6 +- packages/era/src/constants.ts | 31 - packages/era/src/e2s.ts | 184 ++++++ packages/era/src/era/index.ts | 3 + packages/era/src/era/reader.ts | 193 ++++++ packages/era/src/era/util.ts | 134 ++++ packages/era/src/era/writer.ts | 206 +++++++ packages/era/src/eraFile.ts | 299 --------- packages/era/src/helpers.ts | 576 ------------------ packages/era/src/index.ts | 6 +- packages/era/src/types.ts | 45 -- packages/era/src/util.ts | 47 ++ .../era.readwrite.integration.test.ts | 140 ++--- packages/era/test/unit/era.unit.test.ts | 54 +- 15 files changed, 830 insertions(+), 1095 deletions(-) create mode 100644 packages/era/.gitignore delete mode 100644 packages/era/src/constants.ts create mode 100644 packages/era/src/e2s.ts create mode 100644 packages/era/src/era/index.ts create mode 100644 packages/era/src/era/reader.ts create mode 100644 packages/era/src/era/util.ts create mode 100644 packages/era/src/era/writer.ts delete mode 100644 packages/era/src/eraFile.ts delete mode 100644 packages/era/src/helpers.ts delete mode 100644 packages/era/src/types.ts create mode 100644 packages/era/src/util.ts diff --git a/packages/era/.gitignore b/packages/era/.gitignore new file mode 100644 index 000000000000..cda68a3edc90 --- /dev/null +++ b/packages/era/.gitignore @@ -0,0 +1 @@ +*.era diff --git a/packages/era/package.json b/packages/era/package.json index 74f0c9dfcc33..180dd196a206 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -38,11 +38,13 @@ "check-readme": "typescript-docs-verifier" }, "dependencies": { - "@lodestar/types": "^1.34.1", - "@lodestar/utils": "^1.34.1", + "@chainsafe/blst": "^2.2.0", "@lodestar/config": "^1.34.1", "@lodestar/params": "^1.34.1", "@lodestar/reqresp": "^1.34.1", + "@lodestar/state-transition": "^1.34.1", + "@lodestar/types": "^1.34.1", + "@lodestar/utils": "^1.34.1", "uint8arraylist": "^2.4.7" }, "devDependencies": { diff --git a/packages/era/src/constants.ts b/packages/era/src/constants.ts deleted file mode 100644 index 344784dce8f5..000000000000 --- a/packages/era/src/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Known entry types in an E2Store (.e2s) file. - */ -export enum E2StoreEntryType { - Empty = "Empty", - CompressedSignedBeaconBlock = "CompressedSignedBeaconBlock", - CompressedBeaconState = "CompressedBeaconState", - Version = "Version", - SlotIndex = "SlotIndex", -} - -/** - * The exact 2-byte type codes for E2StoreEntryType as defined in the specification. - */ -export const EraTypes = { - [E2StoreEntryType.Empty]: new Uint8Array([0x00, 0x00]), - [E2StoreEntryType.CompressedSignedBeaconBlock]: new Uint8Array([0x01, 0x00]), - [E2StoreEntryType.CompressedBeaconState]: new Uint8Array([0x02, 0x00]), - [E2StoreEntryType.Version]: new Uint8Array([0x65, 0x32]), // "e2" in ASCII - [E2StoreEntryType.SlotIndex]: new Uint8Array([0x69, 0x32]), // "i2" in ASCII -}; - -/** - * The complete version record (8 bytes total). - */ -export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); - -/** - * E2Store header size in bytes - */ -export const E2STORE_HEADER_SIZE = 8; diff --git a/packages/era/src/e2s.ts b/packages/era/src/e2s.ts new file mode 100644 index 000000000000..516625ed3007 --- /dev/null +++ b/packages/era/src/e2s.ts @@ -0,0 +1,184 @@ +import type {FileHandle} from "node:fs/promises"; +import {Slot} from "@lodestar/types"; +import {readInt48, writeInt48} from "./util.ts"; + +/** + * Known entry types in an E2Store (.e2s) file along with their exact 2-byte codes. + */ +export enum EntryType { + Empty = 0, + CompressedSignedBeaconBlock = 1, + CompressedBeaconState = 2, + Version = 0x65 | (0x32 << 8), // "e2" in ASCII + SlotIndex = 0x69 | (0x32 << 8), +} +/** + * Logical, parsed entry from an E2Store file. + */ +export interface Entry { + type: EntryType; + data: Uint8Array; +} + +/** + * Maps slots to file positions in an era file. + * - Block index: count = SLOTS_PER_HISTORICAL_ROOT, maps slots to blocks + * - State index: count = 1, points to the era state + * - Zero offset = empty slot (no block) + */ +export interface SlotIndex { + type: EntryType.SlotIndex; + /** First slot covered by this index (era * SLOTS_PER_HISTORICAL_ROOT) */ + startSlot: Slot; + /** File positions where data can be found. Length varies by index type. */ + offsets: number[]; + /** File position where this index record starts */ + recordStart: number; +} + +/** + * The complete version record (8 bytes total). + */ +export const VERSION_RECORD_BYTES = new Uint8Array([0x65, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + +/** + * E2Store header size in bytes + */ +export const E2STORE_HEADER_SIZE = 8; + +/** + * Helper to read entry at a specific offset from an open file handle. + * Reads header first to determine data length, then reads the complete entry. + */ +export async function readEntry(fh: FileHandle, offset: number): Promise { + // Read header (8 bytes) + const header = new Uint8Array(E2STORE_HEADER_SIZE); + await fh.read(header, 0, E2STORE_HEADER_SIZE, offset); + const {type, length} = parseEntryHeader(header); + + // Read entry payload/data + const data = new Uint8Array(length); + await fh.read(data, 0, data.length, offset + E2STORE_HEADER_SIZE); + + return {type, data}; +} + +/** + * Read an e2Store entry (header + data) + * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0) + */ +export function parseEntryHeader(header: Uint8Array): {type: EntryType; length: number} { + if (header.length < E2STORE_HEADER_SIZE) { + throw new Error(`Buffer too small for E2Store header: need ${E2STORE_HEADER_SIZE} bytes, got ${header.length}`); + } + + // validate entry type from first 2 bytes + const typeCode = header[0] | (header[1] << 8); + const typeEntry = EntryType[EntryType[typeCode] as keyof typeof EntryType]; + if (typeEntry === undefined) { + throw new Error(`Unknown E2Store entry type: 0x${typeCode.toString(16)}`); + } + const type = typeEntry as EntryType; + + // Parse data length from next 4 bytes (offset 2, little endian) + const length = header[2] | (header[3] << 8) | (header[4] << 16) | (header[5] << 24); + + // Validate reserved bytes are zero (offset 6-7) + const reserved = header[6] | (header[7] << 8); + if (reserved !== 0) { + throw new Error(`E2Store reserved bytes must be zero, got: ${reserved}`); + } + + return {type, length}; +} + +export async function readVersion(fh: FileHandle, offset: number): Promise { + const versionHeader = new Uint8Array(E2STORE_HEADER_SIZE); + await fh.read(versionHeader, 0, E2STORE_HEADER_SIZE, offset); + if (Buffer.compare(versionHeader, VERSION_RECORD_BYTES) !== 0) { + throw new Error("Invalid E2Store version record"); + } +} + +/** + * Read a SlotIndex from a file handle. + */ +export async function readSlotIndex(fh: FileHandle, offset: number): Promise { + const recordEnd = offset; + const countBuffer = new Uint8Array(8); + await fh.read(countBuffer, 0, 8, recordEnd - 8); + const count = readInt48(countBuffer, 0); + + const recordStart = recordEnd - (8 * count + 24); + + // Validate index position is within file bounds + if (recordStart < 0) { + throw new Error(`SlotIndex position ${recordStart} is invalid - file too small for count=${count}`); + } + + // Read and validate the slot index entry + const entry = await readEntry(fh, recordStart); + if (entry.type !== EntryType.SlotIndex) { + throw new Error(`Expected SlotIndex entry, got ${entry.type}`); + } + + // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16 + const expectedSize = 8 * count + 16; + if (entry.data.length !== expectedSize) { + throw new Error(`SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}`); + } + + // Parse start slot from payload + const startSlot = readInt48(entry.data, 0); + + const offsets: number[] = []; + for (let i = 0; i < count; i++) { + offsets.push(readInt48(entry.data, 8 * i + 8)); + } + + return { + type: EntryType.SlotIndex, + startSlot, + offsets, + recordStart, + }; +} + +/** + * Write a single E2Store TLV entry (header + payload) + * Header layout: type[2] | length u32 LE | reserved u16(=0) + */ +export async function writeEntry(fh: FileHandle, offset: number, type: EntryType, payload: Uint8Array): Promise { + const header = new Uint8Array(E2STORE_HEADER_SIZE); + // type + header[0] = type; + header[1] = type >>> 8; + // length u32 LE + header[2] = payload.length & 0xff; + header[3] = (payload.length >>> 8) & 0xff; + header[4] = (payload.length >>> 16) & 0xff; + header[5] = (payload.length >>> 24) & 0xff; + await fh.writev([header, payload], offset); +} + +export async function writeVersion(fh: FileHandle, offset: number): Promise { + await fh.write(VERSION_RECORD_BYTES, 0, VERSION_RECORD_BYTES.length, offset); +} + +export function serializeSlotIndex(slotIndex: SlotIndex): Uint8Array { + const count = slotIndex.offsets.length; + const payload = new Uint8Array(count * 8 + 16); + + // startSlot + writeInt48(payload, 0, slotIndex.startSlot); + + // offsets + let off = 8; + for (let i = 0; i < count; i++, off += 8) { + writeInt48(payload, off, slotIndex.offsets[i]); + } + + // trailing count + writeInt48(payload, 8 + count * 8, count); + return payload; +} diff --git a/packages/era/src/era/index.ts b/packages/era/src/era/index.ts new file mode 100644 index 000000000000..30e047a904ca --- /dev/null +++ b/packages/era/src/era/index.ts @@ -0,0 +1,3 @@ +export * from "./reader.js"; +export * from "./util.js"; +export * from "./writer.js"; diff --git a/packages/era/src/era/reader.ts b/packages/era/src/era/reader.ts new file mode 100644 index 000000000000..276ddf092505 --- /dev/null +++ b/packages/era/src/era/reader.ts @@ -0,0 +1,193 @@ +import {type FileHandle, open} from "node:fs/promises"; +import {basename} from "node:path"; +import {PublicKey, Signature, verify} from "@chainsafe/blst"; +import {ChainForkConfig, createCachedGenesis} from "@lodestar/config"; +import {DOMAIN_BEACON_PROPOSER} from "@lodestar/params"; +import {BeaconState, SignedBeaconBlock, Slot, ssz} from "@lodestar/types"; +import {E2STORE_HEADER_SIZE, EntryType, readEntry, readVersion} from "../e2s.ts"; +import {snappyUncompress} from "../util.ts"; +import { + EraIndices, + computeEraNumberFromBlockSlot, + parseEraName, + readAllEraIndices, + readSlotFromBeaconStateBytes, +} from "./util.ts"; + +/** + * EraReader is responsible for reading and validating ERA files. + * + * See https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md + */ +export class EraReader { + readonly config: ChainForkConfig; + /** The underlying file handle */ + readonly fh: FileHandle; + /** The era number retrieved from the file name */ + readonly eraNumber: number; + /** The short historical root retrieved from the file name */ + readonly shortHistoricalRoot: string; + /** An array of state and block indices, one per group */ + readonly groups: EraIndices[]; + + constructor( + config: ChainForkConfig, + fh: FileHandle, + eraNumber: number, + shortHistoricalRoot: string, + indices: EraIndices[] + ) { + this.config = config; + this.fh = fh; + this.eraNumber = eraNumber; + this.shortHistoricalRoot = shortHistoricalRoot; + this.groups = indices; + } + + static async open(config: ChainForkConfig, path: string): Promise { + const fh = await open(path, "r"); + const name = basename(path); + const {configName, eraNumber, shortHistoricalRoot} = parseEraName(name); + if (config.CONFIG_NAME !== configName) { + throw new Error(`Config name mismatch: expected ${config.CONFIG_NAME}, got ${configName}`); + } + const indices = await readAllEraIndices(fh); + return new EraReader(config, fh, eraNumber, shortHistoricalRoot, indices); + } + + /** + * Close the underlying file descriptor + * + * No further actions can be taken after this operation + */ + async close(): Promise { + await this.fh.close(); + } + + async readCompressedState(eraNumber?: number): Promise { + eraNumber = eraNumber ?? this.eraNumber; + const index = this.groups.at(eraNumber - this.eraNumber); + if (!index) { + throw new Error(`No index found for era number ${eraNumber}`); + } + const entry = await readEntry(this.fh, index.stateIndex.recordStart + index.stateIndex.offsets[0]); + + if (entry.type !== EntryType.CompressedBeaconState) { + throw new Error(`Expected CompressedBeaconState, got ${entry.type}`); + } + + return entry.data; + } + + async readSerializedState(eraNumber?: number): Promise { + const compressed = await this.readCompressedState(eraNumber); + return snappyUncompress(compressed); + } + + async readState(eraNumber?: number): Promise { + const serialized = await this.readSerializedState(eraNumber); + const stateSlot = readSlotFromBeaconStateBytes(serialized); + return this.config.getForkTypes(stateSlot).BeaconState.deserialize(serialized); + } + + async readCompressedBlock(slot: Slot): Promise { + const slotEra = computeEraNumberFromBlockSlot(slot); + const index = this.groups.at(slotEra - this.eraNumber); + if (!index) { + throw new Error(`Slot ${slot} is out of range`); + } + if (!index.blocksIndex) { + throw new Error(`No block index found for era number ${slotEra}`); + } + // Calculate offset within the index + const indexOffset = slot - index.blocksIndex.startSlot; + const offset = index.blocksIndex.recordStart + index.blocksIndex.offsets[indexOffset]; + if (offset === 0) { + return null; // Empty slot + } + + const entry = await readEntry(this.fh, offset); + if (entry.type !== EntryType.CompressedSignedBeaconBlock) { + throw new Error(`Expected CompressedSignedBeaconBlock, got ${EntryType[entry.type] ?? "unknown"}`); + } + return entry.data; + } + + async readSerializedBlock(slot: Slot): Promise { + const compressed = await this.readCompressedBlock(slot); + if (compressed === null) return null; + return snappyUncompress(compressed); + } + + async readBlock(slot: Slot): Promise { + const serialized = await this.readSerializedBlock(slot); + if (serialized === null) return null; + return this.config.getForkTypes(slot).SignedBeaconBlock.deserialize(serialized); + } + + /** + * Validate the era file. + * - e2s format correctness + * - era range correctness + * - network correctness for state and blocks + * - block root and signature matches + */ + async validate(): Promise { + for (let groupIndex = 0; groupIndex < this.groups.length; groupIndex++) { + const eraNumber = this.eraNumber + groupIndex; + const index = this.groups[groupIndex]; + + // validate version entry + const start = index.blocksIndex + ? index.blocksIndex.recordStart + index.blocksIndex.offsets[0] - E2STORE_HEADER_SIZE + : index.stateIndex.recordStart + index.stateIndex.offsets[0] - E2STORE_HEADER_SIZE; + await readVersion(this.fh, start); + + // validate state + // the state is loadable and consistent with the given runtime configuration + const state = await this.readState(eraNumber); + const cachedGenesis = createCachedGenesis(this.config, state.genesisValidatorsRoot); + + if (eraNumber === 0 && index.blocksIndex) { + throw new Error("Genesis era (era 0) should not have blocks index"); + } + if (eraNumber !== 0) { + if (!index.blocksIndex) { + throw new Error(`Era ${eraNumber} is missing blocks index`); + } + + // validate blocks + const signatureSets: { + msg: Uint8Array; + pk: PublicKey; + sig: Signature; + }[] = []; + for (let slot = index.blocksIndex.startSlot; slot <= index.blocksIndex.offsets.length; slot++) { + const block = await this.readBlock(slot); + if (block === null) { + if (slot === index.blocksIndex.startSlot) continue; // first slot in the era can't be easily validated + if (Buffer.compare(state.blockRoots[slot - 1], state.blockRoots[slot]) !== 0) { + throw new Error(`Block root mismatch at slot ${slot} for empty slot`); + } + continue; + } + + const blockRoot = this.config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message); + if (Buffer.compare(blockRoot, state.blockRoots[slot]) !== 0) { + throw new Error(`Block root mismatch at slot ${slot}`); + } + const msg = ssz.phase0.SigningData.hashTreeRoot({ + objectRoot: blockRoot, + domain: cachedGenesis.getDomain(slot, DOMAIN_BEACON_PROPOSER), + }); + const pk = PublicKey.fromBytes(state.validators[block.message.proposerIndex].pubkey); + const sig = Signature.fromBytes(block.signature); + signatureSets.push({msg, pk, sig}); + if (!verify(msg, pk, sig, true, true)) { + throw new Error(`Block signature verification failed at slot ${slot}`); + } + } + } + } + } +} diff --git a/packages/era/src/era/util.ts b/packages/era/src/era/util.ts new file mode 100644 index 000000000000..28b69e7e1826 --- /dev/null +++ b/packages/era/src/era/util.ts @@ -0,0 +1,134 @@ +import type {FileHandle} from "node:fs/promises"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_HISTORICAL_ROOT, isForkPostCapella} from "@lodestar/params"; +import {BeaconState, Slot, capella, ssz} from "@lodestar/types"; +import {E2STORE_HEADER_SIZE, SlotIndex, readSlotIndex} from "../e2s.ts"; +import {readUint48} from "../util.ts"; + +/** + * Parsed components of an .era file name. + * Format: --.era + */ +export interface EraFileName { + /** CONFIG_NAME field of runtime config (mainnet, sepolia, holesky, etc.) */ + configName: string; + /** Number of the first era stored in file, 5-digit zero-padded (00000, 00001, etc.) */ + eraNumber: number; + /** First 4 bytes of last historical root, lower-case hex-encoded (8 chars) */ + shortHistoricalRoot: string; +} + +export interface EraIndices { + stateIndex: SlotIndex; + blocksIndex?: SlotIndex; +} + +/** Return true if `slot` is within the era range */ +export function isSlotInRange(slot: Slot, eraNumber: number): boolean { + return computeEraNumberFromBlockSlot(slot) === eraNumber; +} + +export function isValidEraStateSlot(slot: Slot, eraNumber: number): boolean { + return slot % SLOTS_PER_HISTORICAL_ROOT === 0 && slot / SLOTS_PER_HISTORICAL_ROOT === eraNumber; +} + +export function computeEraNumberFromBlockSlot(slot: Slot): number { + return Math.floor(slot / SLOTS_PER_HISTORICAL_ROOT) + 1; +} + +export function computeStartBlockSlotFromEraNumber(eraNumber: number): Slot { + if (eraNumber === 0) { + throw new Error("Genesis era (era 0) does not contain blocks"); + } + return (eraNumber - 1) * SLOTS_PER_HISTORICAL_ROOT; +} + +/** + * Parse era filename. + * + * Format: `--.era` + */ +export function parseEraName(filename: string): {configName: string; eraNumber: number; shortHistoricalRoot: string} { + const match = filename.match(/^(.*)-(\d{5})-([0-9a-f]{8})\.era$/); + if (!match) { + throw new Error(`Invalid era filename format: ${filename}`); + } + return { + configName: match[1], + eraNumber: parseInt(match[2], 10), + shortHistoricalRoot: match[3], + }; +} + +/** + * Read all indices from an era file. + */ +export async function readAllEraIndices(fh: FileHandle): Promise { + let end = (await fh.stat()).size; + + const indices: EraIndices[] = []; + while (end > E2STORE_HEADER_SIZE) { + const index = await readEraIndexes(fh, end); + indices.push(index); + end = index.blocksIndex + ? index.blocksIndex.recordStart + index.blocksIndex.offsets[0] - E2STORE_HEADER_SIZE + : index.stateIndex.recordStart + index.stateIndex.offsets[0] - E2STORE_HEADER_SIZE; + } + return indices; +} + +/** + * Read state and block SlotIndex entries from an era file and validate alignment. + */ +export async function readEraIndexes(fh: FileHandle, end: number): Promise { + const stateIndex = await readSlotIndex(fh, end); + if (stateIndex.offsets.length !== 1) { + throw new Error(`State SlotIndex must have exactly one offset, got ${stateIndex.offsets.length}`); + } + + // Read block index if not genesis era (era 0) + let blocksIndex: SlotIndex | undefined; + if (stateIndex.startSlot > 0) { + blocksIndex = await readSlotIndex(fh, stateIndex.recordStart); + if (blocksIndex.offsets.length !== SLOTS_PER_HISTORICAL_ROOT) { + throw new Error( + `Block SlotIndex must have exactly ${SLOTS_PER_HISTORICAL_ROOT} offsets, got ${blocksIndex.offsets.length}` + ); + } + + // Validate block and state indices are properly aligned + const expectedBlockStartSlot = stateIndex.startSlot - SLOTS_PER_HISTORICAL_ROOT; + if (blocksIndex.startSlot !== expectedBlockStartSlot) { + throw new Error( + `Block index alignment error: expected startSlot=${expectedBlockStartSlot}, ` + + `got startSlot=${blocksIndex.startSlot} (should be exactly one era before state)` + ); + } + } + + return {stateIndex, blocksIndex}; +} + +export function readSlotFromBeaconStateBytes(beaconStateBytes: Uint8Array): Slot { + // not technically a Uint48, but for practical purposes fits within 6 bytes + return readUint48( + beaconStateBytes, + // slot is at offset 40: 8 (genesisTime) + 32 (genesisValidatorsRoot) + 40 + ); +} + +export function getShortHistoricalRoot(config: ChainForkConfig, state: BeaconState): string { + return Buffer.from( + state.slot === 0 + ? state.genesisValidatorsRoot + : // Post-Capella, historical_roots is replaced by historical_summaries + isForkPostCapella(config.getForkName(state.slot)) + ? ssz.capella.HistoricalSummary.hashTreeRoot( + (state as capella.BeaconState).historicalSummaries.at(-1) as capella.BeaconState["historicalSummaries"][0] + ) + : (state.historicalRoots.at(-1) as Uint8Array) + ) + .subarray(0, 4) + .toString("hex"); +} diff --git a/packages/era/src/era/writer.ts b/packages/era/src/era/writer.ts new file mode 100644 index 000000000000..d0aeaf95763b --- /dev/null +++ b/packages/era/src/era/writer.ts @@ -0,0 +1,206 @@ +import {type FileHandle, open, rename} from "node:fs/promises"; +import {format, parse} from "node:path"; +import {ChainForkConfig} from "@lodestar/config"; +import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; +import {E2STORE_HEADER_SIZE, EntryType, SlotIndex, serializeSlotIndex, writeEntry} from "../e2s.ts"; +import {snappyCompress} from "../util.ts"; +import { + computeStartBlockSlotFromEraNumber, + getShortHistoricalRoot, + isSlotInRange, + isValidEraStateSlot, +} from "./util.ts"; + +enum WriterStateType { + InitGroup, + WriteGroup, + FinishedGroup, +} + +type WriterState = + | { + type: WriterStateType.InitGroup; + eraNumber: number; + currentOffset: number; + } + | { + type: WriterStateType.WriteGroup; + eraNumber: number; + currentOffset: number; + blockOffsets: number[]; + lastSlot: Slot; + } + | { + type: WriterStateType.FinishedGroup; + eraNumber: number; + currentOffset: number; + shortHistoricalRoot: string; + }; + +/** + * EraWriter is responsible for writing ERA files. + * + * See https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md + */ +export class EraWriter { + config: ChainForkConfig; + path: string; + fh: FileHandle; + eraNumber: number; + state: WriterState; + + constructor(config: ChainForkConfig, path: string, fh: FileHandle, eraNumber: number) { + this.config = config; + this.path = path; + this.fh = fh; + this.eraNumber = eraNumber; + this.state = { + type: WriterStateType.InitGroup, + eraNumber, + currentOffset: 0, + }; + } + + static async create(config: ChainForkConfig, path: string, eraNumber: number): Promise { + const fh = await open(path, "w"); + return new EraWriter(config, path, fh, eraNumber); + } + + async finish(): Promise { + if (this.state.type !== WriterStateType.FinishedGroup) { + throw new Error("Writer has not been finished"); + } + await this.fh.close(); + + const pathParts = parse(this.path); + const newPath = format({ + ...pathParts, + base: `${this.config.CONFIG_NAME}-${String(this.eraNumber).padStart(5, "0")}-${this.state.shortHistoricalRoot}.era`, + }); + await rename(this.path, newPath); + + return newPath; + } + + async writeVersion(): Promise { + if (this.state.type === WriterStateType.FinishedGroup) { + this.state = { + type: WriterStateType.InitGroup, + eraNumber: this.state.eraNumber + 1, + currentOffset: this.state.currentOffset, + }; + } + if (this.state.type !== WriterStateType.InitGroup) { + throw new Error("Writer has already been initialized"); + } + await writeEntry(this.fh, this.state.currentOffset, EntryType.Version, new Uint8Array(0)); + // Move to writing blocks/state + this.state = { + type: WriterStateType.WriteGroup, + eraNumber: this.state.eraNumber, + currentOffset: this.state.currentOffset + E2STORE_HEADER_SIZE, + blockOffsets: [], + lastSlot: computeStartBlockSlotFromEraNumber(this.state.eraNumber) - 1, + }; + } + + async writeCompressedState(slot: Slot, shortHistoricalRoot: string, data: Uint8Array): Promise { + if (this.state.type === WriterStateType.InitGroup) { + await this.writeVersion(); + } + if (this.state.type !== WriterStateType.WriteGroup) { + throw new Error("unreachable"); + } + const expectedSlot = this.state.eraNumber * SLOTS_PER_HISTORICAL_ROOT; + if (!isValidEraStateSlot(slot, this.state.eraNumber)) { + throw new Error(`State slot must be ${expectedSlot} for era ${this.eraNumber}, got ${slot}`); + } + for (let s = this.state.lastSlot + 1; s < slot; s++) { + this.state.blockOffsets.push(0); // Empty slot + } + const stateOffset = this.state.currentOffset; + await writeEntry(this.fh, this.state.currentOffset, EntryType.CompressedBeaconState, data); + this.state.currentOffset += E2STORE_HEADER_SIZE + data.length; + + if (this.state.eraNumber !== 0) { + const blocksIndex: SlotIndex = { + type: EntryType.SlotIndex, + startSlot: computeStartBlockSlotFromEraNumber(this.state.eraNumber), + offsets: this.state.blockOffsets.map((o) => o - this.state.currentOffset), + recordStart: this.state.currentOffset, + }; + const blocksIndexPayload = serializeSlotIndex(blocksIndex); + await writeEntry(this.fh, this.state.currentOffset, EntryType.SlotIndex, blocksIndexPayload); + this.state.currentOffset += E2STORE_HEADER_SIZE + blocksIndexPayload.length; + } + const stateIndex: SlotIndex = { + type: EntryType.SlotIndex, + startSlot: slot, + offsets: [stateOffset - this.state.currentOffset], + recordStart: this.state.currentOffset, + }; + const stateIndexPayload = serializeSlotIndex(stateIndex); + await writeEntry(this.fh, this.state.currentOffset, EntryType.SlotIndex, stateIndexPayload); + this.state.currentOffset += E2STORE_HEADER_SIZE + stateIndexPayload.length; + + this.state = { + type: WriterStateType.FinishedGroup, + eraNumber: this.state.eraNumber, + currentOffset: this.state.currentOffset, + shortHistoricalRoot, + }; + } + + async writeSerializedState(slot: Slot, shortHistoricalRoot: string, data: Uint8Array): Promise { + const compressed = await snappyCompress(data); + await this.writeCompressedState(slot, shortHistoricalRoot, compressed); + } + + async writeState(state: BeaconState): Promise { + const slot = state.slot; + const shortHistoricalRoot = getShortHistoricalRoot(this.config, state); + const ssz = this.config.getForkTypes(slot).BeaconState.serialize(state); + + await this.writeSerializedState(slot, shortHistoricalRoot, ssz); + } + + async writeCompressedBlock(slot: Slot, data: Uint8Array): Promise { + if (this.state.type === WriterStateType.InitGroup) { + await this.writeVersion(); + } + if (this.state.type !== WriterStateType.WriteGroup) { + throw new Error("Cannot write blocks after writing canonical state"); + } + if (this.eraNumber === 0) { + throw new Error("Genesis era (era 0) does not contain blocks"); + } + + const blockEra = this.state.eraNumber; + if (!isSlotInRange(slot, blockEra)) { + throw new Error(`Slot ${slot} is not in valid block range for era ${blockEra}`); + } + if (slot <= this.state.lastSlot) { + throw new Error(`Slots must be written in ascending order. Last slot: ${this.state.lastSlot}, got: ${slot}`); + } + for (let s = this.state.lastSlot + 1; s < slot; s++) { + this.state.blockOffsets.push(0); // Empty slot + } + await writeEntry(this.fh, this.state.currentOffset, EntryType.CompressedSignedBeaconBlock, data); + this.state.blockOffsets.push(this.state.currentOffset); + this.state.currentOffset += E2STORE_HEADER_SIZE + data.length; + this.state.lastSlot = slot; + } + + async writeSerializedBlock(slot: Slot, data: Uint8Array): Promise { + const compressed = await snappyCompress(data); + await this.writeCompressedBlock(slot, compressed); + } + + async writeBlock(block: SignedBeaconBlock): Promise { + const slot = block.message.slot; + const types = this.config.getForkTypes(slot); + const ssz = types.SignedBeaconBlock.serialize(block); + await this.writeSerializedBlock(slot, ssz); + } +} diff --git a/packages/era/src/eraFile.ts b/packages/era/src/eraFile.ts deleted file mode 100644 index 623d81777fed..000000000000 --- a/packages/era/src/eraFile.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type {FileHandle} from "node:fs/promises"; -import {open} from "node:fs/promises"; -import {basename} from "node:path"; -import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {BeaconState, SignedBeaconBlock, Slot} from "@lodestar/types"; -import {E2StoreEntryType} from "./constants.js"; -import { - compressSnappyFramed, - decompressBeaconState, - decompressSignedBeaconBlock, - getEraIndexes, - getStateOffset, - isSlotInRange, - readBlockSlotIndexFromFile, - readEntry, - readEntryFromFile, - validateEraFile, - writeEraGroup, -} from "./helpers.js"; -import type {EraIndex} from "./types.js"; - -/** - * Parse era number from era filename. - * Format: --.era - */ -function parseEraNumber(filename: string): number { - const match = filename.match(/-(\d{5})-/); - if (!match) { - throw new Error(`Invalid era filename format: ${filename}`); - } - return parseInt(match[1], 10); -} - -/** An open Era file */ -export class EraFile { - readonly fh: FileHandle; - readonly name: string; - readonly eraNumber: number; - - private constructor(fh: FileHandle, name: string, eraNumber: number) { - this.fh = fh; - this.name = name; - this.eraNumber = eraNumber; - } - - /** Create a new era file for writing */ - static async create(path: string, eraNumber: number): Promise { - const fh = await open(path, "w+"); - const name = basename(path); - return new EraFile(fh, name, eraNumber); - } - - /** Open an existing era file for reading */ - static async open(path: string): Promise { - const fh = await open(path, "r"); - const name = basename(path); - const eraNumber = parseEraNumber(name); - return new EraFile(fh, name, eraNumber); - } - - /** - * Close the underlying file descriptor. - * No further actions can be taken after this operation. - */ - async close(): Promise { - await this.fh.close(); - } - - /** - * Fully validate the era file for: - * - e2s format correctness - * - era range correctness - * - network correctness for state and blocks - * - block root and signature matches - */ - async validate(config: ChainForkConfig): Promise { - await validateEraFile(this.fh, this.eraNumber, config); - } - - /** - * Create an Era index from the contents of this file. - */ - async createIndex(): Promise { - return readBlockSlotIndexFromFile(this.fh); - } -} - -/** EraFileReader implementation */ -export class EraFileReader { - readonly era: EraFile; - readonly index: EraIndex; - private readonly config: ChainForkConfig; - - constructor(era: EraFile, index: EraIndex, config: ChainForkConfig) { - this.era = era; - this.index = index; - this.config = config; - } - - async readCompressedCanonicalState(): Promise { - const offset = await getStateOffset(this.era.fh, this.era.eraNumber); - const entry = await readEntryFromFile(this.era.fh, offset); - - if (entry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState, got ${entry.type}`); - } - - return entry.data; - } - - async readCanonicalState(): Promise { - const compressed = await this.readCompressedCanonicalState(); - return decompressBeaconState(compressed, this.era.eraNumber, this.config); - } - - async readCompressedBlock(slot: Slot): Promise { - // Calculate offset within the index - const indexOffset = slot - this.index.startSlot; - if (indexOffset < 0 || indexOffset >= this.index.indices.length) { - throw new Error( - `Slot ${slot} is out of range for this era file (valid range: ${this.index.startSlot} to ${this.index.startSlot + this.index.indices.length - 1})` - ); - } - - const fileOffset = this.index.indices[indexOffset]; - if (fileOffset === 0) { - return null; // Empty slot - } - - const entry = await readEntryFromFile(this.era.fh, fileOffset); - if (entry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock, got ${entry.type}`); - } - return entry.data; - } - - async readBlock(slot: Slot): Promise { - const compressed = await this.readCompressedBlock(slot); - if (compressed === null) return null; - return decompressSignedBeaconBlock(compressed, slot, this.config); - } - - async validate(): Promise { - // Read entire file for validation - const stats = await this.era.fh.stat(); - const buffer = new Uint8Array(stats.size); - await this.era.fh.read(buffer, 0, stats.size, 0); - - // Validate e2s format and era range - const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, this.era.eraNumber); - - // Validate state - const stateOffset = stateSlotIndex.offsets[0]; - if (!stateOffset) throw new Error("No BeaconState in era file"); - - const stateEntry = readEntry(buffer.subarray(stateOffset)); - if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); - } - - const state = decompressBeaconState(stateEntry.data, this.era.eraNumber, this.config); - const expectedStateSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; - if (state.slot !== expectedStateSlot) { - throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); - } - - // Validate blocks if not genesis - if (blockSlotIndex) { - for (let i = 0; i < blockSlotIndex.offsets.length; i++) { - const offset = blockSlotIndex.offsets[i]; - if (!offset) continue; // Empty slot - - const blockEntry = readEntry(buffer.subarray(offset)); - if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); - } - - const slot = blockSlotIndex.startSlot + i; - const block = decompressSignedBeaconBlock(blockEntry.data, slot, this.config); - - // Validate block slot matches - if (block.message.slot !== slot) { - throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); - } - - // Validate block signature exists (basic check) - if (block.signature.length === 0) { - throw new Error(`Block at slot ${slot} has empty signature`); - } - } - } - } -} - -/** EraFileWriter implementation */ -export class EraFileWriter { - readonly era: EraFile; - private readonly config: ChainForkConfig; - private stateWritten = false; - private stateSlot: Slot | undefined; - private stateData: Uint8Array | undefined; - private blocksBySlot = new Map(); - - constructor(era: EraFile, config: ChainForkConfig) { - this.era = era; - this.config = config; - } - - async writeCompressedCanonicalState(slot: Slot, data: Uint8Array): Promise { - if (this.stateWritten) { - throw new Error("Canonical state has already been written"); - } - const expectedSlot = this.era.eraNumber * SLOTS_PER_HISTORICAL_ROOT; - if (slot !== expectedSlot) { - throw new Error(`State slot must be ${expectedSlot} for era ${this.era.eraNumber}, got ${slot}`); - } - this.stateSlot = slot; - this.stateData = data; - this.stateWritten = true; - } - - async writeCanonicalState(state: BeaconState): Promise { - const slot = state.slot; - const types = this.config.getForkTypes(slot); - const ssz = types.BeaconState.serialize(state); - const compressed = await compressSnappyFramed(ssz); - await this.writeCompressedCanonicalState(slot, compressed); - } - - async writeCompressedBlock(slot: Slot, data: Uint8Array): Promise { - // Blocks in era N file are from era N-1 - if (this.era.eraNumber === 0) { - throw new Error("Genesis era (era 0) does not contain blocks"); - } - - const blockEra = this.era.eraNumber - 1; - if (!isSlotInRange(slot, blockEra)) { - const expectedStartSlot = blockEra * SLOTS_PER_HISTORICAL_ROOT; - const expectedEndSlot = expectedStartSlot + SLOTS_PER_HISTORICAL_ROOT; - throw new Error( - `Slot ${slot} is not in valid block range for era ${this.era.eraNumber} file (valid range: ${expectedStartSlot} to ${expectedEndSlot - 1})` - ); - } - this.blocksBySlot.set(slot, data); - } - - async writeBlock(block: SignedBeaconBlock): Promise { - const slot = block.message.slot; - const types = this.config.getForkTypes(slot); - const ssz = types.SignedBeaconBlock.serialize(block); - const compressed = await compressSnappyFramed(ssz); - await this.writeCompressedBlock(slot, compressed); - } - - async finish(): Promise { - if (!this.stateWritten || !this.stateData || this.stateSlot === undefined) { - throw new Error("Must write canonical state before finishing"); - } - - // Helper to convert compressed data to snappy framed format (already compressed) - const snappyFramed = (data: Uint8Array) => data; - - // Prepare blocks map with SSZ data (already compressed) - const blocksBySlotSSZ = new Map(); - for (const [slot, compressed] of this.blocksBySlot) { - blocksBySlotSSZ.set(slot, compressed); - } - - // Write the era group - const eraBytes = writeEraGroup({ - era: this.era.eraNumber, - slotsPerHistoricalRoot: SLOTS_PER_HISTORICAL_ROOT, - snappyFramed, - blocksBySlot: blocksBySlotSSZ, - stateSlot: this.stateSlot, - stateSSZ: this.stateData, - }); - - // Write to file - await this.era.fh.write(eraBytes, 0, eraBytes.length, 0); - - // Create and return index - const {blockSlotIndex} = getEraIndexes(eraBytes, this.era.eraNumber); - - if (!blockSlotIndex) { - // Genesis era - return { - startSlot: 0, - indices: [], - }; - } - - return { - startSlot: blockSlotIndex.startSlot, - indices: blockSlotIndex.offsets, - }; - } -} diff --git a/packages/era/src/helpers.ts b/packages/era/src/helpers.ts deleted file mode 100644 index 3bb143ed8de9..000000000000 --- a/packages/era/src/helpers.ts +++ /dev/null @@ -1,576 +0,0 @@ -import type {FileHandle} from "node:fs/promises"; -import {readFile, writeFile} from "node:fs/promises"; -import {Uint8ArrayList} from "uint8arraylist"; -import {ChainForkConfig} from "@lodestar/config"; -import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/encodingStrategies/sszSnappy"; -import {Slot} from "@lodestar/types"; -import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, VERSION_RECORD_BYTES} from "./constants.js"; -import type {E2StoreEntry, EraIndex, SlotIndex} from "./types.js"; - -/** - * Read an e2Store entry (header + data) - * Header: 2 bytes type + 4 bytes length (LE) + 2 bytes reserved (must be 0) - */ -export function readEntry(bytes: Uint8Array): E2StoreEntry { - if (bytes.length < E2STORE_HEADER_SIZE) { - throw new Error(`Buffer too small for E2Store header: need ${E2STORE_HEADER_SIZE} bytes, got ${bytes.length}`); - } - - // validate entry type from first 2 bytes - const typeBytes = bytes.subarray(0, 2); - const typeEntry = Object.entries(EraTypes).find( - ([, expectedBytes]) => typeBytes[0] === expectedBytes[0] && typeBytes[1] === expectedBytes[1] - ); - if (!typeEntry) { - const typeHex = Array.from(typeBytes) - .map((b) => `0x${b.toString(16).padStart(2, "0")}`) - .join(", "); - throw new Error(`Unknown E2Store entry type: [${typeHex}]`); - } - const type = typeEntry[0] as E2StoreEntryType; - - // Parse data length from next 4 bytes (offset 2, little endian) - const lengthView = new DataView(bytes.buffer, bytes.byteOffset + 2, 4); - const length = lengthView.getUint32(0, true); - - // Validate reserved bytes are zero (offset 6-7) - const reserved = bytes[6] | (bytes[7] << 8); - if (reserved !== 0) { - throw new Error(`E2Store reserved bytes must be zero, got: ${reserved}`); - } - - // Validate data length fits within buffer - const availableDataLength = bytes.length - E2STORE_HEADER_SIZE; - if (length > availableDataLength) { - throw new Error(`E2Store data length ${length} exceeds available buffer space ${availableDataLength}`); - } - - const dataStartOffset = E2STORE_HEADER_SIZE; - const data = bytes.subarray(dataStartOffset, dataStartOffset + length); - - return {type, data}; -} - -/** Read 48-bit signed integer (little-endian) at offset. */ -function readInt64(bytes: Uint8Array, offset: number): number { - return Buffer.prototype.readIntLE.call(bytes, offset, 6); -} - -/** - * Read a SlotIndex from the end of the buffer with validation. - * Validates expected count, entry type and payload size, offset bounds, - * and trailing count. - */ -function readSlotIndex(bytes: Uint8Array, expectedType: "state" | "block"): SlotIndex { - if (bytes.length < 8) { - throw new Error("Buffer too small for SlotIndex count"); - } - const countOffset = bytes.length - 8; - const eofCount = Number(readInt64(bytes, countOffset)); - - // Validate count matches expected type requirements - if (expectedType === "state" && eofCount !== 1) { - throw new Error(`State index must have count=1, got ${eofCount}`); - } - if (expectedType === "block" && eofCount !== SLOTS_PER_HISTORICAL_ROOT) { - throw new Error(`Block index must have count=${SLOTS_PER_HISTORICAL_ROOT}, got ${eofCount}`); - } - - // Calculate where slot index starts in buffer - // Structure: header(8) + startSlot(8) + offsets(count*8) + count(8) - const indexSize = E2STORE_HEADER_SIZE + 16 + eofCount * 8; - const indexStart = bytes.length - indexSize; - - // Validate index position is within file bounds - if (indexStart < 0) { - throw new Error(`SlotIndex position ${indexStart} is invalid - file too small for count=${eofCount}`); - } - - // Read and validate the slot index entry - const entry = readEntry(bytes.subarray(indexStart)); - if (entry.type !== E2StoreEntryType.SlotIndex) { - throw new Error(`Expected SlotIndex entry, got ${entry.type}`); - } - - // Size: startSlot(8) + offsets(count*8) + count(8) = count*8 + 16 - const expectedSize = eofCount * 8 + 16; - if (entry.data.length !== expectedSize) { - throw new Error(`SlotIndex payload size must be exactly ${expectedSize} bytes, got ${entry.data.length}`); - } - - // Parse start slot from payload - const startSlot = Number(readInt64(entry.data, 0)); - - // Parse slot offsets with relative→absolute conversion - const offsets: number[] = []; - for (let i = 0; i < eofCount; i++) { - // Offset field position: after startSlot(8) + i * 8 - const offsetFieldOffset = 8 + i * 8; - const relativeOffset = readInt64(entry.data, offsetFieldOffset); - - if (relativeOffset === 0) { - offsets.push(0); - } else { - // Convert relative offset to absolute header position with bounds validation - const absoluteHeaderOffset = indexStart + relativeOffset; - if (absoluteHeaderOffset < 0 || absoluteHeaderOffset >= bytes.length) { - throw new Error( - `Invalid absolute offset: ${absoluteHeaderOffset} (relative: ${relativeOffset}, ` + - `indexStart: ${indexStart}, fileSize: ${bytes.length})` - ); - } - offsets.push(absoluteHeaderOffset); - } - } - - // Trailing count position: after startSlot(8) + offsets(count*8) - const trailingCountOffset = 8 + eofCount * 8; - const trailingCount = Number(readInt64(entry.data, trailingCountOffset)); - if (trailingCount !== eofCount) { - throw new Error(`SlotIndex trailing count mismatch: expected ${eofCount}, got ${trailingCount}`); - } - - return { - type: E2StoreEntryType.SlotIndex, - startSlot, - offsets, - recordStart: indexStart, - }; -} - -/** - * Read state and block SlotIndex entries from an era file and validate alignment. - */ -export function getEraIndexes( - eraBytes: Uint8Array, - expectedEra?: number -): {stateSlotIndex: SlotIndex; blockSlotIndex?: SlotIndex} { - const stateSlotIndex = readSlotIndex(eraBytes, "state"); - - // Validate state index aligns with expected era boundary - if (expectedEra !== undefined) { - const expectedStateStartSlot = expectedEra * SLOTS_PER_HISTORICAL_ROOT; - if (stateSlotIndex.startSlot !== expectedStateStartSlot) { - throw new Error( - `State index era alignment error: expected startSlot=${expectedStateStartSlot} ` + - `(era ${expectedEra}), got startSlot=${stateSlotIndex.startSlot}` - ); - } - } - - // Read block index if not genesis era (era 0) - let blockSlotIndex: SlotIndex | undefined; - if (stateSlotIndex.startSlot > 0) { - const blockIndexBytes = eraBytes.subarray(0, stateSlotIndex.recordStart); - blockSlotIndex = readSlotIndex(blockIndexBytes, "block"); - - // Validate block and state indices are properly aligned - const expectedBlockStartSlot = stateSlotIndex.startSlot - SLOTS_PER_HISTORICAL_ROOT; - if (blockSlotIndex.startSlot !== expectedBlockStartSlot) { - throw new Error( - `Block index alignment error: expected startSlot=${expectedBlockStartSlot}, ` + - `got startSlot=${blockSlotIndex.startSlot} (should be exactly one era before state)` - ); - } - } - - return {stateSlotIndex, blockSlotIndex}; -} - -/** Decompress snappy-framed data */ -function decompressFrames(compressedData: Uint8Array): Uint8Array { - const decompressor = new SnappyFramesUncompress(); - - const input = new Uint8ArrayList(compressedData); - const result = decompressor.uncompress(input); - - if (result === null) { - throw new Error("Snappy decompression failed - no data returned"); - } - - return result.subarray(); -} - -/** Decompress and deserialize a BeaconState using the apt fork for the era. */ -export function decompressBeaconState(compressedData: Uint8Array, era: number, config: ChainForkConfig) { - const uncompressed = decompressFrames(compressedData); - - const stateSlot = era * SLOTS_PER_HISTORICAL_ROOT; - const types = config.getForkTypes(stateSlot); - - try { - return types.BeaconState.deserialize(uncompressed); - } catch (error) { - throw new Error(`Failed to deserialize BeaconState for era ${era}, slot ${stateSlot}: ${error}`); - } -} - -/** Decompress and deserialize a SignedBeaconBlock using the fork for the given slot. */ -export function decompressSignedBeaconBlock(compressedData: Uint8Array, blockSlot: number, config: ChainForkConfig) { - const uncompressed = decompressFrames(compressedData); - - const types = config.getForkTypes(blockSlot); - - try { - return types.SignedBeaconBlock.deserialize(uncompressed); - } catch (error) { - throw new Error(`Failed to deserialize SignedBeaconBlock for slot ${blockSlot}: ${error}`); - } -} - -type SnappyFramedCompress = (ssz: Uint8Array) => Uint8Array; - -/** - * Write a single E2Store TLV entry (header + payload) - * Header layout: type[2] | length u32 LE | reserved u16(=0) - */ -function writeEntry(type2: Uint8Array, payload: Uint8Array): Uint8Array { - if (type2.length !== 2) throw new Error("type must be 2 bytes"); - const out = new Uint8Array(E2STORE_HEADER_SIZE + payload.length); - // type - out[0] = type2[0]; - out[1] = type2[1]; - // length u32 LE - out[2] = payload.length & 0xff; - out[3] = (payload.length >>> 8) & 0xff; - out[4] = (payload.length >>> 16) & 0xff; - out[5] = (payload.length >>> 24) & 0xff; - // reserved u16 = 0 at [6..7] - out.set(payload, E2STORE_HEADER_SIZE); - return out; -} - -/** In-place encode of a 48-bit signed integer (little-endian) into target at offset. */ -function writeI64LEInto(target: Uint8Array, offset: number, v: number): void { - Buffer.prototype.writeIntLE.call(target, v, offset, 6); -} -function readI64LE(buf: Uint8Array, off: number): number { - return Buffer.prototype.readIntLE.call(buf, off, 6); -} - -/** - * Read block slot index from an era file without loading the entire file into memory. - * Only reads the necessary index data from the end of the file. - */ -export async function readBlockSlotIndexFromFile(fh: FileHandle): Promise { - const stats = await fh.stat(); - const fileSize = stats.size; - - const stateIndexSize = E2STORE_HEADER_SIZE + 24; - const stateIndexStart = fileSize - stateIndexSize; - - // Read state index to get startSlot - const stateIndexBytes = new Uint8Array(stateIndexSize); - await fh.read(stateIndexBytes, 0, stateIndexSize, stateIndexStart); - const stateEntry = readEntry(stateIndexBytes); - const stateStartSlot = readI64LE(stateEntry.data, 0); - - // Genesis era (era 0) has no block index - if (stateStartSlot === 0) { - return {startSlot: 0, indices: []}; - } - - // Read trailing count from block index (8 bytes before state index) - const blockCountBytes = new Uint8Array(8); - await fh.read(blockCountBytes, 0, 8, stateIndexStart - 8); - const blockCount = readI64LE(blockCountBytes, 0); - - // Calculate block index size and read it - const blockIndexSize = E2STORE_HEADER_SIZE + 16 + blockCount * 8; - const blockIndexStart = stateIndexStart - blockIndexSize; - - const blockIndexBytes = new Uint8Array(blockIndexSize); - await fh.read(blockIndexBytes, 0, blockIndexSize, blockIndexStart); - const blockEntry = readEntry(blockIndexBytes); - - // Parse block index - const blockStartSlot = readI64LE(blockEntry.data, 0); - const offsets: number[] = []; - for (let i = 0; i < blockCount; i++) { - const offsetFieldOffset = 8 + i * 8; - const relativeOffset = readI64LE(blockEntry.data, offsetFieldOffset); - if (relativeOffset === 0) { - offsets.push(0); - } else { - const absoluteOffset = blockIndexStart + relativeOffset; - offsets.push(absoluteOffset); - } - } - - return {startSlot: blockStartSlot, indices: offsets}; -} - -/** - * Build SlotIndex payload: startSlot | offsets[count] | count. - * Offsets are i64 relative to the index record start (0 = missing). - * Payload size = count*8 + 16 (header not included). - */ -function buildSlotIndexData(startSlot: number, offsetsAbs: readonly number[], indexRecordStart: number): Uint8Array { - const count = offsetsAbs.length; - const payload = new Uint8Array(count * 8 + 16); - - // startSlot - writeI64LEInto(payload, 0, startSlot); - - // offsets (relative to beginning of index record) - let off = 8; - for (let i = 0; i < count; i++, off += 8) { - const abs = offsetsAbs[i]; - const rel = abs === 0 ? 0 : abs - indexRecordStart; - writeI64LEInto(payload, off, rel); - } - - // trailing count - writeI64LEInto(payload, 8 + count * 8, count); - return payload; -} - -/** Compressed record helpers (snappy framed) */ -function writeCompressedBlock(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { - const framed = snappyFramed(ssz); - return writeEntry(EraTypes[E2StoreEntryType.CompressedSignedBeaconBlock], framed); -} - -function writeCompressedState(ssz: Uint8Array, snappyFramed: SnappyFramedCompress): Uint8Array { - const framed = snappyFramed(ssz); - return writeEntry(EraTypes[E2StoreEntryType.CompressedBeaconState], framed); -} - -/** Concatenate an array of Uint8Array into a single Uint8Array. */ -function concat(chunks: Uint8Array[]): Uint8Array { - const total = chunks.reduce((n, c) => n + c.length, 0); - const out = new Uint8Array(total); - let p = 0; - for (const c of chunks) { - out.set(c, p); - p += c.length; - } - return out; -} - -/** - * Write a single era group to bytes. - * Layout: Version | block* | era-state | SlotIndex(block)? | SlotIndex(state) - * Genesis (era 0): omit block index; always include state index (count=1). - */ -export function writeEraGroup(params: { - era: number; - slotsPerHistoricalRoot: number; - snappyFramed: SnappyFramedCompress; - blocksBySlot: Map; - stateSlot: number; - stateSSZ: Uint8Array; -}): Uint8Array { - const {era, slotsPerHistoricalRoot: SPR, snappyFramed, blocksBySlot, stateSlot, stateSSZ} = params; - - if (stateSlot !== era * SPR) throw new Error(`stateSlot must be era*SPR (${era * SPR}), got ${stateSlot}`); - - const chunks: Uint8Array[] = []; - let cursor = 0; - const push = (b: Uint8Array) => { - chunks.push(b); - cursor += b.length; - }; - - // 1) Version (begin group) - push(VERSION_RECORD_BYTES); - - // 2) Blocks window - const firstBlockSlot = era === 0 ? 0 : stateSlot - SPR; - const blockOffsetsAbs = new Array(era === 0 ? 0 : SPR).fill(0); - if (era > 0) { - for (let slot = firstBlockSlot; slot < stateSlot; slot++) { - const ssz = blocksBySlot.get(slot); - if (!ssz) continue; // empty slot - const rec = writeCompressedBlock(ssz, snappyFramed); - const headerPos = cursor; - push(rec); - // Store header-start offsets (legacy/header-start semantics) - blockOffsetsAbs[slot - firstBlockSlot] = headerPos; - } - } - - // 3) State (exactly one) - const stateRec = writeCompressedState(stateSSZ, snappyFramed); - const stateHeaderPos = cursor; - push(stateRec); - - // 4) Block index (omit for genesis) - if (era > 0) { - const idxHeaderStart = cursor; // beginning of SlotIndex entry - const data = buildSlotIndexData(firstBlockSlot, blockOffsetsAbs, idxHeaderStart); - push(writeEntry(EraTypes[E2StoreEntryType.SlotIndex], data)); - } - - // 5) State index (count=1; startSlot = stateSlot) - { - const idxHeaderStart = cursor; - const data = buildSlotIndexData(stateSlot, [stateHeaderPos], idxHeaderStart); - push(writeEntry(EraTypes[E2StoreEntryType.SlotIndex], data)); - } - - return concat(chunks); -} - -/** - * Read an era index file from disk. - * Format: startSlot (i64 LE) | count (i64 LE) | indices[count] (i64 LE each) - */ -export async function readEraIndexFile(path: string): Promise { - const buffer = await readFile(path); - - if (buffer.length < 16) { - throw new Error(`Index file too small: need at least 16 bytes, got ${buffer.length}`); - } - - const startSlot = Number(readI64LE(buffer, 0)); - const count = Number(readI64LE(buffer, 8)); - - const expectedSize = 16 + count * 8; - if (buffer.length !== expectedSize) { - throw new Error(`Index file size mismatch: expected ${expectedSize} bytes, got ${buffer.length}`); - } - - const indices: number[] = []; - for (let i = 0; i < count; i++) { - indices.push(Number(readI64LE(buffer, 16 + i * 8))); - } - - return {startSlot, indices}; -} - -/** - * Write an era index file to disk. - * Format: startSlot (i64 LE) | count (i64 LE) | indices[count] (i64 LE each) - */ -export async function writeEraIndexFile(path: string, index: EraIndex): Promise { - const count = index.indices.length; - const buffer = new Uint8Array(16 + count * 8); - - // Write startSlot - writeI64LEInto(buffer, 0, index.startSlot); - - // Write count - writeI64LEInto(buffer, 8, count); - - // Write indices - for (let i = 0; i < count; i++) { - writeI64LEInto(buffer, 16 + i * 8, index.indices[i]); - } - - await writeFile(path, buffer); -} - -/** Return true if `slot` is within the era range */ -export function isSlotInRange(slot: Slot, eraNumber: number): boolean { - const eraStartSlot = eraNumber * SLOTS_PER_HISTORICAL_ROOT; - const eraEndSlot = eraStartSlot + SLOTS_PER_HISTORICAL_ROOT; - return slot >= eraStartSlot && slot < eraEndSlot; -} - -/** - * Helper to read entry at a specific offset from an open file handle. - * Reads header first to determine data length, then reads the complete entry. - */ -export async function readEntryFromFile(fh: FileHandle, offset: number): Promise { - // Read header (8 bytes) - const header = new Uint8Array(E2STORE_HEADER_SIZE); - await fh.read(header, 0, E2STORE_HEADER_SIZE, offset); - - // Parse length from header - const lengthView = new DataView(header.buffer, header.byteOffset + 2, 4); - const dataLength = lengthView.getUint32(0, true); - - // Read complete entry (header + data) - const fullEntry = new Uint8Array(E2STORE_HEADER_SIZE + dataLength); - await fh.read(fullEntry, 0, fullEntry.length, offset); - - return readEntry(fullEntry); -} - -/** Compress data using snappy framing */ -export async function compressSnappyFramed(data: Uint8Array): Promise { - const buffers: Buffer[] = []; - for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { - buffers.push(chunk); - } - const total = buffers.reduce((n, b) => n + b.length, 0); - const out = new Uint8Array(total); - let p = 0; - for (const b of buffers) { - out.set(b, p); - p += b.length; - } - return out; -} - -/** - * Get the state offset from the era file. - * Reads only the necessary parts of the file to locate the state index. - */ -export async function getStateOffset(fh: FileHandle, eraNumber: number): Promise { - // For now, read entire file to get indexes - const stats = await fh.stat(); - const buffer = new Uint8Array(stats.size); - await fh.read(buffer, 0, stats.size, 0); - - const {stateSlotIndex} = getEraIndexes(buffer, eraNumber); - const offset = stateSlotIndex.offsets[0]; - if (!offset) throw new Error("No BeaconState in this era"); - - return offset; -} - -/** - * Validate an era file for format correctness, era range, network correctness, and signatures. - */ -export async function validateEraFile(fh: FileHandle, eraNumber: number, config: ChainForkConfig): Promise { - const stats = await fh.stat(); - const buffer = new Uint8Array(stats.size); - await fh.read(buffer, 0, stats.size, 0); - - // Validate e2s format and era range - const {stateSlotIndex, blockSlotIndex} = getEraIndexes(buffer, eraNumber); - - // Validate state - const stateOffset = stateSlotIndex.offsets[0]; - if (!stateOffset) throw new Error("No BeaconState in era file"); - - const stateEntry = readEntry(buffer.subarray(stateOffset)); - if (stateEntry.type !== E2StoreEntryType.CompressedBeaconState) { - throw new Error(`Expected CompressedBeaconState, got ${stateEntry.type}`); - } - - const state = decompressBeaconState(stateEntry.data, eraNumber, config); - const expectedStateSlot = eraNumber * SLOTS_PER_HISTORICAL_ROOT; - if (state.slot !== expectedStateSlot) { - throw new Error(`State slot mismatch: expected ${expectedStateSlot}, got ${state.slot}`); - } - - // Validate blocks if not genesis - if (blockSlotIndex) { - for (let i = 0; i < blockSlotIndex.offsets.length; i++) { - const offset = blockSlotIndex.offsets[i]; - if (!offset) continue; // Empty slot - - const blockEntry = readEntry(buffer.subarray(offset)); - if (blockEntry.type !== E2StoreEntryType.CompressedSignedBeaconBlock) { - throw new Error(`Expected CompressedSignedBeaconBlock at offset ${i}, got ${blockEntry.type}`); - } - - const slot = blockSlotIndex.startSlot + i; - const block = decompressSignedBeaconBlock(blockEntry.data, slot, config); - - // Validate block slot matches - if (block.message.slot !== slot) { - throw new Error(`Block slot mismatch at index ${i}: expected ${slot}, got ${block.message.slot}`); - } - - // Validate block signature exists (basic check) - if (block.signature.length === 0) { - throw new Error(`Block at slot ${slot} has empty signature`); - } - } - } -} diff --git a/packages/era/src/index.ts b/packages/era/src/index.ts index 025d975922cc..fbd76676aee9 100644 --- a/packages/era/src/index.ts +++ b/packages/era/src/index.ts @@ -1,4 +1,2 @@ -export * from "./constants.js"; -export * from "./eraFile.js"; -export * from "./helpers.js"; -export * from "./types.js"; +export * as e2s from "./e2s.js"; +export * as era from "./era/index.js"; diff --git a/packages/era/src/types.ts b/packages/era/src/types.ts deleted file mode 100644 index c43a30c153d0..000000000000 --- a/packages/era/src/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {Slot} from "@lodestar/types"; -import {E2StoreEntryType} from "./constants.js"; - -/** - * Parsed components of an .era file name. - * Format: --.era - */ -export interface EraFileName { - /** CONFIG_NAME field of runtime config (mainnet, sepolia, holesky, etc.) */ - configName: string; - /** Number of the first era stored in file, 5-digit zero-padded (00000, 00001, etc.) */ - eraNumber: number; - /** First 4 bytes of last historical root, lower-case hex-encoded (8 chars) */ - shortHistoricalRoot: string; -} - -/** - * Logical, parsed entry from an E2Store file. - */ -export interface E2StoreEntry { - type: E2StoreEntryType; - data: Uint8Array; -} - -/** - * Maps slots to file positions in an era file. - * - Block index: count = SLOTS_PER_HISTORICAL_ROOT, maps slots to blocks - * - State index: count = 1, points to the era state - * - Zero offset = empty slot (no block) - */ -export interface SlotIndex { - type: E2StoreEntryType.SlotIndex; - /** First slot covered by this index (era * SLOTS_PER_HISTORICAL_ROOT) */ - startSlot: Slot; - /** File positions where data can be found. Length varies by index type. */ - offsets: number[]; - /** File position where this index record starts */ - recordStart: number; -} - -/** Data read from a slot index file */ -export interface EraIndex { - startSlot: number; - indices: number[]; -} diff --git a/packages/era/src/util.ts b/packages/era/src/util.ts new file mode 100644 index 000000000000..9abec02daccc --- /dev/null +++ b/packages/era/src/util.ts @@ -0,0 +1,47 @@ +import {Uint8ArrayList} from "uint8arraylist"; +import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/encodingStrategies/sszSnappy"; + +/** Read 48-bit signed integer (little-endian) at offset. */ +export function readInt48(bytes: Uint8Array, offset: number): number { + return Buffer.prototype.readIntLE.call(bytes, offset, 6); +} + +/** Read 48-bit signed integer (little-endian) at offset. */ +export function readUint48(bytes: Uint8Array, offset: number): number { + return Buffer.prototype.readUintLE.call(bytes, offset, 6); +} + +/** Write 48-bit signed integer (little-endian) into target at offset. */ +export function writeInt48(target: Uint8Array, offset: number, v: number): void { + Buffer.prototype.writeIntLE.call(target, v, offset, 6); +} + +/** Decompress snappy-framed data */ +export function snappyUncompress(compressedData: Uint8Array): Uint8Array { + const decompressor = new SnappyFramesUncompress(); + + const input = new Uint8ArrayList(compressedData); + const result = decompressor.uncompress(input); + + if (result === null) { + throw new Error("Snappy decompression failed - no data returned"); + } + + return result.subarray(); +} + +/** Compress data using snappy framing */ +export async function snappyCompress(data: Uint8Array): Promise { + const buffers: Buffer[] = []; + for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { + buffers.push(chunk); + } + const total = buffers.reduce((n, b) => n + b.length, 0); + const out = new Uint8Array(total); + let p = 0; + for (const b of buffers) { + out.set(b, p); + p += b.length; + } + return out; +} diff --git a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts index b48ae02882d3..a35b45386fa1 100644 --- a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts +++ b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts @@ -1,165 +1,95 @@ import {existsSync, mkdirSync} from "node:fs"; -import path from "node:path"; +import path, {basename} from "node:path"; import {fileURLToPath} from "node:url"; -import {assert, beforeAll, describe, it} from "vitest"; +import {beforeAll, describe, expect, it} from "vitest"; import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; import {mainnetChainConfig} from "@lodestar/config/networks"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {EraFile, EraFileReader, EraFileWriter} from "../../src/index.ts"; +import {EraReader, EraWriter} from "../../src/era/index.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe.runIf(!process.env.CI)("read original era and re-write our own era file", () => { - let cfg: ChainForkConfig; + let config: ChainForkConfig; const eraPath = path.resolve(__dirname, "../mainnet-01506-4781865b.era"); const expectedEra = 1506; beforeAll(() => { - cfg = createChainForkConfig(mainnetChainConfig); + config = createChainForkConfig(mainnetChainConfig); }); - it("reads an existing era file and writes a new era file that round-trips", async () => { + it("validate an existing era file, rewrite into a new era file, and validate that new era file", async () => { const SPR = SLOTS_PER_HISTORICAL_ROOT; const stateSlot = expectedEra * SPR; - const blockStartSlot = stateSlot - SPR; const startTime = Date.now(); let t0 = startTime; console.log("stage: open and read original era file"); - const originalEraFile = await EraFile.open(eraPath); - const originalIndex = await originalEraFile.createIndex(); - const reader = new EraFileReader(originalEraFile, originalIndex, cfg); + const reader = await EraReader.open(config, eraPath); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - console.log("stage: read state from original file"); - const originalState = await reader.readCanonicalState(); - assert.equal(Number(originalState.slot), stateSlot); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); + console.log(`progress: ${reader.groups.length} group(s) found`); - console.log("stage: read blocks from original file"); - const blocks: {slot: number; block: any}[] = []; - for (let i = 0; i < originalIndex.indices.length; i++) { - if (originalIndex.indices[i] === 0) continue; - const slot = blockStartSlot + i; - const block = await reader.readBlock(slot); - assert.ok(block !== null, `Expected block at slot ${slot} but got null`); - blocks.push({slot, block}); - if ((i & 0x1ff) === 0) { - console.log(`progress: scanned ${i}/${SPR} slots, non-empty ${blocks.length}`); - } - } - console.log(`stage: read complete (non-empty blocks=${blocks.length})`); + console.log("stage: validate original era file"); + await reader.validate(); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - await originalEraFile.close(); - - console.log("stage: create and write new era file"); - console.log("stage: create and write new era file"); + console.log("stage: create new era file writer"); const outDir = path.resolve(__dirname, "../out"); if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); - const outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-rewrite.era`); + let outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-deadbeef.era`); - const newEraFile = await EraFile.create(outFile, expectedEra); - const writer = new EraFileWriter(newEraFile, cfg); - - // Write state - console.log("stage: write state"); - await writer.writeCanonicalState(originalState); + const writer = await EraWriter.create(config, outFile, expectedEra); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - // Write all blocks - console.log("stage: write blocks"); - for (const {block} of blocks) { + console.log("stage: read blocks from original and write to new era file"); + const blocksIndex = reader.groups[0].blocksIndex; + if (!blocksIndex) { + throw new Error("Original era file missing blocks index"); + } + for (let slot = blocksIndex.startSlot; slot < blocksIndex.startSlot + blocksIndex.offsets.length; slot++) { + const block = await reader.readBlock(slot); + if (block === null) continue; await writer.writeBlock(block); } console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - // Finish writing - console.log("stage: finish writing"); - await writer.finish(); - await newEraFile.close(); + console.log("stage: read state from original era file"); + const originalState = await reader.readState(); + expect(originalState.slot).to.equal(stateSlot); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - console.log("stage: validate new era file"); - // Open and validate the new file - const validationEraFile = await EraFile.open(outFile); - const validationIndex = await validationEraFile.createIndex(); - const validationReader = new EraFileReader(validationEraFile, validationIndex, cfg); + console.log("stage: write state to new era file"); + await writer.writeState(originalState); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - // Validate entire era file format and correctness - console.log("stage: validate era file format"); - await validationEraFile.validate(cfg); + console.log("stage: finish reading and writing"); + await reader.close(); + outFile = await writer.finish(); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - // Validate index matches - assert.equal(validationIndex.startSlot, blockStartSlot); - assert.equal(validationIndex.indices.length, SPR); - const newNonEmpty = validationIndex.indices.filter((o) => o !== 0).length; - assert.equal(newNonEmpty, blocks.length); - - // Validate empty/non-empty slot pattern matches original - for (let i = 0; i < SPR; i++) { - const originalIsEmpty = originalIndex.indices[i] === 0; - const newIsEmpty = validationIndex.indices[i] === 0; - assert.equal(newIsEmpty, originalIsEmpty, `Slot ${blockStartSlot + i} empty/non-empty status should match`); - } - - // Validate state matches (full comparison via SSZ serialization) - console.log("stage: validate state"); - const validatedState = await validationReader.readCanonicalState(); - - // Serialize both states and compare bytes - this proves they're 100% identical - const originalTypes = cfg.getForkTypes(originalState.slot); - const validatedTypes = cfg.getForkTypes(validatedState.slot); - const originalSSZ = originalTypes.BeaconState.serialize(originalState); - const validatedSSZ = validatedTypes.BeaconState.serialize(validatedState); + expect(basename(outFile)).to.equal(basename(eraPath)); - assert.deepEqual(validatedSSZ, originalSSZ, "State SSZ bytes should match exactly"); + console.log("stage: open and validate new era file"); + const newReader = await EraReader.open(config, outFile); + await newReader.validate(); console.log(` time: ${Date.now() - t0}ms`); t0 = Date.now(); - // Validate ALL blocks match exactly (full comparison via SSZ serialization) - console.log("stage: validate all blocks"); - for (const {slot, block} of blocks) { - const validatedBlock = await validationReader.readBlock(slot); - assert.ok(validatedBlock !== null, `Block at slot ${slot} should not be null`); - - // Serialize both blocks and compare bytes - this proves they're 100% identical - const types = cfg.getForkTypes(slot); - const originalSSZ = types.SignedBeaconBlock.serialize(block); - const validatedSSZ = types.SignedBeaconBlock.serialize(validatedBlock); - assert.deepEqual(validatedSSZ, originalSSZ, `Block at slot ${slot} SSZ bytes should match exactly`); - } - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - // Validate empty slots remain empty - console.log("stage: validate empty slots"); - for (let i = 0; i < originalIndex.indices.length; i++) { - if (originalIndex.indices[i] === 0) { - const slot = blockStartSlot + i; - const validatedBlock = await validationReader.readBlock(slot); - assert.equal(validatedBlock, null, `Slot ${slot} should be empty`); - } - } - console.log(` time: ${Date.now() - t0}ms`); - // Cleanup - await validationEraFile.close(); + await newReader.close(); const totalTime = Date.now() - startTime; - console.log(`Round-trip test passed: ${blocks.length} blocks written and fully validated`); + console.log("Round-trip test passed"); console.log(` Total time: ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`); }, 120000); }); diff --git a/packages/era/test/unit/era.unit.test.ts b/packages/era/test/unit/era.unit.test.ts index 51fb4f452cd1..ebb927e9fa48 100644 --- a/packages/era/test/unit/era.unit.test.ts +++ b/packages/era/test/unit/era.unit.test.ts @@ -1,64 +1,52 @@ import {assert, describe, it} from "vitest"; -import {E2STORE_HEADER_SIZE, E2StoreEntryType, EraTypes, readEntry} from "../../src/index.js"; +import {E2STORE_HEADER_SIZE, EntryType, parseEntryHeader} from "../../src/e2s.ts"; -function header(typeBytes: Uint8Array, dataLen: number): Uint8Array { +function header(type: EntryType, dataLen: number): Uint8Array { const h = new Uint8Array(8); - h[0] = typeBytes[0]; - h[1] = typeBytes[1]; + h[0] = type; + h[1] = type >> 8; // 4-byte LE length h[2] = dataLen & 0xff; h[3] = (dataLen >> 8) & 0xff; h[4] = (dataLen >> 16) & 0xff; h[5] = (dataLen >> 24) & 0xff; // reserved = 0x0000 - h[6] = 0x00; - h[7] = 0x00; + // h[6] = 0x00; + // h[7] = 0x00; return h; } describe("e2Store utilities (unit)", () => { it("should read the type and data correctly", () => { const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const ver = header(EraTypes[E2StoreEntryType.Version], 0); - const bytes = new Uint8Array([...ver, ...header(EraTypes[E2StoreEntryType.Empty], payload.length), ...payload]); + const ver = header(EntryType.Version, 0); + const bytes = new Uint8Array([...ver, ...header(EntryType.Empty, payload.length), ...payload]); // Read the second entry (Empty with payload) - const entry = readEntry(bytes.slice(E2STORE_HEADER_SIZE)); - assert.equal(entry.type, E2StoreEntryType.Empty); - assert.deepEqual(entry.data, payload); - }); - - it("should throw on entry with invalid length", () => { - const bad = new Uint8Array([...header(EraTypes[E2StoreEntryType.Version], 25), 0x01, 0x02, 0x03, 0x04]); - - try { - readEntry(bad); - assert.fail("should have thrown on invalid data length"); - } catch (err) { - assert.instanceOf(err, Error); - } + const entry = parseEntryHeader(bytes.slice(E2STORE_HEADER_SIZE)); + assert.equal(entry.type, EntryType.Empty); + assert.deepEqual(entry.length, payload.length); }); it("should iterate and read multiple entries ", () => { const firstPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]); - const ver = header(EraTypes[E2StoreEntryType.Version], 0); - const first = new Uint8Array([...header(EraTypes[E2StoreEntryType.Empty], firstPayload.length), ...firstPayload]); - const second = header(EraTypes[E2StoreEntryType.Empty], 0); + const ver = header(EntryType.Version, 0); + const first = new Uint8Array([...header(EntryType.Empty, firstPayload.length), ...firstPayload]); + const second = header(EntryType.Empty, 0); const bytes = new Uint8Array([...ver, ...first, ...second]); - const entries: Array> = []; + const entries: Array> = []; let p = 0; while (p + E2STORE_HEADER_SIZE <= bytes.length) { - const e = readEntry(bytes.slice(p)); + const e = parseEntryHeader(bytes.slice(p)); entries.push(e); - p += E2STORE_HEADER_SIZE + e.data.length; + p += E2STORE_HEADER_SIZE + e.length; } assert.equal(entries.length, 3); - assert.equal(entries[0].type, E2StoreEntryType.Version); - assert.equal(entries[0].data.length, 0); - assert.equal(entries[1].type, E2StoreEntryType.Empty); - assert.deepEqual(entries[1].data, firstPayload); - assert.equal(entries[2].type, E2StoreEntryType.Empty); + assert.equal(entries[0].type, EntryType.Version); + assert.equal(entries[0].length, 0); + assert.equal(entries[1].type, EntryType.Empty); + assert.equal(entries[2].type, EntryType.Empty); }); }); From cbb31a359b5b28863aa8fca2253b6f8c3fd8907e Mon Sep 17 00:00:00 2001 From: Cayman Date: Fri, 31 Oct 2025 08:18:29 -0400 Subject: [PATCH 19/34] chore: clean up validate function --- packages/era/src/era/reader.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/era/src/era/reader.ts b/packages/era/src/era/reader.ts index 276ddf092505..906348ffe3bf 100644 --- a/packages/era/src/era/reader.ts +++ b/packages/era/src/era/reader.ts @@ -2,7 +2,7 @@ import {type FileHandle, open} from "node:fs/promises"; import {basename} from "node:path"; import {PublicKey, Signature, verify} from "@chainsafe/blst"; import {ChainForkConfig, createCachedGenesis} from "@lodestar/config"; -import {DOMAIN_BEACON_PROPOSER} from "@lodestar/params"; +import {DOMAIN_BEACON_PROPOSER, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {BeaconState, SignedBeaconBlock, Slot, ssz} from "@lodestar/types"; import {E2STORE_HEADER_SIZE, EntryType, readEntry, readVersion} from "../e2s.ts"; import {snappyUncompress} from "../util.ts"; @@ -157,23 +157,27 @@ export class EraReader { } // validate blocks - const signatureSets: { - msg: Uint8Array; - pk: PublicKey; - sig: Signature; - }[] = []; - for (let slot = index.blocksIndex.startSlot; slot <= index.blocksIndex.offsets.length; slot++) { + for ( + let slot = index.blocksIndex.startSlot; + slot <= index.blocksIndex.startSlot + index.blocksIndex.offsets.length; + slot++ + ) { const block = await this.readBlock(slot); if (block === null) { if (slot === index.blocksIndex.startSlot) continue; // first slot in the era can't be easily validated - if (Buffer.compare(state.blockRoots[slot - 1], state.blockRoots[slot]) !== 0) { + if ( + Buffer.compare( + state.blockRoots[(slot - 1) % SLOTS_PER_HISTORICAL_ROOT], + state.blockRoots[slot % SLOTS_PER_HISTORICAL_ROOT] + ) !== 0 + ) { throw new Error(`Block root mismatch at slot ${slot} for empty slot`); } continue; } const blockRoot = this.config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message); - if (Buffer.compare(blockRoot, state.blockRoots[slot]) !== 0) { + if (Buffer.compare(blockRoot, state.blockRoots[slot % SLOTS_PER_HISTORICAL_ROOT]) !== 0) { throw new Error(`Block root mismatch at slot ${slot}`); } const msg = ssz.phase0.SigningData.hashTreeRoot({ @@ -182,7 +186,6 @@ export class EraReader { }); const pk = PublicKey.fromBytes(state.validators[block.message.proposerIndex].pubkey); const sig = Signature.fromBytes(block.signature); - signatureSets.push({msg, pk, sig}); if (!verify(msg, pk, sig, true, true)) { throw new Error(`Block signature verification failed at slot ${slot}`); } From 17c1f5a33beb05167a877b64b9bfcc7f98a983c8 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Sat, 1 Nov 2025 03:54:57 +0530 Subject: [PATCH 20/34] move encodeSnappy and SnappyFramesUncompress to utils to remove future cyclic dependency --- packages/era/package.json | 6 ++---- packages/era/src/util.ts | 2 +- packages/reqresp/package.json | 6 ------ .../reqresp/src/encodingStrategies/sszSnappy/decode.ts | 2 +- .../reqresp/src/encodingStrategies/sszSnappy/encode.ts | 2 +- .../reqresp/src/encodingStrategies/sszSnappy/index.ts | 3 +-- packages/reqresp/src/utils/errorMessage.ts | 2 +- .../sszSnappy/snappyFrames/uncompress.test.ts | 4 +--- packages/utils/package.json | 10 +++++++++- packages/utils/src/index.ts | 1 + .../snappyFrames => utils/src/snappy}/common.ts | 0 .../snappyFrames => utils/src/snappy}/compress.ts | 2 -- packages/utils/src/snappy/index.ts | 3 +++ .../snappyFrames => utils/src/snappy}/snappy.ts | 0 .../snappyFrames => utils/src/snappy}/snappy_bun.ts | 0 .../snappyFrames => utils/src/snappy}/uncompress.ts | 0 16 files changed, 21 insertions(+), 22 deletions(-) rename packages/{reqresp/src/encodingStrategies/sszSnappy/snappyFrames => utils/src/snappy}/common.ts (100%) rename packages/{reqresp/src/encodingStrategies/sszSnappy/snappyFrames => utils/src/snappy}/compress.ts (87%) create mode 100644 packages/utils/src/snappy/index.ts rename packages/{reqresp/src/encodingStrategies/sszSnappy/snappyFrames => utils/src/snappy}/snappy.ts (100%) rename packages/{reqresp/src/encodingStrategies/sszSnappy/snappyFrames => utils/src/snappy}/snappy_bun.ts (100%) rename packages/{reqresp/src/encodingStrategies/sszSnappy/snappyFrames => utils/src/snappy}/uncompress.ts (100%) diff --git a/packages/era/package.json b/packages/era/package.json index 180dd196a206..2051acc867b4 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -11,11 +11,12 @@ "bugs": { "url": "https://github.com/ChainSafe/lodestar/issues" }, - "version": "1.34.1", + "version": "1.35.0", "type": "module", "exports": { ".": { "bun": "./src/index.ts", + "types": "./lib/index.d.ts", "import": "./lib/index.js" } }, @@ -41,12 +42,9 @@ "@chainsafe/blst": "^2.2.0", "@lodestar/config": "^1.34.1", "@lodestar/params": "^1.34.1", - "@lodestar/reqresp": "^1.34.1", "@lodestar/state-transition": "^1.34.1", "@lodestar/types": "^1.34.1", "@lodestar/utils": "^1.34.1", "uint8arraylist": "^2.4.7" - }, - "devDependencies": { } } diff --git a/packages/era/src/util.ts b/packages/era/src/util.ts index 9abec02daccc..4dffc0a9e755 100644 --- a/packages/era/src/util.ts +++ b/packages/era/src/util.ts @@ -1,5 +1,5 @@ import {Uint8ArrayList} from "uint8arraylist"; -import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/encodingStrategies/sszSnappy"; +import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/utils"; /** Read 48-bit signed integer (little-endian) at offset. */ export function readInt48(bytes: Uint8Array, offset: number): number { diff --git a/packages/reqresp/package.json b/packages/reqresp/package.json index e606b4af89ae..805d2e1a6609 100644 --- a/packages/reqresp/package.json +++ b/packages/reqresp/package.json @@ -29,12 +29,6 @@ "import": "./lib/encodingStrategies/sszSnappy/index.js" } }, - "imports": { - "#snappy": { - "bun": "./src/encodingStrategies/sszSnappy/snappyFrames/snappy_bun.ts", - "default": "./lib/encodingStrategies/sszSnappy/snappyFrames/snappy.js" - } - }, "files": [ "src", "lib", diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts index 9104104a0aa8..2bb322f413d5 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts @@ -1,9 +1,9 @@ import {decode as varintDecode, encodingLength as varintEncodingLength} from "uint8-varint"; import {Uint8ArrayList} from "uint8arraylist"; +import {SnappyFramesUncompress} from "@lodestar/utils"; import {TypeSizes} from "../../types.js"; import {BufferedSource} from "../../utils/index.js"; import {SszSnappyError, SszSnappyErrorCode} from "./errors.js"; -import {SnappyFramesUncompress} from "./snappyFrames/uncompress.js"; import {maxEncodedLen} from "./utils.js"; export const MAX_VARINT_BYTES = 10; diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts index f4559b91f3fe..03c2c0689aa5 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts @@ -1,5 +1,5 @@ import {encode as varintEncode} from "uint8-varint"; -import {encodeSnappy} from "./snappyFrames/compress.js"; +import {encodeSnappy} from "@lodestar/utils"; /** * ssz_snappy encoding strategy writer. diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts index 8f1869f78412..7b46f398c8e3 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts @@ -1,5 +1,4 @@ +export {SnappyFramesUncompress, encodeSnappy} from "@lodestar/utils"; export * from "./decode.js"; export * from "./encode.js"; export * from "./errors.js"; -export {encodeSnappy} from "./snappyFrames/compress.js"; -export {SnappyFramesUncompress} from "./snappyFrames/uncompress.js"; diff --git a/packages/reqresp/src/utils/errorMessage.ts b/packages/reqresp/src/utils/errorMessage.ts index 57de4012c401..aed9fdfe2608 100644 --- a/packages/reqresp/src/utils/errorMessage.ts +++ b/packages/reqresp/src/utils/errorMessage.ts @@ -1,7 +1,7 @@ import {decode as varintDecode, encodingLength as varintEncodingLength} from "uint8-varint"; import {Uint8ArrayList} from "uint8arraylist"; +import {SnappyFramesUncompress} from "@lodestar/utils"; import {writeSszSnappyPayload} from "../encodingStrategies/sszSnappy/encode.js"; -import {SnappyFramesUncompress} from "../encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; import {Encoding} from "../types.js"; // ErrorMessage schema: diff --git a/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts b/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts index b4dcc5085ad1..847584b83b88 100644 --- a/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts +++ b/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts @@ -1,9 +1,7 @@ import {pipe} from "it-pipe"; import {Uint8ArrayList} from "uint8arraylist"; import {describe, expect, it} from "vitest"; -import {ChunkType, IDENTIFIER_FRAME, crc} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/common.js"; -import {encodeSnappy} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/compress.js"; -import {SnappyFramesUncompress} from "../../../../../src/encodingStrategies/sszSnappy/snappyFrames/uncompress.js"; +import {ChunkType, IDENTIFIER_FRAME, SnappyFramesUncompress, crc, encodeSnappy} from "@lodestar/utils"; describe("encodingStrategies / sszSnappy / snappy frames / uncompress", () => { it("should work with short input", () => diff --git a/packages/utils/package.json b/packages/utils/package.json index a512a776b39e..7375dd0c976e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -25,6 +25,10 @@ "bun": "./src/bytes/bun.ts", "browser": "./lib/bytes/browser.js", "default": "./lib/bytes/nodejs.js" + }, + "#snappy": { + "bun": "./src/snappy/snappy_bun.ts", + "default": "./lib/snappy/snappy.js" } }, "files": [ @@ -48,11 +52,15 @@ "types": "lib/index.d.ts", "dependencies": { "@chainsafe/as-sha256": "^1.2.0", + "@chainsafe/fast-crc32c": "^4.2.0", "@lodestar/bun": "git+https://github.com/ChainSafe/lodestar-bun.git", "any-signal": "^4.1.1", "bigint-buffer": "^1.1.5", "case": "^1.6.3", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "snappy": "^7.2.2", + "snappyjs": "^0.7.0", + "uint8arraylist": "^2.4.7" }, "devDependencies": { "@types/js-yaml": "^4.0.5", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c98435c1c1d2..d5a0a4e10cd5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -18,6 +18,7 @@ export * from "./objects.js"; export * from "./promise.js"; export {type RetryOptions, retry} from "./retry.js"; export * from "./sleep.js"; +export * from "./snappy/index.js"; export * from "./sort.js"; export * from "./timeout.js"; export {type RecursivePartial, type RequiredSelective, bnToNum} from "./types.js"; diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/common.ts b/packages/utils/src/snappy/common.ts similarity index 100% rename from packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/common.ts rename to packages/utils/src/snappy/common.ts diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.ts b/packages/utils/src/snappy/compress.ts similarity index 87% rename from packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.ts rename to packages/utils/src/snappy/compress.ts index 0e95a19bcee7..8fc83f27b01a 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/compress.ts +++ b/packages/utils/src/snappy/compress.ts @@ -1,8 +1,6 @@ import {compress} from "#snappy"; import {ChunkType, IDENTIFIER_FRAME, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js"; -// The logic in this file is largely copied (in simplified form) from https://github.com/ChainSafe/node-snappy-stream/ - export async function* encodeSnappy(bytes: Buffer): AsyncGenerator { yield IDENTIFIER_FRAME; diff --git a/packages/utils/src/snappy/index.ts b/packages/utils/src/snappy/index.ts new file mode 100644 index 000000000000..fbf2d79a51d8 --- /dev/null +++ b/packages/utils/src/snappy/index.ts @@ -0,0 +1,3 @@ +export * from "./common.js"; +export {encodeSnappy} from "./compress.js"; +export {SnappyFramesUncompress} from "./uncompress.js"; diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/snappy.ts b/packages/utils/src/snappy/snappy.ts similarity index 100% rename from packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/snappy.ts rename to packages/utils/src/snappy/snappy.ts diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/snappy_bun.ts b/packages/utils/src/snappy/snappy_bun.ts similarity index 100% rename from packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/snappy_bun.ts rename to packages/utils/src/snappy/snappy_bun.ts diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts b/packages/utils/src/snappy/uncompress.ts similarity index 100% rename from packages/reqresp/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts rename to packages/utils/src/snappy/uncompress.ts From 64d8e53c451edd5ded041088c080c8d17a111186 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Sat, 1 Nov 2025 05:42:08 +0530 Subject: [PATCH 21/34] replace with Buffer.prototype --- packages/era/package.json | 8 +++----- packages/era/src/e2s.ts | 24 +++++++++--------------- packages/era/src/util.ts | 31 ++++++++++++++++++++++--------- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/era/package.json b/packages/era/package.json index 2051acc867b4..5701d157305a 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -40,11 +40,9 @@ }, "dependencies": { "@chainsafe/blst": "^2.2.0", - "@lodestar/config": "^1.34.1", - "@lodestar/params": "^1.34.1", - "@lodestar/state-transition": "^1.34.1", - "@lodestar/types": "^1.34.1", - "@lodestar/utils": "^1.34.1", + "@lodestar/config": "^1.35.0", + "@lodestar/params": "^1.35.0", + "@lodestar/types": "^1.35.0", "uint8arraylist": "^2.4.7" } } diff --git a/packages/era/src/e2s.ts b/packages/era/src/e2s.ts index 516625ed3007..aef0294e326c 100644 --- a/packages/era/src/e2s.ts +++ b/packages/era/src/e2s.ts @@ -1,6 +1,6 @@ import type {FileHandle} from "node:fs/promises"; import {Slot} from "@lodestar/types"; -import {readInt48, writeInt48} from "./util.ts"; +import {readInt48, readUint16, readUint32, writeInt48, writeUint16, writeUint32} from "./util.ts"; /** * Known entry types in an E2Store (.e2s) file along with their exact 2-byte codes. @@ -73,18 +73,17 @@ export function parseEntryHeader(header: Uint8Array): {type: EntryType; length: } // validate entry type from first 2 bytes - const typeCode = header[0] | (header[1] << 8); - const typeEntry = EntryType[EntryType[typeCode] as keyof typeof EntryType]; - if (typeEntry === undefined) { + const typeCode = readUint16(header, 0); + if (!(typeCode in EntryType)) { throw new Error(`Unknown E2Store entry type: 0x${typeCode.toString(16)}`); } - const type = typeEntry as EntryType; + const type = typeCode as EntryType; // Parse data length from next 4 bytes (offset 2, little endian) - const length = header[2] | (header[3] << 8) | (header[4] << 16) | (header[5] << 24); + const length = readUint32(header, 2); // Validate reserved bytes are zero (offset 6-7) - const reserved = header[6] | (header[7] << 8); + const reserved = readUint16(header, 6); if (reserved !== 0) { throw new Error(`E2Store reserved bytes must be zero, got: ${reserved}`); } @@ -150,14 +149,9 @@ export async function readSlotIndex(fh: FileHandle, offset: number): Promise { const header = new Uint8Array(E2STORE_HEADER_SIZE); - // type - header[0] = type; - header[1] = type >>> 8; - // length u32 LE - header[2] = payload.length & 0xff; - header[3] = (payload.length >>> 8) & 0xff; - header[4] = (payload.length >>> 16) & 0xff; - header[5] = (payload.length >>> 24) & 0xff; + writeUint16(header, 0, type); // type (2 bytes) + writeUint32(header, 2, payload.length); // length (4 bytes) + // reserved bytes (6-7) remain 0 await fh.writev([header, payload], offset); } diff --git a/packages/era/src/util.ts b/packages/era/src/util.ts index 4dffc0a9e755..b0c4804cb3bd 100644 --- a/packages/era/src/util.ts +++ b/packages/era/src/util.ts @@ -6,16 +6,36 @@ export function readInt48(bytes: Uint8Array, offset: number): number { return Buffer.prototype.readIntLE.call(bytes, offset, 6); } -/** Read 48-bit signed integer (little-endian) at offset. */ +/** Read 48-bit unsigned integer (little-endian) at offset. */ export function readUint48(bytes: Uint8Array, offset: number): number { return Buffer.prototype.readUintLE.call(bytes, offset, 6); } +/** Read 16-bit unsigned integer (little-endian) at offset. */ +export function readUint16(bytes: Uint8Array, offset: number): number { + return Buffer.prototype.readUint16LE.call(bytes, offset); +} + +/** Read 32-bit unsigned integer (little-endian) at offset. */ +export function readUint32(bytes: Uint8Array, offset: number): number { + return Buffer.prototype.readUint32LE.call(bytes, offset); +} + /** Write 48-bit signed integer (little-endian) into target at offset. */ export function writeInt48(target: Uint8Array, offset: number, v: number): void { Buffer.prototype.writeIntLE.call(target, v, offset, 6); } +/** Write 16-bit unsigned integer (little-endian) into target at offset. */ +export function writeUint16(target: Uint8Array, offset: number, v: number): void { + Buffer.prototype.writeUint16LE.call(target, v, offset); +} + +/** Write 32-bit unsigned integer (little-endian) into target at offset. */ +export function writeUint32(target: Uint8Array, offset: number, v: number): void { + Buffer.prototype.writeUint32LE.call(target, v, offset); +} + /** Decompress snappy-framed data */ export function snappyUncompress(compressedData: Uint8Array): Uint8Array { const decompressor = new SnappyFramesUncompress(); @@ -36,12 +56,5 @@ export async function snappyCompress(data: Uint8Array): Promise { for await (const chunk of encodeSnappy(Buffer.from(data.buffer, data.byteOffset, data.byteLength))) { buffers.push(chunk); } - const total = buffers.reduce((n, b) => n + b.length, 0); - const out = new Uint8Array(total); - let p = 0; - for (const b of buffers) { - out.set(b, p); - p += b.length; - } - return out; + return Buffer.concat(buffers); } From 47cc25d65a683e9723e0b12558d5f57a5c2c53e1 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Sat, 1 Nov 2025 05:42:40 +0530 Subject: [PATCH 22/34] fix : out of range bug --- packages/era/src/era/reader.ts | 2 +- packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/era/src/era/reader.ts b/packages/era/src/era/reader.ts index 906348ffe3bf..bf0d0c05116b 100644 --- a/packages/era/src/era/reader.ts +++ b/packages/era/src/era/reader.ts @@ -159,7 +159,7 @@ export class EraReader { // validate blocks for ( let slot = index.blocksIndex.startSlot; - slot <= index.blocksIndex.startSlot + index.blocksIndex.offsets.length; + slot < index.blocksIndex.startSlot + index.blocksIndex.offsets.length; slot++ ) { const block = await this.readBlock(slot); diff --git a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts index a35b45386fa1..f144807f6c36 100644 --- a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts +++ b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts @@ -91,5 +91,5 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file const totalTime = Date.now() - startTime; console.log("Round-trip test passed"); console.log(` Total time: ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`); - }, 120000); + }, 1000000); }); From 30fd6f85d4ec1791e82af5eccb1bc7dce30ffca5 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Sat, 1 Nov 2025 05:43:23 +0530 Subject: [PATCH 23/34] lint --- packages/era/src/e2s.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/era/src/e2s.ts b/packages/era/src/e2s.ts index aef0294e326c..e3cccec86bde 100644 --- a/packages/era/src/e2s.ts +++ b/packages/era/src/e2s.ts @@ -149,7 +149,7 @@ export async function readSlotIndex(fh: FileHandle, offset: number): Promise { const header = new Uint8Array(E2STORE_HEADER_SIZE); - writeUint16(header, 0, type); // type (2 bytes) + writeUint16(header, 0, type); // type (2 bytes) writeUint32(header, 2, payload.length); // length (4 bytes) // reserved bytes (6-7) remain 0 await fh.writev([header, payload], offset); From 1ce7d39583acc43a7e55bbd6260322d5ca72abaf Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Sun, 2 Nov 2025 04:01:39 +0530 Subject: [PATCH 24/34] add comment --- packages/utils/src/snappy/compress.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/src/snappy/compress.ts b/packages/utils/src/snappy/compress.ts index 8fc83f27b01a..e240ed313a72 100644 --- a/packages/utils/src/snappy/compress.ts +++ b/packages/utils/src/snappy/compress.ts @@ -1,6 +1,7 @@ import {compress} from "#snappy"; import {ChunkType, IDENTIFIER_FRAME, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js"; +// The logic in this file is largely copied (in simplified form) from https://github.com/ChainSafe/node-snappy-stream/ export async function* encodeSnappy(bytes: Buffer): AsyncGenerator { yield IDENTIFIER_FRAME; From 9b0a7ab2981a0e2729add0943e802652354edf6b Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Tue, 4 Nov 2025 02:02:41 +0530 Subject: [PATCH 25/34] remove console --- .../era.readwrite.integration.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts index f144807f6c36..e7b783e252df 100644 --- a/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts +++ b/packages/era/test/e2e-mainnet/era.readwrite.integration.test.ts @@ -23,31 +23,14 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file const SPR = SLOTS_PER_HISTORICAL_ROOT; const stateSlot = expectedEra * SPR; - const startTime = Date.now(); - let t0 = startTime; - - console.log("stage: open and read original era file"); const reader = await EraReader.open(config, eraPath); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log(`progress: ${reader.groups.length} group(s) found`); - console.log("stage: validate original era file"); await reader.validate(); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log("stage: create new era file writer"); const outDir = path.resolve(__dirname, "../out"); if (!existsSync(outDir)) mkdirSync(outDir, {recursive: true}); let outFile = path.resolve(outDir, `mainnet-${String(expectedEra).padStart(5, "0")}-deadbeef.era`); const writer = await EraWriter.create(config, outFile, expectedEra); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log("stage: read blocks from original and write to new era file"); const blocksIndex = reader.groups[0].blocksIndex; if (!blocksIndex) { throw new Error("Original era file missing blocks index"); @@ -57,39 +40,16 @@ describe.runIf(!process.env.CI)("read original era and re-write our own era file if (block === null) continue; await writer.writeBlock(block); } - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log("stage: read state from original era file"); const originalState = await reader.readState(); expect(originalState.slot).to.equal(stateSlot); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log("stage: write state to new era file"); await writer.writeState(originalState); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - - console.log("stage: finish reading and writing"); await reader.close(); outFile = await writer.finish(); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); expect(basename(outFile)).to.equal(basename(eraPath)); - - console.log("stage: open and validate new era file"); const newReader = await EraReader.open(config, outFile); await newReader.validate(); - console.log(` time: ${Date.now() - t0}ms`); - t0 = Date.now(); - // Cleanup await newReader.close(); - - const totalTime = Date.now() - startTime; - console.log("Round-trip test passed"); - console.log(` Total time: ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`); }, 1000000); }); From fe8076863431dab27cd87682906a8ebafb13f985 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Tue, 4 Nov 2025 03:17:41 +0530 Subject: [PATCH 26/34] port snappy fnutions from package/utils to package/reqresp/src/utils --- packages/era/package.json | 1 + packages/era/src/util.ts | 2 +- packages/reqresp/package.json | 6 ++++++ .../reqresp/src/encodingStrategies/sszSnappy/decode.ts | 2 +- .../reqresp/src/encodingStrategies/sszSnappy/encode.ts | 2 +- .../reqresp/src/encodingStrategies/sszSnappy/index.ts | 2 +- packages/reqresp/src/utils/errorMessage.ts | 2 +- packages/reqresp/src/utils/index.ts | 1 + .../{utils/src/snappy => reqresp/src/utils}/snappy.ts | 0 .../common.ts => reqresp/src/utils/snappyCommon.ts} | 0 .../src/utils/snappyCompress.ts} | 2 +- packages/reqresp/src/utils/snappyIndex.ts | 3 +++ .../src/utils/snappyUncompress.ts} | 2 +- .../src/snappy => reqresp/src/utils}/snappy_bun.ts | 0 .../sszSnappy/snappyFrames/uncompress.test.ts | 8 +++++++- packages/utils/package.json | 10 +--------- packages/utils/src/index.ts | 1 - packages/utils/src/snappy/index.ts | 3 --- 18 files changed, 26 insertions(+), 21 deletions(-) rename packages/{utils/src/snappy => reqresp/src/utils}/snappy.ts (100%) rename packages/{utils/src/snappy/common.ts => reqresp/src/utils/snappyCommon.ts} (100%) rename packages/{utils/src/snappy/compress.ts => reqresp/src/utils/snappyCompress.ts} (96%) create mode 100644 packages/reqresp/src/utils/snappyIndex.ts rename packages/{utils/src/snappy/uncompress.ts => reqresp/src/utils/snappyUncompress.ts} (99%) rename packages/{utils/src/snappy => reqresp/src/utils}/snappy_bun.ts (100%) delete mode 100644 packages/utils/src/snappy/index.ts diff --git a/packages/era/package.json b/packages/era/package.json index 5701d157305a..00e7c1bb872a 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -42,6 +42,7 @@ "@chainsafe/blst": "^2.2.0", "@lodestar/config": "^1.35.0", "@lodestar/params": "^1.35.0", + "@lodestar/reqresp": "^1.35.0", "@lodestar/types": "^1.35.0", "uint8arraylist": "^2.4.7" } diff --git a/packages/era/src/util.ts b/packages/era/src/util.ts index b0c4804cb3bd..d8afcc4e5540 100644 --- a/packages/era/src/util.ts +++ b/packages/era/src/util.ts @@ -1,5 +1,5 @@ import {Uint8ArrayList} from "uint8arraylist"; -import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/utils"; +import {SnappyFramesUncompress, encodeSnappy} from "@lodestar/reqresp/utils"; /** Read 48-bit signed integer (little-endian) at offset. */ export function readInt48(bytes: Uint8Array, offset: number): number { diff --git a/packages/reqresp/package.json b/packages/reqresp/package.json index 805d2e1a6609..da435703549d 100644 --- a/packages/reqresp/package.json +++ b/packages/reqresp/package.json @@ -29,6 +29,12 @@ "import": "./lib/encodingStrategies/sszSnappy/index.js" } }, + "imports": { + "#snappy": { + "bun": "./src/utils/snappy_bun.ts", + "default": "./lib/utils/snappy.js" + } + }, "files": [ "src", "lib", diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts index 2bb322f413d5..254a9268da9e 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/decode.ts @@ -1,8 +1,8 @@ import {decode as varintDecode, encodingLength as varintEncodingLength} from "uint8-varint"; import {Uint8ArrayList} from "uint8arraylist"; -import {SnappyFramesUncompress} from "@lodestar/utils"; import {TypeSizes} from "../../types.js"; import {BufferedSource} from "../../utils/index.js"; +import {SnappyFramesUncompress} from "../../utils/snappyIndex.js"; import {SszSnappyError, SszSnappyErrorCode} from "./errors.js"; import {maxEncodedLen} from "./utils.js"; diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts index 03c2c0689aa5..edea19b3c198 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/encode.ts @@ -1,5 +1,5 @@ import {encode as varintEncode} from "uint8-varint"; -import {encodeSnappy} from "@lodestar/utils"; +import {encodeSnappy} from "../../utils/snappyIndex.js"; /** * ssz_snappy encoding strategy writer. diff --git a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts index 7b46f398c8e3..8aaf7fc5e6f8 100644 --- a/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts +++ b/packages/reqresp/src/encodingStrategies/sszSnappy/index.ts @@ -1,4 +1,4 @@ -export {SnappyFramesUncompress, encodeSnappy} from "@lodestar/utils"; +export {SnappyFramesUncompress, encodeSnappy} from "../../utils/snappyIndex.js"; export * from "./decode.js"; export * from "./encode.js"; export * from "./errors.js"; diff --git a/packages/reqresp/src/utils/errorMessage.ts b/packages/reqresp/src/utils/errorMessage.ts index aed9fdfe2608..21b0ddd1aa72 100644 --- a/packages/reqresp/src/utils/errorMessage.ts +++ b/packages/reqresp/src/utils/errorMessage.ts @@ -1,8 +1,8 @@ import {decode as varintDecode, encodingLength as varintEncodingLength} from "uint8-varint"; import {Uint8ArrayList} from "uint8arraylist"; -import {SnappyFramesUncompress} from "@lodestar/utils"; import {writeSszSnappyPayload} from "../encodingStrategies/sszSnappy/encode.js"; import {Encoding} from "../types.js"; +import {SnappyFramesUncompress} from "./snappyIndex.js"; // ErrorMessage schema: // diff --git a/packages/reqresp/src/utils/index.ts b/packages/reqresp/src/utils/index.ts index d25f3d684c75..a0831d4f8647 100644 --- a/packages/reqresp/src/utils/index.ts +++ b/packages/reqresp/src/utils/index.ts @@ -6,3 +6,4 @@ export * from "./errorMessage.js"; export * from "./onChunk.js"; export * from "./peerId.js"; export * from "./protocolId.js"; +export * from "./snappyIndex.js"; diff --git a/packages/utils/src/snappy/snappy.ts b/packages/reqresp/src/utils/snappy.ts similarity index 100% rename from packages/utils/src/snappy/snappy.ts rename to packages/reqresp/src/utils/snappy.ts diff --git a/packages/utils/src/snappy/common.ts b/packages/reqresp/src/utils/snappyCommon.ts similarity index 100% rename from packages/utils/src/snappy/common.ts rename to packages/reqresp/src/utils/snappyCommon.ts diff --git a/packages/utils/src/snappy/compress.ts b/packages/reqresp/src/utils/snappyCompress.ts similarity index 96% rename from packages/utils/src/snappy/compress.ts rename to packages/reqresp/src/utils/snappyCompress.ts index e240ed313a72..d0dfaf700897 100644 --- a/packages/utils/src/snappy/compress.ts +++ b/packages/reqresp/src/utils/snappyCompress.ts @@ -1,5 +1,5 @@ import {compress} from "#snappy"; -import {ChunkType, IDENTIFIER_FRAME, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js"; +import {ChunkType, IDENTIFIER_FRAME, UNCOMPRESSED_CHUNK_SIZE, crc} from "./snappyCommon.js"; // The logic in this file is largely copied (in simplified form) from https://github.com/ChainSafe/node-snappy-stream/ export async function* encodeSnappy(bytes: Buffer): AsyncGenerator { diff --git a/packages/reqresp/src/utils/snappyIndex.ts b/packages/reqresp/src/utils/snappyIndex.ts new file mode 100644 index 000000000000..fa79b7ca2a05 --- /dev/null +++ b/packages/reqresp/src/utils/snappyIndex.ts @@ -0,0 +1,3 @@ +export * from "./snappyCommon.js"; +export {encodeSnappy} from "./snappyCompress.js"; +export {SnappyFramesUncompress} from "./snappyUncompress.js"; diff --git a/packages/utils/src/snappy/uncompress.ts b/packages/reqresp/src/utils/snappyUncompress.ts similarity index 99% rename from packages/utils/src/snappy/uncompress.ts rename to packages/reqresp/src/utils/snappyUncompress.ts index d70c89816a10..7a765546ed86 100644 --- a/packages/utils/src/snappy/uncompress.ts +++ b/packages/reqresp/src/utils/snappyUncompress.ts @@ -1,6 +1,6 @@ import {Uint8ArrayList} from "uint8arraylist"; import {uncompress} from "#snappy"; -import {ChunkType, IDENTIFIER, UNCOMPRESSED_CHUNK_SIZE, crc} from "./common.js"; +import {ChunkType, IDENTIFIER, UNCOMPRESSED_CHUNK_SIZE, crc} from "./snappyCommon.js"; export class SnappyFramesUncompress { private buffer = new Uint8ArrayList(); diff --git a/packages/utils/src/snappy/snappy_bun.ts b/packages/reqresp/src/utils/snappy_bun.ts similarity index 100% rename from packages/utils/src/snappy/snappy_bun.ts rename to packages/reqresp/src/utils/snappy_bun.ts diff --git a/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts b/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts index 847584b83b88..56715a48cb40 100644 --- a/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts +++ b/packages/reqresp/test/unit/encodingStrategies/sszSnappy/snappyFrames/uncompress.test.ts @@ -1,7 +1,13 @@ import {pipe} from "it-pipe"; import {Uint8ArrayList} from "uint8arraylist"; import {describe, expect, it} from "vitest"; -import {ChunkType, IDENTIFIER_FRAME, SnappyFramesUncompress, crc, encodeSnappy} from "@lodestar/utils"; +import { + ChunkType, + IDENTIFIER_FRAME, + SnappyFramesUncompress, + crc, + encodeSnappy, +} from "../../../../../src/utils/snappyIndex.js"; describe("encodingStrategies / sszSnappy / snappy frames / uncompress", () => { it("should work with short input", () => diff --git a/packages/utils/package.json b/packages/utils/package.json index 7375dd0c976e..a512a776b39e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -25,10 +25,6 @@ "bun": "./src/bytes/bun.ts", "browser": "./lib/bytes/browser.js", "default": "./lib/bytes/nodejs.js" - }, - "#snappy": { - "bun": "./src/snappy/snappy_bun.ts", - "default": "./lib/snappy/snappy.js" } }, "files": [ @@ -52,15 +48,11 @@ "types": "lib/index.d.ts", "dependencies": { "@chainsafe/as-sha256": "^1.2.0", - "@chainsafe/fast-crc32c": "^4.2.0", "@lodestar/bun": "git+https://github.com/ChainSafe/lodestar-bun.git", "any-signal": "^4.1.1", "bigint-buffer": "^1.1.5", "case": "^1.6.3", - "js-yaml": "^4.1.0", - "snappy": "^7.2.2", - "snappyjs": "^0.7.0", - "uint8arraylist": "^2.4.7" + "js-yaml": "^4.1.0" }, "devDependencies": { "@types/js-yaml": "^4.0.5", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d5a0a4e10cd5..c98435c1c1d2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -18,7 +18,6 @@ export * from "./objects.js"; export * from "./promise.js"; export {type RetryOptions, retry} from "./retry.js"; export * from "./sleep.js"; -export * from "./snappy/index.js"; export * from "./sort.js"; export * from "./timeout.js"; export {type RecursivePartial, type RequiredSelective, bnToNum} from "./types.js"; diff --git a/packages/utils/src/snappy/index.ts b/packages/utils/src/snappy/index.ts deleted file mode 100644 index fbf2d79a51d8..000000000000 --- a/packages/utils/src/snappy/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./common.js"; -export {encodeSnappy} from "./compress.js"; -export {SnappyFramesUncompress} from "./uncompress.js"; From 6375888f88749797edc7a271d7159dae5a1d6624 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Tue, 4 Nov 2025 03:45:19 +0530 Subject: [PATCH 27/34] buump verrsion to 1.36.0 --- packages/era/package.json | 10 +++++----- packages/reqresp/package.json | 4 ---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/era/package.json b/packages/era/package.json index 00e7c1bb872a..bc5a08ad9732 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/ChainSafe/lodestar/issues" }, - "version": "1.35.0", + "version": "1.36.0", "type": "module", "exports": { ".": { @@ -40,10 +40,10 @@ }, "dependencies": { "@chainsafe/blst": "^2.2.0", - "@lodestar/config": "^1.35.0", - "@lodestar/params": "^1.35.0", - "@lodestar/reqresp": "^1.35.0", - "@lodestar/types": "^1.35.0", + "@lodestar/config": "^1.36.0", + "@lodestar/params": "^1.36.0", + "@lodestar/reqresp": "^1.36.0", + "@lodestar/types": "^1.36.0", "uint8arraylist": "^2.4.7" } } diff --git a/packages/reqresp/package.json b/packages/reqresp/package.json index da435703549d..f39fa2464946 100644 --- a/packages/reqresp/package.json +++ b/packages/reqresp/package.json @@ -23,10 +23,6 @@ "bun": "./src/utils/index.ts", "types": "./lib/utils/index.d.ts", "import": "./lib/utils/index.js" - }, - "./encodingStrategies/sszSnappy": { - "bun": "./src/encodingStrategies/sszSnappy/index.ts", - "import": "./lib/encodingStrategies/sszSnappy/index.js" } }, "imports": { From 2df24fbea06be11657cc73bb50f52a8d5426ee7e Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Tue, 4 Nov 2025 03:46:27 +0530 Subject: [PATCH 28/34] push back to 1.35.0 --- packages/era/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/era/package.json b/packages/era/package.json index bc5a08ad9732..00e7c1bb872a 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/ChainSafe/lodestar/issues" }, - "version": "1.36.0", + "version": "1.35.0", "type": "module", "exports": { ".": { @@ -40,10 +40,10 @@ }, "dependencies": { "@chainsafe/blst": "^2.2.0", - "@lodestar/config": "^1.36.0", - "@lodestar/params": "^1.36.0", - "@lodestar/reqresp": "^1.36.0", - "@lodestar/types": "^1.36.0", + "@lodestar/config": "^1.35.0", + "@lodestar/params": "^1.35.0", + "@lodestar/reqresp": "^1.35.0", + "@lodestar/types": "^1.35.0", "uint8arraylist": "^2.4.7" } } From f3f67a48329a30e011ef6eda03d96f03d6adee1c Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 01:21:40 +0530 Subject: [PATCH 29/34] bump to 1.36.0 --- packages/era/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/era/package.json b/packages/era/package.json index 00e7c1bb872a..bc5a08ad9732 100644 --- a/packages/era/package.json +++ b/packages/era/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/ChainSafe/lodestar/issues" }, - "version": "1.35.0", + "version": "1.36.0", "type": "module", "exports": { ".": { @@ -40,10 +40,10 @@ }, "dependencies": { "@chainsafe/blst": "^2.2.0", - "@lodestar/config": "^1.35.0", - "@lodestar/params": "^1.35.0", - "@lodestar/reqresp": "^1.35.0", - "@lodestar/types": "^1.35.0", + "@lodestar/config": "^1.36.0", + "@lodestar/params": "^1.36.0", + "@lodestar/reqresp": "^1.36.0", + "@lodestar/types": "^1.36.0", "uint8arraylist": "^2.4.7" } } From 850c1696e781dba8d7c691b06dd9f9e6f9a43728 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 03:06:27 +0530 Subject: [PATCH 30/34] add readme --- .claude/settings.local.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..6e1125b7dfc1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(yarn vitest run:*)", + "Bash(yarn lint:fix:*)" + ], + "deny": [], + "ask": [] + } +} From 571bc7050bebde58e2cf6b4cc114c4d8c9bdf72e Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 03:26:18 +0530 Subject: [PATCH 31/34] add claude to git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c508d8dd71b6..4b69b3a6a573 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ validators .vscode/launch.json !.vscode/settings.json .vscode/tasks.json +.claude/ # Tests artifacts packages/*/spec-tests* From 4fc585b1a5103c1616ad129c3e28dc7a7691f405 Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 03:27:55 +0530 Subject: [PATCH 32/34] clear cache --- .claude/settings.local.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 6e1125b7dfc1..000000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cat:*)", - "Bash(yarn vitest run:*)", - "Bash(yarn lint:fix:*)" - ], - "deny": [], - "ask": [] - } -} From 374c32d17c503583b6d98bab747676ebc18acaaf Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 13:58:30 +0530 Subject: [PATCH 33/34] add readme --- packages/era/README.md | 62 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/era/README.md b/packages/era/README.md index cb3b8c1e8273..9fb92b2515f4 100644 --- a/packages/era/README.md +++ b/packages/era/README.md @@ -4,7 +4,67 @@ ## Usage -See usage in the `lodestar` package here: +This package provides functionality to read and write [era files](https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md), which are based on the [e2store format](https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md#introduction). + +### Reading/Writing e2s files + +```ts +import {open} from "node:fs/promises"; +import {e2s} from "@lodestar/era"; + +const fh = await open("mainnet-xxxxxx-xxxxxxxx.era"); +const entry = await e2s.readEntry(fh, 0); +entry.type == e2s.EntryType.Version; +``` + +### Reading era files + +```ts +import {era} from "@lodestar/era"; +import {config} from "@lodestar/config/default"; + +// open reader +const reader = await era.EraReader.open(config, "mainnet-xxxxx-xxxxxxxx.era"); + +// check number of groups +reader.groups.length === 1; + +// read blocks +const slot = reader.groups[0].startSlot; + +// return snappy-frame compressed, ssz-serialized block at slot or null if a skip slot +// throws if out of range +await reader.readCompressedBlock(slot); +// same, but for ssz-serialized block +await reader.readSerializedBlock(slot); +// same but for deserialized block +await reader.readBlock(slot); + +// read state(s), one per group +// similar api to blocks, but with an optional eraNumber param for specifying which group's state to read +await reader.readCompressedState(); +await reader.readSerializedState(); +await reader.readState(); +``` + +### Writing era files + +```ts +import {era} from "@lodestar/era"; +import {config} from "@lodestar/config/default"; + +const writer = await era.EraWriter.create(config, "path/to/era"); + +// similar api to reader, can write compressed, serialized, or deserialized items +// first write all blocks for the era +await writer.writeBlock(block); +// ... +// then write the state +await writer.writeState(state); +// if applicable, continue writing eras of blocks and state (an era file can contain multiple eras, or "groups" as the spec states) +// when finished, must call `finish`, which will close the file handler and rename the file to the spec-compliant name +await writer.finish(); +``` ## License From 6ac08874978b400541febdd22af4ed6f581c7d6c Mon Sep 17 00:00:00 2001 From: guha-rahul <19rahul2003@gmail.com> Date: Wed, 5 Nov 2025 15:37:45 +0530 Subject: [PATCH 34/34] fix docs --- packages/era/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/era/README.md b/packages/era/README.md index 9fb92b2515f4..f7f083e0d80a 100644 --- a/packages/era/README.md +++ b/packages/era/README.md @@ -30,7 +30,7 @@ const reader = await era.EraReader.open(config, "mainnet-xxxxx-xxxxxxxx.era"); reader.groups.length === 1; // read blocks -const slot = reader.groups[0].startSlot; +const slot = reader.groups[0].blocksIndex?.startSlot ?? 0; // return snappy-frame compressed, ssz-serialized block at slot or null if a skip slot // throws if out of range @@ -52,14 +52,19 @@ await reader.readState(); ```ts import {era} from "@lodestar/era"; import {config} from "@lodestar/config/default"; +import {SignedBeaconBlock, BeaconState} from "@lodestar/types"; -const writer = await era.EraWriter.create(config, "path/to/era"); +const writer = await era.EraWriter.create(config, "path/to/era", 0); // similar api to reader, can write compressed, serialized, or deserialized items // first write all blocks for the era +// Assuming `block` is a SignedBeaconBlock +declare const block: SignedBeaconBlock; await writer.writeBlock(block); // ... // then write the state +// Assuming `state` is a BeaconState +declare const state: BeaconState; await writer.writeState(state); // if applicable, continue writing eras of blocks and state (an era file can contain multiple eras, or "groups" as the spec states) // when finished, must call `finish`, which will close the file handler and rename the file to the spec-compliant name