From f88280f9b6080720a52b1ca2b467576aced56fb5 Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Thu, 7 Aug 2025 16:59:29 +0000 Subject: [PATCH] fix: copy search params when calling beacon client Fix #15818 --- .../blob-sink/src/client/beacon_api.test.ts | 165 ++++++++++++++++++ .../blob-sink/src/client/beacon_api.ts | 42 +++++ yarn-project/blob-sink/src/client/http.ts | 50 ++---- 3 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 yarn-project/blob-sink/src/client/beacon_api.test.ts create mode 100644 yarn-project/blob-sink/src/client/beacon_api.ts diff --git a/yarn-project/blob-sink/src/client/beacon_api.test.ts b/yarn-project/blob-sink/src/client/beacon_api.test.ts new file mode 100644 index 000000000000..ef4dad880fdc --- /dev/null +++ b/yarn-project/blob-sink/src/client/beacon_api.test.ts @@ -0,0 +1,165 @@ +import { SecretValue } from '@aztec/foundation/config'; + +import { getBeaconNodeFetchOptions } from './beacon_api.js'; +import type { BlobSinkConfig } from './config.js'; + +describe('getBeaconNodeFetchOptions', () => { + const mockConfig: BlobSinkConfig = {}; + + describe('URL construction', () => { + it('should construct URL from string base URL', () => { + const result = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', mockConfig); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs'); + }); + + it('should handle base URLs with paths - absolute API replaces path', () => { + const result = getBeaconNodeFetchOptions('http://localhost:3000/base', '/api/v1/blobs', mockConfig); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs'); + }); + + it('should handle base URLs with paths - relative API appends to path', () => { + const result = getBeaconNodeFetchOptions('http://localhost:3000/base/', 'api/v1/blobs', mockConfig); + expect(result.url.href).toBe('http://localhost:3000/base/api/v1/blobs'); + }); + }); + + describe('search params preservation', () => { + it('should preserve search params from string base URL', () => { + const result = getBeaconNodeFetchOptions( + 'http://localhost:3000?existing=value&another=param', + '/api/v1/blobs', + mockConfig, + ); + + expect(result.url.searchParams.get('existing')).toBe('value'); + expect(result.url.searchParams.get('another')).toBe('param'); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs?existing=value&another=param'); + }); + }); + + describe('API key as query parameter', () => { + it('should add API key as query parameter when no header is specified', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('test-api-key')], + }; + + const result = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 0); + + expect(result.url.searchParams.get('key')).toBe('test-api-key'); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs?key=test-api-key'); + expect(result.headers).toBeUndefined(); + }); + + it('should add API key to existing search params', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('test-api-key')], + }; + + const result = getBeaconNodeFetchOptions('http://localhost:3000?existing=value', '/api/v1/blobs', config, 0); + + expect(result.url.searchParams.get('existing')).toBe('value'); + expect(result.url.searchParams.get('key')).toBe('test-api-key'); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs?existing=value&key=test-api-key'); + }); + + it('should use correct API key based on index', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [ + new SecretValue('first-key'), + new SecretValue('second-key'), + new SecretValue('third-key'), + ], + }; + + const result1 = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 0); + expect(result1.url.searchParams.get('key')).toBe('first-key'); + + const result2 = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 1); + expect(result2.url.searchParams.get('key')).toBe('second-key'); + + const result3 = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 2); + expect(result3.url.searchParams.get('key')).toBe('third-key'); + }); + }); + + describe('API key as header', () => { + it('should add API key as header when header name is specified', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('test-api-key')], + l1ConsensusHostApiKeyHeaders: ['X-API-Key'], + }; + + const result = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 0); + + expect(result.url.searchParams.has('key')).toBe(false); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs'); + expect(result.headers).toEqual({ + 'X-API-Key': 'test-api-key', + }); + }); + + it('should use correct header name and API key based on index', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('first-key'), new SecretValue('second-key')], + l1ConsensusHostApiKeyHeaders: ['X-API-Key-1', 'X-API-Key-2'], + }; + + const result1 = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 0); + expect(result1.headers).toEqual({ + 'X-API-Key-1': 'first-key', + }); + + const result2 = getBeaconNodeFetchOptions('http://localhost:3000', '/api/v1/blobs', config, 1); + expect(result2.headers).toEqual({ + 'X-API-Key-2': 'second-key', + }); + }); + + it('should preserve existing search params when using headers', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('test-api-key')], + l1ConsensusHostApiKeyHeaders: ['Authorization'], + }; + + const result = getBeaconNodeFetchOptions('http://localhost:3000?existing=value', '/api/v1/blobs', config, 0); + + expect(result.url.searchParams.get('existing')).toBe('value'); + expect(result.url.searchParams.has('key')).toBe(false); + expect(result.url.href).toBe('http://localhost:3000/api/v1/blobs?existing=value'); + expect(result.headers).toEqual({ + Authorization: 'test-api-key', + }); + }); + }); + + describe('edge cases', () => { + it('should handle URLs with special characters', () => { + const result = getBeaconNodeFetchOptions( + 'http://localhost:3000?query=hello%20world', + '/api/v1/blobs', + mockConfig, + ); + + expect(result.url.searchParams.get('query')).toBe('hello world'); + }); + + it('should handle complex URL combinations', () => { + const config: BlobSinkConfig = { + l1ConsensusHostApiKeys: [new SecretValue('complex-key')], + l1ConsensusHostApiKeyHeaders: ['Authorization'], + }; + + const result = getBeaconNodeFetchOptions( + 'https://api.example.com:8080/base?existing=value&another=test', + '/beacon/api/v1/blobs', + config, + 0, + ); + + expect(result.url.href).toBe('https://api.example.com:8080/beacon/api/v1/blobs?existing=value&another=test'); + expect(result.headers).toEqual({ + Authorization: 'complex-key', + }); + }); + }); +}); diff --git a/yarn-project/blob-sink/src/client/beacon_api.ts b/yarn-project/blob-sink/src/client/beacon_api.ts new file mode 100644 index 000000000000..684e0b22d26d --- /dev/null +++ b/yarn-project/blob-sink/src/client/beacon_api.ts @@ -0,0 +1,42 @@ +import type { BlobSinkConfig } from './config.js'; + +export function getBeaconNodeFetchOptions( + _baseUrl: string | URL, + api: string, + config: BlobSinkConfig, + l1ConsensusHostIndex?: number, +): { + url: URL; + headers?: Record; +} { + const { l1ConsensusHostApiKeys, l1ConsensusHostApiKeyHeaders } = config; + const l1ConsensusHostApiKey = + l1ConsensusHostIndex !== undefined && l1ConsensusHostApiKeys && l1ConsensusHostApiKeys[l1ConsensusHostIndex]; + const l1ConsensusHostApiKeyHeader = + l1ConsensusHostIndex !== undefined && + l1ConsensusHostApiKeyHeaders && + l1ConsensusHostApiKeyHeaders[l1ConsensusHostIndex]; + + const baseUrl = typeof _baseUrl === 'string' ? new URL(_baseUrl) : _baseUrl; + const url = new URL(api, baseUrl); + + if (baseUrl.searchParams.size > 0) { + for (const [key, value] of baseUrl.searchParams.entries()) { + url.searchParams.append(key, value); + } + } + + if (l1ConsensusHostApiKey && !l1ConsensusHostApiKeyHeader) { + url.searchParams.set('key', l1ConsensusHostApiKey.getValue()); + } + + return { + url, + ...(l1ConsensusHostApiKey && + l1ConsensusHostApiKeyHeader && { + headers: { + [l1ConsensusHostApiKeyHeader]: l1ConsensusHostApiKey.getValue(), + }, + }), + }; +} diff --git a/yarn-project/blob-sink/src/client/http.ts b/yarn-project/blob-sink/src/client/http.ts index efd2462a5bbf..4e64c9d31fb6 100644 --- a/yarn-project/blob-sink/src/client/http.ts +++ b/yarn-project/blob-sink/src/client/http.ts @@ -9,6 +9,7 @@ import { createBlobArchiveClient } from '../archive/factory.js'; import type { BlobArchiveClient } from '../archive/interface.js'; import { outboundTransform } from '../encoding/index.js'; import { BlobWithIndex } from '../types/blob_with_index.js'; +import { getBeaconNodeFetchOptions } from './beacon_api.js'; import { type BlobSinkConfig, getBlobSinkConfigFromEnv } from './config.js'; import type { BlobSinkClientInterface } from './interface.js'; @@ -71,11 +72,12 @@ export class HttpBlobSinkClient implements BlobSinkClientInterface { const l1ConsensusHostUrl = l1ConsensusHostUrls[l1ConsensusHostIndex]; try { const { url, ...options } = getBeaconNodeFetchOptions( - `${l1ConsensusHostUrl}/eth/v1/beacon/headers`, + l1ConsensusHostUrl, + 'eth/v1/beacon/headers', this.config, l1ConsensusHostIndex, ); - const res = await this.fetch(url, options); + const res = await this.fetch(url.href, options); if (res.ok) { this.log.info(`L1 consensus host is reachable`, { l1ConsensusHostUrl }); successfulSourceCount++; @@ -299,22 +301,26 @@ export class HttpBlobSinkClient implements BlobSinkClientInterface { indices: number[], l1ConsensusHostIndex?: number, ): Promise { - let baseUrl = `${hostUrl}/eth/v1/beacon/blob_sidecars/${blockHashOrSlot}`; + let baseUrl = `eth/v1/beacon/blob_sidecars/${blockHashOrSlot}`; if (indices.length > 0) { baseUrl += `?indices=${indices.join(',')}`; } - const { url, ...options } = getBeaconNodeFetchOptions(baseUrl, this.config, l1ConsensusHostIndex); + const { url, ...options } = getBeaconNodeFetchOptions(hostUrl, baseUrl, this.config, l1ConsensusHostIndex); this.log.debug(`Fetching blob sidecar for ${blockHashOrSlot}`, { url, ...options }); - return this.fetch(url, options); + return this.fetch(url.href, options); } private async getLatestSlotNumber(hostUrl: string, l1ConsensusHostIndex?: number): Promise { try { - const baseUrl = `${hostUrl}/eth/v1/beacon/headers/head`; - const { url, ...options } = getBeaconNodeFetchOptions(baseUrl, this.config, l1ConsensusHostIndex); + const { url, ...options } = getBeaconNodeFetchOptions( + hostUrl, + 'eth/v1/beacon/headers/head', + this.config, + l1ConsensusHostIndex, + ); this.log.debug(`Fetching latest slot number`, { url, ...options }); - const res = await this.fetch(url, options); + const res = await this.fetch(url.href, options); if (res.ok) { const body = await res.json(); const slot = parseInt(body.data.header.message.slot); @@ -385,11 +391,12 @@ export class HttpBlobSinkClient implements BlobSinkClientInterface { l1ConsensusHostUrl = l1ConsensusHostUrls[l1ConsensusHostIndex]; try { const { url, ...options } = getBeaconNodeFetchOptions( + l1ConsensusHostUrl, `${l1ConsensusHostUrl}/eth/v1/beacon/headers/${parentBeaconBlockRoot}`, this.config, l1ConsensusHostIndex, ); - const res = await this.fetch(url, options); + const res = await this.fetch(url.href, options); if (res.ok) { const body = await res.json(); @@ -442,28 +449,3 @@ async function getRelevantBlobs( const maybeBlobs = await Promise.all(blobsPromise); return maybeBlobs.filter((b: BlobWithIndex | undefined): b is BlobWithIndex => b !== undefined); } - -function getBeaconNodeFetchOptions(url: string, config: BlobSinkConfig, l1ConsensusHostIndex?: number) { - const { l1ConsensusHostApiKeys, l1ConsensusHostApiKeyHeaders } = config; - const l1ConsensusHostApiKey = - l1ConsensusHostIndex !== undefined && l1ConsensusHostApiKeys && l1ConsensusHostApiKeys[l1ConsensusHostIndex]; - const l1ConsensusHostApiKeyHeader = - l1ConsensusHostIndex !== undefined && - l1ConsensusHostApiKeyHeaders && - l1ConsensusHostApiKeyHeaders[l1ConsensusHostIndex]; - - let formattedUrl = url; - if (l1ConsensusHostApiKey && !l1ConsensusHostApiKeyHeader) { - formattedUrl += `${formattedUrl.includes('?') ? '&' : '?'}key=${l1ConsensusHostApiKey.getValue()}`; - } - - return { - url: formattedUrl, - ...(l1ConsensusHostApiKey && - l1ConsensusHostApiKeyHeader && { - headers: { - [l1ConsensusHostApiKeyHeader]: l1ConsensusHostApiKey.getValue(), - }, - }), - }; -}