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: download blocks as ssz #5923

Merged
merged 7 commits into from
Sep 11, 2023
Merged
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
41 changes: 37 additions & 4 deletions packages/api/src/beacon/client/beacon.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
import {ChainForkConfig} from "@lodestar/config";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/beacon/index.js";
import {IHttpClient, generateGenericJsonClient} from "../../utils/client/index.js";
import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes, BlockId} from "../routes/beacon/index.js";
import {IHttpClient, generateGenericJsonClient, getFetchOptsSerializers} from "../../utils/client/index.js";
import {ResponseFormat} from "../../interfaces.js";
import {BlockResponse, BlockV2Response} from "../routes/beacon/block.js";

/**
* REST HTTP client for beacon routes
*/
export function getClient(config: ChainForkConfig, httpClient: IHttpClient): Api {
const reqSerializers = getReqSerializers(config);
const returnTypes = getReturnTypes();
// All routes return JSON, use a client auto-generator
return generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
// Some routes return JSON, use a client auto-generator
const client = generateGenericJsonClient<Api, ReqTypes>(routesData, reqSerializers, returnTypes, httpClient);
const fetchOptsSerializer = getFetchOptsSerializers<Api, ReqTypes>(routesData, reqSerializers);

return {
...client,
async getBlock<T extends ResponseFormat = "json">(blockId: BlockId, format?: T) {
if (format === "ssz") {
const res = await httpClient.arrayBuffer({
Copy link
Member

Choose a reason for hiding this comment

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

This is sweet. I didnt know our httpClient did so much under the hood. was looking at how its implemented.

...fetchOptsSerializer.getBlock(blockId, format),
});
return {
ok: true,
response: new Uint8Array(res.body),
status: res.status,
} as BlockResponse<T>;
}
return client.getBlock(blockId, format);
},
async getBlockV2<T extends ResponseFormat = "json">(blockId: BlockId, format?: T) {
if (format === "ssz") {
const res = await httpClient.arrayBuffer({
...fetchOptsSerializer.getBlockV2(blockId, format),
});
return {
ok: true,
response: new Uint8Array(res.body),
status: res.status,
} as BlockV2Response<T>;
}
return client.getBlockV2(blockId, format);
},
};
}
58 changes: 39 additions & 19 deletions packages/api/src/beacon/routes/beacon/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
ContainerData,
} from "../../../utils/index.js";
import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js";
import {ApiClientResponse} from "../../../interfaces.js";
import {ApiClientResponse, ResponseFormat} from "../../../interfaces.js";
import {
SignedBlockContents,
SignedBlindedBlockContents,
Expand All @@ -31,6 +31,7 @@ import {
// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes

export type BlockId = RootHex | Slot | "head" | "genesis" | "finalized";
export const mimeTypeSSZ = "application/octet-stream";
Copy link
Member

Choose a reason for hiding this comment

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

This already exists for state downloads. Do you think we should move this to a CONST and pull both from the same place?

export const mimeTypeSSZ = "application/octet-stream";


/**
* True if the response references an unverified execution payload. Optimistic information may be invalidated at
Expand All @@ -51,6 +52,26 @@ export enum BroadcastValidation {
consensusAndEquivocation = "consensus_and_equivocation",
}

export type BlockResponse<T extends ResponseFormat = "json"> = T extends "ssz"
? ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND>
: ApiClientResponse<
{[HttpStatusCode.OK]: {data: allForks.SignedBeaconBlock}},
HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND
>;

export type BlockV2Response<T extends ResponseFormat = "json"> = T extends "ssz"
? ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND>
: ApiClientResponse<
{
[HttpStatusCode.OK]: {
data: allForks.SignedBeaconBlock;
executionOptimistic: ExecutionOptimistic;
version: ForkName;
};
},
HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND
>;

Comment on lines +55 to +74
Copy link
Member

Choose a reason for hiding this comment

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

Very nice TS work!!! 🚀 Is very helpful to see how you created these response types

export type Api = {
/**
* Get block
Expand All @@ -60,26 +81,15 @@ export type Api = {
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlock(blockId: BlockId): Promise<ApiClientResponse<{[HttpStatusCode.OK]: {data: allForks.SignedBeaconBlock}}>>;
getBlock<T extends ResponseFormat = "json">(blockId: BlockId, format?: T): Promise<BlockResponse<T>>;

/**
* Get block
* Retrieves block details for given block id.
* @param blockId Block identifier.
* Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \<slot\>, \<hex encoded blockRoot with 0x prefix\>.
*/
getBlockV2(blockId: BlockId): Promise<
ApiClientResponse<
{
[HttpStatusCode.OK]: {
data: allForks.SignedBeaconBlock;
executionOptimistic: ExecutionOptimistic;
version: ForkName;
};
},
HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND
>
>;
getBlockV2<T extends ResponseFormat = "json">(blockId: BlockId, format?: T): Promise<BlockV2Response<T>>;

/**
* Get block attestations
Expand Down Expand Up @@ -246,11 +256,12 @@ export const routesData: RoutesData<Api> = {

/* eslint-disable @typescript-eslint/naming-convention */

type GetBlockReq = {params: {block_id: string}; headers: {accept?: string}};
Copy link
Member

Choose a reason for hiding this comment

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

Out of curiosity why do we use the snake case for url params. Seems like a good pattern and I see that its the norm throughout the codebase. Just want to expand my understanding.

Copy link
Member

Choose a reason for hiding this comment

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

its part of the beacon api spec
https://github.com/ethereum/beacon-apis

They say: use this specific url path with these specific url query params, should return a response with these codes and these payloads

type BlockIdOnlyReq = {params: {block_id: string}};

export type ReqTypes = {
getBlock: BlockIdOnlyReq;
getBlockV2: BlockIdOnlyReq;
getBlock: GetBlockReq;
getBlockV2: GetBlockReq;
getBlockAttestations: BlockIdOnlyReq;
getBlockHeader: BlockIdOnlyReq;
getBlockHeaders: {query: {slot?: number; parent_root?: string}};
Expand All @@ -263,12 +274,21 @@ export type ReqTypes = {
};

export function getReqSerializers(config: ChainForkConfig): ReqSerializers<Api, ReqTypes> {
const blockIdOnlyReq: ReqSerializer<Api["getBlock"], BlockIdOnlyReq> = {
const blockIdOnlyReq: ReqSerializer<Api["getBlockHeader"], BlockIdOnlyReq> = {
writeReq: (block_id) => ({params: {block_id: String(block_id)}}),
parseReq: ({params}) => [params.block_id],
schema: {params: {block_id: Schema.StringRequired}},
};

const getBlockReq: ReqSerializer<Api["getBlock"], GetBlockReq> = {
writeReq: (block_id, format) => ({
params: {block_id: String(block_id)},
headers: {accept: format === "ssz" ? mimeTypeSSZ : "application/json"},
}),
parseReq: ({params, headers}) => [params.block_id, headers.accept === mimeTypeSSZ ? "ssz" : "json"],
schema: {params: {block_id: Schema.StringRequired}},
};

// Compute block type from JSON payload. See https://github.com/ethereum/eth2.0-APIs/pull/142
const getSignedBeaconBlockType = (data: allForks.SignedBeaconBlock): allForks.AllForksSSZTypes["SignedBeaconBlock"] =>
config.getForkTypes(data.message.slot).SignedBeaconBlock;
Expand Down Expand Up @@ -304,8 +324,8 @@ export function getReqSerializers(config: ChainForkConfig): ReqSerializers<Api,
};

return {
getBlock: blockIdOnlyReq,
getBlockV2: blockIdOnlyReq,
getBlock: getBlockReq,
getBlockV2: getBlockReq,
getBlockAttestations: blockIdOnlyReq,
getBlockHeader: blockIdOnlyReq,
getBlockHeaders: {
Expand Down
39 changes: 37 additions & 2 deletions packages/api/src/beacon/server/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js";
import {ServerApi} from "../../interfaces.js";

export function getRoutes(config: ChainForkConfig, api: ServerApi<Api>): ServerRoutes<Api, ReqTypes> {
// All routes return JSON, use a server auto-generator
return getGenericJsonServer<ServerApi<Api>, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api);
const reqSerializers = getReqSerializers(config);
const returnTypes = getReturnTypes();

// Most of routes return JSON, use a server auto-generator
const serverRoutes = getGenericJsonServer<ServerApi<Api>, ReqTypes>(
{routesData, getReturnTypes, getReqSerializers},
config,
api
);
return {
...serverRoutes,
// Non-JSON routes. Return JSON or binary depending on "accept" header
getBlock: {
...serverRoutes.getBlock,
handler: async (req) => {
const response = await api.getBlock(...reqSerializers.getBlock.parseReq(req));
if (response instanceof Uint8Array) {
// Fastify 3.x.x will automatically add header `Content-Type: application/octet-stream` if Buffer
return Buffer.from(response);
} else {
return returnTypes.getBlock.toJson(response);
}
},
},
getBlockV2: {
...serverRoutes.getBlockV2,
handler: async (req) => {
const response = await api.getBlockV2(...reqSerializers.getBlockV2.parseReq(req));
if (response instanceof Uint8Array) {
// Fastify 3.x.x will automatically add header `Content-Type: application/octet-stream` if Buffer
return Buffer.from(response);
} else {
return returnTypes.getBlockV2.toJson(response);
}
},
},
};
}
1 change: 1 addition & 0 deletions packages/api/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Resolves} from "./utils/types.js";

/* eslint-disable @typescript-eslint/no-explicit-any */

export type ResponseFormat = "json" | "ssz";
Copy link
Member

Choose a reason for hiding this comment

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

This type is also used by the debug state routes. Should we put this somewhere that both can pull from the same type?

export type StateFormat = "json" | "ssz";

export type APIClientHandler = (...args: any) => PromiseLike<ApiClientResponse>;
export type APIServerHandler = (...args: any) => PromiseLike<unknown>;

Expand Down
4 changes: 2 additions & 2 deletions packages/api/test/unit/beacon/testData/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export const testData: GenericServerTestCases<Api> = {
// block

getBlock: {
args: ["head"],
args: ["head", "json"],
Copy link
Member

Choose a reason for hiding this comment

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

Should we also check for ssz? Not sure if its needed. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

these test data are keyed by api name so we can only test with either "json" or "ssz". This is "json" because by default we use it, and majority of consumers use "json" so I'd go with it to maintain the test case going forward

Copy link
Member

Choose a reason for hiding this comment

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

It doesn't look like we unit test the ssz format for state either. I will be happy to add a unit test case after this gets merged. Will be good practice for me. I haven't worked with the API code much

getState: {
args: ["head", "json"],
res: {executionOptimistic: true, data: ssz.phase0.BeaconState.defaultValue()},
},
getStateV2: {
args: ["head", "json"],
res: {executionOptimistic: true, data: ssz.altair.BeaconState.defaultValue(), version: ForkName.altair},
},

Copy link
Member

Choose a reason for hiding this comment

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

This test is just testing the shape/types of the input and output data, not any behavior. Since we added a param to the getBlock functions, we need to add a param here.

res: {data: ssz.phase0.SignedBeaconBlock.defaultValue()},
},
getBlockV2: {
args: ["head"],
args: ["head", "json"],
res: {executionOptimistic: true, data: ssz.bellatrix.SignedBeaconBlock.defaultValue(), version: ForkName.bellatrix},
},
getBlockAttestations: {
Expand Down
12 changes: 9 additions & 3 deletions packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {fromHexString, toHexString} from "@chainsafe/ssz";
import {routes, ServerApi, isSignedBlockContents, isSignedBlindedBlockContents} from "@lodestar/api";
import {routes, ServerApi, isSignedBlockContents, isSignedBlindedBlockContents, ResponseFormat} from "@lodestar/api";
import {computeTimeAtSlot} from "@lodestar/state-transition";
import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params";
import {sleep, toHex} from "@lodestar/utils";
Expand Down Expand Up @@ -243,15 +243,21 @@ export function getBeaconBlockApi({
};
},

async getBlock(blockId) {
async getBlock(blockId, format?: ResponseFormat) {
const {block} = await resolveBlockId(chain, blockId);
if (format === "ssz") {
return config.getForkTypes(block.message.slot).SignedBeaconBlock.serialize(block);
}
return {
data: block,
};
},

async getBlockV2(blockId) {
async getBlockV2(blockId, format?: ResponseFormat) {
const {block, executionOptimistic} = await resolveBlockId(chain, blockId);
if (format === "ssz") {
return config.getForkTypes(block.message.slot).SignedBeaconBlock.serialize(block);
}
return {
executionOptimistic,
data: block,
Expand Down
6 changes: 4 additions & 2 deletions packages/beacon-node/test/sim/mergemock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {LogLevel, sleep} from "@lodestar/utils";
import {TimestampFormatCode} from "@lodestar/logger";
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {ChainConfig} from "@lodestar/config";
import {Epoch, bellatrix} from "@lodestar/types";
import {Epoch, allForks, bellatrix} from "@lodestar/types";
import {ValidatorProposerConfig, BuilderSelection} from "@lodestar/validator";
import {routes} from "@lodestar/api";

Expand Down Expand Up @@ -210,7 +210,9 @@ describe("executionEngine / ExecutionEngineHttp", function () {
let builderBlocks = 0;
await new Promise<void>((resolve, _reject) => {
bn.chain.emitter.on(routes.events.EventType.block, async (blockData) => {
const {data: fullOrBlindedBlock} = await bn.api.beacon.getBlockV2(blockData.block);
const {data: fullOrBlindedBlock} = (await bn.api.beacon.getBlockV2(blockData.block)) as {
data: allForks.SignedBeaconBlock;
};
if (fullOrBlindedBlock !== undefined) {
const blockFeeRecipient = toHexString(
(fullOrBlindedBlock as bellatrix.SignedBeaconBlock).message.body.executionPayload.feeRecipient
Expand Down
7 changes: 5 additions & 2 deletions packages/beacon-node/test/sim/withdrawal-interop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {TimestampFormatCode} from "@lodestar/logger";
import {SLOTS_PER_EPOCH, ForkName} from "@lodestar/params";
import {ChainConfig} from "@lodestar/config";
import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
import {Epoch, capella, Slot} from "@lodestar/types";
import {Epoch, capella, Slot, allForks} from "@lodestar/types";
import {ValidatorProposerConfig} from "@lodestar/validator";

import {ExecutionPayloadStatus, PayloadAttributes} from "../../src/execution/engine/interface.js";
Expand Down Expand Up @@ -369,7 +369,10 @@ async function retrieveCanonicalWithdrawals(bn: BeaconNode, fromSlot: Slot, toSl
});

if (block) {
if ((block.data as capella.SignedBeaconBlock).message.body.executionPayload?.withdrawals.length > 0) {
if (
((block as {data: allForks.SignedBeaconBlock}).data as capella.SignedBeaconBlock).message.body.executionPayload
?.withdrawals.length > 0
) {
withdrawalsBlocks++;
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/state-transition/test/perf/analyzeBlocks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {getClient, ApiError} from "@lodestar/api";
import {config} from "@lodestar/config/default";
import {allForks} from "@lodestar/types";
import {getInfuraBeaconUrl} from "../utils/infura.js";

// Analyze how Ethereum Consensus blocks are in a target network to prepare accurate performance states and blocks
Expand Down Expand Up @@ -52,7 +53,7 @@ async function run(): Promise<void> {
}
ApiError.assert(result.value);

const block = result.value.response.data;
const block = (result.value.response as {data: allForks.SignedBeaconBlock}).data;

blocks++;
attestations += block.message.body.attestations.length;
Expand Down
Loading