@mikeargento/bitgraph-audit 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +1 -1
- package/dist/ceilings.d.ts +25 -0
- package/dist/ceilings.d.ts.map +1 -1
- package/dist/ceilings.js +159 -31
- package/dist/ceilings.js.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/report-md.js +3 -0
- package/dist/report-md.js.map +1 -1
- package/dist/settlement-blobs.d.ts +131 -0
- package/dist/settlement-blobs.d.ts.map +1 -0
- package/dist/settlement-blobs.js +682 -0
- package/dist/settlement-blobs.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/fixtures/settlement/0x018efc046e94610e2530344caf1574e507a3182e1a975fb25b032ec1434a3f5c.bin +0 -0
- package/src/__tests__/fixtures/settlement/ceiling-51979918.json +101 -0
- package/src/__tests__/fixtures/settlement/pointer-51979918.json +69 -0
- package/src/__tests__/settlement.test.ts +254 -0
- package/src/audit.ts +1 -1
- package/src/ceilings.ts +167 -33
- package/src/cli.ts +1 -0
- package/src/index.ts +4 -0
- package/src/report-md.ts +2 -0
- package/src/settlement-blobs.ts +689 -0
- package/src/types.ts +31 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Argento Computing Inc. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The BLOB layer of a Base ceiling's settlement on Ethereum
|
|
5
|
+
* (bitgraph-settlement/1). The pointer layer, in bitgraph-verify, shows that
|
|
6
|
+
* an Ethereum block includes a batcher transaction listing some blob
|
|
7
|
+
* commitments. This layer closes the loop from those commitments to the
|
|
8
|
+
* ceiling transaction's bytes, offline, from blob bytes kept beside the
|
|
9
|
+
* package:
|
|
10
|
+
*
|
|
11
|
+
* blob bytes ─KZG commit→ == pointer.blobs[i].kzgCommitment (the versioned
|
|
12
|
+
* hash the signed transaction lists is
|
|
13
|
+
* 0x01 || SHA-256(commitment)[1:])
|
|
14
|
+
* blob ─OP blob decode→ frames ─assemble→ channel ─brotli/zlib→ batches
|
|
15
|
+
* batch ─timestamp, parent hash→ the Base block
|
|
16
|
+
* transaction_list[i] ─keccak256→ the ceiling transaction's hash
|
|
17
|
+
*
|
|
18
|
+
* A channel is one compressed stream split into frames, so decoding needs
|
|
19
|
+
* frame 0 and every frame after it up to the one holding the bytes. With
|
|
20
|
+
* every frame present the channel is decoded whole. With a PREFIX of the
|
|
21
|
+
* frames (a pointer names one transaction's blobs, and a channel can span
|
|
22
|
+
* two transactions), the stream is decompressed as far as it goes and only
|
|
23
|
+
* the batches and transactions complete inside that prefix are read. That is
|
|
24
|
+
* sound because a compressed stream is sequential: a prefix of its input
|
|
25
|
+
* yields a prefix of its output, and an RLP item complete in the prefix is
|
|
26
|
+
* the same item the whole stream holds. What a prefix cannot show is that
|
|
27
|
+
* the channel as a whole is well formed.
|
|
28
|
+
*
|
|
29
|
+
* Node only: KZG is micro-eth-signer over @noble/curves with the mainnet
|
|
30
|
+
* trusted setup from @paulmillr/trusted-setups (loaded on first use, about
|
|
31
|
+
* 0.3 s per blob to recompute a commitment), and decompression is node:zlib.
|
|
32
|
+
* No network, like the rest of the audit.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { brotliDecompressSync, inflateSync, constants as zlibConstants } from "node:zlib";
|
|
36
|
+
import {
|
|
37
|
+
keccak256, rlpEncode, versionedHashOf,
|
|
38
|
+
evmHexToBytes as hexToBytes, evmBytesToHex as bytesToHex,
|
|
39
|
+
type SettlementPointer, type RlpItem,
|
|
40
|
+
} from "@mikeargento/bitgraph-verify";
|
|
41
|
+
|
|
42
|
+
/** Base mainnet: block number = (timestamp - l2GenesisTime) / blockTime. */
|
|
43
|
+
export const BASE_MAINNET_ROLLUP = Object.freeze({ l2GenesisTime: 1686789347, blockTime: 2 });
|
|
44
|
+
|
|
45
|
+
export const BLOB_BYTES = 131072;
|
|
46
|
+
/** OP Stack blob encoding version 0: (4 * 31 + 3) * 1024 - 4 bytes of data per blob. */
|
|
47
|
+
export const MAX_BLOB_DATA_BYTES = 130044;
|
|
48
|
+
/** Fjord's cap on a channel's decompressed size; decoding refuses to grow past it. */
|
|
49
|
+
const MAX_CHANNEL_BYTES = 100_000_000;
|
|
50
|
+
|
|
51
|
+
export interface SettlementBlobsCheck {
|
|
52
|
+
name: string;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
detail?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SettlementLocated {
|
|
58
|
+
/** The Base block the batch places the transaction in, read from the batch's timestamp. */
|
|
59
|
+
baseBlockNumber: number;
|
|
60
|
+
baseBlockTimestamp: number;
|
|
61
|
+
/** The batch's parent_hash (the hash of the block before), when the batch form carries one. */
|
|
62
|
+
baseParentHash: string | null;
|
|
63
|
+
/** Index in the batch's transaction_list: the block's non-deposit transactions, in order. */
|
|
64
|
+
baseTxIndex: number;
|
|
65
|
+
txHash: string;
|
|
66
|
+
/** The transaction's exact bytes as the batch carries them, 0x hex. */
|
|
67
|
+
rawTx: string;
|
|
68
|
+
/** Whether the batch holding the block was complete in the decoded bytes (false past the end of a frame prefix). */
|
|
69
|
+
batchComplete: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SettlementBlobsResult {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
reason?: string;
|
|
75
|
+
checks: SettlementBlobsCheck[];
|
|
76
|
+
/** What was decoded: frames 0..framesDecoded-1 of the channel, whole or as a prefix. */
|
|
77
|
+
channel?: {
|
|
78
|
+
id: string;
|
|
79
|
+
framesDecoded: number;
|
|
80
|
+
/** The channel's frame count when known (complete, or stated by the pointer). */
|
|
81
|
+
framesTotal: number | null;
|
|
82
|
+
complete: boolean;
|
|
83
|
+
compression: "brotli" | "zlib";
|
|
84
|
+
decodedBytes: number;
|
|
85
|
+
/** Base blocks whose batches were complete in the decoded bytes. */
|
|
86
|
+
baseBlocks: number[];
|
|
87
|
+
};
|
|
88
|
+
located?: SettlementLocated;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SettlementBlobsOptions {
|
|
92
|
+
/** How the rollup turns a batch timestamp into a block number. Base mainnet by default. */
|
|
93
|
+
rollup?: { l2GenesisTime: number; blockTime: number };
|
|
94
|
+
/** The L2 chain id, for rebuilding transactions out of span batches. Base mainnet by default. */
|
|
95
|
+
chainId?: bigint;
|
|
96
|
+
/** What the ceiling record says, each matched against what the batch says when given. */
|
|
97
|
+
expect?: {
|
|
98
|
+
/** The ceiling transaction to locate (else the pointer's `located.txHash`). */
|
|
99
|
+
txHash?: string;
|
|
100
|
+
/** Its exact bytes, 0x hex. */
|
|
101
|
+
rawTx?: string;
|
|
102
|
+
/** The Base block's parent hash, from the ceiling's carried header. */
|
|
103
|
+
baseParentHash?: string;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── KZG, loaded on first use ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
interface Kzg { blobToKzgCommitment(blob: string): string }
|
|
110
|
+
let kzgPromise: Promise<Kzg> | null = null;
|
|
111
|
+
|
|
112
|
+
function loadKzg(): Promise<Kzg> {
|
|
113
|
+
if (!kzgPromise) {
|
|
114
|
+
kzgPromise = Promise.all([import("micro-eth-signer/kzg.js"), import("@paulmillr/trusted-setups/fast-kzg.js")])
|
|
115
|
+
.then(([{ KZG }, { trustedSetup }]) => new KZG(trustedSetup));
|
|
116
|
+
}
|
|
117
|
+
return kzgPromise;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Commitments already computed, by keccak256 of the blob bytes: the batches
|
|
122
|
+
* of some 25 Base blocks share one channel's blobs, so an audit of many
|
|
123
|
+
* ceilings meets the same bytes again and again. A commitment is 0.3 s; a
|
|
124
|
+
* keccak of the bytes is a millisecond.
|
|
125
|
+
*/
|
|
126
|
+
const commitments = new Map<string, string>();
|
|
127
|
+
const COMMITMENT_CACHE_MAX = 256;
|
|
128
|
+
|
|
129
|
+
function commitmentOf(kzg: Kzg, bytes: Uint8Array): string {
|
|
130
|
+
const key = bytesToHex(keccak256(bytes));
|
|
131
|
+
const known = commitments.get(key);
|
|
132
|
+
if (known) return known;
|
|
133
|
+
const c = kzg.blobToKzgCommitment(bytesToHex(bytes)).toLowerCase();
|
|
134
|
+
if (commitments.size >= COMMITMENT_CACHE_MAX) commitments.delete(commitments.keys().next().value as string);
|
|
135
|
+
commitments.set(key, c);
|
|
136
|
+
return c;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── OP Stack blob encoding (version 0) ─────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The data a blob carries: 4 field elements per round, each a 6-bit byte
|
|
143
|
+
* and 31 data bytes, the first round's first element holding the version
|
|
144
|
+
* and a 24-bit length. Padding past the length must be zero. A field
|
|
145
|
+
* element with its top two bits set is not a blob this encoding produced.
|
|
146
|
+
*/
|
|
147
|
+
export function decodeOpBlob(blob: Uint8Array): Uint8Array {
|
|
148
|
+
if (blob.length !== BLOB_BYTES) throw new TypeError(`blob: ${blob.length} bytes, expected ${BLOB_BYTES}`);
|
|
149
|
+
if (blob[1] !== 0) throw new TypeError(`blob: encoding version ${blob[1]}, expected 0`);
|
|
150
|
+
const outLen = (blob[2]! << 16) | (blob[3]! << 8) | blob[4]!;
|
|
151
|
+
if (outLen > MAX_BLOB_DATA_BYTES) throw new TypeError(`blob: data length ${outLen} exceeds ${MAX_BLOB_DATA_BYTES}`);
|
|
152
|
+
const out = new Uint8Array(MAX_BLOB_DATA_BYTES + 4 * 32);
|
|
153
|
+
out.set(blob.subarray(5, 32), 0);
|
|
154
|
+
let opos = 28;
|
|
155
|
+
let ipos = 32;
|
|
156
|
+
const enc = [blob[0]!, 0, 0, 0];
|
|
157
|
+
if (enc[0]! & 0xc0) throw new TypeError("blob: field element 0 has its high bits set");
|
|
158
|
+
const fe = (): number => {
|
|
159
|
+
const b = blob[ipos]!;
|
|
160
|
+
if (b & 0xc0) throw new TypeError(`blob: field element at ${ipos} has its high bits set`);
|
|
161
|
+
out.set(blob.subarray(ipos + 1, ipos + 32), opos);
|
|
162
|
+
opos += 32;
|
|
163
|
+
ipos += 32;
|
|
164
|
+
return b;
|
|
165
|
+
};
|
|
166
|
+
const reassemble = (): void => {
|
|
167
|
+
opos--;
|
|
168
|
+
const x = (enc[0]! & 0x3f) | ((enc[1]! & 0x30) << 2);
|
|
169
|
+
const y = (enc[1]! & 0x0f) | ((enc[3]! & 0x0f) << 4);
|
|
170
|
+
const z = (enc[2]! & 0x3f) | ((enc[3]! & 0x30) << 2);
|
|
171
|
+
out[opos - 32] = z;
|
|
172
|
+
out[opos - 64] = y;
|
|
173
|
+
out[opos - 96] = x;
|
|
174
|
+
};
|
|
175
|
+
for (let i = 1; i < 4; i++) enc[i] = fe();
|
|
176
|
+
reassemble();
|
|
177
|
+
for (let round = 1; round < 1024 && opos < outLen; round++) {
|
|
178
|
+
for (let j = 0; j < 4; j++) enc[j] = fe();
|
|
179
|
+
reassemble();
|
|
180
|
+
}
|
|
181
|
+
for (let i = outLen; i < opos; i++) if (out[i] !== 0) throw new TypeError("blob: non-zero bytes where padding was expected");
|
|
182
|
+
for (; ipos < BLOB_BYTES; ipos++) if (blob[ipos] !== 0) throw new TypeError(`blob: non-zero field element at ${ipos} past the data`);
|
|
183
|
+
return out.slice(0, outLen);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Frames ─────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export interface ChannelFrame {
|
|
189
|
+
channelId: string;
|
|
190
|
+
frameNumber: number;
|
|
191
|
+
frameData: Uint8Array;
|
|
192
|
+
isLast: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Frames out of a blob's data: derivation version 0, then channel_id(16) frame_number(2) length(4) data is_last(1), repeated. */
|
|
196
|
+
export function parseFrames(data: Uint8Array): ChannelFrame[] {
|
|
197
|
+
if (data.length === 0) throw new TypeError("frames: empty data");
|
|
198
|
+
if (data[0] !== 0) throw new TypeError(`frames: derivation version ${data[0]}, expected 0`);
|
|
199
|
+
const frames: ChannelFrame[] = [];
|
|
200
|
+
let p = 1;
|
|
201
|
+
while (p < data.length) {
|
|
202
|
+
if (p + 23 > data.length) throw new TypeError(`frames: truncated frame header at ${p}`);
|
|
203
|
+
const channelId = bytesToHex(data.subarray(p, p + 16));
|
|
204
|
+
const frameNumber = (data[p + 16]! << 8) | data[p + 17]!;
|
|
205
|
+
const len = ((data[p + 18]! << 24) >>> 0) + (data[p + 19]! << 16) + (data[p + 20]! << 8) + data[p + 21]!;
|
|
206
|
+
if (p + 22 + len + 1 > data.length) throw new TypeError(`frames: frame ${frameNumber} runs past the data`);
|
|
207
|
+
const frameData = data.subarray(p + 22, p + 22 + len);
|
|
208
|
+
const last = data[p + 22 + len]!;
|
|
209
|
+
if (last > 1) throw new TypeError(`frames: frame ${frameNumber} has is_last ${last}`);
|
|
210
|
+
frames.push({ channelId, frameNumber, frameData, isLast: last === 1 });
|
|
211
|
+
p += 23 + len;
|
|
212
|
+
}
|
|
213
|
+
return frames;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── RLP at a position (strict about lengths, tolerant of a cut stream) ─────
|
|
217
|
+
|
|
218
|
+
interface RlpSpan { isList: boolean; start: number; end: number }
|
|
219
|
+
|
|
220
|
+
function readLen(buf: Uint8Array, pos: number, lenOfLen: number): number {
|
|
221
|
+
if (buf[pos] === 0) throw new TypeError("rlp: length with a leading zero");
|
|
222
|
+
let n = 0;
|
|
223
|
+
for (let i = 0; i < lenOfLen; i++) n = n * 256 + buf[pos + i]!;
|
|
224
|
+
if (n < 56) throw new TypeError("rlp: long form for a short length");
|
|
225
|
+
return n;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The item at `pos`, or null when its header runs past `limit` (a cut stream). The item's own bytes may still run past `limit`. */
|
|
229
|
+
function rlpSpan(buf: Uint8Array, pos: number, limit: number): RlpSpan | null {
|
|
230
|
+
if (pos >= limit) return null;
|
|
231
|
+
const b = buf[pos]!;
|
|
232
|
+
if (b < 0x80) return { isList: false, start: pos, end: pos + 1 };
|
|
233
|
+
if (b <= 0xb7) {
|
|
234
|
+
const len = b - 0x80;
|
|
235
|
+
if (len === 1 && pos + 1 < limit && buf[pos + 1]! < 0x80) throw new TypeError("rlp: single byte should encode as itself");
|
|
236
|
+
return { isList: false, start: pos + 1, end: pos + 1 + len };
|
|
237
|
+
}
|
|
238
|
+
if (b <= 0xbf) {
|
|
239
|
+
const lenOfLen = b - 0xb7;
|
|
240
|
+
if (pos + 1 + lenOfLen > limit) return null;
|
|
241
|
+
const len = readLen(buf, pos + 1, lenOfLen);
|
|
242
|
+
return { isList: false, start: pos + 1 + lenOfLen, end: pos + 1 + lenOfLen + len };
|
|
243
|
+
}
|
|
244
|
+
if (b <= 0xf7) return { isList: true, start: pos + 1, end: pos + 1 + (b - 0xc0) };
|
|
245
|
+
const lenOfLen = b - 0xf7;
|
|
246
|
+
if (pos + 1 + lenOfLen > limit) return null;
|
|
247
|
+
const len = readLen(buf, pos + 1, lenOfLen);
|
|
248
|
+
return { isList: true, start: pos + 1 + lenOfLen, end: pos + 1 + lenOfLen + len };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function uintOf(bytes: Uint8Array, what: string): bigint {
|
|
252
|
+
if (bytes.length > 0 && bytes[0] === 0) throw new TypeError(`${what}: integer with a leading zero`);
|
|
253
|
+
let n = 0n;
|
|
254
|
+
for (const x of bytes) n = (n << 8n) | BigInt(x);
|
|
255
|
+
return n;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A complete byte-string item at `pos` inside `limit`, or null when cut. Lists are refused. */
|
|
259
|
+
function bytesItem(buf: Uint8Array, pos: number, limit: number, what: string): { bytes: Uint8Array; end: number } | null {
|
|
260
|
+
const s = rlpSpan(buf, pos, limit);
|
|
261
|
+
if (!s) return null;
|
|
262
|
+
if (s.isList) throw new TypeError(`${what}: expected bytes, found a list`);
|
|
263
|
+
if (s.end > limit) return null;
|
|
264
|
+
return { bytes: buf.subarray(s.start, s.end), end: s.end };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Channel: decompression and batches ─────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export function decompressChannel(data: Uint8Array, complete: boolean): { compression: "brotli" | "zlib"; bytes: Uint8Array } {
|
|
270
|
+
if (data.length === 0) throw new TypeError("channel: empty");
|
|
271
|
+
if (data[0] === 0x01) {
|
|
272
|
+
const body = data.subarray(1);
|
|
273
|
+
const bytes = complete
|
|
274
|
+
? brotliDecompressSync(body, { maxOutputLength: MAX_CHANNEL_BYTES })
|
|
275
|
+
: brotliDecompressSync(body, { maxOutputLength: MAX_CHANNEL_BYTES, finishFlush: zlibConstants.BROTLI_OPERATION_FLUSH });
|
|
276
|
+
return { compression: "brotli", bytes: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) };
|
|
277
|
+
}
|
|
278
|
+
if ((data[0]! & 0x0f) === 8) {
|
|
279
|
+
const bytes = inflateSync(data, { maxOutputLength: MAX_CHANNEL_BYTES, finishFlush: zlibConstants.Z_SYNC_FLUSH });
|
|
280
|
+
return { compression: "zlib", bytes: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) };
|
|
281
|
+
}
|
|
282
|
+
throw new TypeError(`channel: unknown compression byte 0x${data[0]!.toString(16)}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface BatchTx { raw: Uint8Array; hash: string }
|
|
286
|
+
export interface DecodedBatch {
|
|
287
|
+
/** 0 singular, 1 span. */
|
|
288
|
+
type: number;
|
|
289
|
+
/** Offset of the batch's RLP item in the decompressed channel. */
|
|
290
|
+
offset: number;
|
|
291
|
+
/** Base blocks in this batch. A cut singular batch has one block with the transactions read so far. */
|
|
292
|
+
blocks: Array<{ number: number; timestamp: number; parentHash: string | null; txs: BatchTx[]; complete: boolean }>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Batches out of a decompressed channel. Each is an RLP byte string
|
|
297
|
+
* `batch_version ++ content`. When `complete` is false the stream may be cut:
|
|
298
|
+
* the last batch is then read as far as it goes and marked incomplete.
|
|
299
|
+
*/
|
|
300
|
+
export function decodeBatches(
|
|
301
|
+
channel: Uint8Array,
|
|
302
|
+
complete: boolean,
|
|
303
|
+
rollup: { l2GenesisTime: number; blockTime: number },
|
|
304
|
+
chainId: bigint,
|
|
305
|
+
): DecodedBatch[] {
|
|
306
|
+
const out: DecodedBatch[] = [];
|
|
307
|
+
const limit = channel.length;
|
|
308
|
+
let p = 0;
|
|
309
|
+
while (p < limit) {
|
|
310
|
+
const s = rlpSpan(channel, p, limit);
|
|
311
|
+
if (!s) {
|
|
312
|
+
if (complete) throw new TypeError(`batches: truncated batch header at ${p}`);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
if (s.isList) throw new TypeError(`batches: the batch at ${p} is a list, expected a byte string`);
|
|
316
|
+
if (s.end - s.start < 1) throw new TypeError(`batches: the batch at ${p} is empty`);
|
|
317
|
+
if (s.start >= limit) break;
|
|
318
|
+
const type = channel[s.start]!;
|
|
319
|
+
const cut = s.end > limit;
|
|
320
|
+
if (cut && complete) throw new TypeError(`batches: the batch at ${p} runs past the channel`);
|
|
321
|
+
const contentStart = s.start + 1;
|
|
322
|
+
const contentEnd = Math.min(s.end, limit);
|
|
323
|
+
if (type === 0) {
|
|
324
|
+
const sb = parseSingularBatch(channel, contentStart, contentEnd, !cut, rollup);
|
|
325
|
+
if (sb) out.push({ type, offset: p, blocks: [sb] });
|
|
326
|
+
} else if (type === 1) {
|
|
327
|
+
if (!cut) out.push({ type, offset: p, blocks: decodeSpanBatch(channel.subarray(contentStart, contentEnd), rollup, chainId) });
|
|
328
|
+
} else if (!cut) {
|
|
329
|
+
throw new TypeError(`batches: unknown batch type ${type} at ${p}`);
|
|
330
|
+
}
|
|
331
|
+
if (cut) break;
|
|
332
|
+
p = s.end;
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* rlp([parent_hash, epoch_number, epoch_hash, timestamp, transaction_list]).
|
|
339
|
+
* `limit` is where readable bytes end; with `complete` the list must end
|
|
340
|
+
* exactly there. Returns null when not even the header fields are readable.
|
|
341
|
+
*/
|
|
342
|
+
function parseSingularBatch(
|
|
343
|
+
buf: Uint8Array, start: number, limit: number, complete: boolean, rollup: { l2GenesisTime: number; blockTime: number },
|
|
344
|
+
): DecodedBatch["blocks"][number] | null {
|
|
345
|
+
const outer = rlpSpan(buf, start, limit);
|
|
346
|
+
if (!outer) return null;
|
|
347
|
+
if (!outer.isList) throw new TypeError("singular batch: expected a list");
|
|
348
|
+
if (complete && outer.end !== limit) throw new TypeError("singular batch: trailing bytes");
|
|
349
|
+
const inner = Math.min(outer.end, limit);
|
|
350
|
+
let pos = outer.start;
|
|
351
|
+
const parent = bytesItem(buf, pos, inner, "singular batch.parent_hash");
|
|
352
|
+
if (!parent) return null;
|
|
353
|
+
if (parent.bytes.length !== 32) throw new TypeError("singular batch: parent_hash is not 32 bytes");
|
|
354
|
+
pos = parent.end;
|
|
355
|
+
const epochNumber = bytesItem(buf, pos, inner, "singular batch.epoch_number");
|
|
356
|
+
if (!epochNumber) return null;
|
|
357
|
+
uintOf(epochNumber.bytes, "singular batch.epoch_number");
|
|
358
|
+
pos = epochNumber.end;
|
|
359
|
+
const epochHash = bytesItem(buf, pos, inner, "singular batch.epoch_hash");
|
|
360
|
+
if (!epochHash) return null;
|
|
361
|
+
if (epochHash.bytes.length !== 32) throw new TypeError("singular batch: epoch_hash is not 32 bytes");
|
|
362
|
+
pos = epochHash.end;
|
|
363
|
+
const ts = bytesItem(buf, pos, inner, "singular batch.timestamp");
|
|
364
|
+
if (!ts) return null;
|
|
365
|
+
const timestamp = uintOf(ts.bytes, "singular batch.timestamp");
|
|
366
|
+
pos = ts.end;
|
|
367
|
+
const list = rlpSpan(buf, pos, inner);
|
|
368
|
+
if (!list) return null;
|
|
369
|
+
if (!list.isList) throw new TypeError("singular batch: transaction_list is not a list");
|
|
370
|
+
const listEnd = Math.min(list.end, inner);
|
|
371
|
+
const txs: BatchTx[] = [];
|
|
372
|
+
let q = list.start;
|
|
373
|
+
let listComplete = list.end <= inner;
|
|
374
|
+
while (q < listEnd) {
|
|
375
|
+
const item = rlpSpan(buf, q, listEnd);
|
|
376
|
+
if (!item) { listComplete = false; break; }
|
|
377
|
+
if (item.isList) throw new TypeError("singular batch: a transaction is a list, expected bytes");
|
|
378
|
+
if (item.end > listEnd) { listComplete = false; break; }
|
|
379
|
+
const raw = buf.subarray(item.start, item.end);
|
|
380
|
+
txs.push({ raw, hash: bytesToHex(keccak256(raw)) });
|
|
381
|
+
q = item.end;
|
|
382
|
+
}
|
|
383
|
+
if (listComplete && q !== list.end) throw new TypeError("singular batch: transaction_list overran its length");
|
|
384
|
+
if (complete && list.end !== outer.end) throw new TypeError("singular batch: bytes after transaction_list");
|
|
385
|
+
const number = blockNumberOf(Number(timestamp), rollup);
|
|
386
|
+
return { number, timestamp: Number(timestamp), parentHash: bytesToHex(parent.bytes), txs, complete: listComplete };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function blockNumberOf(timestamp: number, rollup: { l2GenesisTime: number; blockTime: number }): number {
|
|
390
|
+
const n = (timestamp - rollup.l2GenesisTime) / rollup.blockTime;
|
|
391
|
+
if (!Number.isInteger(n) || n < 0) throw new TypeError(`batch timestamp ${timestamp} is not on the rollup's block grid`);
|
|
392
|
+
return n;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Span batches (type 1), rebuilt into raw transactions ───────────────────
|
|
396
|
+
|
|
397
|
+
class Reader {
|
|
398
|
+
p = 0;
|
|
399
|
+
private readonly b: Uint8Array;
|
|
400
|
+
constructor(b: Uint8Array) { this.b = b; }
|
|
401
|
+
bytes(n: number): Uint8Array {
|
|
402
|
+
if (this.p + n > this.b.length) throw new TypeError("span batch: read past the end");
|
|
403
|
+
const r = this.b.subarray(this.p, this.p + n);
|
|
404
|
+
this.p += n;
|
|
405
|
+
return r;
|
|
406
|
+
}
|
|
407
|
+
uvarint(): bigint {
|
|
408
|
+
let x = 0n;
|
|
409
|
+
let s = 0n;
|
|
410
|
+
for (let i = 0; i < 10; i++) {
|
|
411
|
+
if (this.p >= this.b.length) throw new TypeError("span batch: read past the end");
|
|
412
|
+
const c = this.b[this.p++]!;
|
|
413
|
+
x |= BigInt(c & 0x7f) << s;
|
|
414
|
+
if (c < 0x80) return x;
|
|
415
|
+
s += 7n;
|
|
416
|
+
}
|
|
417
|
+
throw new TypeError("span batch: varint too long");
|
|
418
|
+
}
|
|
419
|
+
/** A big-endian, byte-aligned bit field of `n` bits; returns a reader of bit i. */
|
|
420
|
+
bits(n: number): (i: number) => number {
|
|
421
|
+
const buf = this.bytes(Math.ceil(n / 8));
|
|
422
|
+
let v = 0n;
|
|
423
|
+
for (const x of buf) v = (v << 8n) | BigInt(x);
|
|
424
|
+
return (i) => Number((v >> BigInt(i)) & 1n);
|
|
425
|
+
}
|
|
426
|
+
get done(): boolean { return this.p === this.b.length; }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A span batch (OP Stack Delta): prefix (rel_timestamp, l1_origin_num,
|
|
431
|
+
* parent_check, l1_origin_check) then payload (block_count, origin_bits,
|
|
432
|
+
* block_tx_counts, txs). Transactions are stored by column (signatures,
|
|
433
|
+
* recipients, data, nonces, gas limits, protected bits) and are rebuilt
|
|
434
|
+
* here into their raw form so they can be hashed.
|
|
435
|
+
*/
|
|
436
|
+
export function decodeSpanBatch(
|
|
437
|
+
data: Uint8Array, rollup: { l2GenesisTime: number; blockTime: number }, chainId: bigint,
|
|
438
|
+
): DecodedBatch["blocks"] {
|
|
439
|
+
const r = new Reader(data);
|
|
440
|
+
const relTimestamp = r.uvarint();
|
|
441
|
+
r.uvarint(); // l1_origin_num
|
|
442
|
+
r.bytes(20); // parent_check
|
|
443
|
+
r.bytes(20); // l1_origin_check
|
|
444
|
+
const blockCount = Number(r.uvarint());
|
|
445
|
+
if (blockCount === 0) throw new TypeError("span batch: no blocks");
|
|
446
|
+
r.bits(blockCount); // origin_bits
|
|
447
|
+
const txCounts: number[] = [];
|
|
448
|
+
for (let i = 0; i < blockCount; i++) txCounts.push(Number(r.uvarint()));
|
|
449
|
+
const total = txCounts.reduce((a, b) => a + b, 0);
|
|
450
|
+
const contractCreation = r.bits(total);
|
|
451
|
+
const yParity = r.bits(total);
|
|
452
|
+
const sigs: Array<{ r: Uint8Array; s: Uint8Array }> = [];
|
|
453
|
+
for (let i = 0; i < total; i++) sigs.push({ r: r.bytes(32), s: r.bytes(32) });
|
|
454
|
+
const tos: Array<Uint8Array | null> = [];
|
|
455
|
+
for (let i = 0; i < total; i++) tos.push(contractCreation(i) ? null : r.bytes(20));
|
|
456
|
+
const datas: Array<{ type: number; fields: RlpItem[] }> = [];
|
|
457
|
+
for (let i = 0; i < total; i++) {
|
|
458
|
+
let type = 0;
|
|
459
|
+
if (data[r.p]! < 0xc0) { type = data[r.p]!; r.p++; }
|
|
460
|
+
const s = rlpSpan(data, r.p, data.length);
|
|
461
|
+
if (!s || !s.isList || s.end > data.length) throw new TypeError("span batch: bad tx_data");
|
|
462
|
+
datas.push({ type, fields: listItems(data, s) });
|
|
463
|
+
r.p = s.end;
|
|
464
|
+
}
|
|
465
|
+
const nonces: bigint[] = [];
|
|
466
|
+
for (let i = 0; i < total; i++) nonces.push(r.uvarint());
|
|
467
|
+
const gases: bigint[] = [];
|
|
468
|
+
for (let i = 0; i < total; i++) gases.push(r.uvarint());
|
|
469
|
+
const legacyCount = datas.filter((d) => d.type === 0).length;
|
|
470
|
+
const protectedBits = r.bits(legacyCount);
|
|
471
|
+
if (!r.done) throw new TypeError(`span batch: ${data.length - r.p} trailing bytes`);
|
|
472
|
+
const u = (n: bigint): Uint8Array => {
|
|
473
|
+
if (n === 0n) return new Uint8Array(0);
|
|
474
|
+
let h = n.toString(16);
|
|
475
|
+
if (h.length % 2) h = "0" + h;
|
|
476
|
+
return hexToBytes(h);
|
|
477
|
+
};
|
|
478
|
+
let legacyIndex = 0;
|
|
479
|
+
const txs: BatchTx[] = datas.map((d, i) => {
|
|
480
|
+
const to = tos[i] ?? new Uint8Array(0);
|
|
481
|
+
const yp = BigInt(yParity(i));
|
|
482
|
+
const sig = sigs[i]!;
|
|
483
|
+
const f = d.fields;
|
|
484
|
+
let raw: Uint8Array;
|
|
485
|
+
if (d.type === 0) {
|
|
486
|
+
const [value, gasPrice, input] = f as [Uint8Array, Uint8Array, Uint8Array];
|
|
487
|
+
const prot = protectedBits(legacyIndex++);
|
|
488
|
+
const v = prot ? chainId * 2n + 35n + yp : 27n + yp;
|
|
489
|
+
raw = rlpEncode([u(nonces[i]!), gasPrice, u(gases[i]!), to, value, input, u(v), sig.r, sig.s]);
|
|
490
|
+
} else {
|
|
491
|
+
let body: RlpItem[];
|
|
492
|
+
if (d.type === 1) { const [value, gasPrice, input, acl] = f as [Uint8Array, Uint8Array, Uint8Array, RlpItem[]]; body = [u(chainId), u(nonces[i]!), gasPrice, u(gases[i]!), to, value, input, acl]; }
|
|
493
|
+
else if (d.type === 2) { const [value, tip, maxFee, input, acl] = f as [Uint8Array, Uint8Array, Uint8Array, Uint8Array, RlpItem[]]; body = [u(chainId), u(nonces[i]!), tip, maxFee, u(gases[i]!), to, value, input, acl]; }
|
|
494
|
+
else if (d.type === 4) { const [value, tip, maxFee, input, acl, auth] = f as [Uint8Array, Uint8Array, Uint8Array, Uint8Array, RlpItem[], RlpItem[]]; body = [u(chainId), u(nonces[i]!), tip, maxFee, u(gases[i]!), to, value, input, acl, auth]; }
|
|
495
|
+
else throw new TypeError(`span batch: unsupported transaction type ${d.type}`);
|
|
496
|
+
const enc = rlpEncode([...body, u(yp), sig.r, sig.s]);
|
|
497
|
+
raw = new Uint8Array(1 + enc.length);
|
|
498
|
+
raw[0] = d.type;
|
|
499
|
+
raw.set(enc, 1);
|
|
500
|
+
}
|
|
501
|
+
return { raw, hash: bytesToHex(keccak256(raw)) };
|
|
502
|
+
});
|
|
503
|
+
const firstTimestamp = rollup.l2GenesisTime + Number(relTimestamp);
|
|
504
|
+
const firstBlock = blockNumberOf(firstTimestamp, rollup);
|
|
505
|
+
const blocks: DecodedBatch["blocks"] = [];
|
|
506
|
+
let k = 0;
|
|
507
|
+
for (let b = 0; b < blockCount; b++) {
|
|
508
|
+
blocks.push({ number: firstBlock + b, timestamp: firstTimestamp + b * rollup.blockTime, parentHash: null, txs: txs.slice(k, k + txCounts[b]!), complete: true });
|
|
509
|
+
k += txCounts[b]!;
|
|
510
|
+
}
|
|
511
|
+
return blocks;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The items of a complete RLP list span, as byte strings or nested lists (strict). */
|
|
515
|
+
function listItems(buf: Uint8Array, s: RlpSpan): RlpItem[] {
|
|
516
|
+
const items: RlpItem[] = [];
|
|
517
|
+
let p = s.start;
|
|
518
|
+
while (p < s.end) {
|
|
519
|
+
const it = rlpSpan(buf, p, s.end);
|
|
520
|
+
if (!it || it.end > s.end) throw new TypeError("rlp: list item runs past its list");
|
|
521
|
+
items.push(it.isList ? listItems(buf, it) : buf.slice(it.start, it.end));
|
|
522
|
+
p = it.end;
|
|
523
|
+
}
|
|
524
|
+
return items;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── The check ──────────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
function short(h: string): string {
|
|
530
|
+
return `${h.slice(0, 10)}…${h.slice(-4)}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Verify blob bytes against a settlement pointer and locate the ceiling
|
|
535
|
+
* transaction inside them. `blobBytesByVersionedHash` holds the bytes the
|
|
536
|
+
* caller has (keyed by 0x hex versioned hash, any case); blobs the pointer
|
|
537
|
+
* lists without bytes are skipped, and the channel is decoded from the
|
|
538
|
+
* frames that are present, as a whole when they are all there and as a
|
|
539
|
+
* prefix otherwise. Every check runs in order; the first failure is `reason`.
|
|
540
|
+
*/
|
|
541
|
+
export async function verifySettlementBlobs(
|
|
542
|
+
pointer: SettlementPointer,
|
|
543
|
+
blobBytesByVersionedHash: ReadonlyMap<string, Uint8Array>,
|
|
544
|
+
options: SettlementBlobsOptions = {},
|
|
545
|
+
): Promise<SettlementBlobsResult> {
|
|
546
|
+
const checks: SettlementBlobsCheck[] = [];
|
|
547
|
+
const fail = (name: string, detail: string): SettlementBlobsResult => {
|
|
548
|
+
checks.push({ name, ok: false, detail });
|
|
549
|
+
return { ok: false, reason: `${name}: ${detail}`, checks };
|
|
550
|
+
};
|
|
551
|
+
const pass = (name: string, detail: string): void => { checks.push({ name, ok: true, detail }); };
|
|
552
|
+
const rollup = options.rollup ?? BASE_MAINNET_ROLLUP;
|
|
553
|
+
const chainId = options.chainId ?? 8453n;
|
|
554
|
+
|
|
555
|
+
if (!Array.isArray(pointer?.blobs) || pointer.blobs.length === 0) return fail("blobs", "the pointer names no blobs");
|
|
556
|
+
const supplied = new Map<string, Uint8Array>();
|
|
557
|
+
for (const [k, v] of blobBytesByVersionedHash) supplied.set(k.toLowerCase(), v);
|
|
558
|
+
const present = pointer.blobs.filter((b) => supplied.has(b.versionedHash.toLowerCase()));
|
|
559
|
+
if (present.length === 0) return fail("blobs", "no bytes were supplied for any blob the pointer names");
|
|
560
|
+
|
|
561
|
+
// 1. Each supplied blob commits to the listed commitment.
|
|
562
|
+
let kzg: Kzg;
|
|
563
|
+
try {
|
|
564
|
+
kzg = await loadKzg();
|
|
565
|
+
} catch (e) {
|
|
566
|
+
return fail("kzg", `the KZG trusted setup could not be loaded (${(e as Error).message})`);
|
|
567
|
+
}
|
|
568
|
+
const frames: ChannelFrame[] = [];
|
|
569
|
+
const decodedBlobs: string[] = [];
|
|
570
|
+
for (const ref of [...present].sort((a, b) => a.index - b.index)) {
|
|
571
|
+
const vh = ref.versionedHash.toLowerCase();
|
|
572
|
+
const bytes = supplied.get(vh)!;
|
|
573
|
+
const name = `blob ${ref.index}`;
|
|
574
|
+
if (bytes.length !== BLOB_BYTES) return fail(name, `${bytes.length} bytes, a blob is ${BLOB_BYTES}`);
|
|
575
|
+
let commitment: string;
|
|
576
|
+
try {
|
|
577
|
+
commitment = commitmentOf(kzg, bytes);
|
|
578
|
+
} catch (e) {
|
|
579
|
+
return fail(name, `not a valid blob (${(e as Error).message})`);
|
|
580
|
+
}
|
|
581
|
+
if (commitment !== ref.kzgCommitment.toLowerCase()) return fail(name, "the bytes do not commit to the listed KZG commitment");
|
|
582
|
+
if (versionedHashOf(hexToBytes(commitment)) !== vh) return fail(name, "the commitment does not hash to the versioned hash");
|
|
583
|
+
let fs: ChannelFrame[];
|
|
584
|
+
try {
|
|
585
|
+
fs = parseFrames(decodeOpBlob(bytes));
|
|
586
|
+
} catch (e) {
|
|
587
|
+
return fail(name, `commitment matches, but the blob does not decode (${(e as Error).message})`);
|
|
588
|
+
}
|
|
589
|
+
frames.push(...fs);
|
|
590
|
+
decodedBlobs.push(vh);
|
|
591
|
+
pass(name, `${short(vh)}: KZG commitment matches; ${fs.length} frame${fs.length === 1 ? "" : "s"} (${fs.map((f) => `${short(f.channelId)}#${f.frameNumber}${f.isLast ? " last" : ""}`).join(", ")})`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 2. The channel's frames, 0 upward, whole or as a prefix.
|
|
595
|
+
const ids = [...new Set(frames.map((f) => f.channelId))];
|
|
596
|
+
const wanted = pointer.channel?.id?.toLowerCase();
|
|
597
|
+
let id: string;
|
|
598
|
+
if (wanted) {
|
|
599
|
+
if (!ids.includes(wanted)) return fail("channel", `no supplied blob carries a frame of channel ${short(wanted)}`);
|
|
600
|
+
id = wanted;
|
|
601
|
+
} else if (ids.length === 1) {
|
|
602
|
+
id = ids[0]!;
|
|
603
|
+
} else {
|
|
604
|
+
const withZero = ids.filter((c) => frames.some((f) => f.channelId === c && f.frameNumber === 0));
|
|
605
|
+
if (withZero.length !== 1) return fail("channel", `the blobs carry frames of ${ids.length} channels and the pointer names none`);
|
|
606
|
+
id = withZero[0]!;
|
|
607
|
+
}
|
|
608
|
+
const byNumber = new Map<number, ChannelFrame>();
|
|
609
|
+
for (const f of frames.filter((x) => x.channelId === id)) {
|
|
610
|
+
if (byNumber.has(f.frameNumber)) return fail("channel", `frame ${f.frameNumber} appears twice`);
|
|
611
|
+
byNumber.set(f.frameNumber, f);
|
|
612
|
+
}
|
|
613
|
+
if (!byNumber.has(0)) return fail("channel", `frame 0 of channel ${short(id)} is not among the supplied blobs, so the stream cannot be decoded (frames present: ${[...byNumber.keys()].sort((a, b) => a - b).join(", ")})`);
|
|
614
|
+
const ordered: ChannelFrame[] = [];
|
|
615
|
+
for (let i = 0; byNumber.has(i); i++) ordered.push(byNumber.get(i)!);
|
|
616
|
+
const lastPresent = ordered[ordered.length - 1]!;
|
|
617
|
+
const complete = lastPresent.isLast && byNumber.size === ordered.length;
|
|
618
|
+
if (complete && ordered.filter((f) => f.isLast).length !== 1) return fail("channel", "is_last is set on more than one frame");
|
|
619
|
+
if (!complete && ordered.some((f) => f.isLast)) return fail("channel", "a frame before the last one has is_last set");
|
|
620
|
+
const framesTotal = complete ? ordered.length : (typeof pointer.channel?.frames === "number" ? pointer.channel.frames : null);
|
|
621
|
+
pass("channel", complete
|
|
622
|
+
? `${short(id)}: all ${ordered.length} frames present, one is_last`
|
|
623
|
+
: `${short(id)}: frames 0..${ordered.length - 1}${framesTotal ? ` of ${framesTotal}` : ""} present (a prefix)`);
|
|
624
|
+
|
|
625
|
+
// 3. Decompress and read the batches.
|
|
626
|
+
const joined = new Uint8Array(ordered.reduce((n, f) => n + f.frameData.length, 0));
|
|
627
|
+
let o = 0;
|
|
628
|
+
for (const f of ordered) { joined.set(f.frameData, o); o += f.frameData.length; }
|
|
629
|
+
let decomp: { compression: "brotli" | "zlib"; bytes: Uint8Array };
|
|
630
|
+
try {
|
|
631
|
+
decomp = decompressChannel(joined, complete);
|
|
632
|
+
} catch (e) {
|
|
633
|
+
return fail("decompress", (e as Error).message);
|
|
634
|
+
}
|
|
635
|
+
if (pointer.channel?.compression && pointer.channel.compression !== decomp.compression) return fail("decompress", `the channel is ${decomp.compression}, the pointer says ${pointer.channel.compression}`);
|
|
636
|
+
let batches: DecodedBatch[];
|
|
637
|
+
try {
|
|
638
|
+
batches = decodeBatches(decomp.bytes, complete, rollup, chainId);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
return fail("batches", (e as Error).message);
|
|
641
|
+
}
|
|
642
|
+
const blocks = batches.flatMap((b) => b.blocks);
|
|
643
|
+
if (blocks.length === 0) return fail("batches", "no batch is complete in the decoded bytes");
|
|
644
|
+
const completeBlocks = blocks.filter((b) => b.complete).map((b) => b.number);
|
|
645
|
+
pass("decompress", `${decomp.compression}, ${decomp.bytes.length.toLocaleString("en-US")} bytes${complete ? "" : " (prefix)"}`);
|
|
646
|
+
pass("batches", `${batches.length} batch${batches.length === 1 ? "" : "es"}, Base blocks #${blocks[0]!.number}..#${blocks[blocks.length - 1]!.number}${blocks[blocks.length - 1]!.complete ? "" : " (the last one cut)"}`);
|
|
647
|
+
|
|
648
|
+
// 4. The Base block.
|
|
649
|
+
const target = blocks.find((b) => b.number === pointer.baseBlockNumber);
|
|
650
|
+
if (!target) return fail("block", `Base block ${pointer.baseBlockNumber} is not among the decoded batches`);
|
|
651
|
+
const expectParent = options.expect?.baseParentHash?.toLowerCase();
|
|
652
|
+
if (expectParent && target.parentHash && target.parentHash !== expectParent) return fail("block", `the batch's parent_hash is ${short(target.parentHash)}, the ceiling's header says ${short(expectParent)}`);
|
|
653
|
+
const next = blocks.find((b) => b.number === pointer.baseBlockNumber + 1);
|
|
654
|
+
const hashBound = next?.parentHash ? next.parentHash === pointer.baseBlockHash.toLowerCase() : null;
|
|
655
|
+
if (hashBound === false) return fail("block", `the next batch's parent_hash is ${short(next!.parentHash!)}, not this block's hash ${short(pointer.baseBlockHash)}`);
|
|
656
|
+
pass("block", `Base block ${target.number} at ${target.timestamp}${target.parentHash ? `, parent ${short(target.parentHash)}${expectParent ? " as the ceiling's header says" : ""}` : ""}${hashBound ? "; the next batch's parent_hash is this block's hash" : ""}${target.complete ? `, ${target.txs.length} transactions` : `, ${target.txs.length} transactions read before the cut`}`);
|
|
657
|
+
const channel = {
|
|
658
|
+
id, framesDecoded: ordered.length, framesTotal, complete, compression: decomp.compression, decodedBytes: decomp.bytes.length, baseBlocks: completeBlocks,
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// 5. The ceiling transaction, its hash recomputed from the batch's bytes.
|
|
662
|
+
const txHash = (options.expect?.txHash ?? pointer.located?.txHash)?.toLowerCase();
|
|
663
|
+
if (!txHash) return { ok: true, checks, channel };
|
|
664
|
+
const index = target.txs.findIndex((t) => t.hash === txHash);
|
|
665
|
+
if (index < 0) {
|
|
666
|
+
return fail("transaction", target.complete
|
|
667
|
+
? `${short(txHash)} is not among the ${target.txs.length} transactions of the batch`
|
|
668
|
+
: `${short(txHash)} is not among the ${target.txs.length} transactions readable before the cut; more frames are needed`);
|
|
669
|
+
}
|
|
670
|
+
const tx = target.txs[index]!;
|
|
671
|
+
if (pointer.located && pointer.located.baseTxIndex !== index) return fail("transaction", `located at index ${index}, the pointer says ${pointer.located.baseTxIndex}`);
|
|
672
|
+
const expectRaw = options.expect?.rawTx?.toLowerCase();
|
|
673
|
+
if (expectRaw && bytesToHex(tx.raw) !== expectRaw) return fail("transaction", "the bytes in the batch are not the ceiling's rawTx");
|
|
674
|
+
pass("transaction", `${short(txHash)} at index ${index} of Base block ${target.number}, hash recomputed from the batch bytes${expectRaw ? ", bytes equal the ceiling's rawTx" : ""}`);
|
|
675
|
+
return {
|
|
676
|
+
ok: true,
|
|
677
|
+
checks,
|
|
678
|
+
channel,
|
|
679
|
+
located: {
|
|
680
|
+
baseBlockNumber: target.number,
|
|
681
|
+
baseBlockTimestamp: target.timestamp,
|
|
682
|
+
baseParentHash: target.parentHash,
|
|
683
|
+
baseTxIndex: index,
|
|
684
|
+
txHash,
|
|
685
|
+
rawTx: bytesToHex(tx.raw),
|
|
686
|
+
batchComplete: target.complete,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|