Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions yarn-project/blob-sink/src/client/beacon_api.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
});
42 changes: 42 additions & 0 deletions yarn-project/blob-sink/src/client/beacon_api.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
} {
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(),
},
}),
};
}
50 changes: 16 additions & 34 deletions yarn-project/blob-sink/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -299,22 +301,26 @@ export class HttpBlobSinkClient implements BlobSinkClientInterface {
indices: number[],
l1ConsensusHostIndex?: number,
): Promise<Response> {
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<number | undefined> {
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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(),
},
}),
};
}
Loading