@aztec-labs/blob-client 6.0.0-nightly.20260829

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.
Files changed (96) hide show
  1. package/README.md +71 -0
  2. package/dest/archive/blobscan_archive_client.d.ts +62 -0
  3. package/dest/archive/blobscan_archive_client.d.ts.map +1 -0
  4. package/dest/archive/blobscan_archive_client.js +138 -0
  5. package/dest/archive/config.d.ts +7 -0
  6. package/dest/archive/config.d.ts.map +1 -0
  7. package/dest/archive/config.js +11 -0
  8. package/dest/archive/factory.d.ts +4 -0
  9. package/dest/archive/factory.d.ts.map +1 -0
  10. package/dest/archive/factory.js +7 -0
  11. package/dest/archive/index.d.ts +3 -0
  12. package/dest/archive/index.d.ts.map +1 -0
  13. package/dest/archive/index.js +2 -0
  14. package/dest/archive/instrumentation.d.ts +11 -0
  15. package/dest/archive/instrumentation.d.ts.map +1 -0
  16. package/dest/archive/instrumentation.js +33 -0
  17. package/dest/archive/interface.d.ts +13 -0
  18. package/dest/archive/interface.d.ts.map +1 -0
  19. package/dest/archive/interface.js +1 -0
  20. package/dest/blobstore/blob_store_test_suite.d.ts +3 -0
  21. package/dest/blobstore/blob_store_test_suite.d.ts.map +1 -0
  22. package/dest/blobstore/blob_store_test_suite.js +133 -0
  23. package/dest/blobstore/index.d.ts +3 -0
  24. package/dest/blobstore/index.d.ts.map +1 -0
  25. package/dest/blobstore/index.js +2 -0
  26. package/dest/blobstore/interface.d.ts +12 -0
  27. package/dest/blobstore/interface.d.ts.map +1 -0
  28. package/dest/blobstore/interface.js +1 -0
  29. package/dest/blobstore/memory_blob_store.d.ts +8 -0
  30. package/dest/blobstore/memory_blob_store.d.ts.map +1 -0
  31. package/dest/blobstore/memory_blob_store.js +24 -0
  32. package/dest/client/bin/index.d.ts +3 -0
  33. package/dest/client/bin/index.d.ts.map +1 -0
  34. package/dest/client/bin/index.js +30 -0
  35. package/dest/client/config.d.ts +60 -0
  36. package/dest/client/config.d.ts.map +1 -0
  37. package/dest/client/config.js +75 -0
  38. package/dest/client/factory.d.ts +38 -0
  39. package/dest/client/factory.d.ts.map +1 -0
  40. package/dest/client/factory.js +62 -0
  41. package/dest/client/http.d.ts +87 -0
  42. package/dest/client/http.d.ts.map +1 -0
  43. package/dest/client/http.js +698 -0
  44. package/dest/client/index.d.ts +6 -0
  45. package/dest/client/index.d.ts.map +1 -0
  46. package/dest/client/index.js +5 -0
  47. package/dest/client/interface.d.ts +37 -0
  48. package/dest/client/interface.d.ts.map +1 -0
  49. package/dest/client/interface.js +1 -0
  50. package/dest/client/local.d.ts +13 -0
  51. package/dest/client/local.d.ts.map +1 -0
  52. package/dest/client/local.js +19 -0
  53. package/dest/client/tests.d.ts +11 -0
  54. package/dest/client/tests.d.ts.map +1 -0
  55. package/dest/client/tests.js +51 -0
  56. package/dest/encoding/index.d.ts +15 -0
  57. package/dest/encoding/index.d.ts.map +1 -0
  58. package/dest/encoding/index.js +19 -0
  59. package/dest/filestore/factory.d.ts +51 -0
  60. package/dest/filestore/factory.d.ts.map +1 -0
  61. package/dest/filestore/factory.js +67 -0
  62. package/dest/filestore/filestore_blob_client.d.ts +67 -0
  63. package/dest/filestore/filestore_blob_client.d.ts.map +1 -0
  64. package/dest/filestore/filestore_blob_client.js +115 -0
  65. package/dest/filestore/healthcheck.d.ts +5 -0
  66. package/dest/filestore/healthcheck.d.ts.map +1 -0
  67. package/dest/filestore/healthcheck.js +3 -0
  68. package/dest/filestore/index.d.ts +3 -0
  69. package/dest/filestore/index.d.ts.map +1 -0
  70. package/dest/filestore/index.js +2 -0
  71. package/package.json +93 -0
  72. package/src/archive/blobscan_archive_client.ts +175 -0
  73. package/src/archive/config.ts +14 -0
  74. package/src/archive/factory.ts +11 -0
  75. package/src/archive/fixtures/blobscan_get_blob_data.json +1 -0
  76. package/src/archive/fixtures/blobscan_get_block.json +56 -0
  77. package/src/archive/index.ts +2 -0
  78. package/src/archive/instrumentation.ts +50 -0
  79. package/src/archive/interface.ts +9 -0
  80. package/src/blobstore/blob_store_test_suite.ts +110 -0
  81. package/src/blobstore/index.ts +2 -0
  82. package/src/blobstore/interface.ts +12 -0
  83. package/src/blobstore/memory_blob_store.ts +31 -0
  84. package/src/client/bin/index.ts +35 -0
  85. package/src/client/config.ts +152 -0
  86. package/src/client/factory.ts +100 -0
  87. package/src/client/http.ts +838 -0
  88. package/src/client/index.ts +5 -0
  89. package/src/client/interface.ts +38 -0
  90. package/src/client/local.ts +30 -0
  91. package/src/client/tests.ts +61 -0
  92. package/src/encoding/index.ts +21 -0
  93. package/src/filestore/factory.ts +150 -0
  94. package/src/filestore/filestore_blob_client.ts +149 -0
  95. package/src/filestore/healthcheck.ts +5 -0
  96. package/src/filestore/index.ts +2 -0
@@ -0,0 +1,698 @@
1
+ import { Blob, computeEthVersionedBlobHash } from '@aztec-labs/blob-lib';
2
+ import { makeL1HttpTransport } from '@aztec-labs/ethereum/client';
3
+ import { shuffle } from '@aztec-labs/foundation/array';
4
+ import { createLogger } from '@aztec-labs/foundation/log';
5
+ import { makeBackoff, retry } from '@aztec-labs/foundation/retry';
6
+ import { bufferToHex, hexToBuffer } from '@aztec-labs/foundation/string';
7
+ import { createPublicClient } from 'viem';
8
+ import { createBlobArchiveClient } from '../archive/factory.js';
9
+ import { DEFAULT_HEALTHCHECK_UPLOAD_INTERVAL_MINUTES } from '../filestore/healthcheck.js';
10
+ import { getBlobClientConfigFromEnv } from './config.js';
11
+ export class HttpBlobClient {
12
+ opts;
13
+ log;
14
+ config;
15
+ archiveClient;
16
+ fetch;
17
+ fileStoreClients;
18
+ fileStoreUploadClient;
19
+ disabled;
20
+ healthcheckUploadIntervalId;
21
+ /** Cached beacon genesis time (seconds since Unix epoch). Fetched once at startup. */ beaconGenesisTime;
22
+ /** Cached beacon slot duration in seconds. Fetched once at startup. */ beaconSecondsPerSlot;
23
+ /** Indexes of consensus hosts that serve blob sidecars (supernodes). Populated by testSources(). */ superNodeHostIndexes;
24
+ constructor(config, opts = {}){
25
+ this.opts = opts;
26
+ this.disabled = false;
27
+ this.config = config ?? getBlobClientConfigFromEnv();
28
+ this.archiveClient = opts.archiveClient ?? createBlobArchiveClient(this.config);
29
+ this.log = opts.logger ?? createLogger('blob-client:client');
30
+ this.fileStoreClients = opts.fileStoreClients ?? [];
31
+ this.fileStoreUploadClient = opts.fileStoreUploadClient;
32
+ if (this.fileStoreUploadClient && !opts.onBlobsFetched) {
33
+ this.opts.onBlobsFetched = (blobs)=>{
34
+ this.uploadBlobsToFileStore(blobs);
35
+ };
36
+ }
37
+ this.fetch = async (...args)=>{
38
+ return await retry(()=>fetch(...args), // eslint-disable-next-line @typescript-eslint/no-base-to-string
39
+ `Fetching ${args[0]}`, makeBackoff([
40
+ 1,
41
+ 1,
42
+ 3
43
+ ]), this.log, /*failSilently=*/ true);
44
+ };
45
+ }
46
+ /**
47
+ * Upload fetched blobs to filestore (fire-and-forget).
48
+ * Called automatically when blobs are fetched from any source.
49
+ */ uploadBlobsToFileStore(blobs) {
50
+ if (!this.fileStoreUploadClient) {
51
+ return;
52
+ }
53
+ void this.fileStoreUploadClient.saveBlobs(blobs, true).catch((err)=>{
54
+ this.log.warn(`Failed to upload ${blobs.length} blobs to filestore`, err);
55
+ });
56
+ }
57
+ /**
58
+ * Disables or enables blob storage operations.
59
+ * When disabled, getBlobSidecar returns empty arrays and sendBlobsToFilestore returns false.
60
+ * Useful for testing scenarios where blob storage failure needs to be simulated.
61
+ * @param value - True to disable blob storage, false to enable
62
+ */ setDisabled(value) {
63
+ this.disabled = value;
64
+ this.log.info(`Blob storage ${value ? 'disabled' : 'enabled'}`);
65
+ }
66
+ async testSources() {
67
+ const { l1ConsensusHostUrls } = this.config;
68
+ const archiveUrl = this.archiveClient?.getBaseUrl();
69
+ this.log.info(`Testing configured blob sources`, {
70
+ l1ConsensusHostUrls,
71
+ archiveUrl
72
+ });
73
+ let consensusSuperNodes = 0;
74
+ let consensusNonSuperNodes = 0;
75
+ let archiveSources = 0;
76
+ let blobSinks = 0;
77
+ const detectedSuperNodes = new Set();
78
+ if (l1ConsensusHostUrls && l1ConsensusHostUrls.length > 0) {
79
+ for(let l1ConsensusHostIndex = 0; l1ConsensusHostIndex < l1ConsensusHostUrls.length; l1ConsensusHostIndex++){
80
+ const l1ConsensusHostUrl = l1ConsensusHostUrls[l1ConsensusHostIndex];
81
+ try {
82
+ const { url, ...options } = getBeaconNodeFetchOptions(`${l1ConsensusHostUrl}/eth/v1/beacon/headers/head`, this.config, l1ConsensusHostIndex);
83
+ const res = await this.fetch(url, options);
84
+ if (!res.ok) {
85
+ this.log.error(`Failure reaching L1 consensus host: ${res.statusText} (${res.status})`, {
86
+ l1ConsensusHostUrl
87
+ });
88
+ continue;
89
+ }
90
+ this.log.info(`L1 consensus host is reachable`, {
91
+ l1ConsensusHostUrl
92
+ });
93
+ // Check if the host serves blob sidecars (supernode/semi-supernode).
94
+ // Post-Fusaka (PeerDAS), non-supernode beacon nodes no longer serve the
95
+ // blob sidecar endpoint. A 200 response (even with an empty data array
96
+ // for a slot with no blobs) means the node supports serving blob sidecars.
97
+ const body = await res.json();
98
+ const headSlot = body?.data?.header?.message?.slot;
99
+ if (headSlot) {
100
+ const { url: blobUrl, ...blobOptions } = getBeaconNodeFetchOptions(`${l1ConsensusHostUrl}/eth/v1/beacon/blobs/${headSlot}`, this.config, l1ConsensusHostIndex);
101
+ const blobRes = await this.fetch(blobUrl, blobOptions);
102
+ if (blobRes.ok) {
103
+ this.log.info(`L1 consensus host serves blob sidecars (supernode)`, {
104
+ l1ConsensusHostUrl
105
+ });
106
+ detectedSuperNodes.add(l1ConsensusHostIndex);
107
+ consensusSuperNodes++;
108
+ } else {
109
+ this.log.info(`L1 consensus host does not serve blob sidecars, skipping for blob fetching`, {
110
+ l1ConsensusHostUrl
111
+ });
112
+ consensusNonSuperNodes++;
113
+ }
114
+ } else {
115
+ this.log.info(`L1 consensus host is reachable but could not determine head slot`, {
116
+ l1ConsensusHostUrl
117
+ });
118
+ consensusNonSuperNodes++;
119
+ }
120
+ } catch (err) {
121
+ this.log.error(`Error reaching L1 consensus host`, err, {
122
+ l1ConsensusHostUrl
123
+ });
124
+ }
125
+ }
126
+ }
127
+ this.superNodeHostIndexes = detectedSuperNodes;
128
+ if (this.archiveClient) {
129
+ try {
130
+ const latest = await this.archiveClient.getLatestBlock();
131
+ this.log.info(`Archive client is reachable and synced to L1 block ${latest.number}`, {
132
+ latest,
133
+ archiveUrl
134
+ });
135
+ archiveSources++;
136
+ } catch (err) {
137
+ this.log.error(`Error reaching archive client`, err, {
138
+ archiveUrl
139
+ });
140
+ }
141
+ }
142
+ if (this.fileStoreClients.length > 0) {
143
+ for (const fileStoreClient of this.fileStoreClients){
144
+ try {
145
+ const accessible = await fileStoreClient.testConnection();
146
+ if (accessible) {
147
+ this.log.info(`FileStore is reachable`, {
148
+ url: fileStoreClient.getBaseUrl()
149
+ });
150
+ blobSinks++;
151
+ } else {
152
+ this.log.warn(`FileStore is not accessible`, {
153
+ url: fileStoreClient.getBaseUrl()
154
+ });
155
+ }
156
+ } catch (err) {
157
+ this.log.error(`Error reaching filestore`, err, {
158
+ url: fileStoreClient.getBaseUrl()
159
+ });
160
+ }
161
+ }
162
+ }
163
+ // Emit a single summary after validating all sources
164
+ const successfulSourceCount = consensusSuperNodes + archiveSources + blobSinks;
165
+ let summary = `Blob client running with consensusSuperNodes=${consensusSuperNodes} archiveSources=${archiveSources} blobSinks=${blobSinks}`;
166
+ if (consensusNonSuperNodes > 0) {
167
+ summary += `. ${consensusNonSuperNodes} consensus client(s) ignored because they are not running in supernode or semi-supernode mode`;
168
+ }
169
+ if (successfulSourceCount === 0) {
170
+ if (this.config.blobAllowEmptySources) {
171
+ this.log.warn(summary);
172
+ } else {
173
+ throw new Error(summary);
174
+ }
175
+ } else if (consensusSuperNodes === 0) {
176
+ this.log.warn(summary);
177
+ } else {
178
+ this.log.info(summary);
179
+ }
180
+ }
181
+ async sendBlobsToFilestore(blobs) {
182
+ if (this.disabled) {
183
+ this.log.warn('Blob storage is disabled, not uploading blobs');
184
+ return false;
185
+ }
186
+ if (!this.fileStoreUploadClient) {
187
+ this.log.verbose('No filestore upload configured');
188
+ return false;
189
+ }
190
+ this.log.verbose(`Uploading ${blobs.length} blobs to filestore`);
191
+ try {
192
+ await this.fileStoreUploadClient.saveBlobs(blobs, true);
193
+ return true;
194
+ } catch (err) {
195
+ this.log.error('Failed to upload blobs to filestore', err);
196
+ return false;
197
+ }
198
+ }
199
+ /**
200
+ * Get the blob sidecar.
201
+ *
202
+ * Alternates between two primary sources (consensus and filestore) in a retry loop,
203
+ * then falls back to archive if blobs are still missing. The order of the primary
204
+ * sources is configurable via `blobPreferFilestores`.
205
+ *
206
+ * @param blockHash - The block hash
207
+ * @param blobHashes - The blob hashes to fetch
208
+ * @param opts - Options for slot resolution
209
+ * @returns The blobs
210
+ */ async getBlobSidecar(blockHash, blobHashes, opts) {
211
+ if (this.disabled) {
212
+ this.log.warn('Blob storage is disabled, returning empty blob sidecar');
213
+ return [];
214
+ }
215
+ // Accumulate blobs across sources, preserving order and handling duplicates
216
+ // resultBlobs[i] will contain the blob for blobHashes[i], or undefined if not yet found
217
+ const resultBlobs = new Array(blobHashes.length).fill(undefined);
218
+ // Helper to get missing blob hashes that we still need to fetch
219
+ const getMissingBlobHashes = ()=>blobHashes.map((bh, i)=>resultBlobs[i] === undefined ? bh : undefined).filter((bh)=>bh !== undefined);
220
+ // Return the result, ignoring any undefined ones
221
+ const getFilledBlobs = ()=>resultBlobs.filter((b)=>b !== undefined);
222
+ // Helper to fill in results from fetched blobs
223
+ const fillResults = async (fetchedBlobs)=>{
224
+ const blobs = await processFetchedBlobs(fetchedBlobs, blobHashes, this.log);
225
+ // Fill in any missing positions with matching blobs
226
+ for(let i = 0; i < blobHashes.length; i++){
227
+ if (resultBlobs[i] === undefined) {
228
+ resultBlobs[i] = blobs[i];
229
+ }
230
+ }
231
+ return getFilledBlobs();
232
+ };
233
+ // Fire callback when returning blobs (fire-and-forget)
234
+ const returnWithCallback = (blobs)=>{
235
+ if (blobs.length > 0 && this.opts.onBlobsFetched) {
236
+ void Promise.resolve().then(()=>this.opts.onBlobsFetched(blobs));
237
+ }
238
+ return blobs;
239
+ };
240
+ const ctx = {
241
+ blockHash,
242
+ blobHashes: blobHashes.map(bufferToHex)
243
+ };
244
+ // Lazily resolve the slot number — only resolved when consensus hosts are actually tried.
245
+ let slotNumber;
246
+ let slotResolved = false;
247
+ const getSlotNumber = async ()=>{
248
+ if (!slotResolved) {
249
+ slotNumber = await this.resolveSlotNumber(blockHash, opts);
250
+ slotResolved = true;
251
+ }
252
+ return slotNumber;
253
+ };
254
+ // Build the two source-try functions. The order depends on the config.
255
+ const tryConsensus = ()=>this.tryConsensusHosts(getSlotNumber, getMissingBlobHashes, fillResults, ctx);
256
+ const tryFilestores = ()=>this.tryFileStores(getMissingBlobHashes, fillResults, ctx);
257
+ const preferFilestores = this.config.blobPreferFilestores ?? false;
258
+ const [trySourceA, trySourceB] = preferFilestores ? [
259
+ tryFilestores,
260
+ tryConsensus
261
+ ] : [
262
+ tryConsensus,
263
+ tryFilestores
264
+ ];
265
+ // Historical sync: blobs should already exist, use shorter backoff for transient errors.
266
+ // Near-tip sync: blobs may still be uploading, use longer backoff for eventual consistency.
267
+ const isHistoricalSync = opts?.isHistoricalSync ?? false;
268
+ const backoff = isHistoricalSync ? [
269
+ 1,
270
+ 1
271
+ ] : [
272
+ 1,
273
+ 1,
274
+ 1,
275
+ 2,
276
+ 2
277
+ ];
278
+ // Retry loop: alternate between the two primary sources with backoff.
279
+ try {
280
+ await retry(async ()=>{
281
+ if (getMissingBlobHashes().length > 0) {
282
+ await trySourceA();
283
+ }
284
+ if (getMissingBlobHashes().length > 0) {
285
+ await trySourceB();
286
+ }
287
+ if (getMissingBlobHashes().length > 0) {
288
+ throw new Error('Still missing blobs after trying all primary sources');
289
+ }
290
+ }, 'blob retrieval', makeBackoff(backoff), this.log, true);
291
+ return returnWithCallback(getFilledBlobs());
292
+ } catch {
293
+ // Exhausted retries, continue to archive fallback
294
+ }
295
+ // Archive fallback
296
+ const missingAfterPrimary = getMissingBlobHashes();
297
+ if (missingAfterPrimary.length > 0 && this.archiveClient) {
298
+ const archiveCtx = {
299
+ archiveUrl: this.archiveClient.getBaseUrl(),
300
+ ...ctx
301
+ };
302
+ this.log.trace(`Attempting to get ${missingAfterPrimary.length} blobs from archive`, archiveCtx);
303
+ const allBlobs = await this.archiveClient.getBlobsFromBlock(blockHash);
304
+ if (!allBlobs) {
305
+ this.log.debug('No blobs found from archive client', archiveCtx);
306
+ } else {
307
+ this.log.trace(`Got ${allBlobs.length} blobs from archive client before filtering`, archiveCtx);
308
+ const result = await fillResults(allBlobs);
309
+ this.log.debug(`Got ${allBlobs.length} blobs from archive client (total: ${result.length}/${blobHashes.length})`, archiveCtx);
310
+ if (result.length === blobHashes.length) {
311
+ return returnWithCallback(result);
312
+ }
313
+ }
314
+ }
315
+ const result = getFilledBlobs();
316
+ if (result.length < blobHashes.length) {
317
+ this.log.warn(`Failed to fetch all blobs for ${blockHash} from all blob sources (got ${result.length}/${blobHashes.length})`, {
318
+ l1ConsensusHostUrls: this.config.l1ConsensusHostUrls,
319
+ archiveUrl: this.archiveClient?.getBaseUrl(),
320
+ fileStoreUrls: this.fileStoreClients.map((c)=>c.getBaseUrl())
321
+ });
322
+ }
323
+ return returnWithCallback(result);
324
+ }
325
+ /** Resolves the beacon slot number for the given block hash. Returns undefined if no consensus hosts. */ resolveSlotNumber(blockHash, opts) {
326
+ const { l1ConsensusHostUrls } = this.config;
327
+ if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
328
+ return undefined;
329
+ }
330
+ // If no supernodes, no point resolving the slot
331
+ if (this.superNodeHostIndexes && this.superNodeHostIndexes.size === 0) {
332
+ return undefined;
333
+ }
334
+ return this.getSlotNumber(blockHash, opts?.parentBeaconBlockRoot, opts?.l1BlockTimestamp);
335
+ }
336
+ /**
337
+ * Try all supernode consensus hosts for blob sidecars.
338
+ * Skips hosts that were detected as non-supernodes during testSources().
339
+ */ async tryConsensusHosts(getSlotNumber, getMissingBlobHashes, fillResults, ctx) {
340
+ const { l1ConsensusHostUrls } = this.config;
341
+ if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
342
+ return;
343
+ }
344
+ const slotNumber = await getSlotNumber();
345
+ if (!slotNumber) {
346
+ return;
347
+ }
348
+ for(let l1ConsensusHostIndex = 0; l1ConsensusHostIndex < l1ConsensusHostUrls.length; l1ConsensusHostIndex++){
349
+ const missingHashes = getMissingBlobHashes();
350
+ if (missingHashes.length === 0) {
351
+ break;
352
+ }
353
+ // Skip non-supernode hosts if we've already detected supernodes
354
+ if (this.superNodeHostIndexes && !this.superNodeHostIndexes.has(l1ConsensusHostIndex)) {
355
+ this.log.trace(`Skipping non-supernode consensus host`, {
356
+ l1ConsensusHostUrl: l1ConsensusHostUrls[l1ConsensusHostIndex]
357
+ });
358
+ continue;
359
+ }
360
+ const l1ConsensusHostUrl = l1ConsensusHostUrls[l1ConsensusHostIndex];
361
+ this.log.trace(`Attempting to get ${missingHashes.length} blobs from consensus host`, {
362
+ slotNumber,
363
+ l1ConsensusHostUrl,
364
+ ...ctx
365
+ });
366
+ const blobs = await this.getBlobsFromHost(l1ConsensusHostUrl, slotNumber, l1ConsensusHostIndex, missingHashes);
367
+ const result = await fillResults(blobs);
368
+ this.log.debug(`Got ${blobs.length} blobs from consensus host (total: ${result.length}/${ctx.blobHashes.length})`, {
369
+ slotNumber,
370
+ l1ConsensusHostUrl,
371
+ ...ctx
372
+ });
373
+ }
374
+ }
375
+ /**
376
+ * Try all filestores once (shuffled for load distribution).
377
+ * @param getMissingBlobHashes - Function to get remaining blob hashes to fetch
378
+ * @param fillResults - Callback to fill in results
379
+ * @param ctx - Logging context
380
+ */ async tryFileStores(getMissingBlobHashes, fillResults, ctx) {
381
+ // Shuffle clients for load distribution
382
+ const shuffledClients = [
383
+ ...this.fileStoreClients
384
+ ];
385
+ shuffle(shuffledClients);
386
+ for (const client of shuffledClients){
387
+ const blobHashes = getMissingBlobHashes();
388
+ if (blobHashes.length === 0) {
389
+ return; // All blobs found, no need to try more filestores
390
+ }
391
+ try {
392
+ const blobHashStrings = blobHashes.map((h)=>`0x${h.toString('hex')}`);
393
+ this.log.trace(`Attempting to get ${blobHashStrings.length} blobs from filestore`, {
394
+ url: client.getBaseUrl(),
395
+ ...ctx
396
+ });
397
+ const blobs = await client.getBlobsByHashes(blobHashStrings);
398
+ if (blobs.length > 0) {
399
+ const result = await fillResults(blobs);
400
+ this.log.debug(`Got ${blobs.length} blobs from filestore (total: ${result.length}/${ctx.blobHashes.length})`, {
401
+ url: client.getBaseUrl(),
402
+ ...ctx
403
+ });
404
+ }
405
+ } catch (err) {
406
+ this.log.warn(`Failed to fetch from filestore: ${err}`, {
407
+ url: client.getBaseUrl()
408
+ });
409
+ }
410
+ }
411
+ }
412
+ async getBlobSidecarFrom(hostUrl, blockHashOrSlot, blobHashes = [], l1ConsensusHostIndex) {
413
+ const blobs = await this.getBlobsFromHost(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes);
414
+ return (await processFetchedBlobs(blobs, blobHashes, this.log)).filter((b)=>b !== undefined);
415
+ }
416
+ async getBlobsFromHost(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes) {
417
+ try {
418
+ let res = await this.fetchBlobSidecars(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes);
419
+ if (res.ok) {
420
+ return await parseBlobJsonsFromResponse(await res.json(), this.log);
421
+ }
422
+ if (res.status === 404 && typeof blockHashOrSlot === 'number') {
423
+ const latestSlot = await this.getLatestSlotNumber(hostUrl, l1ConsensusHostIndex);
424
+ this.log.debug(`Requested L1 slot ${blockHashOrSlot} not found, trying out slots up to ${latestSlot}`, {
425
+ hostUrl,
426
+ status: res.status,
427
+ statusText: res.statusText
428
+ });
429
+ let maxRetries = 10;
430
+ let currentSlot = blockHashOrSlot + 1;
431
+ while(res.status === 404 && maxRetries > 0 && latestSlot !== undefined && currentSlot <= latestSlot){
432
+ this.log.debug(`Trying slot ${currentSlot}`);
433
+ res = await this.fetchBlobSidecars(hostUrl, currentSlot, l1ConsensusHostIndex, blobHashes);
434
+ if (res.ok) {
435
+ return await parseBlobJsonsFromResponse(await res.json(), this.log);
436
+ }
437
+ currentSlot++;
438
+ maxRetries--;
439
+ }
440
+ }
441
+ this.log.warn(`Unable to get blob sidecar for ${blockHashOrSlot}: ${res.statusText} (${res.status})`, {
442
+ status: res.status,
443
+ statusText: res.statusText,
444
+ body: await res.text().catch(()=>'Failed to read response body')
445
+ });
446
+ return [];
447
+ } catch (error) {
448
+ this.log.warn(`Error getting blob sidecar from ${hostUrl}: ${error.message ?? error}`);
449
+ return [];
450
+ }
451
+ }
452
+ fetchBlobSidecars(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes) {
453
+ let baseUrl = `${hostUrl}/eth/v1/beacon/blobs/${blockHashOrSlot}`;
454
+ if (blobHashes && blobHashes.length > 0) {
455
+ const params = new URLSearchParams();
456
+ for (const hash of blobHashes){
457
+ params.append('versioned_hashes', `0x${hash.toString('hex')}`);
458
+ }
459
+ baseUrl += `?${params.toString()}`;
460
+ }
461
+ const { url, logSafeUrl, ...options } = getBeaconNodeFetchOptions(baseUrl, this.config, l1ConsensusHostIndex);
462
+ this.log.debug(`Fetching blob sidecar for ${blockHashOrSlot}`, {
463
+ url: logSafeUrl,
464
+ ...options
465
+ });
466
+ // No retry here — this is called inside the main retry loop in getBlobSidecar
467
+ return fetch(url, options);
468
+ }
469
+ async getLatestSlotNumber(hostUrl, l1ConsensusHostIndex) {
470
+ try {
471
+ const baseUrl = `${hostUrl}/eth/v1/beacon/headers/head`;
472
+ const { url, logSafeUrl, ...options } = getBeaconNodeFetchOptions(baseUrl, this.config, l1ConsensusHostIndex);
473
+ this.log.debug(`Fetching latest slot number`, {
474
+ url: logSafeUrl,
475
+ ...options
476
+ });
477
+ const res = await this.fetch(url, options);
478
+ if (res.ok) {
479
+ const body = await res.json();
480
+ const slot = parseInt(body.data.header.message.slot);
481
+ if (Number.isNaN(slot)) {
482
+ this.log.error(`Failed to parse slot number from response from ${hostUrl}`, {
483
+ body
484
+ });
485
+ return undefined;
486
+ }
487
+ return slot;
488
+ }
489
+ } catch (err) {
490
+ this.log.error(`Error getting latest slot number from ${hostUrl}`, err);
491
+ return undefined;
492
+ }
493
+ }
494
+ /**
495
+ * Get the slot number from the consensus host
496
+ * As of eip-4788, the parentBeaconBlockRoot is included in the execution layer.
497
+ * This allows us to query the consensus layer for the slot number of the parent block, which we will then use
498
+ * to request blobs from the consensus layer.
499
+ *
500
+ * If this returns undefined, it means that we are not connected to a real consensus host, and we should
501
+ * query blobs with the blockHash.
502
+ *
503
+ * If this returns a number, then we should query blobs with the slot number
504
+ *
505
+ * @param blockHash - The block hash
506
+ * @returns The slot number
507
+ */ async getSlotNumber(blockHash, parentBeaconBlockRoot, l1BlockTimestamp) {
508
+ const { l1ConsensusHostUrls, l1RpcUrls } = this.config;
509
+ if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
510
+ this.log.debug('No consensus host url configured');
511
+ return undefined;
512
+ }
513
+ // Primary path: compute slot from timestamp if genesis config is cached (no network call needed)
514
+ if (l1BlockTimestamp !== undefined && this.beaconGenesisTime !== undefined && this.beaconSecondsPerSlot !== undefined) {
515
+ const slot = Number((l1BlockTimestamp - this.beaconGenesisTime) / BigInt(this.beaconSecondsPerSlot));
516
+ this.log.debug(`Computed slot ${slot} from L1 block timestamp`, {
517
+ l1BlockTimestamp
518
+ });
519
+ return slot;
520
+ }
521
+ if (!parentBeaconBlockRoot) {
522
+ // parentBeaconBlockRoot not provided by caller — fetch it from the execution RPC
523
+ if (!l1RpcUrls || l1RpcUrls.length === 0) {
524
+ this.log.debug('No execution host url configured');
525
+ return undefined;
526
+ }
527
+ const client = createPublicClient({
528
+ transport: makeL1HttpTransport(l1RpcUrls, {
529
+ timeout: this.config.l1HttpTimeoutMS
530
+ })
531
+ });
532
+ try {
533
+ const res = await client.request({
534
+ method: 'eth_getBlockByHash',
535
+ params: [
536
+ blockHash,
537
+ /*tx flag*/ false
538
+ ]
539
+ });
540
+ if (res.parentBeaconBlockRoot) {
541
+ parentBeaconBlockRoot = res.parentBeaconBlockRoot;
542
+ }
543
+ } catch (err) {
544
+ this.log.error(`Error getting parent beacon block root`, err);
545
+ }
546
+ }
547
+ if (!parentBeaconBlockRoot) {
548
+ this.log.error(`No parent beacon block root found for block ${blockHash}`);
549
+ return undefined;
550
+ }
551
+ // Query beacon chain to get the slot number for that block root
552
+ let l1ConsensusHostUrl;
553
+ for(let l1ConsensusHostIndex = 0; l1ConsensusHostIndex < l1ConsensusHostUrls.length; l1ConsensusHostIndex++){
554
+ l1ConsensusHostUrl = l1ConsensusHostUrls[l1ConsensusHostIndex];
555
+ try {
556
+ const { url, ...options } = getBeaconNodeFetchOptions(`${l1ConsensusHostUrl}/eth/v1/beacon/headers/${parentBeaconBlockRoot}`, this.config, l1ConsensusHostIndex);
557
+ const res = await this.fetch(url, options);
558
+ if (res.ok) {
559
+ const body = await res.json();
560
+ // Add one to get the slot number of the original block hash
561
+ return Number(body.data.header.message.slot) + 1;
562
+ }
563
+ } catch (err) {
564
+ this.log.error(`Error getting slot number`, err);
565
+ }
566
+ }
567
+ return undefined;
568
+ }
569
+ /** @internal - exposed for testing */ getArchiveClient() {
570
+ return this.archiveClient;
571
+ }
572
+ /** Returns true if this client can upload blobs to filestore. */ canUpload() {
573
+ return this.fileStoreUploadClient !== undefined;
574
+ }
575
+ /**
576
+ * Start the blob client.
577
+ * Fetches and caches beacon genesis config for timestamp-based slot resolution,
578
+ * then uploads the initial healthcheck file (awaited) and starts periodic uploads.
579
+ */ async start() {
580
+ await this.fetchBeaconConfig();
581
+ if (!this.fileStoreUploadClient) {
582
+ return;
583
+ }
584
+ await this.fileStoreUploadClient.uploadHealthcheck();
585
+ this.log.debug('Initial healthcheck file uploaded');
586
+ this.startPeriodicHealthcheckUpload();
587
+ }
588
+ /**
589
+ * Start periodic healthcheck upload to the file store to ensure it remains available even if accidentally deleted.
590
+ */ startPeriodicHealthcheckUpload() {
591
+ const intervalMs = (this.config.blobHealthcheckUploadIntervalMinutes ?? DEFAULT_HEALTHCHECK_UPLOAD_INTERVAL_MINUTES) * 60 * 1000;
592
+ this.healthcheckUploadIntervalId = setInterval(()=>{
593
+ void this.fileStoreUploadClient.uploadHealthcheck().catch((err)=>{
594
+ this.log.warn('Failed to upload periodic healthcheck file', err);
595
+ });
596
+ }, intervalMs);
597
+ }
598
+ /**
599
+ * Fetches and caches beacon genesis time and slot duration from the first available consensus host.
600
+ * These static values enable timestamp-based slot resolution, eliminating the per-fetch headers call.
601
+ * Logs a warning and leaves fields undefined if all hosts fail, callers fall back gracefully.
602
+ */ async fetchBeaconConfig() {
603
+ const { l1ConsensusHostUrls } = this.config;
604
+ if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
605
+ return;
606
+ }
607
+ for(let i = 0; i < l1ConsensusHostUrls.length; i++){
608
+ try {
609
+ const { url: genesisUrl, ...genesisOptions } = getBeaconNodeFetchOptions(`${l1ConsensusHostUrls[i]}/eth/v1/config/genesis`, this.config, i);
610
+ const { url: specUrl, ...specOptions } = getBeaconNodeFetchOptions(`${l1ConsensusHostUrls[i]}/eth/v1/config/spec`, this.config, i);
611
+ const [genesisRes, specRes] = await Promise.all([
612
+ this.fetch(genesisUrl, genesisOptions),
613
+ this.fetch(specUrl, specOptions)
614
+ ]);
615
+ if (genesisRes.ok && specRes.ok) {
616
+ const genesis = await genesisRes.json();
617
+ const spec = await specRes.json();
618
+ this.beaconGenesisTime = BigInt(genesis.data.genesisTime);
619
+ this.beaconSecondsPerSlot = parseInt(spec.data.secondsPerSlot);
620
+ this.log.debug(`Fetched beacon genesis config`, {
621
+ genesisTime: this.beaconGenesisTime,
622
+ secondsPerSlot: this.beaconSecondsPerSlot
623
+ });
624
+ return;
625
+ }
626
+ } catch (err) {
627
+ this.log.warn(`Failed to fetch beacon config from host ${l1ConsensusHostUrls[i]}`, err);
628
+ }
629
+ }
630
+ this.log.warn('Could not fetch beacon genesis config from any consensus host — will use headers call fallback');
631
+ }
632
+ /**
633
+ * Stop the blob client, clearing any periodic tasks.
634
+ */ stop() {
635
+ if (this.healthcheckUploadIntervalId) {
636
+ clearInterval(this.healthcheckUploadIntervalId);
637
+ this.healthcheckUploadIntervalId = undefined;
638
+ }
639
+ }
640
+ }
641
+ async function parseBlobJsonsFromResponse(response, logger) {
642
+ try {
643
+ return await Promise.all(response.data.map(parseBlobJson));
644
+ } catch (err) {
645
+ logger.error(`Error parsing blob json from response`, err);
646
+ return [];
647
+ }
648
+ }
649
+ // Blobs will be in this form when requested from the blob client, or from the beacon chain via `getBlobSidecars`:
650
+ // https://ethereum.github.io/beacon-APIs/?urls.primaryName=dev#/Beacon/getBlobSidecars
651
+ // Here we attempt to parse the response data to Buffer, and check the lengths (via Blob's constructor), to avoid
652
+ // throwing an error down the line when calling Blob.fromJson().
653
+ async function parseBlobJson(rawHex) {
654
+ const blobBuffer = Buffer.from(rawHex.slice(2), 'hex');
655
+ const blob = await Blob.fromBlobBuffer(blobBuffer);
656
+ return blob.toJSON();
657
+ }
658
+ // Returns an array that maps each blob hash to the corresponding blob, or undefined if the blob is not found
659
+ // or the data does not match the commitment.
660
+ async function processFetchedBlobs(blobs, blobHashes, logger) {
661
+ const requestedBlobHashes = new Set(blobHashes.map(bufferToHex));
662
+ const hashToBlob = new Map();
663
+ for (const blobJson of blobs){
664
+ const hashHex = bufferToHex(computeEthVersionedBlobHash(hexToBuffer(blobJson.kzg_commitment)));
665
+ if (!requestedBlobHashes.has(hashHex) || hashToBlob.has(hashHex)) {
666
+ continue;
667
+ }
668
+ try {
669
+ const blob = await Blob.fromJson(blobJson);
670
+ hashToBlob.set(hashHex, blob);
671
+ } catch (err) {
672
+ // If the above throws, it's likely that the blob commitment does not match the hash of the blob data.
673
+ logger.error(`Error converting blob from json`, err);
674
+ }
675
+ }
676
+ return blobHashes.map((h)=>hashToBlob.get(bufferToHex(h)));
677
+ }
678
+ function getBeaconNodeFetchOptions(url, config, l1ConsensusHostIndex) {
679
+ const { l1ConsensusHostApiKeys, l1ConsensusHostApiKeyHeaders } = config;
680
+ const l1ConsensusHostApiKey = l1ConsensusHostIndex !== undefined && l1ConsensusHostApiKeys && l1ConsensusHostApiKeys[l1ConsensusHostIndex];
681
+ const l1ConsensusHostApiKeyHeader = l1ConsensusHostIndex !== undefined && l1ConsensusHostApiKeyHeaders && l1ConsensusHostApiKeyHeaders[l1ConsensusHostIndex];
682
+ let formattedUrl = url;
683
+ let logSafeUrl = url;
684
+ if (l1ConsensusHostApiKey && l1ConsensusHostApiKey.getValue() !== '' && !l1ConsensusHostApiKeyHeader) {
685
+ const separator = formattedUrl.includes('?') ? '&' : '?';
686
+ formattedUrl += `${separator}key=${l1ConsensusHostApiKey.getValue()}`;
687
+ logSafeUrl += `${separator}key=[REDACTED]`;
688
+ }
689
+ return {
690
+ url: formattedUrl,
691
+ logSafeUrl,
692
+ ...l1ConsensusHostApiKey && l1ConsensusHostApiKeyHeader && {
693
+ headers: {
694
+ [l1ConsensusHostApiKeyHeader]: l1ConsensusHostApiKey.getValue()
695
+ }
696
+ }
697
+ };
698
+ }