@aztec/blob-client 0.0.1-commit.03f7ef2 → 0.0.1-commit.04852196a

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 (39) hide show
  1. package/README.md +10 -1
  2. package/dest/archive/config.js +1 -1
  3. package/dest/archive/instrumentation.d.ts +1 -1
  4. package/dest/archive/instrumentation.d.ts.map +1 -1
  5. package/dest/archive/instrumentation.js +13 -13
  6. package/dest/blobstore/blob_store_test_suite.js +9 -9
  7. package/dest/client/config.d.ts +5 -1
  8. package/dest/client/config.d.ts.map +1 -1
  9. package/dest/client/config.js +5 -0
  10. package/dest/client/factory.d.ts +3 -2
  11. package/dest/client/factory.d.ts.map +1 -1
  12. package/dest/client/factory.js +5 -2
  13. package/dest/client/http.d.ts +24 -2
  14. package/dest/client/http.d.ts.map +1 -1
  15. package/dest/client/http.js +133 -47
  16. package/dest/client/interface.d.ts +18 -1
  17. package/dest/client/interface.d.ts.map +1 -1
  18. package/dest/client/local.d.ts +3 -1
  19. package/dest/client/local.d.ts.map +1 -1
  20. package/dest/client/local.js +3 -0
  21. package/dest/client/tests.js +3 -3
  22. package/dest/filestore/filestore_blob_client.d.ts +14 -2
  23. package/dest/filestore/filestore_blob_client.d.ts.map +1 -1
  24. package/dest/filestore/filestore_blob_client.js +29 -5
  25. package/dest/filestore/healthcheck.d.ts +5 -0
  26. package/dest/filestore/healthcheck.d.ts.map +1 -0
  27. package/dest/filestore/healthcheck.js +3 -0
  28. package/package.json +8 -8
  29. package/src/archive/config.ts +1 -1
  30. package/src/archive/instrumentation.ts +22 -13
  31. package/src/blobstore/blob_store_test_suite.ts +9 -9
  32. package/src/client/config.ts +10 -0
  33. package/src/client/factory.ts +7 -2
  34. package/src/client/http.ts +175 -41
  35. package/src/client/interface.ts +17 -0
  36. package/src/client/local.ts +5 -0
  37. package/src/client/tests.ts +2 -2
  38. package/src/filestore/filestore_blob_client.ts +33 -5
  39. package/src/filestore/healthcheck.ts +5 -0
@@ -9,6 +9,7 @@ import { type RpcBlock, createPublicClient, fallback, http } from 'viem';
9
9
  import { createBlobArchiveClient } from '../archive/factory.js';
10
10
  import type { BlobArchiveClient } from '../archive/interface.js';
11
11
  import type { FileStoreBlobClient } from '../filestore/filestore_blob_client.js';
12
+ import { DEFAULT_HEALTHCHECK_UPLOAD_INTERVAL_MINUTES } from '../filestore/healthcheck.js';
12
13
  import { type BlobClientConfig, getBlobClientConfigFromEnv } from './config.js';
13
14
  import type { BlobClientInterface, GetBlobSidecarOptions } from './interface.js';
14
15
 
@@ -21,6 +22,12 @@ export class HttpBlobClient implements BlobClientInterface {
21
22
  protected readonly fileStoreUploadClient: FileStoreBlobClient | undefined;
22
23
 
23
24
  private disabled = false;
25
+ private healthcheckUploadIntervalId?: NodeJS.Timeout;
26
+
27
+ /** Cached beacon genesis time (seconds since Unix epoch). Fetched once at startup. */
28
+ private beaconGenesisTime?: bigint;
29
+ /** Cached beacon slot duration in seconds. Fetched once at startup. */
30
+ private beaconSecondsPerSlot?: number;
24
31
 
25
32
  constructor(
26
33
  config?: BlobClientConfig,
@@ -213,8 +220,8 @@ export class HttpBlobClient implements BlobClientInterface {
213
220
  const getFilledBlobs = (): Blob[] => resultBlobs.filter((b): b is Blob => b !== undefined);
214
221
 
215
222
  // Helper to fill in results from fetched blobs
216
- const fillResults = (fetchedBlobs: BlobJson[]): Blob[] => {
217
- const blobs = processFetchedBlobs(fetchedBlobs, blobHashes, this.log);
223
+ const fillResults = async (fetchedBlobs: BlobJson[]): Promise<Blob[]> => {
224
+ const blobs = await processFetchedBlobs(fetchedBlobs, blobHashes, this.log);
218
225
  // Fill in any missing positions with matching blobs
219
226
  for (let i = 0; i < blobHashes.length; i++) {
220
227
  if (resultBlobs[i] === undefined) {
@@ -249,7 +256,7 @@ export class HttpBlobClient implements BlobClientInterface {
249
256
  // The beacon api can query by slot number, so we get that first
250
257
  const consensusCtx = { l1ConsensusHostUrls, ...ctx };
251
258
  this.log.trace(`Attempting to get slot number for block hash`, consensusCtx);
252
- const slotNumber = await this.getSlotNumber(blockHash);
259
+ const slotNumber = await this.getSlotNumber(blockHash, opts?.parentBeaconBlockRoot, opts?.l1BlockTimestamp);
253
260
  this.log.debug(`Got slot number ${slotNumber} from consensus host for querying blobs`, consensusCtx);
254
261
 
255
262
  if (slotNumber) {
@@ -266,8 +273,13 @@ export class HttpBlobClient implements BlobClientInterface {
266
273
  l1ConsensusHostUrl,
267
274
  ...ctx,
268
275
  });
269
- const blobs = await this.getBlobsFromHost(l1ConsensusHostUrl, slotNumber, l1ConsensusHostIndex);
270
- const result = fillResults(blobs);
276
+ const blobs = await this.getBlobsFromHost(
277
+ l1ConsensusHostUrl,
278
+ slotNumber,
279
+ l1ConsensusHostIndex,
280
+ getMissingBlobHashes(),
281
+ );
282
+ const result = await fillResults(blobs);
271
283
  this.log.debug(
272
284
  `Got ${blobs.length} blobs from consensus host (total: ${result.length}/${blobHashes.length})`,
273
285
  { slotNumber, l1ConsensusHostUrl, ...ctx },
@@ -310,7 +322,7 @@ export class HttpBlobClient implements BlobClientInterface {
310
322
  this.log.debug('No blobs found from archive client', archiveCtx);
311
323
  } else {
312
324
  this.log.trace(`Got ${allBlobs.length} blobs from archive client before filtering`, archiveCtx);
313
- const result = fillResults(allBlobs);
325
+ const result = await fillResults(allBlobs);
314
326
  this.log.debug(
315
327
  `Got ${allBlobs.length} blobs from archive client (total: ${result.length}/${blobHashes.length})`,
316
328
  archiveCtx,
@@ -343,7 +355,7 @@ export class HttpBlobClient implements BlobClientInterface {
343
355
  */
344
356
  private async tryFileStores(
345
357
  getMissingBlobHashes: () => Buffer[],
346
- fillResults: (blobs: BlobJson[]) => Blob[],
358
+ fillResults: (blobs: BlobJson[]) => Promise<Blob[]>,
347
359
  ctx: { blockHash: string; blobHashes: string[] },
348
360
  ): Promise<void> {
349
361
  // Shuffle clients for load distribution
@@ -364,7 +376,7 @@ export class HttpBlobClient implements BlobClientInterface {
364
376
  });
365
377
  const blobs = await client.getBlobsByHashes(blobHashStrings);
366
378
  if (blobs.length > 0) {
367
- const result = fillResults(blobs);
379
+ const result = await fillResults(blobs);
368
380
  this.log.debug(
369
381
  `Got ${blobs.length} blobs from filestore (total: ${result.length}/${ctx.blobHashes.length})`,
370
382
  {
@@ -385,19 +397,20 @@ export class HttpBlobClient implements BlobClientInterface {
385
397
  blobHashes: Buffer[] = [],
386
398
  l1ConsensusHostIndex?: number,
387
399
  ): Promise<Blob[]> {
388
- const blobs = await this.getBlobsFromHost(hostUrl, blockHashOrSlot, l1ConsensusHostIndex);
389
- return processFetchedBlobs(blobs, blobHashes, this.log).filter((b): b is Blob => b !== undefined);
400
+ const blobs = await this.getBlobsFromHost(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes);
401
+ return (await processFetchedBlobs(blobs, blobHashes, this.log)).filter((b): b is Blob => b !== undefined);
390
402
  }
391
403
 
392
404
  public async getBlobsFromHost(
393
405
  hostUrl: string,
394
406
  blockHashOrSlot: string | number,
395
407
  l1ConsensusHostIndex?: number,
408
+ blobHashes?: Buffer[],
396
409
  ): Promise<BlobJson[]> {
397
410
  try {
398
- let res = await this.fetchBlobSidecars(hostUrl, blockHashOrSlot, l1ConsensusHostIndex);
411
+ let res = await this.fetchBlobSidecars(hostUrl, blockHashOrSlot, l1ConsensusHostIndex, blobHashes);
399
412
  if (res.ok) {
400
- return parseBlobJsonsFromResponse(await res.json(), this.log);
413
+ return await parseBlobJsonsFromResponse(await res.json(), this.log);
401
414
  }
402
415
 
403
416
  if (res.status === 404 && typeof blockHashOrSlot === 'number') {
@@ -412,9 +425,9 @@ export class HttpBlobClient implements BlobClientInterface {
412
425
  let currentSlot = blockHashOrSlot + 1;
413
426
  while (res.status === 404 && maxRetries > 0 && latestSlot !== undefined && currentSlot <= latestSlot) {
414
427
  this.log.debug(`Trying slot ${currentSlot}`);
415
- res = await this.fetchBlobSidecars(hostUrl, currentSlot, l1ConsensusHostIndex);
428
+ res = await this.fetchBlobSidecars(hostUrl, currentSlot, l1ConsensusHostIndex, blobHashes);
416
429
  if (res.ok) {
417
- return parseBlobJsonsFromResponse(await res.json(), this.log);
430
+ return await parseBlobJsonsFromResponse(await res.json(), this.log);
418
431
  }
419
432
  currentSlot++;
420
433
  maxRetries--;
@@ -437,8 +450,17 @@ export class HttpBlobClient implements BlobClientInterface {
437
450
  hostUrl: string,
438
451
  blockHashOrSlot: string | number,
439
452
  l1ConsensusHostIndex?: number,
453
+ blobHashes?: Buffer[],
440
454
  ): Promise<Response> {
441
- const baseUrl = `${hostUrl}/eth/v1/beacon/blob_sidecars/${blockHashOrSlot}`;
455
+ let baseUrl = `${hostUrl}/eth/v1/beacon/blobs/${blockHashOrSlot}`;
456
+
457
+ if (blobHashes && blobHashes.length > 0) {
458
+ const params = new URLSearchParams();
459
+ for (const hash of blobHashes) {
460
+ params.append('versioned_hashes', `0x${hash.toString('hex')}`);
461
+ }
462
+ baseUrl += `?${params.toString()}`;
463
+ }
442
464
 
443
465
  const { url, ...options } = getBeaconNodeFetchOptions(baseUrl, this.config, l1ConsensusHostIndex);
444
466
  this.log.debug(`Fetching blob sidecar for ${blockHashOrSlot}`, { url, ...options });
@@ -480,34 +502,50 @@ export class HttpBlobClient implements BlobClientInterface {
480
502
  * @param blockHash - The block hash
481
503
  * @returns The slot number
482
504
  */
483
- private async getSlotNumber(blockHash: `0x${string}`): Promise<number | undefined> {
505
+ private async getSlotNumber(
506
+ blockHash: `0x${string}`,
507
+ parentBeaconBlockRoot?: string,
508
+ l1BlockTimestamp?: bigint,
509
+ ): Promise<number | undefined> {
484
510
  const { l1ConsensusHostUrls, l1RpcUrls } = this.config;
485
511
  if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
486
512
  this.log.debug('No consensus host url configured');
487
513
  return undefined;
488
514
  }
489
515
 
490
- if (!l1RpcUrls || l1RpcUrls.length === 0) {
491
- this.log.debug('No execution host url configured');
492
- return undefined;
516
+ // Primary path: compute slot from timestamp if genesis config is cached (no network call needed)
517
+ if (
518
+ l1BlockTimestamp !== undefined &&
519
+ this.beaconGenesisTime !== undefined &&
520
+ this.beaconSecondsPerSlot !== undefined
521
+ ) {
522
+ const slot = Number((l1BlockTimestamp - this.beaconGenesisTime) / BigInt(this.beaconSecondsPerSlot));
523
+ this.log.debug(`Computed slot ${slot} from L1 block timestamp`, { l1BlockTimestamp });
524
+ return slot;
493
525
  }
494
526
 
495
- // Ping execution node to get the parentBeaconBlockRoot for this block
496
- let parentBeaconBlockRoot: string | undefined;
497
- const client = createPublicClient({
498
- transport: fallback(l1RpcUrls.map(url => http(url, { batch: false }))),
499
- });
500
- try {
501
- const res: RpcBlock = await client.request({
502
- method: 'eth_getBlockByHash',
503
- params: [blockHash, /*tx flag*/ false],
527
+ if (!parentBeaconBlockRoot) {
528
+ // parentBeaconBlockRoot not provided by caller — fetch it from the execution RPC
529
+ if (!l1RpcUrls || l1RpcUrls.length === 0) {
530
+ this.log.debug('No execution host url configured');
531
+ return undefined;
532
+ }
533
+
534
+ const client = createPublicClient({
535
+ transport: fallback(l1RpcUrls.map(url => http(url, { batch: false }))),
504
536
  });
537
+ try {
538
+ const res: RpcBlock = await client.request({
539
+ method: 'eth_getBlockByHash',
540
+ params: [blockHash, /*tx flag*/ false],
541
+ });
505
542
 
506
- if (res.parentBeaconBlockRoot) {
507
- parentBeaconBlockRoot = res.parentBeaconBlockRoot;
543
+ if (res.parentBeaconBlockRoot) {
544
+ parentBeaconBlockRoot = res.parentBeaconBlockRoot;
545
+ }
546
+ } catch (err) {
547
+ this.log.error(`Error getting parent beacon block root`, err);
508
548
  }
509
- } catch (err) {
510
- this.log.error(`Error getting parent beacon block root`, err);
511
549
  }
512
550
 
513
551
  if (!parentBeaconBlockRoot) {
@@ -545,12 +583,105 @@ export class HttpBlobClient implements BlobClientInterface {
545
583
  public getArchiveClient(): BlobArchiveClient | undefined {
546
584
  return this.archiveClient;
547
585
  }
586
+
587
+ /** Returns true if this client can upload blobs to filestore. */
588
+ public canUpload(): boolean {
589
+ return this.fileStoreUploadClient !== undefined;
590
+ }
591
+
592
+ /**
593
+ * Start the blob client.
594
+ * Fetches and caches beacon genesis config for timestamp-based slot resolution,
595
+ * then uploads the initial healthcheck file (awaited) and starts periodic uploads.
596
+ */
597
+ public async start(): Promise<void> {
598
+ await this.fetchBeaconConfig();
599
+
600
+ if (!this.fileStoreUploadClient) {
601
+ return;
602
+ }
603
+
604
+ await this.fileStoreUploadClient.uploadHealthcheck();
605
+ this.log.debug('Initial healthcheck file uploaded');
606
+
607
+ this.startPeriodicHealthcheckUpload();
608
+ }
609
+
610
+ /**
611
+ * Start periodic healthcheck upload to the file store to ensure it remains available even if accidentally deleted.
612
+ */
613
+ private startPeriodicHealthcheckUpload(): void {
614
+ const intervalMs =
615
+ (this.config.blobHealthcheckUploadIntervalMinutes ?? DEFAULT_HEALTHCHECK_UPLOAD_INTERVAL_MINUTES) * 60 * 1000;
616
+
617
+ this.healthcheckUploadIntervalId = setInterval(() => {
618
+ void this.fileStoreUploadClient!.uploadHealthcheck().catch(err => {
619
+ this.log.warn('Failed to upload periodic healthcheck file', err);
620
+ });
621
+ }, intervalMs);
622
+ }
623
+
624
+ /**
625
+ * Fetches and caches beacon genesis time and slot duration from the first available consensus host.
626
+ * These static values enable timestamp-based slot resolution, eliminating the per-fetch headers call.
627
+ * Logs a warning and leaves fields undefined if all hosts fail, callers fall back gracefully.
628
+ */
629
+ private async fetchBeaconConfig(): Promise<void> {
630
+ const { l1ConsensusHostUrls } = this.config;
631
+ if (!l1ConsensusHostUrls || l1ConsensusHostUrls.length === 0) {
632
+ return;
633
+ }
634
+
635
+ for (let i = 0; i < l1ConsensusHostUrls.length; i++) {
636
+ try {
637
+ const { url: genesisUrl, ...genesisOptions } = getBeaconNodeFetchOptions(
638
+ `${l1ConsensusHostUrls[i]}/eth/v1/config/genesis`,
639
+ this.config,
640
+ i,
641
+ );
642
+ const { url: specUrl, ...specOptions } = getBeaconNodeFetchOptions(
643
+ `${l1ConsensusHostUrls[i]}/eth/v1/config/spec`,
644
+ this.config,
645
+ i,
646
+ );
647
+
648
+ const [genesisRes, specRes] = await Promise.all([
649
+ this.fetch(genesisUrl, genesisOptions),
650
+ this.fetch(specUrl, specOptions),
651
+ ]);
652
+
653
+ if (genesisRes.ok && specRes.ok) {
654
+ const genesis = await genesisRes.json();
655
+ const spec = await specRes.json();
656
+ this.beaconGenesisTime = BigInt(genesis.data.genesisTime);
657
+ this.beaconSecondsPerSlot = parseInt(spec.data.secondsPerSlot);
658
+ this.log.debug(`Fetched beacon genesis config`, {
659
+ genesisTime: this.beaconGenesisTime,
660
+ secondsPerSlot: this.beaconSecondsPerSlot,
661
+ });
662
+ return;
663
+ }
664
+ } catch (err) {
665
+ this.log.warn(`Failed to fetch beacon config from host ${l1ConsensusHostUrls[i]}`, err);
666
+ }
667
+ }
668
+ this.log.warn('Could not fetch beacon genesis config from any consensus host — will use headers call fallback');
669
+ }
670
+
671
+ /**
672
+ * Stop the blob client, clearing any periodic tasks.
673
+ */
674
+ public stop(): void {
675
+ if (this.healthcheckUploadIntervalId) {
676
+ clearInterval(this.healthcheckUploadIntervalId);
677
+ this.healthcheckUploadIntervalId = undefined;
678
+ }
679
+ }
548
680
  }
549
681
 
550
- function parseBlobJsonsFromResponse(response: any, logger: Logger): BlobJson[] {
682
+ async function parseBlobJsonsFromResponse(response: any, logger: Logger): Promise<BlobJson[]> {
551
683
  try {
552
- const blobs = response.data.map(parseBlobJson);
553
- return blobs;
684
+ return await Promise.all((response.data as string[]).map(parseBlobJson));
554
685
  } catch (err) {
555
686
  logger.error(`Error parsing blob json from response`, err);
556
687
  return [];
@@ -561,16 +692,19 @@ function parseBlobJsonsFromResponse(response: any, logger: Logger): BlobJson[] {
561
692
  // https://ethereum.github.io/beacon-APIs/?urls.primaryName=dev#/Beacon/getBlobSidecars
562
693
  // Here we attempt to parse the response data to Buffer, and check the lengths (via Blob's constructor), to avoid
563
694
  // throwing an error down the line when calling Blob.fromJson().
564
- function parseBlobJson(data: any): BlobJson {
565
- const blobBuffer = Buffer.from(data.blob.slice(2), 'hex');
566
- const commitmentBuffer = Buffer.from(data.kzg_commitment.slice(2), 'hex');
567
- const blob = new Blob(blobBuffer, commitmentBuffer);
695
+ async function parseBlobJson(rawHex: string): Promise<BlobJson> {
696
+ const blobBuffer = Buffer.from(rawHex.slice(2), 'hex');
697
+ const blob = await Blob.fromBlobBuffer(blobBuffer);
568
698
  return blob.toJSON();
569
699
  }
570
700
 
571
701
  // Returns an array that maps each blob hash to the corresponding blob, or undefined if the blob is not found
572
702
  // or the data does not match the commitment.
573
- function processFetchedBlobs(blobs: BlobJson[], blobHashes: Buffer[], logger: Logger): (Blob | undefined)[] {
703
+ async function processFetchedBlobs(
704
+ blobs: BlobJson[],
705
+ blobHashes: Buffer[],
706
+ logger: Logger,
707
+ ): Promise<(Blob | undefined)[]> {
574
708
  const requestedBlobHashes = new Set<string>(blobHashes.map(bufferToHex));
575
709
  const hashToBlob = new Map<string, Blob>();
576
710
  for (const blobJson of blobs) {
@@ -580,7 +714,7 @@ function processFetchedBlobs(blobs: BlobJson[], blobHashes: Buffer[], logger: Lo
580
714
  }
581
715
 
582
716
  try {
583
- const blob = Blob.fromJson(blobJson);
717
+ const blob = await Blob.fromJson(blobJson);
584
718
  hashToBlob.set(hashHex, blob);
585
719
  } catch (err) {
586
720
  // If the above throws, it's likely that the blob commitment does not match the hash of the blob data.
@@ -11,6 +11,17 @@ export interface GetBlobSidecarOptions {
11
11
  * - Near tip: FileStore first with no retries (data should exist), L1 consensus second (freshest data), then FileStore with retries, then archive (eg. blobscan)
12
12
  */
13
13
  isHistoricalSync?: boolean;
14
+ /**
15
+ * The parent beacon block root for the L1 block containing the blobs.
16
+ * If provided, skips the eth_getBlockByHash execution RPC call inside getSlotNumber.
17
+ */
18
+ parentBeaconBlockRoot?: string;
19
+ /**
20
+ * The timestamp of the L1 execution block containing the blobs.
21
+ * When provided alongside a cached beacon genesis config (fetched at startup), allows computing
22
+ * the beacon slot directly via timestamp math, skipping the beacon headers network call entirely.
23
+ */
24
+ l1BlockTimestamp?: bigint;
14
25
  }
15
26
 
16
27
  export interface BlobClientInterface {
@@ -18,6 +29,12 @@ export interface BlobClientInterface {
18
29
  sendBlobsToFilestore(blobs: Blob[]): Promise<boolean>;
19
30
  /** Fetches the given blob sidecars by block hash and blob hashes. */
20
31
  getBlobSidecar(blockId: string, blobHashes?: Buffer[], opts?: GetBlobSidecarOptions): Promise<Blob[]>;
32
+ /** Starts the blob client (e.g., uploads healthcheck file if not exists). */
33
+ start?(): Promise<void>;
21
34
  /** Tests all configured blob sources and logs whether they are reachable or not. */
22
35
  testSources(): Promise<void>;
36
+ /** Stops the blob client, clearing any periodic tasks. */
37
+ stop?(): void;
38
+ /** Returns true if this client can upload blobs to filestore. */
39
+ canUpload(): boolean;
23
40
  }
@@ -22,4 +22,9 @@ export class LocalBlobClient implements BlobClientInterface {
22
22
  public getBlobSidecar(_blockId: string, blobHashes: Buffer[], _opts?: GetBlobSidecarOptions): Promise<Blob[]> {
23
23
  return this.blobStore.getBlobsByHashes(blobHashes);
24
24
  }
25
+
26
+ /** Returns true if this client can upload blobs. Always true for local client. */
27
+ public canUpload(): boolean {
28
+ return true;
29
+ }
25
30
  }
@@ -28,7 +28,7 @@ export function runBlobClientTests(
28
28
  });
29
29
 
30
30
  it('should send and retrieve blobs by hash', async () => {
31
- const blob = makeRandomBlob(5);
31
+ const blob = await makeRandomBlob(5);
32
32
  const blobHash = blob.getEthVersionedBlobHash();
33
33
 
34
34
  await client.sendBlobsToFilestore([blob]);
@@ -39,7 +39,7 @@ export function runBlobClientTests(
39
39
  });
40
40
 
41
41
  it('should handle multiple blobs', async () => {
42
- const blobs = Array.from({ length: 3 }, () => makeRandomBlob(7));
42
+ const blobs = await Promise.all(Array.from({ length: 3 }, () => makeRandomBlob(7)));
43
43
  const blobHashes = blobs.map(blob => blob.getEthVersionedBlobHash());
44
44
 
45
45
  await client.sendBlobsToFilestore(blobs);
@@ -3,6 +3,7 @@ import { type Logger, createLogger } from '@aztec/foundation/log';
3
3
  import type { FileStore, ReadOnlyFileStore } from '@aztec/stdlib/file-store';
4
4
 
5
5
  import { inboundTransform, outboundTransform } from '../encoding/index.js';
6
+ import { HEALTHCHECK_CONTENT, HEALTHCHECK_FILENAME } from './healthcheck.js';
6
7
 
7
8
  /**
8
9
  * A blob client that uses a FileStore (S3/GCS/local) as the data source.
@@ -27,6 +28,14 @@ export class FileStoreBlobClient {
27
28
  return `${this.basePath}/blobs/${versionedBlobHash}.data`;
28
29
  }
29
30
 
31
+ /**
32
+ * Get the path for the healthcheck file.
33
+ * Format: basePath/.healthcheck
34
+ */
35
+ private healthcheckPath(): string {
36
+ return `${this.basePath}/${HEALTHCHECK_FILENAME}`;
37
+ }
38
+
30
39
  /**
31
40
  * Fetch blobs by their versioned hashes.
32
41
  * @param blobHashes - Array of versioned blob hashes (0x-prefixed hex strings)
@@ -104,12 +113,31 @@ export class FileStoreBlobClient {
104
113
  }
105
114
 
106
115
  /**
107
- * Test if the filestore connection is working.
116
+ * Test if the filestore connection is working by checking for healthcheck file.
117
+ * The healthcheck file is uploaded periodically by writable clients via HttpBlobClient.start().
118
+ * This provides a uniform connection test across all store types (S3/GCS/Local/HTTP).
119
+ */
120
+ async testConnection(): Promise<boolean> {
121
+ try {
122
+ return await this.store.exists(this.healthcheckPath());
123
+ } catch (err: any) {
124
+ this.log.warn(`Connection test failed: ${err?.message ?? String(err)}`);
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Upload the healthcheck file if it doesn't already exist.
131
+ * This enables read-only clients (HTTP) to verify connectivity.
108
132
  */
109
- testConnection(): Promise<boolean> {
110
- // This implementation will be improved in a separate PR
111
- // Currently underlying filestore implementations do not expose an easy way to test connectivitiy
112
- return Promise.resolve(true);
133
+ async uploadHealthcheck(): Promise<void> {
134
+ if (!this.isWritable()) {
135
+ this.log.trace('Cannot upload healthcheck: store is read-only');
136
+ return;
137
+ }
138
+ const path = this.healthcheckPath();
139
+ await (this.store as FileStore).save(path, Buffer.from(HEALTHCHECK_CONTENT));
140
+ this.log.debug(`Uploaded healthcheck file to ${path}`);
113
141
  }
114
142
 
115
143
  /**
@@ -0,0 +1,5 @@
1
+ /** Constants for healthcheck file used to test file store connectivity. */
2
+
3
+ export const HEALTHCHECK_FILENAME = '.healthcheck';
4
+ export const HEALTHCHECK_CONTENT = 'ok';
5
+ export const DEFAULT_HEALTHCHECK_UPLOAD_INTERVAL_MINUTES = 60; // 1 hour