Skip to content

Commit a3c4c98

Browse files
acolytec3ScottyPoi
andauthored
Add era file support (#3883)
* Add support for era files * update era comments * spelling * era: clean up switch statement --------- Co-authored-by: ScottyPoi <[email protected]>
1 parent 8630cc8 commit a3c4c98

File tree

5 files changed

+177
-6
lines changed

5 files changed

+177
-6
lines changed

config/cspell-ts.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
}
2525
],
2626
"words": [
27+
"bytestring",
2728
"binarytree",
2829
"merkelize",
2930
"kaust",
@@ -634,4 +635,4 @@
634635
"bytevector",
635636
"blobschedule"
636637
]
637-
}
638+
}

packages/era/src/e2store.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { bigInt64ToBytes, bytesToHex, concatBytes, equalsBytes } from '@ethereum
33
import { uint256 } from 'micro-eth-signer/ssz'
44

55
import { compressData, decompressData } from './snappy.js'
6-
import { Era1Types } from './types.js'
6+
import { Era1Types, EraTypes } from './types.js'
77

88
import type { e2StoreEntry } from './types.js'
99

@@ -14,19 +14,18 @@ export async function parseEntry(entry: e2StoreEntry) {
1414
const decompressed = await decompressData(entry.data)
1515
let data
1616
switch (bytesToHex(entry.type)) {
17-
case bytesToHex(Era1Types.CompressedHeader):
18-
data = RLP.decode(decompressed)
19-
break
2017
case bytesToHex(Era1Types.CompressedBody): {
2118
const [txs, uncles, withdrawals] = RLP.decode(decompressed)
2219
data = { txs, uncles, withdrawals }
2320
break
2421
}
22+
case bytesToHex(Era1Types.CompressedHeader):
2523
case bytesToHex(Era1Types.CompressedReceipts):
26-
data = decompressed
2724
data = RLP.decode(decompressed)
2825
break
2926
case bytesToHex(Era1Types.AccumulatorRoot):
27+
case bytesToHex(EraTypes.CompressedBeaconState):
28+
case bytesToHex(EraTypes.CompressedSignedBeaconBlockType):
3029
data = decompressed
3130
break
3231
default:

packages/era/src/era.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { equalsBytes } from '@ethereumjs/util'
2+
import * as ssz from 'micro-eth-signer/ssz'
3+
4+
import { EraTypes, parseEntry, readEntry } from './index.js'
5+
6+
import type { SlotIndex } from './index.js'
7+
8+
/**
9+
* Reads a Slot Index from the end of a bytestring representing an era file
10+
* @param bytes a Uint8Array bytestring representing a {@link SlotIndex} plus any arbitrary prefixed data
11+
* @returns a deserialized {@link SlotIndex}
12+
*/
13+
export const readSlotIndex = (bytes: Uint8Array): SlotIndex => {
14+
const recordEnd = bytes.length
15+
const countBytes = bytes.slice(recordEnd - 8)
16+
const count = Number(new DataView(countBytes.buffer).getBigInt64(0, true))
17+
const recordStart = recordEnd - (8 * count + 24)
18+
const slotIndexEntry = readEntry(bytes.subarray(recordStart, recordEnd))
19+
if (equalsBytes(slotIndexEntry.type, EraTypes.SlotIndex) === false) {
20+
throw new Error(`expected SlotIndex type, got ${slotIndexEntry.type}`)
21+
}
22+
23+
const startSlot = Number(
24+
new DataView(slotIndexEntry.data.slice(0, 8).buffer).getBigInt64(0, true),
25+
)
26+
const slotOffsets = []
27+
28+
for (let i = 0; i < count; i++) {
29+
const slotEntry = slotIndexEntry.data.subarray((i + 1) * 8, (i + 2) * 8)
30+
const slotOffset = Number(new DataView(slotEntry.slice(0, 8).buffer).getBigInt64(0, true))
31+
slotOffsets.push(slotOffset)
32+
}
33+
return {
34+
startSlot,
35+
recordStart,
36+
slotOffsets,
37+
}
38+
}
39+
40+
/**
41+
* Reads a an era file and extracts the State and Block slot indices
42+
* @param eraContents a bytestring representing a serialized era file
43+
* @returns a dictionary containing the State and Block Slot Indices (if present)
44+
*/
45+
export const getEraIndexes = (
46+
eraContents: Uint8Array,
47+
): { stateSlotIndex: SlotIndex; blockSlotIndex: SlotIndex | undefined } => {
48+
const stateSlotIndex = readSlotIndex(eraContents)
49+
let blockSlotIndex = undefined
50+
if (stateSlotIndex.startSlot > 0) {
51+
blockSlotIndex = readSlotIndex(eraContents.slice(0, stateSlotIndex.recordStart))
52+
}
53+
return { stateSlotIndex, blockSlotIndex }
54+
}
55+
56+
/**
57+
*
58+
* @param eraData a bytestring representing an era file
59+
* @returns a BeaconState object of the same time as returned by {@link ssz.ETH2_TYPES.BeaconState}
60+
* @throws if BeaconState cannot be found
61+
*/
62+
export const readBeaconState = async (eraData: Uint8Array) => {
63+
const indices = getEraIndexes(eraData)
64+
const stateEntry = readEntry(
65+
eraData.slice(indices.stateSlotIndex.recordStart + indices.stateSlotIndex.slotOffsets[0]),
66+
)
67+
const data = await parseEntry(stateEntry)
68+
if (equalsBytes(stateEntry.type, EraTypes.CompressedBeaconState) === false) {
69+
throw new Error(`expected CompressedBeaconState type, got ${stateEntry.type}`)
70+
}
71+
return ssz.ETH2_TYPES.BeaconState.decode(data.data as Uint8Array)
72+
}
73+
74+
/**
75+
*
76+
* @param eraData a bytestring representing an era file
77+
* @returns a decompressed SignedBeaconBlock object of the same time as returned by {@link ssz.ETH2_TYPES.SignedBeaconBlock}
78+
* @throws if SignedBeaconBlock cannot be found
79+
*/
80+
export const readBeaconBlock = async (eraData: Uint8Array, offset: number) => {
81+
const indices = getEraIndexes(eraData)
82+
const blockEntry = readEntry(
83+
eraData.slice(
84+
indices.blockSlotIndex!.recordStart + indices.blockSlotIndex!.slotOffsets[offset],
85+
),
86+
)
87+
const data = await parseEntry(blockEntry)
88+
if (equalsBytes(blockEntry.type, EraTypes.CompressedSignedBeaconBlockType) === false) {
89+
throw new Error(`expected CompressedSignedBeaconBlockType type, got ${blockEntry.type}`)
90+
}
91+
return ssz.ETH2_TYPES.SignedBeaconBlock.decode(data.data as Uint8Array)
92+
}
93+
94+
/**
95+
* Reads a an era file and yields a stream of decompressed SignedBeaconBlocks
96+
* @param eraFile Uint8Array a serialized era file
97+
* @returns a stream of decompressed SignedBeaconBlocks or undefined if no blocks are present
98+
*/
99+
export async function* readBlocksFromEra(eraFile: Uint8Array) {
100+
const indices = getEraIndexes(eraFile)
101+
const maxBlocks = indices.blockSlotIndex?.slotOffsets.length
102+
if (maxBlocks === undefined) {
103+
// Return early if no blocks are present
104+
return
105+
}
106+
107+
for (let x = 0; x < maxBlocks; x++) {
108+
try {
109+
const block = await readBeaconBlock(eraFile, x)
110+
yield block
111+
} catch {
112+
// noop - we skip empty slots
113+
}
114+
}
115+
}

packages/era/src/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type e2StoreEntry = {
55
data: Uint8Array
66
}
77

8+
/** Era 1 Type Identifiers */
89
export const Era1Types = {
910
Version: new Uint8Array([0x65, 0x32]),
1011
CompressedHeader: new Uint8Array([0x03, 0x00]),
@@ -20,9 +21,24 @@ export const VERSION = {
2021
data: new Uint8Array([]),
2122
}
2223

24+
/** Era1 SSZ containers */
2325
export const HeaderRecord = ssz.container({
2426
blockHash: ssz.bytevector(32),
2527
totalDifficulty: ssz.uint256,
2628
})
2729

2830
export const EpochAccumulator = ssz.list(8192, HeaderRecord)
31+
32+
/** Era Type Identifiers */
33+
export const EraTypes = {
34+
CompressedSignedBeaconBlockType: new Uint8Array([0x01, 0x00]),
35+
CompressedBeaconState: new Uint8Array([0x02, 0x00]),
36+
Empty: new Uint8Array([0x00, 0x00]),
37+
SlotIndex: new Uint8Array([0x69, 0x32]),
38+
}
39+
40+
export type SlotIndex = {
41+
startSlot: number
42+
recordStart: number
43+
slotOffsets: number[]
44+
}

packages/era/test/era.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { readFileSync } from 'fs'
2+
import { assert, beforeAll, describe, it } from 'vitest'
3+
4+
import { readBeaconBlock, readBeaconState, readBlocksFromEra, readSlotIndex } from '../src/era.js'
5+
import { readBinaryFile } from '../src/index.js'
6+
7+
// To test this, download mainnet-01339-75d1c621.era from https://mainnet.era.nimbus.team/mainnet-01339-75d1c621.era
8+
// This era file is around 500mb in size so don't commit it to the repo
9+
describe.skip('it should be able to extract beacon objects from an era file', () => {
10+
let data: Uint8Array
11+
beforeAll(() => {
12+
data = readBinaryFile(__dirname + '/mainnet-01339-75d1c621.era')
13+
})
14+
it('should read a slot index from the era file', async () => {
15+
const slotIndex = readSlotIndex(data)
16+
assert.equal(slotIndex.startSlot, 10969088)
17+
})
18+
it('should extract the beacon state', async () => {
19+
const state = await readBeaconState(data)
20+
assert.equal(Number(state.slot), 10969088)
21+
}, 30000)
22+
it('should read a block from the era file and decompress it', async () => {
23+
const block = await readBeaconBlock(data, 0)
24+
assert.equal(Number(block.message.slot), 10960896)
25+
})
26+
it('read blocks from an era file', async () => {
27+
let count = 0
28+
for await (const block of readBlocksFromEra(data)) {
29+
assert.exists(block.message.slot)
30+
count++
31+
if (count > 10) break
32+
}
33+
}, 30000)
34+
it('reads no blocks from the genesis era file', async () => {
35+
const data = new Uint8Array(readFileSync(__dirname + '/mainnet-00000-4b363db9.era'))
36+
for await (const block of readBlocksFromEra(data)) {
37+
assert.equal(block, undefined)
38+
}
39+
})
40+
})

0 commit comments

Comments
 (0)