Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(blobs): blob sink #10079

Merged
merged 4 commits into from
Jan 4, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions yarn-project/blob-sink/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@aztec/foundation/eslint');
7 changes: 7 additions & 0 deletions yarn-project/blob-sink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Blob Sink

A HTTP api that emulated the https://ethereum.github.io/beacon-APIs/?urls.primaryName=dev#/Beacon/getBlobSidecars API.
Maddiaa0 marked this conversation as resolved.
Show resolved Hide resolved

## When is this used?

This service will run alongside end to end tests to capture the blob transactions that are sent alongside a `propose` transaction.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extending just a tiny bit here on why would be neat, just as it seems like it could be one of those "magic" things that people don't know why really exists.

84 changes: 84 additions & 0 deletions yarn-project/blob-sink/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"name": "@aztec/blob-sink",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./dest/index.js"
},
"inherits": [
"../package.common.json"
],
"scripts": {
"build": "yarn clean && tsc -b",
"build:dev": "tsc -b --watch",
"clean": "rm -rf ./dest .tsbuildinfo",
"formatting": "run -T prettier --check ./src && run -T eslint ./src",
"formatting:fix": "run -T eslint --fix ./src && run -T prettier -w ./src",
"test": "HARDWARE_CONCURRENCY=${HARDWARE_CONCURRENCY:-16} RAYON_NUM_THREADS=${RAYON_NUM_THREADS:-4} NODE_NO_WARNINGS=1 node --experimental-vm-modules ../node_modules/.bin/jest --passWithNoTests --maxWorkers=${JEST_MAX_WORKERS:-8}"
},
"jest": {
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.[cm]?js$": "$1"
},
"testRegex": "./src/.*\\.test\\.(js|mjs|ts)$",
"rootDir": "./src",
"transform": {
"^.+\\.tsx?$": [
"@swc/jest",
{
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"decoratorVersion": "2022-03"
}
}
}
]
},
"extensionsToTreatAsEsm": [
".ts"
],
"reporters": [
"default"
],
"testTimeout": 30000,
"setupFiles": [
"../../foundation/src/jest/setup.mjs"
]
},
"dependencies": {
"@aztec/circuit-types": "workspace:^",
"@aztec/foundation": "workspace:^",
"@aztec/kv-store": "workspace:*",
"@aztec/telemetry-client": "workspace:*",
"express": "^4.21.1",
"source-map-support": "^0.5.21",
"tslib": "^2.4.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
"@types/jest": "^29.5.0",
"@types/memdown": "^3.0.0",
"@types/node": "^18.7.23",
"@types/source-map-support": "^0.5.10",
"@types/supertest": "^6.0.2",
"jest": "^29.5.0",
"jest-mock-extended": "^3.0.3",
"supertest": "^7.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
},
"files": [
"dest",
"src",
"!*.test.*"
],
"types": "./dest/index.d.ts",
"engines": {
"node": ">=18"
}
}
91 changes: 91 additions & 0 deletions yarn-project/blob-sink/src/blob-sink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Blob } from '@aztec/foundation/blob';
import { Fr } from '@aztec/foundation/fields';

import request from 'supertest';

import { BlobSinkServer } from './server.js';

describe('BlobSinkService', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be tests here for the different types of blockId? e.g., genesis, finalized, slot, block hash, head

I think we could probably get away with not having every, but just that we should be very aware that it differs a bit then.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note, not supporting the different types as we wont use them

let service: BlobSinkServer;

beforeEach(async () => {
service = new BlobSinkServer({
port: 0, // Using port 0 lets the OS assign a random available port
});
await service.start();
});

afterEach(async () => {
await service.stop();
});

it('should store and retrieve a blob sidecar', async () => {
// Create a test blob
const testFields = [Fr.random(), Fr.random(), Fr.random()];
const blob = Blob.fromFields(testFields);
const blockId = '0x1234';

// Post the blob
const postResponse = await request(service.getApp())
.post('/blob_sidecar')
.send({
// eslint-disable-next-line camelcase
block_id: blockId,
blobs: [
{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably good to add at least 1 more index here.

index: 0,
blob: blob.toBuffer(),
},
],
});

expect(postResponse.status).toBe(200);

// Retrieve the blob
const getResponse = await request(service.getApp()).get(`/eth/v1/beacon/blob_sidecars/${blockId}`);

expect(getResponse.status).toBe(200);

// Convert the response blob back to a Blob object and verify it matches
const retrievedBlobs = getResponse.body.data;

const retrievedBlob = Blob.fromBuffer(Buffer.from(retrievedBlobs[0].blob, 'hex'));
expect(retrievedBlob.fieldsHash.toString()).toBe(blob.fieldsHash.toString());
expect(retrievedBlob.commitment.toString('hex')).toBe(blob.commitment.toString('hex'));
});

it('should return an error if the block ID is invalid (POST)', async () => {
const response = await request(service.getApp()).post('/blob_sidecar').send({
// eslint-disable-next-line camelcase
block_id: undefined,
});

expect(response.status).toBe(400);
});

it('should return an error if the block ID is invalid (GET)', async () => {
const response = await request(service.getApp()).get('/eth/v1/beacon/blob_sidecars/invalid-id');

expect(response.status).toBe(400);
});

it('should return 404 for non-existent blob', async () => {
const response = await request(service.getApp()).get('/eth/v1/beacon/blob_sidecars/0x999999');

expect(response.status).toBe(404);
});

it('should reject invalid block IDs', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this and should return an error if the block ID is invalid (GET) be one test? Seems like they are the same with an extra check here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

const response = await request(service.getApp()).get('/eth/v1/beacon/blob_sidecars/invalid-id');

expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid block_id parameter');
});

it('should reject negative block IDs', async () => {
const response = await request(service.getApp()).get('/eth/v1/beacon/blob_sidecars/-123');

expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid block_id parameter');
});
});
94 changes: 94 additions & 0 deletions yarn-project/blob-sink/src/blobstore/blob_store_test_suite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Blob } from '@aztec/foundation/blob';
import { Fr } from '@aztec/foundation/fields';

import { BlobWithIndex } from '../types/index.js';
import { type BlobStore } from './interface.js';

export function describeBlobStore(getBlobStore: () => BlobStore) {
let blobStore: BlobStore;

beforeEach(() => {
blobStore = getBlobStore();
});

it('should store and retrieve a blob', async () => {
// Create a test blob with random fields
const testFields = [Fr.random(), Fr.random(), Fr.random()];
const blob = Blob.fromFields(testFields);
const blockId = '12345';
Maddiaa0 marked this conversation as resolved.
Show resolved Hide resolved
const blobWithIndex = new BlobWithIndex(blob, 0);

// Store the blob
await blobStore.addBlobSidecars(blockId, [blobWithIndex]);

// Retrieve the blob
const retrievedBlobs = await blobStore.getBlobSidecars(blockId);
const [retrievedBlob] = retrievedBlobs!;

// Verify the blob was retrieved and matches
expect(retrievedBlob).toBeDefined();
expect(retrievedBlob.blob.fieldsHash.toString()).toBe(blob.fieldsHash.toString());
expect(retrievedBlob.blob.commitment.toString('hex')).toBe(blob.commitment.toString('hex'));
});

it('should return undefined for non-existent blob', async () => {
const nonExistentBlob = await blobStore.getBlobSidecars('999999');
expect(nonExistentBlob).toBeUndefined();
});

it('should handle multiple blobs with different block IDs', async () => {
// Create two different blobs
const blob1 = Blob.fromFields([Fr.random(), Fr.random()]);
const blob2 = Blob.fromFields([Fr.random(), Fr.random(), Fr.random()]);
const blobWithIndex1 = new BlobWithIndex(blob1, 0);
const blobWithIndex2 = new BlobWithIndex(blob2, 0);

// Store both blobs
await blobStore.addBlobSidecars('1', [blobWithIndex1]);
await blobStore.addBlobSidecars('2', [blobWithIndex2]);

// Retrieve and verify both blobs
const retrieved1 = await blobStore.getBlobSidecars('1');
const retrieved2 = await blobStore.getBlobSidecars('2');
const [retrievedBlob1] = retrieved1!;
const [retrievedBlob2] = retrieved2!;

expect(retrievedBlob1.blob.commitment.toString('hex')).toBe(blob1.commitment.toString('hex'));
expect(retrievedBlob2.blob.commitment.toString('hex')).toBe(blob2.commitment.toString('hex'));
});

it('should overwrite blob when using same block ID', async () => {
// Create two different blobs
const originalBlob = Blob.fromFields([Fr.random()]);
const newBlob = Blob.fromFields([Fr.random(), Fr.random()]);
const blockId = '1';
const originalBlobWithIndex = new BlobWithIndex(originalBlob, 0);
const newBlobWithIndex = new BlobWithIndex(newBlob, 0);

// Store original blob
await blobStore.addBlobSidecars(blockId, [originalBlobWithIndex]);

// Overwrite with new blob
await blobStore.addBlobSidecars(blockId, [newBlobWithIndex]);

// Retrieve and verify it's the new blob
const retrievedBlobs = await blobStore.getBlobSidecars(blockId);
const [retrievedBlob] = retrievedBlobs!;
expect(retrievedBlob.blob.commitment.toString('hex')).toBe(newBlob.commitment.toString('hex'));
expect(retrievedBlob.blob.commitment.toString('hex')).not.toBe(originalBlob.commitment.toString('hex'));
});

it('should handle multiple blobs with the same block ID', async () => {
const blob1 = Blob.fromFields([Fr.random()]);
const blob2 = Blob.fromFields([Fr.random()]);
const blobWithIndex1 = new BlobWithIndex(blob1, 0);
const blobWithIndex2 = new BlobWithIndex(blob2, 0);

await blobStore.addBlobSidecars('1', [blobWithIndex1, blobWithIndex2]);
const retrievedBlobs = await blobStore.getBlobSidecars('1');
const [retrievedBlob1, retrievedBlob2] = retrievedBlobs!;

expect(retrievedBlob1.blob.commitment.toString('hex')).toBe(blob1.commitment.toString('hex'));
expect(retrievedBlob2.blob.commitment.toString('hex')).toBe(blob2.commitment.toString('hex'));
});
}
8 changes: 8 additions & 0 deletions yarn-project/blob-sink/src/blobstore/disk_blob_store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { openTmpStore } from '@aztec/kv-store/lmdb';

import { describeBlobStore } from './blob_store_test_suite.js';
import { DiskBlobStore } from './disk_blob_store.js';

describe('DiskBlobStore', () => {
describeBlobStore(() => new DiskBlobStore(openTmpStore()));
});
25 changes: 25 additions & 0 deletions yarn-project/blob-sink/src/blobstore/disk_blob_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type AztecKVStore, type AztecMap } from '@aztec/kv-store';

import { type BlobWithIndex, BlobsWithIndexes } from '../types/index.js';
import { type BlobStore } from './interface.js';

export class DiskBlobStore implements BlobStore {
blobs: AztecMap<string, Buffer>;

constructor(store: AztecKVStore) {
this.blobs = store.openMap('blobs');
}

public getBlobSidecars(blockId: string): Promise<BlobWithIndex[] | undefined> {
const blobBuffer = this.blobs.get(`${blockId}`);
if (!blobBuffer) {
return Promise.resolve(undefined);
}
return Promise.resolve(BlobsWithIndexes.fromBuffer(blobBuffer).blobs);
}

public async addBlobSidecars(blockId: string, blobSidecars: BlobWithIndex[]): Promise<void> {
await this.blobs.set(blockId, new BlobsWithIndexes(blobSidecars).toBuffer());
return Promise.resolve();
}
}
3 changes: 3 additions & 0 deletions yarn-project/blob-sink/src/blobstore/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './memory_blob_store.js';
export * from './disk_blob_store.js';
export * from './interface.js';
12 changes: 12 additions & 0 deletions yarn-project/blob-sink/src/blobstore/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { type BlobWithIndex } from '../types/index.js';

export interface BlobStore {
/**
* Get a blob by block id
*/
getBlobSidecars: (blockId: string) => Promise<BlobWithIndex[] | undefined>;
/**
* Add a blob to the store
*/
addBlobSidecars: (blockId: string, blobSidecars: BlobWithIndex[]) => Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { describeBlobStore } from './blob_store_test_suite.js';
import { MemoryBlobStore } from './memory_blob_store.js';

describe('MemoryBlobStore', () => {
describeBlobStore(() => new MemoryBlobStore());
});
19 changes: 19 additions & 0 deletions yarn-project/blob-sink/src/blobstore/memory_blob_store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type BlobWithIndex, BlobsWithIndexes } from '../types/index.js';
import { type BlobStore } from './interface.js';

export class MemoryBlobStore implements BlobStore {
private blobs: Map<string, Buffer> = new Map();

public getBlobSidecars(blockId: string): Promise<BlobWithIndex[] | undefined> {
const blobBuffer = this.blobs.get(blockId);
if (!blobBuffer) {
return Promise.resolve(undefined);
}
return Promise.resolve(BlobsWithIndexes.fromBuffer(blobBuffer).blobs);
}

public addBlobSidecars(blockId: string, blobSidecars: BlobWithIndex[]): Promise<void> {
this.blobs.set(blockId, new BlobsWithIndexes(blobSidecars).toBuffer());
return Promise.resolve();
}
}
7 changes: 7 additions & 0 deletions yarn-project/blob-sink/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { type DataStoreConfig } from '@aztec/kv-store/config';

export interface BlobSinkConfig {
port?: number;
dataStoreConfig?: DataStoreConfig;
otelMetricsCollectorUrl?: string;
}
27 changes: 27 additions & 0 deletions yarn-project/blob-sink/src/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type AztecKVStore } from '@aztec/kv-store';
import { createStore } from '@aztec/kv-store/lmdb';
import { type TelemetryClient } from '@aztec/telemetry-client';

import { type BlobSinkConfig } from './config.js';
import { BlobSinkServer } from './server.js';

// If data store settings are provided, the store is created and returned.
// Otherwise, undefined is returned and an in memory store will be used.
async function getDataStoreConfig(config?: BlobSinkConfig): Promise<AztecKVStore | undefined> {
if (!config?.dataStoreConfig) {
return undefined;
}
return await createStore('blob-sink', config.dataStoreConfig);
}

/**
* Creates a blob sink service from the provided config.
*/
export async function createBlobSinkServer(
config?: BlobSinkConfig,
telemetry?: TelemetryClient,
): Promise<BlobSinkServer> {
const store = await getDataStoreConfig(config);

return new BlobSinkServer(config, store, telemetry);
}
Loading
Loading