From f8593a9a81f7744f4fad2de3e00d4ae04826f9ce Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Mon, 10 Jun 2024 15:16:27 +0100 Subject: [PATCH] feat: provide first-class ssz support on api (#6749) * Add config route definitions * Add debug route definitions * Add events route description * Add lightclient route definitions * Flatten function params * Type safety for optional params in write / parse req * Method args are optional if only optional props * Fix genesisValidatorsRoot type issue * Revert requiring all params in write / parse req * Update http client errors * Add lodestar route definitions * Add node route definitions * Add proof route definitions * Add builder route definitions * Add validator route definitions * Application method response can be void * Generic options can be passed to application methods * Default endpoint request type has body property * Improve types of transform methods * Export server types from index (to be removed) * Update config api impl * Update lightclient api impl * Update events api impl * Update lodestar api impl * Update proof api impl * Update node api impl * Update debug api impl * Update state api impl * Update pool api impl * Update blocks api impl * Partially update validator api impl * Update beacon routes export * Align submitPoolBlsToExecutionChange method args * Filters are always a object * Update errors messages * Add beacon client methods * Add missing routeId label to stream time metric * Fix json casing in codecs * Apply remaining changes from #6227 * Produce block apis only have version meta * Add block values meta to all produce block apis * Apply changes from #6337 * Handle unsafe version in WithMeta and WithVersion * Restore server api error * Update fastify route types * Update server routes / handlers * Remove unnecessary type cast * Restore per route clients * Fix beacon route types * Remove option to patch fetch from http client * Update eventstream client, remove fetch override Fallback does not work like this, see #6180 for proper solution * Use StringType for validator status until #6059 * Remove empty fetch.ts file * Add a few todos * Update builder client and server routes * Update beacon exports * Update api index exports * Update builder index imports * Improve type safety of schema definitions * Add headers to fastify schema * Fix schema definition type * Add missing schemas to route definitions * Fix response codec type * Remove response codec type casts * Fix casing in json only codec * Reuse EmptyResponseCodec * Update base rest api server * Update keymanager routes, client and server * Reuse data types in keymanager impl * Do not await setting headers, not a promise * Improve type safety of empty codecs * Only require to implement supported req methods * Handle requests that only support one format * Handle responses that only support one format * Add json / ssz only req codecs * Update only support errors * Fix assertion * Set correct accept header if only supports one format * Fix eslint / prettier issues * More formatting fixes * Fix fallback request retries in case of http errors * Formatting of res.error * Add add retry functionality to http client (from #6387) * Update rewards routes and server (#6178 and #6260) * Allow to omit body in ssz req if not defined * Always set metadata headers in response * Cache wire format in api response * Only call raw body for json meta * Update api package tests (wip) * Test json and ssz format in generic server tests * Add a bunch of todos * Fix a few broken route definitions * Fix partial config test * Another todo note * Stringify body of json requests * Override default response json method * Validate external consensus version headers in request * Add error handling todo * Skip body schema validation for ssz request bodies * Clean up generic server tests * Pass node, proof, events generic tests * Use enum for media types * Fix a bunch of route definitions * Add justified to blockid type * Properly handle booleans, remove block values codec * Create Uint8Array test data without allocating Buffer * Let fastify handle Buffer conversion * Convert Buffer to Uint8Array in content type parser * Fix build issues * Fix fork type in builder routes * Add some notes * Properly parse request headers * Fix incorrect type assumptions in transform * Generic server tests are passing (except lightclient) * Correctly handle APIs with empty responses * Update getHeader return type to reflect no bid responses * Do not append '?' to URL if query string is empty * Let server handler set status code for parsing errors * Remove unused import * Rename function, request specific * Completely drop ssz support from getSpec * Spec tests are passing against latest releases * Drop unused fastify route config * Drop ssz request from builder routes, not yet supported * Remove import * Apply change from #6695 * Update execution optimistic meta * Apply changes from #6645 * Add workaround to fix epoch committees type issue * Add todo to fix inefficient state conversion * Convert committee to normal array * Apply changes from #6655 * Align args of validators endpoints * Convert indices to str in rewards apis * Update api spec version of README badges * Revert table formatting changes * Make this accessible for class-basd API implementations * Throw err if metadata is accessed for failed response * Add assertOk to api response * Tweak api error message * Update operationIds match spec value * Add missing version to blob sidecars metadata * Test headers and ssz bodies against spec * Minor reordering of code in spec parsing * submitBlindedBlock throws err if fork is not execution * responseOk might be undefined * Remove statusOk from route definition * Remove stale comment * Less build errors in beacon-node * getBlobSidecars return version from server impl * Update validator produce block impl * More expressive pool method args * Application methods might be undefined in mock implementations * Adress open TODOs in server handler * Api response methods are synchronous now * Fix all remaining build issues * Use more performant from/toHex in server api impls * Clean up some TODOs * Fix ApiError type * Errors related to parsing return a 400 status code * Simplify method binding * Forward api context to application methods * There is no easy way to make generic opts typesafe * Better separation of server / client code * Fix comment about missing builder bid * Remove todo, not worth the change / extra indentation * Rename route definitions functions * Return 400 if data passed to keymanager is invalid * Properly handle response metadata headers * Fix lint issues * Add header jsdoc * Move metadata related code into separate file * Remove ssz from POST requests without body * Only set content-type header if body exists * Fix headers extra * POST requests without body are handled similar to GET requests * Fix http client options tests * Improve validation and type safety of JSON metadata * Add type guard for request without body * Differentiate based on body instead of GET vs POST * More renaming * Simplify RequestCode type * Review routes, improve validation * Remaining local diff * Fix accept header handling if only support one wire format * Update 406 error to more closely match spec example * Enforce version header via custom check instead of schema * Use ssz as default request wire format * Log failure to receive head event to verbose * Do not set default value for context * Update getClient return type to better align with method name * Consistent pattern to get route definitions * Dedupe api client type for builder and keymanager * Fix fallback logic if server returns http error * Update head event error logging * Retry 415 errors with JSON and cache SSZ not supported * Use fetch spy to assert call times * Update comment * Update getLightClientUpdatesByRange endpoint meta * Do not forward ssz bytes of blinded block to publishBlock * Fix lightclient e2e tests * Version header in publishBlock api is optional * Reduce type duplication * Add option to override request init in route definition * Add JsonOnlyResp codec * Validate boolean str value from headers * Document default wire formats * Simplify merging of inits in http client * Remove type hacks from fetchBeaconHealth * Reduce call stack in http client * Add .ssz() equivalent method for json to api response * More http client tests * Ensure topics query is provided to eventstream api * Validate request content type in handler Fastify does not cover all edge cases * Review routes, fix param docs, no empty comments * Fix typo * Add note about builder spec not supporting ssz * Consistently move keymanager jsdoc to routes * Sanitize user provided init values before merging * Remove unused ssz only codec * Allow passing wire formats as string literals * chore: review proof routes (#6843) Review proof routes * chore: review lightclient routes (#6842) Review lightclient routes * chore: review node routes (#6844) Review node routes * feat: add cli flags to configure http wire format (#6840) * Review PR, mostly cosmetic changes * Fix event stream error handling --------- Co-authored-by: Cayman --- packages/api/README.md | 2 +- packages/api/package.json | 3 + packages/api/src/beacon/client/beacon.ts | 46 +- packages/api/src/beacon/client/config.ts | 13 +- packages/api/src/beacon/client/debug.ts | 58 +- packages/api/src/beacon/client/events.ts | 80 +- packages/api/src/beacon/client/index.ts | 16 +- packages/api/src/beacon/client/lightclient.ts | 13 +- packages/api/src/beacon/client/lodestar.ts | 13 +- packages/api/src/beacon/client/node.ts | 13 +- packages/api/src/beacon/client/proof.ts | 72 +- packages/api/src/beacon/client/validator.ts | 13 +- packages/api/src/beacon/index.ts | 10 +- .../api/src/beacon/routes/beacon/block.ts | 762 +++++---- .../api/src/beacon/routes/beacon/index.ts | 70 +- packages/api/src/beacon/routes/beacon/pool.ts | 377 +++-- .../api/src/beacon/routes/beacon/rewards.ts | 359 ++--- .../api/src/beacon/routes/beacon/state.ts | 759 ++++----- packages/api/src/beacon/routes/config.ts | 137 +- packages/api/src/beacon/routes/debug.ts | 291 ++-- packages/api/src/beacon/routes/events.ts | 100 +- packages/api/src/beacon/routes/index.ts | 39 +- packages/api/src/beacon/routes/lightclient.ts | 273 ++-- packages/api/src/beacon/routes/lodestar.ts | 424 +++-- packages/api/src/beacon/routes/node.ts | 299 ++-- packages/api/src/beacon/routes/proof.ts | 104 +- packages/api/src/beacon/routes/validator.ts | 1387 ++++++++++------- packages/api/src/beacon/server/beacon.ts | 66 +- packages/api/src/beacon/server/config.ts | 10 +- packages/api/src/beacon/server/debug.ts | 65 +- packages/api/src/beacon/server/events.ts | 42 +- packages/api/src/beacon/server/index.ts | 48 +- packages/api/src/beacon/server/lightclient.ts | 10 +- packages/api/src/beacon/server/lodestar.ts | 10 +- packages/api/src/beacon/server/node.ts | 30 +- packages/api/src/beacon/server/proof.ts | 47 +- packages/api/src/beacon/server/validator.ts | 32 +- packages/api/src/builder/client.ts | 16 +- packages/api/src/builder/index.ts | 22 +- packages/api/src/builder/routes.ts | 207 ++- packages/api/src/builder/server/index.ts | 27 +- packages/api/src/index.ts | 20 +- packages/api/src/interfaces.ts | 37 - packages/api/src/keymanager/client.ts | 13 +- packages/api/src/keymanager/index.ts | 23 +- packages/api/src/keymanager/routes.ts | 773 +++++---- packages/api/src/keymanager/server/index.ts | 27 +- packages/api/src/server/index.ts | 2 + packages/api/src/utils/acceptHeader.ts | 81 - packages/api/src/utils/client/client.ts | 125 -- packages/api/src/utils/client/error.ts | 10 + packages/api/src/utils/client/httpClient.ts | 493 +++--- packages/api/src/utils/client/index.ts | 8 +- packages/api/src/utils/client/method.ts | 50 + packages/api/src/utils/client/request.ts | 108 ++ packages/api/src/utils/client/response.ts | 201 +++ packages/api/src/utils/codecs.ts | 144 ++ packages/api/src/utils/fork.ts | 40 + packages/api/src/utils/headers.ts | 161 ++ .../src/utils/{client => }/httpStatusCode.ts | 0 packages/api/src/utils/index.ts | 2 +- packages/api/src/utils/metadata.ts | 164 ++ packages/api/src/utils/routes.ts | 39 - packages/api/src/utils/schema.ts | 55 +- packages/api/src/utils/serdes.ts | 18 + .../src/utils/server/{errors.ts => error.ts} | 2 +- .../api/src/utils/server/genericJsonServer.ts | 59 - packages/api/src/utils/server/handler.ts | 146 ++ packages/api/src/utils/server/index.ts | 9 +- packages/api/src/utils/server/method.ts | 39 + packages/api/src/utils/server/parser.ts | 27 + .../api/src/utils/server/registerRoute.ts | 17 - packages/api/src/utils/server/route.ts | 45 + packages/api/src/utils/server/types.ts | 33 - packages/api/src/utils/types.ts | 354 ++--- packages/api/src/utils/urlFormat.ts | 6 +- packages/api/src/utils/wireFormat.ts | 24 + .../test/perf/compileRouteUrlFormater.test.ts | 8 +- .../beacon/genericServerTest/beacon.test.ts | 4 +- .../beacon/genericServerTest/config.test.ts | 12 +- .../beacon/genericServerTest/debug.test.ts | 24 +- .../beacon/genericServerTest/events.test.ts | 26 +- .../genericServerTest/lightclient.test.ts | 4 +- .../beacon/genericServerTest/node.test.ts | 4 +- .../beacon/genericServerTest/proofs.test.ts | 4 +- .../genericServerTest/validator.test.ts | 4 +- .../api/test/unit/beacon/oapiSpec.test.ts | 70 +- .../api/test/unit/beacon/testData/beacon.ts | 145 +- .../api/test/unit/beacon/testData/config.ts | 10 +- .../api/test/unit/beacon/testData/debug.ts | 20 +- .../api/test/unit/beacon/testData/events.ts | 6 +- .../test/unit/beacon/testData/lightclient.ts | 36 +- .../api/test/unit/beacon/testData/node.ts | 18 +- .../api/test/unit/beacon/testData/proofs.ts | 19 +- .../test/unit/beacon/testData/validator.ts | 135 +- .../api/test/unit/builder/builder.test.ts | 4 +- .../api/test/unit/builder/oapiSpec.test.ts | 7 +- packages/api/test/unit/builder/testData.ts | 16 +- .../api/test/unit/client/httpClient.test.ts | 314 +++- .../unit/client/httpClientFallback.test.ts | 29 +- .../unit/client/httpClientOptions.test.ts | 57 +- .../api/test/unit/client/urlFormat.test.ts | 6 +- .../test/unit/keymanager/keymanager.test.ts | 4 +- .../api/test/unit/keymanager/oapiSpec.test.ts | 9 +- packages/api/test/unit/keymanager/testData.ts | 46 +- .../{acceptHeader.test.ts => headers.test.ts} | 44 +- packages/api/test/utils/checkAgainstSpec.ts | 86 +- packages/api/test/utils/genericServerTest.ts | 111 +- packages/api/test/utils/parseOpenApiSpec.ts | 103 +- packages/api/test/utils/utils.ts | 13 +- packages/beacon-node/src/api/impl/api.ts | 4 +- .../src/api/impl/beacon/blocks/index.ts | 145 +- .../beacon-node/src/api/impl/beacon/index.ts | 5 +- .../src/api/impl/beacon/pool/index.ts | 41 +- .../src/api/impl/beacon/rewards/index.ts | 19 +- .../src/api/impl/beacon/state/index.ts | 93 +- .../src/api/impl/beacon/state/utils.ts | 7 +- .../beacon-node/src/api/impl/config/index.ts | 5 +- .../beacon-node/src/api/impl/debug/index.ts | 35 +- packages/beacon-node/src/api/impl/errors.ts | 2 +- .../beacon-node/src/api/impl/events/index.ts | 9 +- .../src/api/impl/lightclient/index.ts | 42 +- .../src/api/impl/lodestar/index.ts | 38 +- .../beacon-node/src/api/impl/node/index.ts | 25 +- .../beacon-node/src/api/impl/proof/index.ts | 25 +- .../src/api/impl/validator/index.ts | 651 ++++---- packages/beacon-node/src/api/rest/base.ts | 3 + packages/beacon-node/src/api/rest/index.ts | 7 +- .../src/chain/beaconProposerCache.ts | 10 +- .../beacon-node/src/execution/builder/http.ts | 36 +- packages/beacon-node/src/node/nodejs.ts | 6 +- .../api/impl/beacon/node/endpoints.test.ts | 16 +- .../api/impl/beacon/state/endpoint.test.ts | 18 +- .../e2e/api/impl/lightclient/endpoint.test.ts | 44 +- .../test/e2e/api/lodestar/lodestar.test.ts | 38 +- .../test/e2e/chain/lightclient.test.ts | 16 +- .../test/scripts/blsPubkeyBytesFrequency.ts | 9 +- .../beacon-node/test/sim/mergemock.test.ts | 2 +- .../test/unit/api/impl/beacon/beacon.test.ts | 3 +- .../beacon/blocks/getBlockHeaders.test.ts | 9 +- .../test/unit/api/impl/config/config.test.ts | 5 +- .../test/unit/api/impl/events/events.test.ts | 8 +- .../impl/validator/duties/proposer.test.ts | 37 +- .../validator/produceAttestationData.test.ts | 6 +- .../api/impl/validator/produceBlockV2.test.ts | 12 +- .../api/impl/validator/produceBlockV3.test.ts | 12 +- .../test/unit/chain/beaconProposerCache.ts | 16 +- .../beacon-node/test/utils/node/validator.ts | 49 +- packages/cli/src/cmds/lightclient/handler.ts | 9 +- .../cmds/validator/blsToExecutionChange.ts | 26 +- packages/cli/src/cmds/validator/handler.ts | 26 +- .../cli/src/cmds/validator/keymanager/impl.ts | 241 ++- .../src/cmds/validator/keymanager/server.ts | 7 +- packages/cli/src/cmds/validator/options.ts | 18 + .../validator/slashingProtection/utils.ts | 6 +- .../cli/src/cmds/validator/voluntaryExit.ts | 27 +- packages/cli/src/networks/index.ts | 14 +- .../cli/test/e2e/blsToExecutionchange.test.ts | 14 +- .../test/e2e/importKeystoresFromApi.test.ts | 32 +- .../test/e2e/importRemoteKeysFromApi.test.ts | 34 +- .../e2e/propserConfigfromKeymanager.test.ts | 122 +- packages/cli/test/e2e/runDevCmd.test.ts | 6 +- packages/cli/test/e2e/voluntaryExit.test.ts | 16 +- .../cli/test/e2e/voluntaryExitFromApi.test.ts | 22 +- .../e2e/voluntaryExitRemoteSigner.test.ts | 14 +- packages/cli/test/sim/endpoints.test.ts | 45 +- .../crucible/assertions/blobsAssertion.ts | 6 +- .../attestationParticipationAssertion.ts | 6 +- .../defaults/connectedPeerCountAssertion.ts | 5 +- .../assertions/defaults/finalizedAssertion.ts | 6 +- .../assertions/defaults/headAssertion.ts | 8 +- .../assertions/executionHeadAssertion.ts | 8 +- .../crucible/assertions/forkAssertion.ts | 6 +- .../lighthousePeerScoreAssertion.ts | 10 +- .../crucible/assertions/mergeAssertion.ts | 6 +- .../crucible/assertions/nodeAssertion.ts | 6 +- .../assertions/withdrawalsAssertion.ts | 27 +- .../crucible/clients/beacon/lighthouse.ts | 6 +- .../cli/test/utils/crucible/interfaces.ts | 8 +- .../cli/test/utils/crucible/simulation.ts | 8 +- .../test/utils/crucible/simulationTracker.ts | 30 +- .../cli/test/utils/crucible/utils/network.ts | 31 +- .../cli/test/utils/crucible/utils/syncing.ts | 63 +- .../cli/test/utils/mockBeaconApiServer.ts | 16 +- packages/cli/test/utils/validator.ts | 18 +- packages/flare/src/cmds/selfSlashAttester.ts | 13 +- packages/flare/src/cmds/selfSlashProposer.ts | 13 +- packages/light-client/src/transport/rest.ts | 43 +- packages/light-client/src/utils/api.ts | 4 +- packages/light-client/src/utils/utils.ts | 17 +- .../test/mocks/EventsServerApiMock.ts | 15 +- .../test/mocks/LightclientServerApiMock.ts | 69 +- .../light-client/test/unit/sync.node.test.ts | 18 +- .../light-client/test/utils/getGenesisData.ts | 9 +- packages/light-client/test/utils/server.ts | 13 +- packages/prover/README.md | 2 +- .../src/proof_provider/payload_store.ts | 4 +- .../src/proof_provider/proof_provider.ts | 6 +- packages/prover/src/utils/consensus.ts | 37 +- .../unit/proof_provider/payload_store.test.ts | 45 +- packages/reqresp/README.md | 2 +- .../test/perf/analyzeBlocks.ts | 14 +- .../test/perf/analyzeEpochs.ts | 14 +- .../test/utils/testFileCache.ts | 35 +- packages/types/src/utils/stringType.ts | 3 + packages/validator/src/genesis.ts | 8 +- .../validator/src/services/attestation.ts | 38 +- .../src/services/attestationDuties.ts | 19 +- packages/validator/src/services/block.ts | 61 +- .../validator/src/services/blockDuties.ts | 13 +- .../src/services/chainHeaderTracker.ts | 16 +- .../src/services/doppelgangerService.ts | 14 +- packages/validator/src/services/indices.ts | 23 +- .../src/services/prepareBeaconProposer.ts | 12 +- .../validator/src/services/syncCommittee.ts | 26 +- .../src/services/syncCommitteeDuties.ts | 12 +- packages/validator/src/validator.ts | 65 +- .../test/unit/services/attestation.test.ts | 67 +- .../unit/services/attestationDuties.test.ts | 48 +- .../test/unit/services/block.test.ts | 96 +- .../test/unit/services/blockDuties.test.ts | 97 +- .../test/unit/services/doppelganger.test.ts | 33 +- .../unit/services/syncCommitteDuties.test.ts | 53 +- .../test/unit/services/syncCommittee.test.ts | 58 +- packages/validator/test/utils/apiStub.ts | 18 +- .../validator/test/utils/validatorStore.ts | 4 +- 226 files changed, 8009 insertions(+), 6727 deletions(-) delete mode 100644 packages/api/src/interfaces.ts create mode 100644 packages/api/src/server/index.ts delete mode 100644 packages/api/src/utils/acceptHeader.ts delete mode 100644 packages/api/src/utils/client/client.ts create mode 100644 packages/api/src/utils/client/error.ts create mode 100644 packages/api/src/utils/client/method.ts create mode 100644 packages/api/src/utils/client/request.ts create mode 100644 packages/api/src/utils/client/response.ts create mode 100644 packages/api/src/utils/codecs.ts create mode 100644 packages/api/src/utils/fork.ts create mode 100644 packages/api/src/utils/headers.ts rename packages/api/src/utils/{client => }/httpStatusCode.ts (100%) create mode 100644 packages/api/src/utils/metadata.ts delete mode 100644 packages/api/src/utils/routes.ts rename packages/api/src/utils/server/{errors.ts => error.ts} (76%) delete mode 100644 packages/api/src/utils/server/genericJsonServer.ts create mode 100644 packages/api/src/utils/server/handler.ts create mode 100644 packages/api/src/utils/server/method.ts create mode 100644 packages/api/src/utils/server/parser.ts delete mode 100644 packages/api/src/utils/server/registerRoute.ts create mode 100644 packages/api/src/utils/server/route.ts delete mode 100644 packages/api/src/utils/server/types.ts create mode 100644 packages/api/src/utils/wireFormat.ts rename packages/api/test/unit/utils/{acceptHeader.test.ts => headers.test.ts} (56%) diff --git a/packages/api/README.md b/packages/api/README.md index 39d7098d60ce..5a1178e9c766 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1,7 +1,7 @@ # Lodestar Eth Consensus API [![Discord](https://img.shields.io/discord/593655374469660673.svg?label=Discord&logo=discord)](https://discord.gg/aMxzVcr) -[![ETH Beacon APIs Spec v2.1.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.1.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.1.0) +[![ETH Beacon APIs Spec v2.5.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.5.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.5.0) ![ES Version](https://img.shields.io/badge/ES-2021-yellow) ![Node Version](https://img.shields.io/badge/node-22.x-green) diff --git a/packages/api/package.json b/packages/api/package.json index ae147b5fda7c..67b966fcfb30 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,6 +17,9 @@ ".": { "import": "./lib/index.js" }, + "./server": { + "import": "./lib/server/index.js" + }, "./beacon": { "import": "./lib/beacon/index.js" }, diff --git a/packages/api/src/beacon/client/beacon.ts b/packages/api/src/beacon/client/beacon.ts index ef1e1983577d..803c2e44d6e4 100644 --- a/packages/api/src/beacon/client/beacon.ts +++ b/packages/api/src/beacon/client/beacon.ts @@ -1,46 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -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"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/beacon/index.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for beacon routes */ -export function getClient(config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(config); - const returnTypes = getReturnTypes(); - // Some routes return JSON, use a client auto-generator - const client = generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient) as Api; - const fetchOptsSerializer = getFetchOptsSerializers(routesData, reqSerializers); - - return { - ...client, - async getBlock(blockId: BlockId, format?: T) { - if (format === "ssz") { - const res = await httpClient.arrayBuffer({ - ...fetchOptsSerializer.getBlock(blockId, format), - }); - return { - ok: true, - response: new Uint8Array(res.body), - status: res.status, - } as BlockResponse; - } - return client.getBlock(blockId, format); - }, - async getBlockV2(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; - } - return client.getBlockV2(blockId, format); - }, - }; +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/config.ts b/packages/api/src/beacon/client/config.ts index b005410e1400..66cddbc52136 100644 --- a/packages/api/src/beacon/client/config.ts +++ b/packages/api/src/beacon/client/config.ts @@ -1,13 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {generateGenericJsonClient, IHttpClient} from "../../utils/client/index.js"; -import {Api, getReqSerializers, getReturnTypes, ReqTypes, routesData} from "../routes/config.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/config.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for config routes */ -export function getClient(config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/debug.ts b/packages/api/src/beacon/client/debug.ts index b322f2b21403..4df3bef12cf8 100644 --- a/packages/api/src/beacon/client/debug.ts +++ b/packages/api/src/beacon/client/debug.ts @@ -1,60 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ApiClientResponse, ResponseFormat} from "../../interfaces.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {generateGenericJsonClient, getFetchOptsSerializers, IHttpClient} from "../../utils/client/index.js"; -import {StateId} from "../routes/beacon/state.js"; -import {Api, getReqSerializers, getReturnTypes, ReqTypes, routesData} from "../routes/debug.js"; +import {ApiClientMethods, createApiClientMethods, IHttpClient} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/debug.js"; -// As Jul 2022, it takes up to 3 mins to download states so make this 5 mins for reservation -const GET_STATE_TIMEOUT_MS = 5 * 60 * 1000; +export type ApiClient = ApiClientMethods; /** * REST HTTP client for debug routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // Some routes return JSON, use a client auto-generator - const client = generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); - // For `getState()` generate request serializer - const fetchOptsSerializers = getFetchOptsSerializers(routesData, reqSerializers); - - return { - ...client, - - // TODO: Debug the type issue - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - async getState(stateId: string, format?: ResponseFormat) { - if (format === "ssz") { - const res = await httpClient.arrayBuffer({ - ...fetchOptsSerializers.getState(stateId, format), - timeoutMs: GET_STATE_TIMEOUT_MS, - }); - return { - ok: true, - response: new Uint8Array(res.body), - status: res.status, - } as ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}>; - } - return client.getState(stateId, format); - }, - - // TODO: Debug the type issue - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - async getStateV2(stateId: StateId, format?: ResponseFormat) { - if (format === "ssz") { - const res = await httpClient.arrayBuffer({ - ...fetchOptsSerializers.getStateV2(stateId, format), - timeoutMs: GET_STATE_TIMEOUT_MS, - }); - return {ok: true, response: new Uint8Array(res.body), status: res.status} as ApiClientResponse<{ - [HttpStatusCode.OK]: Uint8Array; - }>; - } - - return client.getStateV2(stateId, format); - }, - }; +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/events.ts b/packages/api/src/beacon/client/events.ts index 1517d4bf3a56..97e2fdc40d75 100644 --- a/packages/api/src/beacon/client/events.ts +++ b/packages/api/src/beacon/client/events.ts @@ -1,59 +1,63 @@ -import {Api, BeaconEvent, routesData, getEventSerdes} from "../routes/events.js"; -import {stringifyQuery, urlJoin} from "../../utils/client/format.js"; +import {ChainForkConfig} from "@lodestar/config"; import {getEventSource} from "../../utils/client/eventSource.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; +import {stringifyQuery, urlJoin} from "../../utils/client/format.js"; +import {ApiClientMethods} from "../../utils/client/method.js"; +import {RouteDefinitionExtra} from "../../utils/client/request.js"; +import {ApiResponse} from "../../utils/client/response.js"; +import {BeaconEvent, Endpoints, getDefinitions, getEventSerdes} from "../routes/events.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for events routes */ -export function getClient(baseUrl: string): Api { +export function getClient(config: ChainForkConfig, baseUrl: string): ApiClient { + const definitions = getDefinitions(config); const eventSerdes = getEventSerdes(); return { - eventstream: async (topics, signal, onEvent) => { + eventstream: async ({topics, signal, onEvent, onError, onClose}) => { const query = stringifyQuery({topics}); - const url = `${urlJoin(baseUrl, routesData.eventstream.url)}?${query}`; + const url = `${urlJoin(baseUrl, definitions.eventstream.url)}?${query}`; // eslint-disable-next-line @typescript-eslint/naming-convention const EventSource = await getEventSource(); const eventSource = new EventSource(url); - try { - await new Promise((resolve, reject) => { - for (const topic of topics) { - eventSource.addEventListener(topic, ((event: MessageEvent) => { - const message = eventSerdes.fromJson(topic, JSON.parse(event.data)); - onEvent({type: topic, message} as BeaconEvent); - }) as EventListener); - } - - // EventSource will try to reconnect always on all errors - // `eventSource.onerror` events are informative but don't indicate the EventSource closed - // The only way to abort the connection from the client is via eventSource.close() - eventSource.onerror = function onerror(err) { - const errEs = err as unknown as EventSourceError; - // Consider 400 and 500 status errors unrecoverable, close the eventsource - if (errEs.status === 400) { - reject(Error(`400 Invalid topics: ${errEs.message}`)); - } - if (errEs.status === 500) { - reject(Error(`500 Internal Server Error: ${errEs.message}`)); - } - - // TODO: else log the error somewhere - // console.log("eventstream client error", errEs); - }; - - // And abort resolve the promise so finally {} eventSource.close() - signal.addEventListener("abort", () => resolve(), {once: true}); - }); - } finally { + const close = (): void => { eventSource.close(); + onClose?.(); + signal.removeEventListener("abort", close); + }; + signal.addEventListener("abort", close, {once: true}); + + for (const topic of topics) { + eventSource.addEventListener(topic, (event: MessageEvent) => { + const message = eventSerdes.fromJson(topic, JSON.parse(event.data)); + onEvent({type: topic, message} as BeaconEvent); + }); } - return {ok: true, response: undefined, status: HttpStatusCode.OK}; + // EventSource will try to reconnect always on all errors + // `eventSource.onerror` events are informative but don't indicate the EventSource closed + // The only way to abort the connection from the client is via eventSource.close() + eventSource.onerror = function onerror(err) { + const errEs = err as unknown as EventSourceError; + + // Ignore noisy errors due to beacon node being offline + if (!errEs.message?.includes("ECONNREFUSED")) { + onError?.(new Error(errEs.message)); + } + + // Consider 400 and 500 status errors unrecoverable, close the eventsource + if (errEs.status === 400 || errEs.status === 500) { + close(); + } + }; + + return new ApiResponse(definitions.eventstream as RouteDefinitionExtra); }, }; } // https://github.com/EventSource/eventsource/blob/82e034389bd2c08d532c63172b8e858c5b185338/lib/eventsource.js#L143 -type EventSourceError = {status?: number; message: string}; +type EventSourceError = {status?: number; message?: string}; diff --git a/packages/api/src/beacon/client/index.ts b/packages/api/src/beacon/client/index.ts index 9fbe17bf337a..6512d23673c5 100644 --- a/packages/api/src/beacon/client/index.ts +++ b/packages/api/src/beacon/client/index.ts @@ -1,6 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api} from "../routes/index.js"; -import {IHttpClient, HttpClient, HttpClientOptions, HttpClientModules} from "../../utils/client/index.js"; +import { + ApiClientMethods, + HttpClient, + HttpClientModules, + HttpClientOptions, + IHttpClient, +} from "../../utils/client/index.js"; +import {Endpoints} from "../routes/index.js"; import * as beacon from "./beacon.js"; import * as configApi from "./config.js"; @@ -17,10 +23,12 @@ type ClientModules = HttpClientModules & { httpClient?: IHttpClient; }; +export type ApiClient = {[K in keyof Endpoints]: ApiClientMethods}; + /** * REST HTTP client for all routes */ -export function getClient(opts: HttpClientOptions, modules: ClientModules): Api { +export function getClient(opts: HttpClientOptions, modules: ClientModules): ApiClient { const {config} = modules; const httpClient = modules.httpClient ?? new HttpClient(opts, modules); @@ -28,7 +36,7 @@ export function getClient(opts: HttpClientOptions, modules: ClientModules): Api beacon: beacon.getClient(config, httpClient), config: configApi.getClient(config, httpClient), debug: debug.getClient(config, httpClient), - events: events.getClient(httpClient.baseUrl), + events: events.getClient(config, httpClient.baseUrl), lightclient: lightclient.getClient(config, httpClient), lodestar: lodestar.getClient(config, httpClient), node: node.getClient(config, httpClient), diff --git a/packages/api/src/beacon/client/lightclient.ts b/packages/api/src/beacon/client/lightclient.ts index 44092757629f..dbb4f3047de0 100644 --- a/packages/api/src/beacon/client/lightclient.ts +++ b/packages/api/src/beacon/client/lightclient.ts @@ -1,13 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/lightclient.js"; -import {IHttpClient, generateGenericJsonClient} from "../../utils/client/index.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/lightclient.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for lightclient routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/lodestar.ts b/packages/api/src/beacon/client/lodestar.ts index 9f6dfa305ad5..a2aeb2224acd 100644 --- a/packages/api/src/beacon/client/lodestar.ts +++ b/packages/api/src/beacon/client/lodestar.ts @@ -1,13 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/lodestar.js"; -import {IHttpClient, generateGenericJsonClient} from "../../utils/client/index.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/lodestar.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for lodestar routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/node.ts b/packages/api/src/beacon/client/node.ts index adea7b1b5420..8f6599e9ec40 100644 --- a/packages/api/src/beacon/client/node.ts +++ b/packages/api/src/beacon/client/node.ts @@ -1,13 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {generateGenericJsonClient, IHttpClient} from "../../utils/client/index.js"; -import {Api, getReqSerializers, getReturnTypes, ReqTypes, routesData} from "../routes/node.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/node.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for beacon routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/proof.ts b/packages/api/src/beacon/client/proof.ts index 5188725da148..504afe324f3e 100644 --- a/packages/api/src/beacon/client/proof.ts +++ b/packages/api/src/beacon/client/proof.ts @@ -1,70 +1,12 @@ -import {CompactMultiProof, ProofType} from "@chainsafe/persistent-merkle-tree"; import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReqSerializers} from "../routes/proof.js"; -import {IHttpClient, getFetchOptsSerializers, HttpError} from "../../utils/client/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/proof.js"; + +export type ApiClient = ApiClientMethods; /** - * REST HTTP client for lightclient routes + * REST HTTP client for proof routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - - // For `getStateProof()` generate request serializer - const fetchOptsSerializers = getFetchOptsSerializers(routesData, reqSerializers); - - return { - async getStateProof(stateId, descriptor) { - try { - const res = await httpClient.arrayBuffer(fetchOptsSerializers.getStateProof(stateId, descriptor)); - // reuse the response ArrayBuffer - if (!Number.isInteger(res.body.byteLength / 32)) { - throw new Error("Invalid proof data: Length not divisible by 32"); - } - - const proof: CompactMultiProof = { - type: ProofType.compactMulti, - descriptor, - leaves: Array.from({length: res.body.byteLength / 32}, (_, i) => new Uint8Array(res.body, i * 32, 32)), - }; - - return {ok: true, response: {data: proof}, status: HttpStatusCode.OK}; - } catch (err) { - if (err instanceof HttpError) { - return { - ok: false, - error: {code: err.status, message: err.message, operationId: "proof.getStateProof"}, - status: err.status, - }; - } - throw err; - } - }, - async getBlockProof(blockId, descriptor) { - try { - const res = await httpClient.arrayBuffer(fetchOptsSerializers.getBlockProof(blockId, descriptor)); - // reuse the response ArrayBuffer - if (!Number.isInteger(res.body.byteLength / 32)) { - throw new Error("Invalid proof data: Length not divisible by 32"); - } - - const proof: CompactMultiProof = { - type: ProofType.compactMulti, - descriptor, - leaves: Array.from({length: res.body.byteLength / 32}, (_, i) => new Uint8Array(res.body, i * 32, 32)), - }; - - return {ok: true, response: {data: proof}, status: HttpStatusCode.OK}; - } catch (err) { - if (err instanceof HttpError) { - return { - ok: false, - error: {code: err.status, message: err.message, operationId: "proof.getStateProof"}, - status: err.status, - }; - } - throw err; - } - }, - }; +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/client/validator.ts b/packages/api/src/beacon/client/validator.ts index d93c4ac6ed58..79c56c6da590 100644 --- a/packages/api/src/beacon/client/validator.ts +++ b/packages/api/src/beacon/client/validator.ts @@ -1,13 +1,12 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "../routes/validator.js"; -import {IHttpClient, generateGenericJsonClient} from "../../utils/client/index.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../../utils/client/index.js"; +import {Endpoints, getDefinitions} from "../routes/validator.js"; + +export type ApiClient = ApiClientMethods; /** * REST HTTP client for validator routes */ -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/beacon/index.ts b/packages/api/src/beacon/index.ts index 7f0e10536d4d..660f2241db2a 100644 --- a/packages/api/src/beacon/index.ts +++ b/packages/api/src/beacon/index.ts @@ -1,15 +1,15 @@ -import type {Api} from "./routes/index.js"; +import type {Endpoints} from "./routes/index.js"; // NOTE: Don't export server here so it's not bundled to all consumers import * as routes from "./routes/index.js"; export {routes}; -export {getClient} from "./client/index.js"; -export type {Api}; +export {getClient, type ApiClient} from "./client/index.js"; +export type {Endpoints}; // Declare namespaces for CLI options -export type ApiNamespace = keyof Api; -const allNamespacesObj: {[K in keyof Api]: true} = { +export type ApiNamespace = keyof Endpoints; +const allNamespacesObj: {[K in keyof Endpoints]: true} = { beacon: true, config: true, debug: true, diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 7dd5511a22f6..380efcc9825a 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -1,51 +1,58 @@ -import {ContainerType} from "@chainsafe/ssz"; -import {ForkName} from "@lodestar/params"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ListCompositeType, ValueOf} from "@chainsafe/ssz"; import {ChainForkConfig} from "@lodestar/config"; -import {phase0, allForks, Slot, Root, ssz, RootHex, deneb, isSignedBlockContents} from "@lodestar/types"; - +import {allForks, Slot, ssz, RootHex, deneb, phase0, isSignedBlockContents} from "@lodestar/types"; +import {ForkName, ForkSeq} from "@lodestar/params"; +import {Endpoint, RequestCodec, RouteDefinitions, Schema} from "../../../utils/index.js"; +import {EmptyMeta, EmptyMetaCodec, EmptyResponseCodec, EmptyResponseData, WithVersion} from "../../../utils/codecs.js"; import { - RoutesData, - ReturnTypes, - ArrayOf, - Schema, - WithVersion, - reqOnlyBody, - TypeJson, - ReqSerializers, - ReqSerializer, - ContainerDataExecutionOptimistic, - WithExecutionOptimistic, - ContainerData, - WithFinalized, -} from "../../../utils/index.js"; -import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; -import {parseAcceptHeader, writeAcceptHeader} from "../../../utils/acceptHeader.js"; -import {ApiClientResponse, ResponseFormat} from "../../../interfaces.js"; -import {allForksSignedBlockContentsReqSerializer} from "../../../utils/routes.js"; + ExecutionOptimisticAndFinalizedCodec, + ExecutionOptimisticAndFinalizedMeta, + ExecutionOptimisticFinalizedAndVersionCodec, + ExecutionOptimisticFinalizedAndVersionMeta, + MetaHeader, +} from "../../../utils/metadata.js"; +import {getBlindedForkTypes, toForkName} from "../../../utils/fork.js"; +import {fromHeaders} from "../../../utils/headers.js"; +import {WireFormat} from "../../../utils/wireFormat.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type BlockId = RootHex | Slot | "head" | "genesis" | "finalized"; - -/** - * True if the response references an unverified execution payload. Optimistic information may be invalidated at - * a later time. If the field is not present, assume the False value. - */ -export type ExecutionOptimistic = boolean; - -/** - * True if the response references the finalized history of the chain, as determined by fork choice. - */ -export type Finalized = boolean; - -export type BlockHeaderResponse = { - root: Root; - canonical: boolean; - header: phase0.SignedBeaconBlockHeader; +export const BlockHeaderResponseType = new ContainerType({ + root: ssz.Root, + canonical: ssz.Boolean, + header: ssz.phase0.SignedBeaconBlockHeader, +}); +export const BlockHeadersResponseType = new ListCompositeType(BlockHeaderResponseType, 1000); +export const RootResponseType = new ContainerType({ + root: ssz.Root, +}); +export const SignedBlockContentsType = new ContainerType( + { + signedBlock: ssz.deneb.SignedBeaconBlock, + kzgProofs: ssz.deneb.KZGProofs, + blobs: ssz.deneb.Blobs, + }, + {jsonCase: "eth2"} +); + +export type BlockHeaderResponse = ValueOf; +export type BlockHeadersResponse = ValueOf; +export type RootResponse = ValueOf; +export type SignedBlockContents = ValueOf; + +export type BlockId = RootHex | Slot | "head" | "genesis" | "finalized" | "justified"; + +export type BlockArgs = { + /** + * Block identifier. + * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. + */ + blockId: BlockId; }; export enum BroadcastValidation { - /* + /** NOTE: The value `none` is not part of the spec. In case a node is configured only with the unknownBlockSync, it needs to know the unknown parent blocks on the network @@ -58,120 +65,78 @@ export enum BroadcastValidation { consensusAndEquivocation = "consensus_and_equivocation", } -export type BlockResponse = 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 "ssz" - ? ApiClientResponse<{[HttpStatusCode.OK]: Uint8Array}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND> - : ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: allForks.SignedBeaconBlock; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - version: ForkName; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - >; - -export type Api = { +export type Endpoints = { /** * Get block * Returns the complete `SignedBeaconBlock` for a given block ID. - * Depending on the `Accept` header it can be returned either as JSON or SSZ-serialized bytes. - * - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. */ - getBlock(blockId: BlockId, format?: T): Promise>; + getBlock: Endpoint< + // ⏎ + "GET", + BlockArgs, + {params: {block_id: string}}, + phase0.SignedBeaconBlock, + EmptyMeta + >; /** * 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", \, \. */ - getBlockV2(blockId: BlockId, format?: T): Promise>; + getBlockV2: Endpoint< + "GET", + BlockArgs, + {params: {block_id: string}}, + allForks.SignedBeaconBlock, + ExecutionOptimisticFinalizedAndVersionMeta + >; /** * Get block attestations * Retrieves attestation included in requested block. - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. */ - getBlockAttestations(blockId: BlockId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: phase0.Attestation[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getBlockAttestations: Endpoint< + "GET", + BlockArgs, + {params: {block_id: string}}, + allForks.BeaconBlockBody["attestations"], + ExecutionOptimisticAndFinalizedMeta >; /** * Get block header * Retrieves block header for given block id. - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. */ - getBlockHeader(blockId: BlockId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: BlockHeaderResponse; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getBlockHeader: Endpoint< + "GET", + BlockArgs, + {params: {block_id: string}}, + BlockHeaderResponse, + ExecutionOptimisticAndFinalizedMeta >; /** * Get block headers * Retrieves block headers matching given query. By default it will fetch current head slot blocks. - * @param slot - * @param parentRoot */ - getBlockHeaders(filters: Partial<{slot: Slot; parentRoot: string}>): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: BlockHeaderResponse[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST - > + getBlockHeaders: Endpoint< + "GET", + {slot?: Slot; parentRoot?: string}, + {query: {slot?: number; parent_root?: string}}, + BlockHeaderResponse[], + ExecutionOptimisticAndFinalizedMeta >; /** * Get block root * Retrieves hashTreeRoot of BeaconBlock/BeaconBlockHeader - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. */ - getBlockRoot(blockId: BlockId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: {root: Root}; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getBlockRoot: Endpoint< + "GET", + BlockArgs, + {params: {block_id: string}}, + RootResponse, + ExecutionOptimisticAndFinalizedMeta >; /** @@ -183,238 +148,391 @@ export type Api = { * therefore validate the block internally, however blocks which fail the validation are still * broadcast but a different status code is returned (202) * - * @param requestBody The `SignedBeaconBlock` object composed of `BeaconBlock` object (produced by beacon node) and validator signature. - * @returns any The block was validated successfully and has been broadcast. It has also been integrated into the beacon node's database. + * Returns if the block was validated successfully and has been broadcast. It has also been integrated into the beacon node's database. */ - publishBlock(blockOrContents: allForks.SignedBeaconBlockOrContents): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: void; - [HttpStatusCode.ACCEPTED]: void; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + publishBlock: Endpoint< + "POST", + {signedBlockOrContents: allForks.SignedBeaconBlockOrContents}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta >; - publishBlockV2( - blockOrContents: allForks.SignedBeaconBlockOrContents, - opts?: {broadcastValidation?: BroadcastValidation} - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: void; - [HttpStatusCode.ACCEPTED]: void; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + publishBlockV2: Endpoint< + "POST", + { + signedBlockOrContents: allForks.SignedBeaconBlockOrContents; + broadcastValidation?: BroadcastValidation; + }, + {body: unknown; headers: {[MetaHeader.Version]: string}; query: {broadcast_validation?: string}}, + EmptyResponseData, + EmptyMeta >; /** * Publish a signed blinded block by submitting it to the mev relay and patching in the block * transactions beacon node gets in response. */ - publishBlindedBlock(blindedBlock: allForks.SignedBlindedBeaconBlock): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: void; - [HttpStatusCode.ACCEPTED]: void; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + publishBlindedBlock: Endpoint< + "POST", + {signedBlindedBlock: allForks.SignedBlindedBeaconBlock}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + EmptyResponseData, + EmptyMeta >; - publishBlindedBlockV2( - blindedBlockOrContents: allForks.SignedBlindedBeaconBlock, - opts: {broadcastValidation?: BroadcastValidation} - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: void; - [HttpStatusCode.ACCEPTED]: void; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + publishBlindedBlockV2: Endpoint< + "POST", + { + signedBlindedBlock: allForks.SignedBlindedBeaconBlock; + broadcastValidation?: BroadcastValidation; + }, + {body: unknown; headers: {[MetaHeader.Version]: string}; query: {broadcast_validation?: string}}, + EmptyResponseData, + EmptyMeta >; + /** * Get block BlobSidecar * Retrieves BlobSidecar included in requested block. - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. - * @param indices Array of indices for blob sidecars to request for in the specified block. Returns all blob sidecars in the block if not specified. */ - getBlobSidecars( - blockId: BlockId, - indices?: number[] - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - data: deneb.BlobSidecars; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }> + getBlobSidecars: Endpoint< + "GET", + BlockArgs & { + /** + * Array of indices for blob sidecars to request for in the specified block. + * Returns all blob sidecars in the block if not specified. + */ + indices?: number[]; + }, + {params: {block_id: string}; query: {indices?: number[]}}, + deneb.BlobSidecars, + ExecutionOptimisticFinalizedAndVersionMeta >; }; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getBlock: {url: "/eth/v1/beacon/blocks/{block_id}", method: "GET"}, - getBlockV2: {url: "/eth/v2/beacon/blocks/{block_id}", method: "GET"}, - getBlockAttestations: {url: "/eth/v1/beacon/blocks/{block_id}/attestations", method: "GET"}, - getBlockHeader: {url: "/eth/v1/beacon/headers/{block_id}", method: "GET"}, - getBlockHeaders: {url: "/eth/v1/beacon/headers", method: "GET"}, - getBlockRoot: {url: "/eth/v1/beacon/blocks/{block_id}/root", method: "GET"}, - publishBlock: {url: "/eth/v1/beacon/blocks", method: "POST"}, - publishBlockV2: {url: "/eth/v2/beacon/blocks", method: "POST"}, - publishBlindedBlock: {url: "/eth/v1/beacon/blinded_blocks", method: "POST"}, - publishBlindedBlockV2: {url: "/eth/v2/beacon/blinded_blocks", method: "POST"}, - getBlobSidecars: {url: "/eth/v1/beacon/blob_sidecars/{block_id}", method: "GET"}, +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const blockIdOnlyReq: RequestCodec> = { + writeReq: ({blockId}) => ({params: {block_id: blockId.toString()}}), + parseReq: ({params}) => ({blockId: params.block_id}), + schema: {params: {block_id: Schema.StringRequired}}, }; -/* eslint-disable @typescript-eslint/naming-convention */ - -type GetBlockReq = {params: {block_id: string}; headers: {accept?: string}}; -type BlockIdOnlyReq = {params: {block_id: string}}; - -export type ReqTypes = { - getBlock: GetBlockReq; - getBlockV2: GetBlockReq; - getBlockAttestations: BlockIdOnlyReq; - getBlockHeader: BlockIdOnlyReq; - getBlockHeaders: {query: {slot?: number; parent_root?: string}}; - getBlockRoot: BlockIdOnlyReq; - publishBlock: {body: unknown}; - publishBlockV2: {body: unknown; query: {broadcast_validation?: string}}; - publishBlindedBlock: {body: unknown}; - publishBlindedBlockV2: {body: unknown; query: {broadcast_validation?: string}}; - getBlobSidecars: {params: {block_id: string}; query: {indices?: number[]}}; -}; - -export function getReqSerializers(config: ChainForkConfig): ReqSerializers { - const blockIdOnlyReq: ReqSerializer = { - writeReq: (block_id) => ({params: {block_id: String(block_id)}}), - parseReq: ({params}) => [params.block_id], - schema: {params: {block_id: Schema.StringRequired}}, - }; - - const getBlockReq: ReqSerializer = { - writeReq: (block_id, format) => ({ - params: {block_id: String(block_id)}, - headers: {accept: writeAcceptHeader(format)}, - }), - parseReq: ({params, headers}) => [params.block_id, parseAcceptHeader(headers.accept)], - 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; - - const AllForksSignedBlockOrContents: TypeJson = { - toJson: (data) => - isSignedBlockContents(data) - ? allForksSignedBlockContentsReqSerializer(getSignedBeaconBlockType).toJson(data) - : getSignedBeaconBlockType(data).toJson(data), - - fromJson: (data) => - (data as {signed_block: unknown}).signed_block !== undefined - ? allForksSignedBlockContentsReqSerializer(getSignedBeaconBlockType).fromJson(data) - : getSignedBeaconBlockType(data as allForks.SignedBeaconBlock).fromJson(data), - }; - - const getSignedBlindedBeaconBlockType = ( - data: allForks.SignedBlindedBeaconBlock - ): allForks.AllForksBlindedSSZTypes["SignedBeaconBlock"] => - config.getBlindedForkTypes(data.message.slot).SignedBeaconBlock; - - const AllForksSignedBlindedBlock: TypeJson = { - toJson: (data) => getSignedBlindedBeaconBlockType(data).toJson(data), - fromJson: (data) => getSignedBlindedBeaconBlockType(data as allForks.SignedBlindedBeaconBlock).fromJson(data), - }; - - function extractSlot(signedBlockOrContents: allForks.SignedBeaconBlockOrContents): Slot { - return isSignedBlockContents(signedBlockOrContents) - ? signedBlockOrContents.signedBlock.message.slot - : signedBlockOrContents.message.slot; - } - +export function getDefinitions(config: ChainForkConfig): RouteDefinitions { return { - getBlock: getBlockReq, - getBlockV2: getBlockReq, - getBlockAttestations: blockIdOnlyReq, - getBlockHeader: blockIdOnlyReq, + getBlock: { + url: "/eth/v1/beacon/blocks/{block_id}", + method: "GET", + req: blockIdOnlyReq, + resp: { + data: ssz.phase0.SignedBeaconBlock, + meta: EmptyMetaCodec, + }, + }, + getBlockV2: { + url: "/eth/v2/beacon/blocks/{block_id}", + method: "GET", + req: blockIdOnlyReq, + resp: { + data: WithVersion((fork) => ssz[fork].SignedBeaconBlock), + meta: ExecutionOptimisticFinalizedAndVersionCodec, + }, + }, + getBlockAttestations: { + url: "/eth/v1/beacon/blocks/{block_id}/attestations", + method: "GET", + req: blockIdOnlyReq, + resp: { + data: ssz.phase0.BeaconBlockBody.fields.attestations, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + getBlockHeader: { + url: "/eth/v1/beacon/headers/{block_id}", + method: "GET", + req: blockIdOnlyReq, + resp: { + data: BlockHeaderResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, getBlockHeaders: { - writeReq: (filters) => ({query: {slot: filters?.slot, parent_root: filters?.parentRoot}}), - parseReq: ({query}) => [{slot: query?.slot, parentRoot: query?.parent_root}], - schema: {query: {slot: Schema.Uint, parent_root: Schema.String}}, + url: "/eth/v1/beacon/headers", + method: "GET", + req: { + writeReq: ({slot, parentRoot}) => ({query: {slot, parent_root: parentRoot}}), + parseReq: ({query}) => ({slot: query.slot, parentRoot: query.parent_root}), + schema: {query: {slot: Schema.Uint, parent_root: Schema.String}}, + }, + resp: { + data: BlockHeadersResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + getBlockRoot: { + url: "/eth/v1/beacon/blocks/{block_id}/root", + method: "GET", + req: blockIdOnlyReq, + resp: { + data: RootResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + publishBlock: { + url: "/eth/v1/beacon/blocks", + method: "POST", + req: { + writeReqJson: ({signedBlockOrContents}) => { + const slot = isSignedBlockContents(signedBlockOrContents) + ? signedBlockOrContents.signedBlock.message.slot + : signedBlockOrContents.message.slot; + return { + body: + config.getForkSeq(slot) < ForkSeq.deneb + ? config + .getForkTypes(slot) + .SignedBeaconBlock.toJson(signedBlockOrContents as allForks.SignedBeaconBlock) + : SignedBlockContentsType.toJson(signedBlockOrContents as SignedBlockContents), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + }, + }; + }, + parseReqJson: ({body, headers}) => { + let forkName: ForkName; + // As per spec, version header is optional for JSON requests + const versionHeader = fromHeaders(headers, MetaHeader.Version, false); + if (versionHeader !== undefined) { + forkName = toForkName(versionHeader); + } else { + // Determine fork from slot in JSON payload + forkName = config.getForkName( + (body as {signed_block: unknown}).signed_block !== undefined + ? (body as {signed_block: allForks.SignedBeaconBlock}).signed_block.message.slot + : (body as allForks.SignedBeaconBlock).message.slot + ); + } + const forkSeq = config.forks[forkName].seq; + return { + signedBlockOrContents: + forkSeq < ForkSeq.deneb + ? ssz[forkName].SignedBeaconBlock.fromJson(body) + : SignedBlockContentsType.fromJson(body), + }; + }, + writeReqSsz: ({signedBlockOrContents}) => { + const slot = isSignedBlockContents(signedBlockOrContents) + ? signedBlockOrContents.signedBlock.message.slot + : signedBlockOrContents.message.slot; + return { + body: + config.getForkSeq(slot) < ForkSeq.deneb + ? config + .getForkTypes(slot) + .SignedBeaconBlock.serialize(signedBlockOrContents as allForks.SignedBeaconBlock) + : SignedBlockContentsType.serialize(signedBlockOrContents as SignedBlockContents), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + }, + }; + }, + parseReqSsz: ({body, headers}) => { + const forkName = toForkName(fromHeaders(headers, MetaHeader.Version)); + const forkSeq = config.forks[forkName].seq; + return { + signedBlockOrContents: + forkSeq < ForkSeq.deneb + ? ssz[forkName].SignedBeaconBlock.deserialize(body) + : SignedBlockContentsType.deserialize(body), + }; + }, + schema: { + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, }, - getBlockRoot: blockIdOnlyReq, - publishBlock: reqOnlyBody(AllForksSignedBlockOrContents, Schema.Object), publishBlockV2: { - writeReq: (item, {broadcastValidation} = {}) => ({ - body: AllForksSignedBlockOrContents.toJson(item), - query: {broadcast_validation: broadcastValidation}, - headers: {"Eth-Consensus-Version": config.getForkName(extractSlot(item))}, - }), - parseReq: ({body, query}) => [ - AllForksSignedBlockOrContents.fromJson(body), - {broadcastValidation: query.broadcast_validation as BroadcastValidation}, - ], - schema: { - body: Schema.Object, - query: {broadcast_validation: Schema.String}, + url: "/eth/v2/beacon/blocks", + method: "POST", + req: { + writeReqJson: ({signedBlockOrContents, broadcastValidation}) => { + const slot = isSignedBlockContents(signedBlockOrContents) + ? signedBlockOrContents.signedBlock.message.slot + : signedBlockOrContents.message.slot; + return { + body: + config.getForkSeq(slot) < ForkSeq.deneb + ? config + .getForkTypes(slot) + .SignedBeaconBlock.toJson(signedBlockOrContents as allForks.SignedBeaconBlock) + : SignedBlockContentsType.toJson(signedBlockOrContents as SignedBlockContents), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + }, + query: {broadcast_validation: broadcastValidation}, + }; + }, + parseReqJson: ({body, headers, query}) => { + const forkName = toForkName(fromHeaders(headers, MetaHeader.Version)); + const forkSeq = config.forks[forkName].seq; + return { + signedBlockOrContents: + forkSeq < ForkSeq.deneb + ? ssz[forkName].SignedBeaconBlock.fromJson(body) + : SignedBlockContentsType.fromJson(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, + }; + }, + writeReqSsz: ({signedBlockOrContents, broadcastValidation}) => { + const slot = isSignedBlockContents(signedBlockOrContents) + ? signedBlockOrContents.signedBlock.message.slot + : signedBlockOrContents.message.slot; + return { + body: + config.getForkSeq(slot) < ForkSeq.deneb + ? config + .getForkTypes(slot) + .SignedBeaconBlock.serialize(signedBlockOrContents as allForks.SignedBeaconBlock) + : SignedBlockContentsType.serialize(signedBlockOrContents as SignedBlockContents), + headers: { + [MetaHeader.Version]: config.getForkName(slot), + }, + query: {broadcast_validation: broadcastValidation}, + }; + }, + parseReqSsz: ({body, headers, query}) => { + const forkName = toForkName(fromHeaders(headers, MetaHeader.Version)); + const forkSeq = config.forks[forkName].seq; + return { + signedBlockOrContents: + forkSeq < ForkSeq.deneb + ? ssz[forkName].SignedBeaconBlock.deserialize(body) + : SignedBlockContentsType.deserialize(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, + }; + }, + schema: { + body: Schema.Object, + query: {broadcast_validation: Schema.String}, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, + }, + }, + publishBlindedBlock: { + url: "/eth/v1/beacon/blinded_blocks", + method: "POST", + req: { + writeReqJson: ({signedBlindedBlock}) => { + const fork = config.getForkName(signedBlindedBlock.message.slot); + return { + body: getBlindedForkTypes(fork).SignedBeaconBlock.toJson(signedBlindedBlock), + headers: { + [MetaHeader.Version]: fork, + }, + }; + }, + parseReqJson: ({body, headers}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlindedBlock: getBlindedForkTypes(fork).SignedBeaconBlock.fromJson(body), + }; + }, + writeReqSsz: ({signedBlindedBlock}) => { + const fork = config.getForkName(signedBlindedBlock.message.slot); + return { + body: getBlindedForkTypes(fork).SignedBeaconBlock.serialize(signedBlindedBlock), + headers: { + [MetaHeader.Version]: fork, + }, + }; + }, + parseReqSsz: ({body, headers}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlindedBlock: getBlindedForkTypes(fork).SignedBeaconBlock.deserialize(body), + }; + }, + schema: { + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, }, }, - publishBlindedBlock: reqOnlyBody(AllForksSignedBlindedBlock, Schema.Object), publishBlindedBlockV2: { - writeReq: (item, {broadcastValidation}) => ({ - body: AllForksSignedBlindedBlock.toJson(item), - query: {broadcast_validation: broadcastValidation}, - headers: {"Eth-Consensus-Version": config.getForkName(item.message.slot)}, - }), - parseReq: ({body, query}) => [ - AllForksSignedBlindedBlock.fromJson(body), - {broadcastValidation: query.broadcast_validation as BroadcastValidation}, - ], - schema: { - body: Schema.Object, - query: {broadcast_validation: Schema.String}, + url: "/eth/v2/beacon/blinded_blocks", + method: "POST", + req: { + writeReqJson: ({signedBlindedBlock, broadcastValidation}) => { + const fork = config.getForkName(signedBlindedBlock.message.slot); + return { + body: getBlindedForkTypes(fork).SignedBeaconBlock.toJson(signedBlindedBlock), + + headers: { + [MetaHeader.Version]: fork, + }, + query: {broadcast_validation: broadcastValidation}, + }; + }, + parseReqJson: ({body, headers, query}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlindedBlock: getBlindedForkTypes(fork).SignedBeaconBlock.fromJson(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, + }; + }, + writeReqSsz: ({signedBlindedBlock, broadcastValidation}) => { + const fork = config.getForkName(signedBlindedBlock.message.slot); + return { + body: getBlindedForkTypes(fork).SignedBeaconBlock.serialize(signedBlindedBlock), + headers: { + [MetaHeader.Version]: fork, + }, + query: {broadcast_validation: broadcastValidation}, + }; + }, + parseReqSsz: ({body, headers, query}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlindedBlock: getBlindedForkTypes(fork).SignedBeaconBlock.deserialize(body), + broadcastValidation: query.broadcast_validation as BroadcastValidation, + }; + }, + schema: { + body: Schema.Object, + query: {broadcast_validation: Schema.String}, + headers: {[MetaHeader.Version]: Schema.String}, + }, + }, + resp: EmptyResponseCodec, + init: { + requestWireFormat: WireFormat.ssz, }, }, getBlobSidecars: { - writeReq: (block_id, indices) => ({ - params: {block_id: String(block_id)}, - query: {indices}, - }), - parseReq: ({params, query}) => [params.block_id, query.indices], - schema: { - params: {block_id: Schema.StringRequired}, - query: {indices: Schema.UintArray}, + url: "/eth/v1/beacon/blob_sidecars/{block_id}", + method: "GET", + req: { + writeReq: ({blockId, indices}) => ({params: {block_id: blockId.toString()}, query: {indices}}), + parseReq: ({params, query}) => ({blockId: params.block_id, indices: query.indices}), + schema: {params: {block_id: Schema.StringRequired}, query: {indices: Schema.UintArray}}, + }, + resp: { + data: ssz.deneb.BlobSidecars, + meta: ExecutionOptimisticFinalizedAndVersionCodec, }, }, }; } - -export function getReturnTypes(): ReturnTypes { - const BeaconHeaderResType = new ContainerType({ - root: ssz.Root, - canonical: ssz.Boolean, - header: ssz.phase0.SignedBeaconBlockHeader, - }); - - const RootContainer = new ContainerType({ - root: ssz.Root, - }); - - return { - getBlock: ContainerData(ssz.phase0.SignedBeaconBlock), - getBlockV2: WithFinalized(WithExecutionOptimistic(WithVersion((fork) => ssz[fork].SignedBeaconBlock))), - getBlockAttestations: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(ssz.phase0.Attestation))), - getBlockHeader: WithFinalized(ContainerDataExecutionOptimistic(BeaconHeaderResType)), - getBlockHeaders: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(BeaconHeaderResType))), - getBlockRoot: WithFinalized(ContainerDataExecutionOptimistic(RootContainer)), - getBlobSidecars: WithFinalized(ContainerDataExecutionOptimistic(ssz.deneb.BlobSidecars)), - }; -} diff --git a/packages/api/src/beacon/routes/beacon/index.ts b/packages/api/src/beacon/routes/beacon/index.ts index 95c78f45ea39..f70792f9d76f 100644 --- a/packages/api/src/beacon/routes/beacon/index.ts +++ b/packages/api/src/beacon/routes/beacon/index.ts @@ -1,8 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; import {phase0, ssz} from "@lodestar/types"; -import {ApiClientResponse} from "../../../interfaces.js"; -import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; -import {RoutesData, ReturnTypes, reqEmpty, ContainerData} from "../../../utils/index.js"; +import {Endpoint, RouteDefinitions} from "../../../utils/types.js"; +import {EmptyArgs, EmptyRequestCodec, EmptyMeta, EmptyMetaCodec, EmptyRequest} from "../../../utils/codecs.js"; import * as block from "./block.js"; import * as pool from "./pool.js"; import * as state from "./state.js"; @@ -10,12 +9,11 @@ import * as rewards from "./rewards.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -// NOTE: We choose to split the block, pool, and state namespaces so the files are not too big. +// NOTE: We choose to split the block, pool, state and rewards namespaces so the files are not too big. // However, for a consumer all these methods are within the same service "beacon" export {block, pool, state, rewards}; export {BroadcastValidation} from "./block.js"; export type {BlockId, BlockHeaderResponse} from "./block.js"; -export type {AttestationFilters} from "./pool.js"; export type { BlockRewards, AttestationsRewards, @@ -28,8 +26,6 @@ export type { StateId, ValidatorId, ValidatorStatus, - ValidatorFilters, - CommitteesFilters, FinalityCheckpoints, ValidatorResponse, ValidatorBalance, @@ -37,42 +33,34 @@ export type { EpochSyncCommitteeResponse, } from "./state.js"; -export type Api = block.Api & - pool.Api & - state.Api & - rewards.Api & { - getGenesis(): Promise>; +export type Endpoints = block.Endpoints & + pool.Endpoints & + state.Endpoints & + rewards.Endpoints & { + getGenesis: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + phase0.Genesis, + EmptyMeta + >; }; -export const routesData: RoutesData = { - getGenesis: {url: "/eth/v1/beacon/genesis", method: "GET"}, - ...block.routesData, - ...pool.routesData, - ...state.routesData, - ...rewards.routesData, -}; - -export type ReqTypes = { - [K in keyof ReturnType]: ReturnType[K]["writeReq"]>; -}; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function getReqSerializers(config: ChainForkConfig) { - return { - getGenesis: reqEmpty, - ...block.getReqSerializers(config), - ...pool.getReqSerializers(), - ...state.getReqSerializers(), - ...rewards.getReqSerializers(), - }; -} - -export function getReturnTypes(): ReturnTypes { +export function getDefinitions(config: ChainForkConfig): RouteDefinitions { return { - getGenesis: ContainerData(ssz.phase0.Genesis), - ...block.getReturnTypes(), - ...pool.getReturnTypes(), - ...state.getReturnTypes(), - ...rewards.getReturnTypes(), + getGenesis: { + url: "/eth/v1/beacon/genesis", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: ssz.phase0.Genesis, + meta: EmptyMetaCodec, + }, + }, + ...block.getDefinitions(config), + ...pool.getDefinitions(config), + ...state.getDefinitions(config), + ...rewards.getDefinitions(config), }; } diff --git a/packages/api/src/beacon/routes/beacon/pool.ts b/packages/api/src/beacon/routes/beacon/pool.ts index 65467e98b721..f957390131fe 100644 --- a/packages/api/src/beacon/routes/beacon/pool.ts +++ b/packages/api/src/beacon/routes/beacon/pool.ts @@ -1,70 +1,98 @@ -import {phase0, altair, capella, CommitteeIndex, Slot, ssz} from "@lodestar/types"; -import {ApiClientResponse} from "../../../interfaces.js"; -import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {phase0, capella, CommitteeIndex, Slot, ssz} from "@lodestar/types"; +import {Schema, Endpoint, RouteDefinitions} from "../../../utils/index.js"; import { - RoutesData, - ReturnTypes, ArrayOf, - Schema, - reqOnlyBody, - ReqSerializers, - reqEmpty, - ReqEmpty, - ContainerData, -} from "../../../utils/index.js"; + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, + EmptyResponseCodec, + EmptyResponseData, +} from "../../../utils/codecs.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type AttestationFilters = { - slot: Slot; - committeeIndex: CommitteeIndex; -}; +const AttestationListType = ArrayOf(ssz.phase0.Attestation); +const AttesterSlashingListType = ArrayOf(ssz.phase0.AttesterSlashing); +const ProposerSlashingListType = ArrayOf(ssz.phase0.ProposerSlashing); +const SignedVoluntaryExitListType = ArrayOf(ssz.phase0.SignedVoluntaryExit); +const SignedBLSToExecutionChangeListType = ArrayOf(ssz.capella.SignedBLSToExecutionChange); +const SyncCommitteeMessageListType = ArrayOf(ssz.altair.SyncCommitteeMessage); + +type AttestationList = ValueOf; +type AttesterSlashingList = ValueOf; +type ProposerSlashingList = ValueOf; +type SignedVoluntaryExitList = ValueOf; +type SignedBLSToExecutionChangeList = ValueOf; +type SyncCommitteeMessageList = ValueOf; -export type Api = { +export type Endpoints = { /** * Get Attestations from operations pool * Retrieves attestations known by the node but not necessarily incorporated into any block - * @param slot - * @param committeeIndex - * @returns any Successful response - * @throws ApiError */ - getPoolAttestations( - filters?: Partial - ): Promise>; + getPoolAttestations: Endpoint< + "GET", + {slot?: Slot; committeeIndex?: CommitteeIndex}, + {query: {slot?: number; committee_index?: number}}, + AttestationList, + EmptyMeta + >; /** * Get AttesterSlashings from operations pool * Retrieves attester slashings known by the node but not necessarily incorporated into any block - * @returns any Successful response - * @throws ApiError */ - getPoolAttesterSlashings(): Promise>; + getPoolAttesterSlashings: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + AttesterSlashingList, + EmptyMeta + >; /** * Get ProposerSlashings from operations pool * Retrieves proposer slashings known by the node but not necessarily incorporated into any block - * @returns any Successful response - * @throws ApiError */ - getPoolProposerSlashings(): Promise>; + getPoolProposerSlashings: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + ProposerSlashingList, + EmptyMeta + >; /** * Get SignedVoluntaryExit from operations pool * Retrieves voluntary exits known by the node but not necessarily incorporated into any block - * @returns any Successful response - * @throws ApiError */ - getPoolVoluntaryExits(): Promise>; + getPoolVoluntaryExits: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SignedVoluntaryExitList, + EmptyMeta + >; /** * Get SignedBLSToExecutionChange from operations pool * Retrieves BLSToExecutionChange known by the node but not necessarily incorporated into any block - * @returns any Successful response - * @throws ApiError */ - getPoolBlsToExecutionChanges(): Promise< - ApiClientResponse<{[HttpStatusCode.OK]: {data: capella.SignedBLSToExecutionChange[]}}> + getPoolBLSToExecutionChanges: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SignedBLSToExecutionChangeList, + EmptyMeta >; /** @@ -74,125 +102,214 @@ export type Api = { * If an attestation is validated successfully the node MUST publish that attestation on the appropriate subnet. * * If one or more attestations fail validation the node MUST return a 400 error with details of which attestations have failed, and why. - * - * @param requestBody - * @returns any Attestations are stored in pool and broadcast on appropriate subnet - * @throws ApiError */ - submitPoolAttestations( - attestations: phase0.Attestation[] - ): Promise>; + submitPoolAttestations: Endpoint< + "POST", + {signedAttestations: AttestationList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** * Submit AttesterSlashing object to node's pool * Submits AttesterSlashing object to node's pool and if passes validation node MUST broadcast it to network. - * @param requestBody - * @returns any Success - * @throws ApiError */ - submitPoolAttesterSlashings( - slashing: phase0.AttesterSlashing - ): Promise>; + submitPoolAttesterSlashings: Endpoint< + "POST", + {attesterSlashing: phase0.AttesterSlashing}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** * Submit ProposerSlashing object to node's pool * Submits ProposerSlashing object to node's pool and if passes validation node MUST broadcast it to network. - * @param requestBody - * @returns any Success - * @throws ApiError */ - submitPoolProposerSlashings( - slashing: phase0.ProposerSlashing - ): Promise>; + submitPoolProposerSlashings: Endpoint< + "POST", + {proposerSlashing: phase0.ProposerSlashing}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** * Submit SignedVoluntaryExit object to node's pool * Submits SignedVoluntaryExit object to node's pool and if passes validation node MUST broadcast it to network. - * @param requestBody - * @returns any Voluntary exit is stored in node and broadcasted to network - * @throws ApiError */ - submitPoolVoluntaryExit( - exit: phase0.SignedVoluntaryExit - ): Promise>; + submitPoolVoluntaryExit: Endpoint< + "POST", + {signedVoluntaryExit: phase0.SignedVoluntaryExit}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** - * Submit SignedBLSToExecutionChange object to node's pool - * Submits SignedBLSToExecutionChange object to node's pool and if passes validation node MUST broadcast it to network. - * @param requestBody - * @returns any BLSToExecutionChange is stored in node and broadcasted to network - * @throws ApiError + * Submit SignedBLSToExecutionChange objects to node's pool + * Submits SignedBLSToExecutionChange objects to node's pool and if passes validation node MUST broadcast it to network. */ - submitPoolBlsToExecutionChange( - blsToExecutionChange: capella.SignedBLSToExecutionChange[] - ): Promise>; + submitPoolBLSToExecutionChange: Endpoint< + "POST", + {blsToExecutionChanges: capella.SignedBLSToExecutionChange[]}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** - * TODO: Add description + * Submit SyncCommitteeMessage objects to node's pool + * Submits SyncCommitteeMessage objects to node's pool and if passes validation node MUST broadcast it to network. */ - submitPoolSyncCommitteeSignatures( - signatures: altair.SyncCommitteeMessage[] - ): Promise>; -}; - -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getPoolAttestations: {url: "/eth/v1/beacon/pool/attestations", method: "GET"}, - getPoolAttesterSlashings: {url: "/eth/v1/beacon/pool/attester_slashings", method: "GET"}, - getPoolProposerSlashings: {url: "/eth/v1/beacon/pool/proposer_slashings", method: "GET"}, - getPoolVoluntaryExits: {url: "/eth/v1/beacon/pool/voluntary_exits", method: "GET"}, - getPoolBlsToExecutionChanges: {url: "/eth/v1/beacon/pool/bls_to_execution_changes", method: "GET"}, - submitPoolAttestations: {url: "/eth/v1/beacon/pool/attestations", method: "POST"}, - submitPoolAttesterSlashings: {url: "/eth/v1/beacon/pool/attester_slashings", method: "POST"}, - submitPoolProposerSlashings: {url: "/eth/v1/beacon/pool/proposer_slashings", method: "POST"}, - submitPoolVoluntaryExit: {url: "/eth/v1/beacon/pool/voluntary_exits", method: "POST"}, - submitPoolBlsToExecutionChange: {url: "/eth/v1/beacon/pool/bls_to_execution_changes", method: "POST"}, - submitPoolSyncCommitteeSignatures: {url: "/eth/v1/beacon/pool/sync_committees", method: "POST"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ -export type ReqTypes = { - getPoolAttestations: {query: {slot?: number; committee_index?: number}}; - getPoolAttesterSlashings: ReqEmpty; - getPoolProposerSlashings: ReqEmpty; - getPoolVoluntaryExits: ReqEmpty; - getPoolBlsToExecutionChanges: ReqEmpty; - submitPoolAttestations: {body: unknown}; - submitPoolAttesterSlashings: {body: unknown}; - submitPoolProposerSlashings: {body: unknown}; - submitPoolVoluntaryExit: {body: unknown}; - submitPoolBlsToExecutionChange: {body: unknown}; - submitPoolSyncCommitteeSignatures: {body: unknown}; + submitPoolSyncCommitteeSignatures: Endpoint< + "POST", + {signatures: SyncCommitteeMessageList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; }; -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { getPoolAttestations: { - writeReq: (filters) => ({query: {slot: filters?.slot, committee_index: filters?.committeeIndex}}), - parseReq: ({query}) => [{slot: query.slot, committeeIndex: query.committee_index}], - schema: {query: {slot: Schema.Uint, committee_index: Schema.Uint}}, + url: "/eth/v1/beacon/pool/attestations", + method: "GET", + req: { + writeReq: ({slot, committeeIndex}) => ({query: {slot, committee_index: committeeIndex}}), + parseReq: ({query}) => ({slot: query.slot, committeeIndex: query.committee_index}), + schema: {query: {slot: Schema.Uint, committee_index: Schema.Uint}}, + }, + resp: { + data: AttestationListType, + meta: EmptyMetaCodec, + }, + }, + getPoolAttesterSlashings: { + url: "/eth/v1/beacon/pool/attester_slashings", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: AttesterSlashingListType, + meta: EmptyMetaCodec, + }, + }, + getPoolProposerSlashings: { + url: "/eth/v1/beacon/pool/proposer_slashings", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: ProposerSlashingListType, + meta: EmptyMetaCodec, + }, + }, + getPoolVoluntaryExits: { + url: "/eth/v1/beacon/pool/voluntary_exits", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: SignedVoluntaryExitListType, + meta: EmptyMetaCodec, + }, + }, + getPoolBLSToExecutionChanges: { + url: "/eth/v1/beacon/pool/bls_to_execution_changes", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: SignedBLSToExecutionChangeListType, + meta: EmptyMetaCodec, + }, + }, + submitPoolAttestations: { + url: "/eth/v1/beacon/pool/attestations", + method: "POST", + req: { + writeReqJson: ({signedAttestations}) => ({body: AttestationListType.toJson(signedAttestations)}), + parseReqJson: ({body}) => ({signedAttestations: AttestationListType.fromJson(body)}), + writeReqSsz: ({signedAttestations}) => ({body: AttestationListType.serialize(signedAttestations)}), + parseReqSsz: ({body}) => ({signedAttestations: AttestationListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, + }, + submitPoolAttesterSlashings: { + url: "/eth/v1/beacon/pool/attester_slashings", + method: "POST", + req: { + writeReqJson: ({attesterSlashing}) => ({body: ssz.phase0.AttesterSlashing.toJson(attesterSlashing)}), + parseReqJson: ({body}) => ({attesterSlashing: ssz.phase0.AttesterSlashing.fromJson(body)}), + writeReqSsz: ({attesterSlashing}) => ({body: ssz.phase0.AttesterSlashing.serialize(attesterSlashing)}), + parseReqSsz: ({body}) => ({attesterSlashing: ssz.phase0.AttesterSlashing.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + }, + submitPoolProposerSlashings: { + url: "/eth/v1/beacon/pool/proposer_slashings", + method: "POST", + req: { + writeReqJson: ({proposerSlashing}) => ({body: ssz.phase0.ProposerSlashing.toJson(proposerSlashing)}), + parseReqJson: ({body}) => ({proposerSlashing: ssz.phase0.ProposerSlashing.fromJson(body)}), + writeReqSsz: ({proposerSlashing}) => ({body: ssz.phase0.ProposerSlashing.serialize(proposerSlashing)}), + parseReqSsz: ({body}) => ({proposerSlashing: ssz.phase0.ProposerSlashing.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + }, + submitPoolVoluntaryExit: { + url: "/eth/v1/beacon/pool/voluntary_exits", + method: "POST", + req: { + writeReqJson: ({signedVoluntaryExit}) => ({body: ssz.phase0.SignedVoluntaryExit.toJson(signedVoluntaryExit)}), + parseReqJson: ({body}) => ({signedVoluntaryExit: ssz.phase0.SignedVoluntaryExit.fromJson(body)}), + writeReqSsz: ({signedVoluntaryExit}) => ({body: ssz.phase0.SignedVoluntaryExit.serialize(signedVoluntaryExit)}), + parseReqSsz: ({body}) => ({signedVoluntaryExit: ssz.phase0.SignedVoluntaryExit.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + }, + submitPoolBLSToExecutionChange: { + url: "/eth/v1/beacon/pool/bls_to_execution_changes", + method: "POST", + req: { + writeReqJson: ({blsToExecutionChanges}) => ({ + body: SignedBLSToExecutionChangeListType.toJson(blsToExecutionChanges), + }), + parseReqJson: ({body}) => ({blsToExecutionChanges: SignedBLSToExecutionChangeListType.fromJson(body)}), + writeReqSsz: ({blsToExecutionChanges}) => ({ + body: SignedBLSToExecutionChangeListType.serialize(blsToExecutionChanges), + }), + parseReqSsz: ({body}) => ({blsToExecutionChanges: SignedBLSToExecutionChangeListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, + }, + submitPoolSyncCommitteeSignatures: { + url: "/eth/v1/beacon/pool/sync_committees", + method: "POST", + req: { + writeReqJson: ({signatures}) => ({body: SyncCommitteeMessageListType.toJson(signatures)}), + parseReqJson: ({body}) => ({signatures: SyncCommitteeMessageListType.fromJson(body)}), + writeReqSsz: ({signatures}) => ({body: SyncCommitteeMessageListType.serialize(signatures)}), + parseReqSsz: ({body}) => ({signatures: SyncCommitteeMessageListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, }, - getPoolAttesterSlashings: reqEmpty, - getPoolProposerSlashings: reqEmpty, - getPoolVoluntaryExits: reqEmpty, - getPoolBlsToExecutionChanges: reqEmpty, - submitPoolAttestations: reqOnlyBody(ArrayOf(ssz.phase0.Attestation), Schema.ObjectArray), - submitPoolAttesterSlashings: reqOnlyBody(ssz.phase0.AttesterSlashing, Schema.Object), - submitPoolProposerSlashings: reqOnlyBody(ssz.phase0.ProposerSlashing, Schema.Object), - submitPoolVoluntaryExit: reqOnlyBody(ssz.phase0.SignedVoluntaryExit, Schema.Object), - submitPoolBlsToExecutionChange: reqOnlyBody(ArrayOf(ssz.capella.SignedBLSToExecutionChange), Schema.ObjectArray), - submitPoolSyncCommitteeSignatures: reqOnlyBody(ArrayOf(ssz.altair.SyncCommitteeMessage), Schema.ObjectArray), - }; -} - -export function getReturnTypes(): ReturnTypes { - return { - getPoolAttestations: ContainerData(ArrayOf(ssz.phase0.Attestation)), - getPoolAttesterSlashings: ContainerData(ArrayOf(ssz.phase0.AttesterSlashing)), - getPoolProposerSlashings: ContainerData(ArrayOf(ssz.phase0.ProposerSlashing)), - getPoolVoluntaryExits: ContainerData(ArrayOf(ssz.phase0.SignedVoluntaryExit)), - getPoolBlsToExecutionChanges: ContainerData(ArrayOf(ssz.capella.SignedBLSToExecutionChange)), }; } diff --git a/packages/api/src/beacon/routes/beacon/rewards.ts b/packages/api/src/beacon/routes/beacon/rewards.ts index f2136760c290..c65282625343 100644 --- a/packages/api/src/beacon/routes/beacon/rewards.ts +++ b/packages/api/src/beacon/routes/beacon/rewards.ts @@ -1,82 +1,107 @@ -import {ContainerType} from "@chainsafe/ssz"; -import {Epoch, ssz, ValidatorIndex} from "@lodestar/types"; - -import { - RoutesData, - ReturnTypes, - Schema, - ReqSerializers, - ContainerDataExecutionOptimistic, - ArrayOf, - WithFinalized, -} from "../../../utils/index.js"; -import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../../interfaces.js"; -import {BlockId} from "./block.js"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {Epoch, ssz} from "@lodestar/types"; + +import {Schema, Endpoint, RouteDefinitions} from "../../../utils/index.js"; +import {fromValidatorIdsStr, toValidatorIdsStr} from "../../../utils/serdes.js"; +import {ArrayOf, JsonOnlyReq} from "../../../utils/codecs.js"; +import {ExecutionOptimisticAndFinalizedCodec, ExecutionOptimisticAndFinalizedMeta} from "../../../utils/metadata.js"; +import {BlockArgs} from "./block.js"; import {ValidatorId} from "./state.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -/** - * True if the response references an unverified execution payload. Optimistic information may be invalidated at - * a later time. If the field is not present, assume the False value. - */ -export type ExecutionOptimistic = boolean; - -/** - * True if the response references the finalized history of the chain, as determined by fork choice. - */ -export type Finalized = boolean; +const BlockRewardsType = new ContainerType( + { + /** Proposer of the block, the proposer index who receives these rewards */ + proposerIndex: ssz.ValidatorIndex, + /** Total block reward, equal to attestations + sync_aggregate + proposer_slashings + attester_slashings */ + total: ssz.UintNum64, + /** Block reward component due to included attestations */ + attestations: ssz.UintNum64, + /** Block reward component due to included sync_aggregate */ + syncAggregate: ssz.UintNum64, + /** Block reward component due to included proposer_slashings */ + proposerSlashings: ssz.UintNum64, + /** Block reward component due to included attester_slashings */ + attesterSlashings: ssz.UintNum64, + }, + {jsonCase: "eth2"} +); + +const AttestationsRewardType = new ContainerType( + { + /** Reward for head vote. Could be negative to indicate penalty */ + head: ssz.UintNum64, + /** Reward for target vote. Could be negative to indicate penalty */ + target: ssz.UintNum64, + /** Reward for source vote. Could be negative to indicate penalty */ + source: ssz.UintNum64, + /** Inclusion delay reward (phase0 only) */ + inclusionDelay: ssz.UintNum64, + /** Inactivity penalty. Should be a negative number to indicate penalty */ + inactivity: ssz.UintNum64, + }, + {jsonCase: "eth2"} +); + +const IdealAttestationsRewardsType = new ContainerType( + { + ...AttestationsRewardType.fields, + effectiveBalance: ssz.UintNum64, + }, + {jsonCase: "eth2"} +); + +const TotalAttestationsRewardsType = new ContainerType( + { + ...AttestationsRewardType.fields, + validatorIndex: ssz.ValidatorIndex, + }, + {jsonCase: "eth2"} +); + +const AttestationsRewardsType = new ContainerType( + { + idealRewards: ArrayOf(IdealAttestationsRewardsType), + totalRewards: ArrayOf(TotalAttestationsRewardsType), + }, + {jsonCase: "eth2"} +); + +const SyncCommitteeRewardsType = ArrayOf( + new ContainerType( + { + validatorIndex: ssz.ValidatorIndex, + reward: ssz.UintNum64, + }, + {jsonCase: "eth2"} + ) +); /** * Rewards info for a single block. Every reward value is in Gwei. */ -export type BlockRewards = { - /** Proposer of the block, the proposer index who receives these rewards */ - proposerIndex: ValidatorIndex; - /** Total block reward, equal to attestations + sync_aggregate + proposer_slashings + attester_slashings */ - total: number; - /** Block reward component due to included attestations */ - attestations: number; - /** Block reward component due to included sync_aggregate */ - syncAggregate: number; - /** Block reward component due to included proposer_slashings */ - proposerSlashings: number; - /** Block reward component due to included attester_slashings */ - attesterSlashings: number; -}; +export type BlockRewards = ValueOf; /** * Rewards for a single set of (ideal or actual depending on usage) attestations. Reward value is in Gwei */ -type AttestationsReward = { - /** Reward for head vote. Could be negative to indicate penalty */ - head: number; - /** Reward for target vote. Could be negative to indicate penalty */ - target: number; - /** Reward for source vote. Could be negative to indicate penalty */ - source: number; - /** Inclusion delay reward (phase0 only) */ - inclusionDelay: number; - /** Inactivity penalty. Should be a negative number to indicate penalty */ - inactivity: number; -}; +export type AttestationsReward = ValueOf; /** * Rewards info for ideal attestations ie. Maximum rewards could be earned by making timely head, target and source vote. * `effectiveBalance` is in Gwei */ -export type IdealAttestationsReward = AttestationsReward & {effectiveBalance: number}; +export type IdealAttestationsReward = ValueOf; /** * Rewards info for actual attestations */ -export type TotalAttestationsReward = AttestationsReward & {validatorIndex: ValidatorIndex}; +export type TotalAttestationsReward = ValueOf; -export type AttestationsRewards = { - idealRewards: IdealAttestationsReward[]; - totalRewards: TotalAttestationsReward[]; -}; +export type AttestationsRewards = ValueOf; /** * Rewards info for sync committee participation. Every reward value is in Gwei. @@ -84,174 +109,112 @@ export type AttestationsRewards = { * participating in sync committee. Please refer to `BlockRewards.syncAggregate` for rewards of proposer including sync committee * outputs into their block */ -export type SyncCommitteeRewards = {validatorIndex: ValidatorIndex; reward: number}[]; +export type SyncCommitteeRewards = ValueOf; -export type Api = { +export type Endpoints = { /** * Get block rewards * Returns the info of rewards received by the block proposer - * - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. */ - getBlockRewards(blockId: BlockId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: BlockRewards; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getBlockRewards: Endpoint< + "GET", + BlockArgs, + {params: {block_id: string}}, + BlockRewards, + ExecutionOptimisticAndFinalizedMeta >; + /** * Get attestations rewards * Negative values indicate penalties. `inactivity` can only be either 0 or negative number since it is penalty only - * - * @param epoch The epoch to get rewards info from - * @param validatorIds List of validator indices or pubkeys to filter in */ - getAttestationsRewards( - epoch: Epoch, - validatorIds?: ValidatorId[] - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: AttestationsRewards; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getAttestationsRewards: Endpoint< + "POST", + { + /** The epoch to get rewards info from */ + epoch: Epoch; + /** List of validator indices or pubkeys to filter in */ + validatorIds?: ValidatorId[]; + }, + {params: {epoch: number}; body: string[]}, + AttestationsRewards, + ExecutionOptimisticAndFinalizedMeta >; /** * Get sync committee rewards * Returns participant reward value for each sync committee member at the given block. - * - * @param blockId Block identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", \, \. - * @param validatorIds List of validator indices or pubkeys to filter in */ - getSyncCommitteeRewards( - blockId: BlockId, - validatorIds?: ValidatorId[] - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: SyncCommitteeRewards; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getSyncCommitteeRewards: Endpoint< + "POST", + BlockArgs & { + /** List of validator indices or pubkeys to filter in */ + validatorIds?: ValidatorId[]; + }, + {params: {block_id: string}; body: string[]}, + SyncCommitteeRewards, + ExecutionOptimisticAndFinalizedMeta >; }; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getBlockRewards: {url: "/eth/v1/beacon/rewards/blocks/{block_id}", method: "GET"}, - getAttestationsRewards: {url: "/eth/v1/beacon/rewards/attestations/{epoch}", method: "POST"}, - getSyncCommitteeRewards: {url: "/eth/v1/beacon/rewards/sync_committee/{block_id}", method: "POST"}, -}; - -export type ReqTypes = { - /* eslint-disable @typescript-eslint/naming-convention */ - getBlockRewards: {params: {block_id: string}}; - getAttestationsRewards: {params: {epoch: number}; body: ValidatorId[]}; - getSyncCommitteeRewards: {params: {block_id: string}; body: ValidatorId[]}; -}; - -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { getBlockRewards: { - writeReq: (block_id) => ({params: {block_id: String(block_id)}}), - parseReq: ({params}) => [params.block_id], - schema: {params: {block_id: Schema.StringRequired}}, + url: "/eth/v1/beacon/rewards/blocks/{block_id}", + method: "GET", + req: { + writeReq: ({blockId}) => ({params: {block_id: blockId.toString()}}), + parseReq: ({params}) => ({blockId: params.block_id}), + schema: {params: {block_id: Schema.StringRequired}}, + }, + resp: { + data: BlockRewardsType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, }, getAttestationsRewards: { - writeReq: (epoch, validatorIds) => ({params: {epoch: epoch}, body: validatorIds || []}), - parseReq: ({params, body}) => [params.epoch, body], - schema: { - params: {epoch: Schema.UintRequired}, - body: Schema.UintOrStringArray, + url: "/eth/v1/beacon/rewards/attestations/{epoch}", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({epoch, validatorIds}) => ({ + params: {epoch}, + body: toValidatorIdsStr(validatorIds) || [], + }), + parseReqJson: ({params, body}) => ({ + epoch: params.epoch, + validatorIds: fromValidatorIdsStr(body), + }), + schema: { + params: {epoch: Schema.UintRequired}, + body: Schema.UintOrStringArray, + }, + }), + resp: { + data: AttestationsRewardsType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, getSyncCommitteeRewards: { - writeReq: (block_id, validatorIds) => ({params: {block_id: String(block_id)}, body: validatorIds || []}), - parseReq: ({params, body}) => [params.block_id, body], - schema: { - params: {block_id: Schema.StringRequired}, - body: Schema.UintOrStringArray, + url: "/eth/v1/beacon/rewards/sync_committee/{block_id}", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({blockId, validatorIds}) => ({ + params: {block_id: blockId.toString()}, + body: toValidatorIdsStr(validatorIds) || [], + }), + parseReqJson: ({params, body}) => ({ + blockId: params.block_id, + validatorIds: fromValidatorIdsStr(body), + }), + schema: { + params: {block_id: Schema.StringRequired}, + body: Schema.UintOrStringArray, + }, + }), + resp: { + data: SyncCommitteeRewardsType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, }; } - -export function getReturnTypes(): ReturnTypes { - const BlockRewardsResponse = new ContainerType( - { - proposerIndex: ssz.ValidatorIndex, - total: ssz.UintNum64, - attestations: ssz.UintNum64, - syncAggregate: ssz.UintNum64, - proposerSlashings: ssz.UintNum64, - attesterSlashings: ssz.UintNum64, - }, - {jsonCase: "eth2"} - ); - - const IdealAttestationsRewardsResponse = new ContainerType( - { - head: ssz.UintNum64, - target: ssz.UintNum64, - source: ssz.UintNum64, - inclusionDelay: ssz.UintNum64, - inactivity: ssz.UintNum64, - effectiveBalance: ssz.UintNum64, - }, - {jsonCase: "eth2"} - ); - - const TotalAttestationsRewardsResponse = new ContainerType( - { - head: ssz.UintNum64, - target: ssz.UintNum64, - source: ssz.UintNum64, - inclusionDelay: ssz.UintNum64, - inactivity: ssz.UintNum64, - validatorIndex: ssz.ValidatorIndex, - }, - {jsonCase: "eth2"} - ); - - const AttestationsRewardsResponse = new ContainerType( - { - idealRewards: ArrayOf(IdealAttestationsRewardsResponse), - totalRewards: ArrayOf(TotalAttestationsRewardsResponse), - }, - {jsonCase: "eth2"} - ); - - const SyncCommitteeRewardsResponse = new ContainerType( - { - validatorIndex: ssz.ValidatorIndex, - reward: ssz.UintNum64, - }, - {jsonCase: "eth2"} - ); - - return { - getBlockRewards: WithFinalized(ContainerDataExecutionOptimistic(BlockRewardsResponse)), - getAttestationsRewards: WithFinalized(ContainerDataExecutionOptimistic(AttestationsRewardsResponse)), - getSyncCommitteeRewards: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(SyncCommitteeRewardsResponse))), - }; -} diff --git a/packages/api/src/beacon/routes/beacon/state.ts b/packages/api/src/beacon/routes/beacon/state.ts index 0c3875f8a3de..4f8d414f5b6c 100644 --- a/packages/api/src/beacon/routes/beacon/state.ts +++ b/packages/api/src/beacon/routes/beacon/state.ts @@ -1,34 +1,28 @@ -import {ContainerType} from "@chainsafe/ssz"; -import {phase0, CommitteeIndex, Slot, ValidatorIndex, Epoch, Root, ssz, StringType, RootHex} from "@lodestar/types"; -import {ApiClientResponse} from "../../../interfaces.js"; -import {HttpStatusCode} from "../../../utils/client/httpStatusCode.js"; -import {fromU64Str, toU64Str} from "../../../utils/serdes.js"; -import { - RoutesData, - ReturnTypes, - ArrayOf, - ContainerDataExecutionOptimistic, - Schema, - ReqSerializers, - ReqSerializer, - WithFinalized, -} from "../../../utils/index.js"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {MAX_VALIDATORS_PER_COMMITTEE} from "@lodestar/params"; +import {phase0, CommitteeIndex, Slot, Epoch, ssz, RootHex, StringType} from "@lodestar/types"; +import {Endpoint, RequestCodec, RouteDefinitions, Schema} from "../../../utils/index.js"; +import {ArrayOf, JsonOnlyReq} from "../../../utils/codecs.js"; +import {ExecutionOptimisticAndFinalizedCodec, ExecutionOptimisticAndFinalizedMeta} from "../../../utils/metadata.js"; +import {fromValidatorIdsStr, toValidatorIdsStr} from "../../../utils/serdes.js"; +import {WireFormat} from "../../../utils/wireFormat.js"; +import {RootResponse, RootResponseType} from "./block.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes export type StateId = RootHex | Slot | "head" | "genesis" | "finalized" | "justified"; -export type ValidatorId = string | number; -/** - * True if the response references an unverified execution payload. Optimistic information may be invalidated at - * a later time. If the field is not present, assume the False value. - */ -export type ExecutionOptimistic = boolean; +export type StateArgs = { + /** + * State identifier. + * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. + */ + stateId: StateId; +}; -/** - * True if the response references the finalized history of the chain, as determined by fork choice. - */ -export type Finalized = boolean; +export type ValidatorId = string | number; export type ValidatorStatus = | "active" @@ -42,492 +36,411 @@ export type ValidatorStatus = | "withdrawal_possible" | "withdrawal_done"; -export type ValidatorFilters = { - id?: ValidatorId[]; - status?: ValidatorStatus[]; -}; -export type CommitteesFilters = { - epoch?: Epoch; - index?: CommitteeIndex; - slot?: Slot; -}; - -export type FinalityCheckpoints = { - previousJustified: phase0.Checkpoint; - currentJustified: phase0.Checkpoint; - finalized: phase0.Checkpoint; -}; - -export type ValidatorResponse = { - index: ValidatorIndex; - balance: number; - status: ValidatorStatus; - validator: phase0.Validator; -}; - -export type ValidatorBalance = { - index: ValidatorIndex; - balance: number; -}; - -export type EpochCommitteeResponse = { - index: CommitteeIndex; - slot: Slot; - validators: ArrayLike; -}; - -export type EpochSyncCommitteeResponse = { - /** all of the validator indices in the current sync committee */ - validators: ValidatorIndex[]; - // TODO: This property will likely be deprecated - /** Subcommittee slices of the current sync committee */ - validatorAggregates: ValidatorIndex[][]; -}; - -export type Api = { +export const RandaoResponseType = new ContainerType({ + randao: ssz.Root, +}); +export const FinalityCheckpointsType = new ContainerType( + { + previousJustified: ssz.phase0.Checkpoint, + currentJustified: ssz.phase0.Checkpoint, + finalized: ssz.phase0.Checkpoint, + }, + {jsonCase: "eth2"} +); +export const ValidatorResponseType = new ContainerType({ + index: ssz.ValidatorIndex, + balance: ssz.UintNum64, + status: new StringType(), + validator: ssz.phase0.Validator, +}); +export const EpochCommitteeResponseType = new ContainerType({ + index: ssz.CommitteeIndex, + slot: ssz.Slot, + validators: ArrayOf(ssz.ValidatorIndex, MAX_VALIDATORS_PER_COMMITTEE), +}); +export const ValidatorBalanceType = new ContainerType({ + index: ssz.ValidatorIndex, + balance: ssz.UintNum64, +}); +export const EpochSyncCommitteeResponseType = new ContainerType( + { + /** All of the validator indices in the current sync committee */ + validators: ArrayOf(ssz.ValidatorIndex), + // TODO: This property will likely be deprecated + /** Subcommittee slices of the current sync committee */ + validatorAggregates: ArrayOf(ArrayOf(ssz.ValidatorIndex)), + }, + {jsonCase: "eth2"} +); +export const ValidatorResponseListType = ArrayOf(ValidatorResponseType); +export const EpochCommitteeResponseListType = ArrayOf(EpochCommitteeResponseType); +export const ValidatorBalanceListType = ArrayOf(ValidatorBalanceType); + +export type RandaoResponse = ValueOf; +export type FinalityCheckpoints = ValueOf; +export type ValidatorResponse = ValueOf; +export type EpochCommitteeResponse = ValueOf; +export type ValidatorBalance = ValueOf; +export type EpochSyncCommitteeResponse = ValueOf; + +export type ValidatorResponseList = ValueOf; +export type EpochCommitteeResponseList = ValueOf; +export type ValidatorBalanceList = ValueOf; + +export type Endpoints = { /** * Get state SSZ HashTreeRoot * Calculates HashTreeRoot for state with given 'stateId'. If stateId is root, same value will be returned. - * - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. */ - getStateRoot(stateId: StateId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: {root: Root}; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateRoot: Endpoint< + "GET", + StateArgs, + {params: {state_id: string}}, + RootResponse, + ExecutionOptimisticAndFinalizedMeta >; /** * Get Fork object for requested state * Returns [Fork](https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/beacon-chain.md#fork) object for state with given 'stateId'. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. */ - getStateFork(stateId: StateId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: phase0.Fork; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateFork: Endpoint< + "GET", + StateArgs, + {params: {state_id: string}}, + phase0.Fork, + ExecutionOptimisticAndFinalizedMeta >; /** * Fetch the RANDAO mix for the requested epoch from the state identified by 'stateId'. - * - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param epoch Fetch randao mix for the given epoch. If an epoch is not specified then the RANDAO mix for the state's current epoch will be returned. */ - getStateRandao( - stateId: StateId, - epoch?: Epoch - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: {randao: Root}; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateRandao: Endpoint< + "GET", + StateArgs & { + /** + * Fetch randao mix for the given epoch. If an epoch is not specified + * then the RANDAO mix for the state's current epoch will be returned. + */ + epoch?: Epoch; + }, + {params: {state_id: string}; query: {epoch?: number}}, + RandaoResponse, + ExecutionOptimisticAndFinalizedMeta >; /** * Get state finality checkpoints * Returns finality checkpoints for state with given 'stateId'. * In case finality is not yet achieved, checkpoint should return epoch 0 and ZERO_HASH as root. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. */ - getStateFinalityCheckpoints(stateId: StateId): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: FinalityCheckpoints; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateFinalityCheckpoints: Endpoint< + "GET", + StateArgs, + {params: {state_id: string}}, + FinalityCheckpoints, + ExecutionOptimisticAndFinalizedMeta >; /** - * Get validators from state - * Returns filterable list of validators with their balance, status and index. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param id Either hex encoded public key (with 0x prefix) or validator index - * @param status [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ) + * Get validator from state by id + * Returns validator specified by state and id or public key along with status and balance. */ - getStateValidators( - stateId: StateId, - filters?: ValidatorFilters - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: ValidatorResponse[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateValidator: Endpoint< + "GET", + StateArgs & { + /** Either hex encoded public key (with 0x prefix) or validator index */ + validatorId: ValidatorId; + }, + {params: {state_id: string; validator_id: ValidatorId}}, + ValidatorResponse, + ExecutionOptimisticAndFinalizedMeta >; /** * Get validators from state * Returns filterable list of validators with their balance, status and index. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param id Either hex encoded public key (with 0x prefix) or validator index - * @param status [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ) */ - postStateValidators( - stateId: StateId, - filters?: ValidatorFilters - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: ValidatorResponse[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getStateValidators: Endpoint< + "GET", + StateArgs & { + /** Either hex encoded public key (with 0x prefix) or validator index */ + validatorIds?: ValidatorId[]; + /** [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ) */ + statuses?: ValidatorStatus[]; + }, + {params: {state_id: string}; query: {id?: ValidatorId[]; status?: ValidatorStatus[]}}, + ValidatorResponseList, + ExecutionOptimisticAndFinalizedMeta >; /** - * Get validator from state by id - * Returns validator specified by state and id or public key along with status and balance. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param validatorId Either hex encoded public key (with 0x prefix) or validator index + * Get validators from state + * Returns filterable list of validators with their balance, status and index. */ - getStateValidator( - stateId: StateId, - validatorId: ValidatorId - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: ValidatorResponse; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + postStateValidators: Endpoint< + "POST", + StateArgs & { + /** Either hex encoded public key (with 0x prefix) or validator index */ + validatorIds?: ValidatorId[]; + /** [Validator status specification](https://hackmd.io/ofFJ5gOmQpu1jjHilHbdQQ) */ + statuses?: ValidatorStatus[]; + }, + {params: {state_id: string}; body: {ids?: string[]; statuses?: ValidatorStatus[]}}, + ValidatorResponseList, + ExecutionOptimisticAndFinalizedMeta >; /** * Get validator balances from state * Returns filterable list of validator balances. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param id Either hex encoded public key (with 0x prefix) or validator index */ - getStateValidatorBalances( - stateId: StateId, - indices?: ValidatorId[] - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: ValidatorBalance[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST - > + getStateValidatorBalances: Endpoint< + "GET", + StateArgs & { + /** Either hex encoded public key (with 0x prefix) or validator index */ + validatorIds?: ValidatorId[]; + }, + {params: {state_id: string}; query: {id?: ValidatorId[]}}, + ValidatorBalanceList, + ExecutionOptimisticAndFinalizedMeta >; /** * Get validator balances from state * Returns filterable list of validator balances. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param id Either hex encoded public key (with 0x prefix) or validator index */ - postStateValidatorBalances( - stateId: StateId, - indices?: ValidatorId[] - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: ValidatorBalance[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST - > + postStateValidatorBalances: Endpoint< + "POST", + StateArgs & { + /** Either hex encoded public key (with 0x prefix) or validator index */ + validatorIds?: ValidatorId[]; + }, + {params: {state_id: string}; body: string[]}, + ValidatorBalanceList, + ExecutionOptimisticAndFinalizedMeta >; /** * Get all committees for a state. * Retrieves the committees for the given state. - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. - * @param epoch Fetch committees for the given epoch. If not present then the committees for the epoch of the state will be obtained. - * @param index Restrict returned values to those matching the supplied committee index. - * @param slot Restrict returned values to those matching the supplied slot. */ - getEpochCommittees( - stateId: StateId, - filters?: CommitteesFilters - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: EpochCommitteeResponse[]; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getEpochCommittees: Endpoint< + "GET", + StateArgs & { + /** Fetch committees for the given epoch. If not present then the committees for the epoch of the state will be obtained. */ + epoch?: Epoch; + /** Restrict returned values to those matching the supplied committee index. */ + index?: CommitteeIndex; + /** Restrict returned values to those matching the supplied slot. */ + slot?: Slot; + }, + {params: {state_id: string}; query: {slot?: number; epoch?: number; index?: number}}, + EpochCommitteeResponseList, + ExecutionOptimisticAndFinalizedMeta >; - getEpochSyncCommittees( - stateId: StateId, - epoch?: Epoch - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: EpochSyncCommitteeResponse; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - }; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getEpochSyncCommittees: Endpoint< + "GET", + StateArgs & {epoch?: Epoch}, + {params: {state_id: string}; query: {epoch?: number}}, + EpochSyncCommitteeResponse, + ExecutionOptimisticAndFinalizedMeta >; }; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getEpochCommittees: {url: "/eth/v1/beacon/states/{state_id}/committees", method: "GET"}, - getEpochSyncCommittees: {url: "/eth/v1/beacon/states/{state_id}/sync_committees", method: "GET"}, - getStateFinalityCheckpoints: {url: "/eth/v1/beacon/states/{state_id}/finality_checkpoints", method: "GET"}, - getStateFork: {url: "/eth/v1/beacon/states/{state_id}/fork", method: "GET"}, - getStateRoot: {url: "/eth/v1/beacon/states/{state_id}/root", method: "GET"}, - getStateRandao: {url: "/eth/v1/beacon/states/{state_id}/randao", method: "GET"}, - getStateValidator: {url: "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", method: "GET"}, - getStateValidators: {url: "/eth/v1/beacon/states/{state_id}/validators", method: "GET"}, - postStateValidators: {url: "/eth/v1/beacon/states/{state_id}/validators", method: "POST"}, - getStateValidatorBalances: {url: "/eth/v1/beacon/states/{state_id}/validator_balances", method: "GET"}, - postStateValidatorBalances: {url: "/eth/v1/beacon/states/{state_id}/validator_balances", method: "POST"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ - -type StateIdOnlyReq = {params: {state_id: string}}; - -export type ReqTypes = { - getEpochCommittees: {params: {state_id: StateId}; query: {slot?: number; epoch?: number; index?: number}}; - getEpochSyncCommittees: {params: {state_id: StateId}; query: {epoch?: number}}; - getStateFinalityCheckpoints: StateIdOnlyReq; - getStateFork: StateIdOnlyReq; - getStateRoot: StateIdOnlyReq; - getStateRandao: {params: {state_id: StateId}; query: {epoch?: number}}; - getStateValidator: {params: {state_id: StateId; validator_id: ValidatorId}}; - getStateValidators: {params: {state_id: StateId}; query: {id?: ValidatorId[]; status?: ValidatorStatus[]}}; - postStateValidators: {params: {state_id: StateId}; body: {ids?: string[]; statuses?: ValidatorStatus[]}}; - getStateValidatorBalances: {params: {state_id: StateId}; query: {id?: ValidatorId[]}}; - postStateValidatorBalances: {params: {state_id: StateId}; body?: string[]}; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const stateIdOnlyReq: RequestCodec> = { + writeReq: ({stateId}) => ({params: {state_id: stateId.toString()}}), + parseReq: ({params}) => ({stateId: params.state_id}), + schema: {params: {state_id: Schema.StringRequired}}, }; -export function getReqSerializers(): ReqSerializers { - const stateIdOnlyReq: ReqSerializer = { - writeReq: (state_id) => ({params: {state_id: String(state_id)}}), - parseReq: ({params}) => [params.state_id], - schema: {params: {state_id: Schema.StringRequired}}, - }; - +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { getEpochCommittees: { - writeReq: (state_id, filters) => ({params: {state_id}, query: filters || {}}), - parseReq: ({params, query}) => [params.state_id, query], - schema: { - params: {state_id: Schema.StringRequired}, - query: {slot: Schema.Uint, epoch: Schema.Uint, index: Schema.Uint}, + url: "/eth/v1/beacon/states/{state_id}/committees", + method: "GET", + req: { + writeReq: ({stateId, epoch, index, slot}) => ({ + params: {state_id: stateId.toString()}, + query: {epoch, index, slot}, + }), + parseReq: ({params, query}) => ({ + stateId: params.state_id, + epoch: query.epoch, + index: query.index, + slot: query.slot, + }), + schema: { + params: {state_id: Schema.StringRequired}, + query: {slot: Schema.Uint, epoch: Schema.Uint, index: Schema.Uint}, + }, + }, + resp: { + data: EpochCommitteeResponseListType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - getEpochSyncCommittees: { - writeReq: (state_id, epoch) => ({params: {state_id}, query: {epoch}}), - parseReq: ({params, query}) => [params.state_id, query.epoch], - schema: { - params: {state_id: Schema.StringRequired}, - query: {epoch: Schema.Uint}, + url: "/eth/v1/beacon/states/{state_id}/sync_committees", + method: "GET", + req: { + writeReq: ({stateId, epoch}) => ({params: {state_id: stateId.toString()}, query: {epoch}}), + parseReq: ({params, query}) => ({stateId: params.state_id, epoch: query.epoch}), + schema: { + params: {state_id: Schema.StringRequired}, + query: {epoch: Schema.Uint}, + }, + }, + resp: { + data: EpochSyncCommitteeResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + getStateFinalityCheckpoints: { + url: "/eth/v1/beacon/states/{state_id}/finality_checkpoints", + method: "GET", + req: stateIdOnlyReq, + resp: { + data: FinalityCheckpointsType, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + getStateFork: { + url: "/eth/v1/beacon/states/{state_id}/fork", + method: "GET", + req: stateIdOnlyReq, + resp: { + data: ssz.phase0.Fork, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + }, + getStateRoot: { + url: "/eth/v1/beacon/states/{state_id}/root", + method: "GET", + req: stateIdOnlyReq, + resp: { + data: RootResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - - getStateFinalityCheckpoints: stateIdOnlyReq, - getStateFork: stateIdOnlyReq, - getStateRoot: stateIdOnlyReq, - getStateRandao: { - writeReq: (state_id, epoch) => ({params: {state_id}, query: {epoch}}), - parseReq: ({params, query}) => [params.state_id, query.epoch], - schema: { - params: {state_id: Schema.StringRequired}, - query: {epoch: Schema.Uint}, + url: "/eth/v1/beacon/states/{state_id}/randao", + method: "GET", + req: { + writeReq: ({stateId, epoch}) => ({params: {state_id: stateId.toString()}, query: {epoch}}), + parseReq: ({params, query}) => ({stateId: params.state_id, epoch: query.epoch}), + schema: { + params: {state_id: Schema.StringRequired}, + query: {epoch: Schema.Uint}, + }, + }, + resp: { + data: RandaoResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - getStateValidator: { - writeReq: (state_id, validator_id) => ({params: {state_id, validator_id}}), - parseReq: ({params}) => [params.state_id, params.validator_id], - schema: { - params: {state_id: Schema.StringRequired, validator_id: Schema.StringRequired}, + url: "/eth/v1/beacon/states/{state_id}/validators/{validator_id}", + method: "GET", + req: { + writeReq: ({stateId, validatorId}) => ({params: {state_id: stateId.toString(), validator_id: validatorId}}), + parseReq: ({params}) => ({stateId: params.state_id, validatorId: params.validator_id}), + schema: { + params: {state_id: Schema.StringRequired, validator_id: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: ValidatorResponseType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - getStateValidators: { - writeReq: (state_id, filters) => ({params: {state_id}, query: filters || {}}), - parseReq: ({params, query}) => [params.state_id, query], - schema: { - params: {state_id: Schema.StringRequired}, - query: {id: Schema.UintOrStringArray, status: Schema.StringArray}, + url: "/eth/v1/beacon/states/{state_id}/validators", + method: "GET", + req: { + writeReq: ({stateId, validatorIds: id, statuses}) => ({ + params: {state_id: stateId.toString()}, + query: {id, status: statuses}, + }), + parseReq: ({params, query}) => ({stateId: params.state_id, validatorIds: query.id, statuses: query.status}), + schema: { + params: {state_id: Schema.StringRequired}, + query: {id: Schema.UintOrStringArray, status: Schema.StringArray}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: ValidatorResponseListType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - postStateValidators: { - writeReq: (state_id, filters) => ({ - params: {state_id}, - body: { - ids: filters?.id?.map((id) => (typeof id === "string" ? id : toU64Str(id))), - statuses: filters?.status, + url: "/eth/v1/beacon/states/{state_id}/validators", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({stateId, validatorIds, statuses}) => ({ + params: {state_id: stateId.toString()}, + body: { + ids: toValidatorIdsStr(validatorIds), + statuses, + }, + }), + parseReqJson: ({params, body = {}}) => ({ + stateId: params.state_id, + validatorIds: fromValidatorIdsStr(body.ids), + statuses: body.statuses, + }), + schema: { + params: {state_id: Schema.StringRequired}, + body: Schema.Object, }, }), - parseReq: ({params, body}) => [ - params.state_id, - { - id: body.ids?.map((id) => (typeof id === "string" && id.startsWith("0x") ? id : fromU64Str(id))), - status: body.statuses, - }, - ], - schema: { - params: {state_id: Schema.StringRequired}, - body: Schema.Object, + resp: { + onlySupport: WireFormat.json, + data: ValidatorResponseListType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - getStateValidatorBalances: { - writeReq: (state_id, id) => ({params: {state_id}, query: {id}}), - parseReq: ({params, query}) => [params.state_id, query.id], - schema: { - params: {state_id: Schema.StringRequired}, - query: {id: Schema.UintOrStringArray}, + url: "/eth/v1/beacon/states/{state_id}/validator_balances", + method: "GET", + req: { + writeReq: ({stateId, validatorIds}) => ({params: {state_id: stateId.toString()}, query: {id: validatorIds}}), + parseReq: ({params, query}) => ({stateId: params.state_id, validatorIds: query.id}), + schema: { + params: {state_id: Schema.StringRequired}, + query: {id: Schema.UintOrStringArray}, + }, + }, + resp: { + data: ValidatorBalanceListType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, - postStateValidatorBalances: { - writeReq: (state_id, ids) => ({ - params: {state_id}, - body: ids?.map((id) => (typeof id === "string" ? id : toU64Str(id))) || [], + url: "/eth/v1/beacon/states/{state_id}/validator_balances", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({stateId, validatorIds}) => ({ + params: {state_id: stateId.toString()}, + body: toValidatorIdsStr(validatorIds) || [], + }), + parseReqJson: ({params, body = []}) => ({ + stateId: params.state_id, + validatorIds: fromValidatorIdsStr(body), + }), + schema: { + params: {state_id: Schema.StringRequired}, + body: Schema.UintOrStringArray, + }, }), - parseReq: ({params, body}) => [ - params.state_id, - body?.map((id) => (typeof id === "string" && id.startsWith("0x") ? id : fromU64Str(id))), - ], - schema: { - params: {state_id: Schema.StringRequired}, - body: Schema.UintOrStringArray, + resp: { + data: ValidatorBalanceListType, + meta: ExecutionOptimisticAndFinalizedCodec, }, }, }; } - -export function getReturnTypes(): ReturnTypes { - const RootContainer = new ContainerType({ - root: ssz.Root, - }); - - const RandaoContainer = new ContainerType({ - randao: ssz.Root, - }); - - const FinalityCheckpoints = new ContainerType( - { - previousJustified: ssz.phase0.Checkpoint, - currentJustified: ssz.phase0.Checkpoint, - finalized: ssz.phase0.Checkpoint, - }, - {jsonCase: "eth2"} - ); - - const ValidatorResponse = new ContainerType( - { - index: ssz.ValidatorIndex, - balance: ssz.UintNum64, - status: new StringType(), - validator: ssz.phase0.Validator, - }, - {jsonCase: "eth2"} - ); - - const ValidatorBalance = new ContainerType( - { - index: ssz.ValidatorIndex, - balance: ssz.UintNum64, - }, - {jsonCase: "eth2"} - ); - - const EpochCommitteeResponse = new ContainerType( - { - index: ssz.CommitteeIndex, - slot: ssz.Slot, - validators: ssz.phase0.CommitteeIndices, - }, - {jsonCase: "eth2"} - ); - - const EpochSyncCommitteesResponse = new ContainerType( - { - validators: ArrayOf(ssz.ValidatorIndex), - validatorAggregates: ArrayOf(ArrayOf(ssz.ValidatorIndex)), - }, - {jsonCase: "eth2"} - ); - - return { - getStateRoot: WithFinalized(ContainerDataExecutionOptimistic(RootContainer)), - getStateFork: WithFinalized(ContainerDataExecutionOptimistic(ssz.phase0.Fork)), - getStateRandao: WithFinalized(ContainerDataExecutionOptimistic(RandaoContainer)), - getStateFinalityCheckpoints: WithFinalized(ContainerDataExecutionOptimistic(FinalityCheckpoints)), - getStateValidators: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(ValidatorResponse))), - postStateValidators: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(ValidatorResponse))), - getStateValidator: WithFinalized(ContainerDataExecutionOptimistic(ValidatorResponse)), - getStateValidatorBalances: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(ValidatorBalance))), - postStateValidatorBalances: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(ValidatorBalance))), - getEpochCommittees: WithFinalized(ContainerDataExecutionOptimistic(ArrayOf(EpochCommitteeResponse))), - getEpochSyncCommittees: WithFinalized(ContainerDataExecutionOptimistic(EpochSyncCommitteesResponse)), - }; -} diff --git a/packages/api/src/beacon/routes/config.ts b/packages/api/src/beacon/routes/config.ts index 0d009c844fa2..f8a606af1431 100644 --- a/packages/api/src/beacon/routes/config.ts +++ b/packages/api/src/beacon/routes/config.ts @@ -1,42 +1,60 @@ -import {ByteVectorType, ContainerType} from "@chainsafe/ssz"; -import {BeaconPreset} from "@lodestar/params"; -import {ChainConfig} from "@lodestar/config"; -import {Bytes32, UintNum64, phase0, ssz} from "@lodestar/types"; -import {mapValues} from "@lodestar/utils"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {ssz} from "@lodestar/types"; import { ArrayOf, - ReqEmpty, - reqEmpty, - ReturnTypes, - ReqSerializers, - RoutesData, - sameType, - ContainerData, -} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, + JsonOnlyResp, +} from "../../utils/codecs.js"; +import {Endpoint, RouteDefinitions} from "../../utils/index.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type DepositContract = { - chainId: UintNum64; - address: Bytes32; -}; +export const DepositContractType = new ContainerType( + { + chainId: ssz.UintNum64, + address: ssz.ExecutionAddress, + }, + {jsonCase: "eth2"} +); -export type Spec = BeaconPreset & ChainConfig; +export const ForkListType = ArrayOf(ssz.phase0.Fork); -export type Api = { +export type DepositContract = ValueOf; +export type ForkList = ValueOf; +export type Spec = Record; + +export type Endpoints = { /** * Get deposit contract address. * Retrieve Eth1 deposit contract address and chain ID. */ - getDepositContract(): Promise>; + getDepositContract: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + DepositContract, + EmptyMeta + >; /** * Get scheduled upcoming forks. * Retrieve all scheduled upcoming forks this node is aware of. */ - getForkSchedule(): Promise>; + getForkSchedule: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + ForkList, + EmptyMeta + >; /** * Retrieve specification configuration used on this node. The configuration should include: @@ -48,37 +66,52 @@ export type Api = { * - any value starting with 0x in the spec is returned as a hex string * - numeric values are returned as a quoted integer */ - getSpec(): Promise}}>>; + getSpec: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + Spec, + EmptyMeta + >; }; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getDepositContract: {url: "/eth/v1/config/deposit_contract", method: "GET"}, - getForkSchedule: {url: "/eth/v1/config/fork_schedule", method: "GET"}, - getSpec: {url: "/eth/v1/config/spec", method: "GET"}, -}; - -export type ReqTypes = {[K in keyof Api]: ReqEmpty}; - -export function getReqSerializers(): ReqSerializers { - return mapValues(routesData, () => reqEmpty); -} - -/* eslint-disable @typescript-eslint/naming-convention */ -export function getReturnTypes(): ReturnTypes { - const DepositContract = new ContainerType( - { - chainId: ssz.UintNum64, - address: new ByteVectorType(20), - }, - {jsonCase: "eth2"} - ); - +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { - getDepositContract: ContainerData(DepositContract), - getForkSchedule: ContainerData(ArrayOf(ssz.phase0.Fork)), - getSpec: ContainerData(sameType()), + getDepositContract: { + url: "/eth/v1/config/deposit_contract", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: DepositContractType, + meta: EmptyMetaCodec, + }, + }, + getForkSchedule: { + url: "/eth/v1/config/fork_schedule", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: ForkListType, + meta: EmptyMetaCodec, + }, + }, + getSpec: { + url: "/eth/v1/config/spec", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResp({ + data: { + toJson: (data) => data, + fromJson: (data) => { + if (typeof data !== "object" || data === null) { + throw Error("JSON must be of type object"); + } + return data as Spec; + }, + }, + meta: EmptyMetaCodec, + }), + }, }; } diff --git a/packages/api/src/beacon/routes/debug.ts b/packages/api/src/beacon/routes/debug.ts index 773ae86728b5..2128f7204c6a 100644 --- a/packages/api/src/beacon/routes/debug.ts +++ b/packages/api/src/beacon/routes/debug.ts @@ -1,31 +1,31 @@ -import {ContainerType, ValueOf} from "@chainsafe/ssz"; -import {ForkName} from "@lodestar/params"; -import {allForks, Slot, RootHex, ssz, StringType} from "@lodestar/types"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, Type, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {allForks, ssz, StringType, phase0} from "@lodestar/types"; import { ArrayOf, - ReturnTypes, - RoutesData, - Schema, + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, WithVersion, - TypeJson, - reqEmpty, - ReqSerializers, - ReqEmpty, - ReqSerializer, - ContainerDataExecutionOptimistic, - WithExecutionOptimistic, - WithFinalized, - ContainerData, -} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {parseAcceptHeader, writeAcceptHeader} from "../../utils/acceptHeader.js"; -import {ApiClientResponse, ResponseFormat} from "../../interfaces.js"; -import {ExecutionOptimistic, Finalized, StateId} from "./beacon/state.js"; +} from "../../utils/codecs.js"; +import { + ExecutionOptimisticFinalizedAndVersionCodec, + ExecutionOptimisticFinalizedAndVersionMeta, + ExecutionOptimisticAndFinalizedCodec, + ExecutionOptimisticAndFinalizedMeta, +} from "../../utils/metadata.js"; +import {Endpoint, RouteDefinitions} from "../../utils/types.js"; +import {WireFormat} from "../../utils/wireFormat.js"; +import {Schema} from "../../utils/schema.js"; +import {StateArgs} from "./beacon/state.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes const stringType = new StringType(); -const protoNodeSszType = new ContainerType( +const ProtoNodeResponseType = new ContainerType( { executionPayloadBlockHash: stringType, executionPayloadNumber: ssz.UintNum64, @@ -51,163 +51,164 @@ const protoNodeSszType = new ContainerType( }, {jsonCase: "eth2"} ); +const SlotRootType = new ContainerType( + { + slot: ssz.Slot, + root: stringType, + }, + {jsonCase: "eth2"} +); +const SlotRootExecutionOptimisticType = new ContainerType( + { + slot: ssz.Slot, + root: stringType, + executionOptimistic: ssz.Boolean, + }, + {jsonCase: "eth2"} +); + +const ProtoNodeResponseListType = ArrayOf(ProtoNodeResponseType); +const SlotRootListType = ArrayOf(SlotRootType); +const SlotRootExecutionOptimisticListType = ArrayOf(SlotRootExecutionOptimisticType); -type ProtoNodeApiType = ValueOf; +type ProtoNodeResponseList = ValueOf; +type SlotRootList = ValueOf; +type SlotRootExecutionOptimisticList = ValueOf; -export type Api = { +export type Endpoints = { /** * Retrieves all possible chain heads (leaves of fork choice tree). */ - getDebugChainHeads(): Promise>; + getDebugChainHeads: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SlotRootList, + EmptyMeta + >; /** * Retrieves all possible chain heads (leaves of fork choice tree). */ - getDebugChainHeadsV2(): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: {data: {slot: Slot; root: RootHex; executionOptimistic: ExecutionOptimistic}[]}; - }> + getDebugChainHeadsV2: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SlotRootExecutionOptimisticList, + EmptyMeta >; /** * Dump all ProtoArray's nodes to debug */ - getProtoArrayNodes(): Promise>; + getProtoArrayNodes: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + ProtoNodeResponseList, + EmptyMeta + >; /** * Get full BeaconState object * Returns full BeaconState object for given stateId. * Depending on `Accept` header it can be returned either as json or as bytes serialized by SSZ - * - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. */ - getState( - stateId: StateId, - format?: "json" - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: {data: allForks.BeaconState; executionOptimistic: ExecutionOptimistic; finalized: Finalized}; - }> - >; - getState(stateId: StateId, format: "ssz"): Promise>; - getState( - stateId: StateId, - format?: ResponseFormat - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: - | Uint8Array - | {data: allForks.BeaconState; executionOptimistic: ExecutionOptimistic; finalized: Finalized}; - }> + getState: Endpoint< + "GET", + StateArgs, + {params: {state_id: string}}, + phase0.BeaconState, + ExecutionOptimisticAndFinalizedMeta >; /** * Get full BeaconState object * Returns full BeaconState object for given stateId. * Depending on `Accept` header it can be returned either as json or as bytes serialized by SSZ - * - * @param stateId State identifier. - * Can be one of: "head" (canonical head in node's view), "genesis", "finalized", "justified", \, \. */ - getStateV2( - stateId: StateId, - format?: "json" - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - data: allForks.BeaconState; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - version: ForkName; - }; - }> - >; - getStateV2(stateId: StateId, format: "ssz"): Promise>; - getStateV2( - stateId: StateId, - format?: ResponseFormat - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: - | Uint8Array - | { - data: allForks.BeaconState; - executionOptimistic: ExecutionOptimistic; - finalized: Finalized; - version: ForkName; - }; - }> + getStateV2: Endpoint< + "GET", + StateArgs, + {params: {state_id: string}}, + allForks.BeaconState, + ExecutionOptimisticFinalizedAndVersionMeta >; }; -export const routesData: RoutesData = { - getDebugChainHeads: {url: "/eth/v1/debug/beacon/heads", method: "GET"}, - getDebugChainHeadsV2: {url: "/eth/v2/debug/beacon/heads", method: "GET"}, - getProtoArrayNodes: {url: "/eth/v0/debug/forkchoice", method: "GET"}, - getState: {url: "/eth/v1/debug/beacon/states/{state_id}", method: "GET"}, - getStateV2: {url: "/eth/v2/debug/beacon/states/{state_id}", method: "GET"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ - -export type ReqTypes = { - getDebugChainHeads: ReqEmpty; - getDebugChainHeadsV2: ReqEmpty; - getProtoArrayNodes: ReqEmpty; - getState: {params: {state_id: string}; headers: {accept?: string}}; - getStateV2: {params: {state_id: string}; headers: {accept?: string}}; -}; - -export function getReqSerializers(): ReqSerializers { - const getState: ReqSerializer = { - writeReq: (state_id, format) => ({ - params: {state_id: String(state_id)}, - headers: {accept: writeAcceptHeader(format)}, - }), - parseReq: ({params, headers}) => [params.state_id, parseAcceptHeader(headers.accept)], - schema: {params: {state_id: Schema.StringRequired}}, - }; +// Default timeout is not sufficient to download state as JSON +const GET_STATE_TIMEOUT_MS = 5 * 60 * 1000; +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { - getDebugChainHeads: reqEmpty, - getDebugChainHeadsV2: reqEmpty, - getProtoArrayNodes: reqEmpty, - getState: getState, - getStateV2: getState, - }; -} - -export function getReturnTypes(): ReturnTypes { - const SlotRoot = new ContainerType( - { - slot: ssz.Slot, - root: stringType, + getDebugChainHeads: { + url: "/eth/v1/debug/beacon/heads", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: SlotRootListType, + meta: EmptyMetaCodec, + onlySupport: WireFormat.json, + }, }, - {jsonCase: "eth2"} - ); - - const SlotRootExecutionOptimistic = new ContainerType( - { - slot: ssz.Slot, - root: stringType, - executionOptimistic: ssz.Boolean, + getDebugChainHeadsV2: { + url: "/eth/v2/debug/beacon/heads", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: SlotRootExecutionOptimisticListType, + meta: EmptyMetaCodec, + onlySupport: WireFormat.json, + }, + }, + getProtoArrayNodes: { + url: "/eth/v0/debug/forkchoice", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: ProtoNodeResponseListType, + meta: EmptyMetaCodec, + onlySupport: WireFormat.json, + }, + }, + getState: { + url: "/eth/v1/debug/beacon/states/{state_id}", + method: "GET", + req: { + writeReq: ({stateId}) => ({params: {state_id: stateId.toString()}}), + parseReq: ({params}) => ({stateId: params.state_id}), + schema: { + params: {state_id: Schema.StringRequired}, + }, + }, + resp: { + data: ssz.phase0.BeaconState, + meta: ExecutionOptimisticAndFinalizedCodec, + }, + init: { + timeoutMs: GET_STATE_TIMEOUT_MS, + }, + }, + getStateV2: { + url: "/eth/v2/debug/beacon/states/{state_id}", + method: "GET", + req: { + writeReq: ({stateId}) => ({params: {state_id: stateId.toString()}}), + parseReq: ({params}) => ({stateId: params.state_id}), + schema: { + params: {state_id: Schema.StringRequired}, + }, + }, + resp: { + data: WithVersion((fork) => ssz[fork].BeaconState as Type), + meta: ExecutionOptimisticFinalizedAndVersionCodec, + }, + init: { + timeoutMs: GET_STATE_TIMEOUT_MS, + }, }, - {jsonCase: "eth2"} - ); - - return { - getDebugChainHeads: ContainerData(ArrayOf(SlotRoot)), - getDebugChainHeadsV2: ContainerData(ArrayOf(SlotRootExecutionOptimistic)), - getProtoArrayNodes: ContainerData(ArrayOf(protoNodeSszType)), - getState: WithFinalized(ContainerDataExecutionOptimistic(ssz.phase0.BeaconState)), - getStateV2: WithFinalized( - WithExecutionOptimistic( - // Teku returns fork as UPPERCASE - WithVersion( - (fork: ForkName) => ssz[fork.toLowerCase() as ForkName].BeaconState as TypeJson - ) - ) - ), }; } diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index ddcc57104523..0b88175d7588 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -1,10 +1,14 @@ import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; import {Epoch, phase0, capella, Slot, ssz, StringType, RootHex, altair, UintNum64, allForks} from "@lodestar/types"; -import {isForkExecution, ForkName, isForkLightClient} from "@lodestar/params"; +import {ForkName} from "@lodestar/params"; -import {RouteDef, TypeJson, WithVersion} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; +import {Endpoint, RouteDefinitions, Schema} from "../../utils/index.js"; +import {EmptyMeta, EmptyResponseCodec, EmptyResponseData} from "../../utils/codecs.js"; +import {getExecutionForkTypes, getLightClientForkTypes} from "../../utils/fork.js"; +import {VersionType} from "../../utils/metadata.js"; + +// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes const stringType = new StringType(); export const blobSidecarSSE = new ContainerType( @@ -19,8 +23,6 @@ export const blobSidecarSSE = new ContainerType( ); type BlobSidecarSSE = ValueOf; -// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes - export enum EventType { /** * The node has finished processing, resulting in a new head. previous_duty_dependent_root is @@ -119,42 +121,72 @@ export type EventData = { export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType]; -export type Api = { +type EventstreamArgs = { + /** Event types to subscribe to */ + topics: EventType[]; + signal: AbortSignal; + onEvent: (event: BeaconEvent) => void; + onError?: (err: Error) => void; + onClose?: () => void; +}; + +export type Endpoints = { /** * Subscribe to beacon node events * Provides endpoint to subscribe to beacon node Server-Sent-Events stream. * Consumers should use [eventsource](https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface) * implementation to listen on those events. * - * @param topics Event types to subscribe to - * @returns Opened SSE stream. + * Returns if SSE stream has been opened. */ - eventstream( - topics: EventType[], - signal: AbortSignal, - onEvent: (event: BeaconEvent) => void - ): Promise>; -}; - -export const routesData: {[K in keyof Api]: RouteDef} = { - eventstream: {url: "/eth/v1/events", method: "GET"}, + eventstream: Endpoint< + // ⏎ + "GET", + EventstreamArgs, + {query: {topics: EventType[]}}, + EmptyResponseData, + EmptyMeta + >; }; -export type ReqTypes = { - eventstream: { - query: {topics: EventType[]}; +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { + return { + eventstream: { + url: "/eth/v1/events", + method: "GET", + req: { + writeReq: ({topics}) => ({query: {topics}}), + parseReq: ({query}) => ({topics: query.topics}) as EventstreamArgs, + schema: { + query: {topics: Schema.StringArrayRequired}, + }, + }, + resp: EmptyResponseCodec, + }, }; -}; +} -// It doesn't make sense to define a getReqSerializers() here given the exotic argument of eventstream() -// The request is very simple: (topics) => {query: {topics}}, and the test will ensure compatibility server - client +export type TypeJson = { + toJson: (data: T) => unknown; // server + fromJson: (data: unknown) => T; // client +}; export function getTypeByEvent(): {[K in EventType]: TypeJson} { - const getLightClientType = (fork: ForkName): allForks.AllForksLightClientSSZTypes => { - if (!isForkLightClient(fork)) { - throw Error(`Invalid fork=${fork} for lightclient fork types`); - } - return ssz.allForksLightClient[fork]; + // eslint-disable-next-line @typescript-eslint/naming-convention + const WithVersion = (getType: (fork: ForkName) => TypeJson): TypeJson<{data: T; version: ForkName}> => { + return { + toJson: ({data, version}) => ({ + data: getType(version).toJson(data), + version, + }), + fromJson: (val) => { + const {version} = VersionType.fromJson(val); + return { + data: getType(version).fromJson((val as {data: unknown}).data), + version, + }; + }, + }; }; return { @@ -211,15 +243,15 @@ export function getTypeByEvent(): {[K in EventType]: TypeJson} { ), [EventType.contributionAndProof]: ssz.altair.SignedContributionAndProof, - [EventType.payloadAttributes]: WithVersion((fork) => - isForkExecution(fork) ? ssz.allForksExecution[fork].SSEPayloadAttributes : ssz.bellatrix.SSEPayloadAttributes - ), + [EventType.payloadAttributes]: WithVersion((fork) => getExecutionForkTypes(fork).SSEPayloadAttributes), [EventType.blobSidecar]: blobSidecarSSE, [EventType.lightClientOptimisticUpdate]: WithVersion( - (fork) => getLightClientType(fork).LightClientOptimisticUpdate + (fork) => getLightClientForkTypes(fork).LightClientOptimisticUpdate + ), + [EventType.lightClientFinalityUpdate]: WithVersion( + (fork) => getLightClientForkTypes(fork).LightClientFinalityUpdate ), - [EventType.lightClientFinalityUpdate]: WithVersion((fork) => getLightClientType(fork).LightClientFinalityUpdate), }; } diff --git a/packages/api/src/beacon/routes/index.ts b/packages/api/src/beacon/routes/index.ts index c3fb15f8a6a4..aef8f3fc4eab 100644 --- a/packages/api/src/beacon/routes/index.ts +++ b/packages/api/src/beacon/routes/index.ts @@ -1,12 +1,12 @@ -import {Api as BeaconApi} from "./beacon/index.js"; -import {Api as ConfigApi} from "./config.js"; -import {Api as DebugApi} from "./debug.js"; -import {Api as EventsApi} from "./events.js"; -import {Api as LightclientApi} from "./lightclient.js"; -import {Api as LodestarApi} from "./lodestar.js"; -import {Api as NodeApi} from "./node.js"; -import {Api as ProofApi} from "./proof.js"; -import {Api as ValidatorApi} from "./validator.js"; +import {Endpoints as BeaconEndpoints} from "./beacon/index.js"; +import {Endpoints as ConfigEndpoints} from "./config.js"; +import {Endpoints as DebugEndpoints} from "./debug.js"; +import {Endpoints as EventsEndpoints} from "./events.js"; +import {Endpoints as LightclientEndpoints} from "./lightclient.js"; +import {Endpoints as LodestarEndpoints} from "./lodestar.js"; +import {Endpoints as NodeEndpoints} from "./node.js"; +import {Endpoints as ProofEndpoints} from "./proof.js"; +import {Endpoints as ValidatorEndpoints} from "./validator.js"; import * as beacon from "./beacon/index.js"; import * as config from "./config.js"; @@ -19,18 +19,19 @@ import * as proof from "./proof.js"; import * as validator from "./validator.js"; export {beacon, config, debug, events, lightclient, lodestar, node, proof, validator}; -export type Api = { - beacon: BeaconApi; - config: ConfigApi; - debug: DebugApi; - events: EventsApi; - lightclient: LightclientApi; - lodestar: LodestarApi; - node: NodeApi; - proof: ProofApi; - validator: ValidatorApi; +export type Endpoints = { + beacon: BeaconEndpoints; + config: ConfigEndpoints; + debug: DebugEndpoints; + events: EventsEndpoints; + lightclient: LightclientEndpoints; + lodestar: LodestarEndpoints; + node: NodeEndpoints; + proof: ProofEndpoints; + validator: ValidatorEndpoints; }; +// TODO: update to reflect new design // Reasoning of the API definitions // ================================ // diff --git a/packages/api/src/beacon/routes/lightclient.ts b/packages/api/src/beacon/routes/lightclient.ts index bbd7e9ff3864..ed5e290a747c 100644 --- a/packages/api/src/beacon/routes/lightclient.ts +++ b/packages/api/src/beacon/routes/lightclient.ts @@ -1,22 +1,27 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ListCompositeType, ValueOf} from "@chainsafe/ssz"; import {ssz, SyncPeriod, allForks} from "@lodestar/types"; -import {ForkName, isForkLightClient} from "@lodestar/params"; +import {ForkName} from "@lodestar/params"; +import {ChainForkConfig} from "@lodestar/config"; +import {Endpoint, RouteDefinitions, Schema} from "../../utils/index.js"; +import {VersionCodec, VersionMeta} from "../../utils/metadata.js"; +import {getLightClientForkTypes, toForkName} from "../../utils/fork.js"; import { - ArrayOf, - ReturnTypes, - RoutesData, - Schema, - ReqSerializers, - reqEmpty, - ReqEmpty, + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, WithVersion, - ContainerData, -} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; + JsonOnlyResp, +} from "../../utils/codecs.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type Api = { +export const HashListType = new ListCompositeType(ssz.Root, 10000); +export type HashList = ValueOf; + +export type Endpoints = { /** * Returns an array of best updates given a `startPeriod` and `count` number of sync committee period to return. * Best is defined by (in order of priority): @@ -24,135 +29,163 @@ export type Api = { * - Has most bits * - Oldest update */ - getUpdates( - startPeriod: SyncPeriod, - count: number - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - version: ForkName; - data: allForks.LightClientUpdate; - }[]; - }> + getLightClientUpdatesByRange: Endpoint< + "GET", + {startPeriod: SyncPeriod; count: number}, + {query: {start_period: number; count: number}}, + allForks.LightClientUpdate[], + {versions: ForkName[]} >; + /** * Returns the latest optimistic head update available. Clients should use the SSE type `light_client_optimistic_update` * unless to get the very first head update after syncing, or if SSE are not supported by the server. */ - getOptimisticUpdate(): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - version: ForkName; - data: allForks.LightClientOptimisticUpdate; - }; - }> + getLightClientOptimisticUpdate: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + allForks.LightClientOptimisticUpdate, + VersionMeta >; - getFinalityUpdate(): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - version: ForkName; - data: allForks.LightClientFinalityUpdate; - }; - }> + + getLightClientFinalityUpdate: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + allForks.LightClientFinalityUpdate, + VersionMeta >; + /** * Fetch a bootstrapping state with a proof to a trusted block root. * The trusted block root should be fetched with similar means to a weak subjectivity checkpoint. * Only block roots for checkpoints are guaranteed to be available. */ - getBootstrap(blockRoot: string): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - version: ForkName; - data: allForks.LightClientBootstrap; - }; - }> + getLightClientBootstrap: Endpoint< + "GET", + {blockRoot: string}, + {params: {block_root: string}}, + allForks.LightClientBootstrap, + VersionMeta >; + /** * Returns an array of sync committee hashes based on the provided period and count */ - getCommitteeRoot( - startPeriod: SyncPeriod, - count: number - ): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - data: Uint8Array[]; - }; - }> + getLightClientCommitteeRoot: Endpoint< + "GET", + {startPeriod: SyncPeriod; count: number}, + {query: {start_period: number; count: number}}, + HashList, + EmptyMeta >; }; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getUpdates: {url: "/eth/v1/beacon/light_client/updates", method: "GET"}, - getOptimisticUpdate: {url: "/eth/v1/beacon/light_client/optimistic_update", method: "GET"}, - getFinalityUpdate: {url: "/eth/v1/beacon/light_client/finality_update", method: "GET"}, - getBootstrap: {url: "/eth/v1/beacon/light_client/bootstrap/{block_root}", method: "GET"}, - getCommitteeRoot: {url: "/eth/v0/beacon/light_client/committee_root", method: "GET"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ -export type ReqTypes = { - getUpdates: {query: {start_period: number; count: number}}; - getOptimisticUpdate: ReqEmpty; - getFinalityUpdate: ReqEmpty; - getBootstrap: {params: {block_root: string}}; - getCommitteeRoot: {query: {start_period: number; count: number}}; -}; - -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { - getUpdates: { - writeReq: (start_period, count) => ({query: {start_period, count}}), - parseReq: ({query}) => [query.start_period, query.count], - schema: {query: {start_period: Schema.UintRequired, count: Schema.UintRequired}}, + getLightClientUpdatesByRange: { + url: "/eth/v1/beacon/light_client/updates", + method: "GET", + req: { + writeReq: ({startPeriod, count}) => ({query: {start_period: startPeriod, count}}), + parseReq: ({query}) => ({startPeriod: query.start_period, count: query.count}), + schema: {query: {start_period: Schema.UintRequired, count: Schema.UintRequired}}, + }, + resp: JsonOnlyResp({ + data: { + toJson: (data, meta) => { + const json: unknown[] = []; + for (const [i, update] of data.entries()) { + json.push(getLightClientForkTypes(meta.versions[i]).LightClientUpdate.toJson(update)); + } + return json; + }, + fromJson: (data, meta) => { + const updates = data as unknown[]; + const value: allForks.LightClientUpdate[] = []; + for (let i = 0; i < updates.length; i++) { + const version = meta.versions[i]; + value.push(getLightClientForkTypes(version).LightClientUpdate.fromJson(updates[i])); + } + return value; + }, + }, + meta: { + toJson: (meta) => meta, + fromJson: (val) => val as {versions: ForkName[]}, + toHeadersObject: () => ({}), + fromHeaders: () => ({versions: []}), + }, + transform: { + toResponse: (data, meta) => { + const updates = data as unknown[]; + const resp: unknown[] = []; + for (let i = 0; i < updates.length; i++) { + resp.push({data: updates[i], version: (meta as {versions: string[]}).versions[i]}); + } + return resp; + }, + fromResponse: (resp) => { + if (!Array.isArray(resp)) { + throw Error("JSON is not an array"); + } + const updates: allForks.LightClientUpdate[] = []; + const meta: {versions: ForkName[]} = {versions: []}; + for (const {data, version} of resp as {data: allForks.LightClientUpdate; version: string}[]) { + updates.push(data); + meta.versions.push(toForkName(version)); + } + return {data: updates, meta}; + }, + }, + }), }, - - getOptimisticUpdate: reqEmpty, - getFinalityUpdate: reqEmpty, - - getBootstrap: { - writeReq: (block_root) => ({params: {block_root}}), - parseReq: ({params}) => [params.block_root], - schema: {params: {block_root: Schema.StringRequired}}, + getLightClientOptimisticUpdate: { + url: "/eth/v1/beacon/light_client/optimistic_update", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: WithVersion((fork) => getLightClientForkTypes(fork).LightClientOptimisticUpdate), + meta: VersionCodec, + }, }, - getCommitteeRoot: { - writeReq: (start_period, count) => ({query: {start_period, count}}), - parseReq: ({query}) => [query.start_period, query.count], - schema: {query: {start_period: Schema.UintRequired, count: Schema.UintRequired}}, + getLightClientFinalityUpdate: { + url: "/eth/v1/beacon/light_client/finality_update", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: WithVersion((fork) => getLightClientForkTypes(fork).LightClientFinalityUpdate), + meta: VersionCodec, + }, + }, + getLightClientBootstrap: { + url: "/eth/v1/beacon/light_client/bootstrap/{block_root}", + method: "GET", + req: { + writeReq: ({blockRoot}) => ({params: {block_root: blockRoot}}), + parseReq: ({params}) => ({blockRoot: params.block_root}), + schema: {params: {block_root: Schema.StringRequired}}, + }, + resp: { + data: WithVersion((fork) => getLightClientForkTypes(fork).LightClientBootstrap), + meta: VersionCodec, + }, + }, + getLightClientCommitteeRoot: { + url: "/eth/v0/beacon/light_client/committee_root", + method: "GET", + req: { + writeReq: ({startPeriod, count}) => ({query: {start_period: startPeriod, count}}), + parseReq: ({query}) => ({startPeriod: query.start_period, count: query.count}), + schema: {query: {start_period: Schema.UintRequired, count: Schema.UintRequired}}, + }, + resp: { + data: HashListType, + meta: EmptyMetaCodec, + }, }, - }; -} - -export function getReturnTypes(): ReturnTypes { - // Form a TypeJson convertor for getUpdates - const VersionedUpdate = WithVersion((fork: ForkName) => - isForkLightClient(fork) ? ssz.allForksLightClient[fork].LightClientUpdate : ssz.altair.LightClientUpdate - ); - const getUpdates = { - toJson: (updates: {version: ForkName; data: allForks.LightClientUpdate}[]) => - updates.map((data) => VersionedUpdate.toJson(data)), - fromJson: (updates: unknown[]) => updates.map((data) => VersionedUpdate.fromJson(data)), - }; - - return { - getUpdates, - getOptimisticUpdate: WithVersion((fork: ForkName) => - isForkLightClient(fork) - ? ssz.allForksLightClient[fork].LightClientOptimisticUpdate - : ssz.altair.LightClientOptimisticUpdate - ), - getFinalityUpdate: WithVersion((fork: ForkName) => - isForkLightClient(fork) - ? ssz.allForksLightClient[fork].LightClientFinalityUpdate - : ssz.altair.LightClientFinalityUpdate - ), - getBootstrap: WithVersion((fork: ForkName) => - isForkLightClient(fork) ? ssz.allForksLightClient[fork].LightClientBootstrap : ssz.altair.LightClientBootstrap - ), - getCommitteeRoot: ContainerData(ArrayOf(ssz.Root)), }; } diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 527199d95dad..a97d065cfe4d 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -1,16 +1,15 @@ +import {ChainForkConfig} from "@lodestar/config"; import {Epoch, RootHex, Slot} from "@lodestar/types"; -import {ApiClientResponse} from "../../interfaces.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; +import {Schema, Endpoint, RouteDefinitions} from "../../utils/index.js"; import { - jsonType, - ReqEmpty, - reqEmpty, - ReturnTypes, - ReqSerializers, - RoutesData, - sameType, - Schema, -} from "../../utils/index.js"; + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyRequest, + EmptyResponseCodec, + EmptyResponseData, + JsonOnlyResponseCodec, +} from "../../utils/codecs.js"; import {FilterGetPeers, NodePeer, PeerDirection, PeerState} from "./node.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes @@ -76,172 +75,317 @@ export type LodestarNodePeer = NodePeer & { export type LodestarThreadType = "main" | "network" | "discv5"; -export type Api = { - /** Trigger to write a heapdump of either main/network/discv5 thread to disk at `dirpath`. May take > 1min */ - writeHeapdump( - thread?: LodestarThreadType, - dirpath?: string - ): Promise>; - /** Trigger to write profile of either main/network/discv5 thread to disk */ - writeProfile( - thread?: LodestarThreadType, - duration?: number, - dirpath?: string - ): Promise>; +export type Endpoints = { + /** Trigger to write a heapdump to disk at `dirpath`. May take > 1min */ + writeHeapdump: Endpoint< + "POST", + {thread?: LodestarThreadType; dirpath?: string}, + {query: {thread?: LodestarThreadType; dirpath?: string}}, + {filepath: string}, + EmptyMeta + >; + /** Trigger to write 10m network thread profile to disk */ + writeProfile: Endpoint< + "POST", + { + thread?: LodestarThreadType; + duration?: number; + dirpath?: string; + }, + {query: {thread?: LodestarThreadType; duration?: number; dirpath?: string}}, + {filepath: string}, + EmptyMeta + >; /** TODO: description */ - getLatestWeakSubjectivityCheckpointEpoch(): Promise>; + getLatestWeakSubjectivityCheckpointEpoch: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + Epoch, + EmptyMeta + >; /** TODO: description */ - getSyncChainsDebugState(): Promise>; + getSyncChainsDebugState: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SyncChainDebugState[], + EmptyMeta + >; /** Dump all items in a gossip queue, by gossipType */ - getGossipQueueItems(gossipType: string): Promise>; + getGossipQueueItems: Endpoint< + // ⏎ + "GET", + {gossipType: string}, + {params: {gossipType: string}}, + unknown[], + EmptyMeta + >; /** Dump all items in the regen queue */ - getRegenQueueItems(): Promise>; + getRegenQueueItems: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + RegenQueueItem[], + EmptyMeta + >; /** Dump all items in the block processor queue */ - getBlockProcessorQueueItems(): Promise>; + getBlockProcessorQueueItems: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + BlockProcessorQueueItem[], + EmptyMeta + >; /** Dump a summary of the states in the block state cache and checkpoint state cache */ - getStateCacheItems(): Promise>; + getStateCacheItems: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + StateCacheItem[], + EmptyMeta + >; /** Dump peer gossip stats by peer */ - getGossipPeerScoreStats(): Promise>; + getGossipPeerScoreStats: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + GossipPeerScoreStat[], + EmptyMeta + >; /** Dump lodestar score stats by peer */ - getLodestarPeerScoreStats(): Promise>; + getLodestarPeerScoreStats: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + PeerScoreStat[], + EmptyMeta + >; /** Run GC with `global.gc()` */ - runGC(): Promise>; + runGC: Endpoint< + // ⏎ + "POST", + EmptyArgs, + EmptyRequest, + EmptyResponseData, + EmptyMeta + >; /** Drop all states in the state cache */ - dropStateCache(): Promise>; + dropStateCache: Endpoint< + // ⏎ + "POST", + EmptyArgs, + EmptyRequest, + EmptyResponseData, + EmptyMeta + >; /** Connect to peer at this multiaddress */ - connectPeer(peerId: string, multiaddrStrs: string[]): Promise>; + connectPeer: Endpoint< + // ⏎ + "POST", + {peerId: string; multiaddrs: string[]}, + {query: {peerId: string; multiaddr: string[]}}, + EmptyResponseData, + EmptyMeta + >; /** Disconnect peer */ - disconnectPeer(peerId: string): Promise>; + disconnectPeer: Endpoint< + // ⏎ + "POST", + {peerId: string}, + {query: {peerId: string}}, + EmptyResponseData, + EmptyMeta + >; /** Same to node api with new fields */ - getPeers( - filters?: FilterGetPeers - ): Promise>; + getPeers: Endpoint< + "GET", + FilterGetPeers, + {query: {state?: PeerState[]; direction?: PeerDirection[]}}, + LodestarNodePeer[], + {count: number} + >; /** Dump Discv5 Kad values */ - discv5GetKadValues(): Promise>; + discv5GetKadValues: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + string[], + EmptyMeta + >; /** * Dump level-db entry keys for a given Bucket declared in code, or for all buckets. - * @param bucket must be the string name of a bucket entry: `allForks_blockArchive` */ - dumpDbBucketKeys(bucket: string): Promise>; + dumpDbBucketKeys: Endpoint< + "GET", + { + /** Must be the string name of a bucket entry: `allForks_blockArchive` */ + bucket: string; + }, + {params: {bucket: string}}, + string[], + EmptyMeta + >; /** Return all entries in the StateArchive index with bucket index_stateArchiveRootIndex */ - dumpDbStateIndex(): Promise>; -}; - -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - writeHeapdump: {url: "/eth/v1/lodestar/write_heapdump", method: "POST"}, - writeProfile: {url: "/eth/v1/lodestar/write_profile", method: "POST"}, - getLatestWeakSubjectivityCheckpointEpoch: {url: "/eth/v1/lodestar/ws_epoch", method: "GET"}, - getSyncChainsDebugState: {url: "/eth/v1/lodestar/sync_chains_debug_state", method: "GET"}, - getGossipQueueItems: {url: "/eth/v1/lodestar/gossip_queue_items/:gossipType", method: "GET"}, - getRegenQueueItems: {url: "/eth/v1/lodestar/regen_queue_items", method: "GET"}, - getBlockProcessorQueueItems: {url: "/eth/v1/lodestar/block_processor_queue_items", method: "GET"}, - getStateCacheItems: {url: "/eth/v1/lodestar/state_cache_items", method: "GET"}, - getGossipPeerScoreStats: {url: "/eth/v1/lodestar/gossip_peer_score_stats", method: "GET"}, - getLodestarPeerScoreStats: {url: "/eth/v1/lodestar/lodestar_peer_score_stats", method: "GET"}, - runGC: {url: "/eth/v1/lodestar/gc", method: "POST"}, - dropStateCache: {url: "/eth/v1/lodestar/drop_state_cache", method: "POST"}, - connectPeer: {url: "/eth/v1/lodestar/connect_peer", method: "POST"}, - disconnectPeer: {url: "/eth/v1/lodestar/disconnect_peer", method: "POST"}, - getPeers: {url: "/eth/v1/lodestar/peers", method: "GET"}, - discv5GetKadValues: {url: "/eth/v1/debug/discv5_kad_values", method: "GET"}, - dumpDbBucketKeys: {url: "/eth/v1/debug/dump_db_bucket_keys/:bucket", method: "GET"}, - dumpDbStateIndex: {url: "/eth/v1/debug/dump_db_state_index", method: "GET"}, -}; - -export type ReqTypes = { - writeHeapdump: {query: {thread?: LodestarThreadType; dirpath?: string}}; - writeProfile: {query: {thread?: LodestarThreadType; duration?: number; dirpath?: string}}; - getLatestWeakSubjectivityCheckpointEpoch: ReqEmpty; - getSyncChainsDebugState: ReqEmpty; - getGossipQueueItems: {params: {gossipType: string}}; - getRegenQueueItems: ReqEmpty; - getBlockProcessorQueueItems: ReqEmpty; - getStateCacheItems: ReqEmpty; - getGossipPeerScoreStats: ReqEmpty; - getLodestarPeerScoreStats: ReqEmpty; - runGC: ReqEmpty; - dropStateCache: ReqEmpty; - connectPeer: {query: {peerId: string; multiaddr: string[]}}; - disconnectPeer: {query: {peerId: string}}; - getPeers: {query: {state?: PeerState[]; direction?: PeerDirection[]}}; - discv5GetKadValues: ReqEmpty; - dumpDbBucketKeys: {params: {bucket: string}}; - dumpDbStateIndex: ReqEmpty; + dumpDbStateIndex: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + {root: RootHex; slot: Slot}[], + EmptyMeta + >; }; -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { writeHeapdump: { - writeReq: (thread, dirpath) => ({query: {thread, dirpath}}), - parseReq: ({query}) => [query.thread, query.dirpath], - schema: {query: {dirpath: Schema.String}}, + url: "/eth/v1/lodestar/write_heapdump", + method: "POST", + req: { + writeReq: ({thread, dirpath}) => ({query: {thread, dirpath}}), + parseReq: ({query}) => ({thread: query.thread, dirpath: query.dirpath}), + schema: {query: {thread: Schema.String, dirpath: Schema.String}}, + }, + resp: JsonOnlyResponseCodec, }, writeProfile: { - writeReq: (thread, duration, dirpath) => ({query: {thread, duration, dirpath}}), - parseReq: ({query}) => [query.thread, query.duration, query.dirpath], - schema: {query: {dirpath: Schema.String}}, + url: "/eth/v1/lodestar/write_profile", + method: "POST", + req: { + writeReq: ({thread, duration, dirpath}) => ({query: {thread, duration, dirpath}}), + parseReq: ({query}) => ({thread: query.thread, duration: query.duration, dirpath: query.dirpath}), + schema: {query: {thread: Schema.String, duration: Schema.Uint, dirpath: Schema.String}}, + }, + resp: JsonOnlyResponseCodec, + }, + getLatestWeakSubjectivityCheckpointEpoch: { + url: "/eth/v1/lodestar/ws_epoch", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getSyncChainsDebugState: { + url: "/eth/v1/lodestar/sync_chains_debug_state", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, }, - getLatestWeakSubjectivityCheckpointEpoch: reqEmpty, - getSyncChainsDebugState: reqEmpty, getGossipQueueItems: { - writeReq: (gossipType) => ({params: {gossipType}}), - parseReq: ({params}) => [params.gossipType], - schema: {params: {gossipType: Schema.StringRequired}}, - }, - getRegenQueueItems: reqEmpty, - getBlockProcessorQueueItems: reqEmpty, - getStateCacheItems: reqEmpty, - getGossipPeerScoreStats: reqEmpty, - getLodestarPeerScoreStats: reqEmpty, - runGC: reqEmpty, - dropStateCache: reqEmpty, + url: "/eth/v1/lodestar/gossip_queue_items/:gossipType", + method: "GET", + req: { + writeReq: ({gossipType}) => ({params: {gossipType}}), + parseReq: ({params}) => ({gossipType: params.gossipType}), + schema: {params: {gossipType: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, + }, + getRegenQueueItems: { + url: "/eth/v1/lodestar/regen_queue_items", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getBlockProcessorQueueItems: { + url: "/eth/v1/lodestar/block_processor_queue_items", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getStateCacheItems: { + url: "/eth/v1/lodestar/state_cache_items", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getGossipPeerScoreStats: { + url: "/eth/v1/lodestar/gossip_peer_score_stats", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getLodestarPeerScoreStats: { + url: "/eth/v1/lodestar/lodestar_peer_score_stats", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + runGC: { + url: "/eth/v1/lodestar/gc", + method: "POST", + req: EmptyRequestCodec, + resp: EmptyResponseCodec, + }, + dropStateCache: { + url: "/eth/v1/lodestar/drop_state_cache", + method: "POST", + req: EmptyRequestCodec, + resp: EmptyResponseCodec, + }, connectPeer: { - writeReq: (peerId, multiaddr) => ({query: {peerId, multiaddr}}), - parseReq: ({query}) => [query.peerId, query.multiaddr], - schema: {query: {peerId: Schema.StringRequired, multiaddr: Schema.StringArray}}, + url: "/eth/v1/lodestar/connect_peer", + method: "POST", + req: { + writeReq: ({peerId, multiaddrs}) => ({query: {peerId, multiaddr: multiaddrs}}), + parseReq: ({query}) => ({peerId: query.peerId, multiaddrs: query.multiaddr}), + schema: {query: {peerId: Schema.StringRequired, multiaddr: Schema.StringArray}}, + }, + resp: EmptyResponseCodec, }, disconnectPeer: { - writeReq: (peerId) => ({query: {peerId}}), - parseReq: ({query}) => [query.peerId], - schema: {query: {peerId: Schema.StringRequired}}, + url: "/eth/v1/lodestar/disconnect_peer", + method: "POST", + req: { + writeReq: ({peerId}) => ({query: {peerId}}), + parseReq: ({query}) => ({peerId: query.peerId}), + schema: {query: {peerId: Schema.StringRequired}}, + }, + resp: EmptyResponseCodec, }, getPeers: { - writeReq: (filters) => ({query: filters || {}}), - parseReq: ({query}) => [query], - schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}}, + url: "/eth/v1/lodestar/peers", + method: "GET", + req: { + writeReq: ({state, direction}) => ({query: {state, direction}}), + parseReq: ({query}) => ({state: query.state, direction: query.direction}), + schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}}, + }, + resp: JsonOnlyResponseCodec, + }, + discv5GetKadValues: { + url: "/eth/v1/debug/discv5_kad_values", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, }, - discv5GetKadValues: reqEmpty, dumpDbBucketKeys: { - writeReq: (bucket) => ({params: {bucket}}), - parseReq: ({params}) => [params.bucket], - schema: {params: {bucket: Schema.String}}, + url: "/eth/v1/debug/dump_db_bucket_keys/:bucket", + method: "GET", + req: { + writeReq: ({bucket}) => ({params: {bucket}}), + parseReq: ({params}) => ({bucket: params.bucket}), + schema: {params: {bucket: Schema.String}}, + }, + resp: JsonOnlyResponseCodec, + }, + dumpDbStateIndex: { + url: "/eth/v1/debug/dump_db_state_index", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, }, - dumpDbStateIndex: reqEmpty, - }; -} - -export function getReturnTypes(): ReturnTypes { - return { - writeHeapdump: sameType(), - writeProfile: sameType(), - getLatestWeakSubjectivityCheckpointEpoch: sameType(), - getSyncChainsDebugState: jsonType("snake"), - getGossipQueueItems: jsonType("snake"), - getRegenQueueItems: jsonType("snake"), - getBlockProcessorQueueItems: jsonType("snake"), - getStateCacheItems: jsonType("snake"), - getGossipPeerScoreStats: jsonType("snake"), - getLodestarPeerScoreStats: jsonType("snake"), - getPeers: jsonType("snake"), - discv5GetKadValues: jsonType("snake"), - dumpDbBucketKeys: sameType(), - dumpDbStateIndex: sameType(), }; } diff --git a/packages/api/src/beacon/routes/node.ts b/packages/api/src/beacon/routes/node.ts index 9a954fe6ad57..1ff0378c3330 100644 --- a/packages/api/src/beacon/routes/node.ts +++ b/packages/api/src/beacon/routes/node.ts @@ -1,31 +1,49 @@ -import {ContainerType} from "@chainsafe/ssz"; -import {allForks, ssz, StringType} from "@lodestar/types"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {ssz, stringType} from "@lodestar/types"; +import {Endpoint, RouteDefinitions, Schema} from "../../utils/index.js"; import { ArrayOf, - reqEmpty, - jsonType, - ReturnTypes, - RoutesData, - Schema, - ReqSerializers, - ReqEmpty, - ContainerData, -} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, + EmptyResponseCodec, + EmptyResponseData, + JsonOnlyResponseCodec, +} from "../../utils/codecs.js"; +import {HttpStatusCode} from "../../utils/httpStatusCode.js"; +import {WireFormat} from "../../utils/wireFormat.js"; // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type NetworkIdentity = { - /** Cryptographic hash of a peer’s public key. [Read more](https://docs.libp2p.io/concepts/peer-id/) */ - peerId: string; - /** Ethereum node record. [Read more](https://eips.ethereum.org/EIPS/eip-778) */ - enr: string; - p2pAddresses: string[]; - discoveryAddresses: string[]; - /** Based on Ethereum Consensus [Metadata object](https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md#metadata) */ - metadata: allForks.Metadata; -}; +export const NetworkIdentityType = new ContainerType( + { + /** Cryptographic hash of a peer’s public key. [Read more](https://docs.libp2p.io/concepts/peer-id/) */ + peerId: stringType, + /** Ethereum node record. [Read more](https://eips.ethereum.org/EIPS/eip-778) */ + enr: stringType, + p2pAddresses: ArrayOf(stringType), + discoveryAddresses: ArrayOf(stringType), + /** Based on Ethereum Consensus [Metadata object](https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md#metadata) */ + metadata: ssz.altair.Metadata, + }, + {jsonCase: "eth2"} +); + +export const PeerCountType = new ContainerType( + { + disconnected: ssz.UintNum64, + connecting: ssz.UintNum64, + connected: ssz.UintNum64, + disconnecting: ssz.UintNum64, + }, + {jsonCase: "eth2"} +); + +export type NetworkIdentity = ValueOf; export type PeerState = "disconnected" | "connecting" | "connected" | "disconnecting"; export type PeerDirection = "inbound" | "outbound"; @@ -39,12 +57,9 @@ export type NodePeer = { direction: PeerDirection | null; }; -export type PeerCount = { - disconnected: number; - connecting: number; - connected: number; - disconnecting: number; -}; +export type PeersMeta = {count: number}; + +export type PeerCount = ValueOf; export type FilterGetPeers = { state?: PeerState[]; @@ -70,155 +85,175 @@ export enum NodeHealth { NOT_INITIALIZED_OR_ISSUES = HttpStatusCode.SERVICE_UNAVAILABLE, } -export type NodeHealthOptions = { - syncingStatus?: number; -}; - /** * Read information about the beacon node. */ -export type Api = { +export type Endpoints = { /** * Get node network identity * Retrieves data about the node's network presence */ - getNetworkIdentity: () => Promise>; + getNetworkIdentity: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + NetworkIdentity, + EmptyMeta + >; + /** * Get node network peers * Retrieves data about the node's network peers. By default this returns all peers. Multiple query params are combined using AND conditions - * @param state - * @param direction */ - getPeers( - filters?: FilterGetPeers - ): Promise>; + getPeers: Endpoint< + "GET", + FilterGetPeers, + {query: {state?: PeerState[]; direction?: PeerDirection[]}}, + NodePeer[], + PeersMeta + >; + /** * Get peer * Retrieves data about the given peer - * @param peerId */ - getPeer( - peerId: string - ): Promise< - ApiClientResponse<{[HttpStatusCode.OK]: {data: NodePeer}}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND> + getPeer: Endpoint< + // ⏎ + "GET", + {peerId: string}, + {params: {peer_id: string}}, + NodePeer, + EmptyMeta >; /** * Get peer count * Retrieves number of known peers. */ - getPeerCount(): Promise>; + getPeerCount: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + PeerCount, + EmptyMeta + >; /** * Get version string of the running beacon node. * Requests that the beacon node identify information about its implementation in a format similar to a [HTTP User-Agent](https://tools.ietf.org/html/rfc7231#section-5.5.3) field. */ - getNodeVersion(): Promise>; + getNodeVersion: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + {version: string}, + EmptyMeta + >; /** * Get node syncing status * Requests the beacon node to describe if it's currently syncing or not, and if it is, what block it is up to. */ - getSyncingStatus(): Promise>; + getSyncingStatus: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SyncingStatus, + EmptyMeta + >; /** * Get health check * Returns node health status in http status codes. Useful for load balancers. */ - getHealth( - options?: NodeHealthOptions - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.PARTIAL_CONTENT]: void}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + getHealth: Endpoint< + // ⏎ + "GET", + {syncingStatus?: number}, + {query: {syncing_status?: number}}, + EmptyResponseData, + EmptyMeta >; }; -export const routesData: RoutesData = { - getNetworkIdentity: {url: "/eth/v1/node/identity", method: "GET"}, - getPeers: {url: "/eth/v1/node/peers", method: "GET"}, - getPeer: {url: "/eth/v1/node/peers/{peer_id}", method: "GET"}, - getPeerCount: {url: "/eth/v1/node/peer_count", method: "GET"}, - getNodeVersion: {url: "/eth/v1/node/version", method: "GET"}, - getSyncingStatus: {url: "/eth/v1/node/syncing", method: "GET"}, - getHealth: {url: "/eth/v1/node/health", method: "GET"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ - -export type ReqTypes = { - getNetworkIdentity: ReqEmpty; - getPeers: {query: {state?: PeerState[]; direction?: PeerDirection[]}}; - getPeer: {params: {peer_id: string}}; - getPeerCount: ReqEmpty; - getNodeVersion: ReqEmpty; - getSyncingStatus: ReqEmpty; - getHealth: {query: {syncing_status?: number}}; -}; - -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { - getNetworkIdentity: reqEmpty, - + getNetworkIdentity: { + url: "/eth/v1/node/identity", + method: "GET", + req: EmptyRequestCodec, + resp: { + onlySupport: WireFormat.json, + data: NetworkIdentityType, + meta: EmptyMetaCodec, + }, + }, getPeers: { - writeReq: (filters) => ({query: filters || {}}), - parseReq: ({query}) => [query], - schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}}, + url: "/eth/v1/node/peers", + method: "GET", + req: { + writeReq: ({state, direction}) => ({query: {state, direction}}), + parseReq: ({query}) => ({state: query.state, direction: query.direction}), + schema: {query: {state: Schema.StringArray, direction: Schema.StringArray}}, + }, + resp: { + ...JsonOnlyResponseCodec, + meta: { + toJson: (d) => d, + fromJson: (d) => ({count: (d as PeersMeta).count}), + toHeadersObject: () => ({}), + fromHeaders: () => ({}) as PeersMeta, + }, + transform: { + toResponse: (data, meta) => ({data, meta}), + fromResponse: (resp) => resp as {data: NodePeer[]; meta: PeersMeta}, + }, + }, }, getPeer: { - writeReq: (peer_id) => ({params: {peer_id}}), - parseReq: ({params}) => [params.peer_id], - schema: {params: {peer_id: Schema.StringRequired}}, + url: "/eth/v1/node/peers/{peer_id}", + method: "GET", + req: { + writeReq: ({peerId}) => ({params: {peer_id: peerId}}), + parseReq: ({params}) => ({peerId: params.peer_id}), + schema: {params: {peer_id: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, }, - - getPeerCount: reqEmpty, - getNodeVersion: reqEmpty, - getSyncingStatus: reqEmpty, - getHealth: { - writeReq: (options) => ({ - query: options?.syncingStatus !== undefined ? {syncing_status: options.syncingStatus} : {}, - }), - parseReq: ({query}) => [{syncingStatus: query.syncing_status}], - schema: {query: {syncing_status: Schema.Uint}}, + getPeerCount: { + url: "/eth/v1/node/peer_count", + method: "GET", + req: EmptyRequestCodec, + resp: { + data: PeerCountType, + meta: EmptyMetaCodec, + }, }, - }; -} - -export function getReturnTypes(): ReturnTypes { - const stringType = new StringType(); - const NetworkIdentity = new ContainerType( - { - peerId: stringType, - enr: stringType, - p2pAddresses: ArrayOf(stringType), - discoveryAddresses: ArrayOf(stringType), - metadata: ssz.altair.Metadata, + getNodeVersion: { + url: "/eth/v1/node/version", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, }, - {jsonCase: "eth2"} - ); - - const PeerCount = new ContainerType( - { - disconnected: ssz.UintNum64, - connecting: ssz.UintNum64, - connected: ssz.UintNum64, - disconnecting: ssz.UintNum64, + getSyncingStatus: { + url: "/eth/v1/node/syncing", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, + getHealth: { + url: "/eth/v1/node/health", + method: "GET", + req: { + writeReq: ({syncingStatus}) => ({query: {syncing_status: syncingStatus}}), + parseReq: ({query}) => ({syncingStatus: query.syncing_status}), + schema: {query: {syncing_status: Schema.Uint}}, + }, + resp: EmptyResponseCodec, }, - {jsonCase: "eth2"} - ); - - return { - // - // TODO: Consider just converting the JSON case without custom types - // - getNetworkIdentity: ContainerData(NetworkIdentity), - // All these types don't contain any BigInt nor Buffer instances. - // Use jsonType() to translate the casing in a generic way. - getPeers: jsonType("snake"), - getPeer: jsonType("snake"), - getPeerCount: ContainerData(PeerCount), - getNodeVersion: jsonType("snake"), - getSyncingStatus: jsonType("snake"), }; } diff --git a/packages/api/src/beacon/routes/proof.ts b/packages/api/src/beacon/routes/proof.ts index 14964ad63730..5c20a0194fc5 100644 --- a/packages/api/src/beacon/routes/proof.ts +++ b/packages/api/src/beacon/routes/proof.ts @@ -1,63 +1,81 @@ -import {Proof} from "@chainsafe/persistent-merkle-tree"; -import {fromHexString, toHexString} from "@chainsafe/ssz"; -import {ReturnTypes, RoutesData, Schema, sameType, ReqSerializers} from "../../utils/index.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../../interfaces.js"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {CompactMultiProof, ProofType} from "@chainsafe/persistent-merkle-tree"; +import {ByteListType, ContainerType, fromHexString, toHexString} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {ssz} from "@lodestar/types"; +import {Endpoint, RouteDefinitions, Schema} from "../../utils/index.js"; +import {ArrayOf} from "../../utils/codecs.js"; +import {VersionCodec, VersionMeta} from "../../utils/metadata.js"; + +export const CompactMultiProofType = new ContainerType({ + leaves: ArrayOf(ssz.Root, 10000), + descriptor: new ByteListType(2048), +}); // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -export type Api = { +export type Endpoints = { /** * Returns a multiproof of `descriptor` at the requested `stateId`. * The requested `stateId` may not be available. Regular nodes only keep recent states in memory. */ - getStateProof( - stateId: string, - descriptor: Uint8Array - ): Promise>; + getStateProof: Endpoint< + "GET", + {stateId: string; descriptor: Uint8Array}, + {params: {state_id: string}; query: {format: string}}, + CompactMultiProof, + VersionMeta + >; /** * Returns a multiproof of `descriptor` at the requested `blockId`. * The requested `blockId` may not be available. Regular nodes only keep recent states in memory. */ - getBlockProof( - blockId: string, - descriptor: Uint8Array - ): Promise>; -}; - -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getStateProof: {url: "/eth/v0/beacon/proof/state/{state_id}", method: "GET"}, - getBlockProof: {url: "/eth/v0/beacon/proof/block/{block_id}", method: "GET"}, + getBlockProof: Endpoint< + "GET", + {blockId: string; descriptor: Uint8Array}, + {params: {block_id: string}; query: {format: string}}, + CompactMultiProof, + VersionMeta + >; }; -/* eslint-disable @typescript-eslint/naming-convention */ -export type ReqTypes = { - getStateProof: {params: {state_id: string}; query: {format: string}}; - getBlockProof: {params: {block_id: string}; query: {format: string}}; -}; - -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { getStateProof: { - writeReq: (state_id, descriptor) => ({params: {state_id}, query: {format: toHexString(descriptor)}}), - parseReq: ({params, query}) => [params.state_id, fromHexString(query.format)], - schema: {params: {state_id: Schema.StringRequired}, query: {format: Schema.StringRequired}}, + url: "/eth/v0/beacon/proof/state/{state_id}", + method: "GET", + req: { + writeReq: ({stateId, descriptor}) => ({params: {state_id: stateId}, query: {format: toHexString(descriptor)}}), + parseReq: ({params, query}) => ({stateId: params.state_id, descriptor: fromHexString(query.format)}), + schema: {params: {state_id: Schema.StringRequired}, query: {format: Schema.StringRequired}}, + }, + resp: { + data: { + toJson: (data) => CompactMultiProofType.toJson(data), + fromJson: (data) => ({...CompactMultiProofType.fromJson(data), type: ProofType.compactMulti}), + serialize: (data) => CompactMultiProofType.serialize(data), + deserialize: (data) => ({...CompactMultiProofType.deserialize(data), type: ProofType.compactMulti}), + }, + meta: VersionCodec, + }, }, getBlockProof: { - writeReq: (block_id, descriptor) => ({params: {block_id}, query: {format: toHexString(descriptor)}}), - parseReq: ({params, query}) => [params.block_id, fromHexString(query.format)], - schema: {params: {block_id: Schema.StringRequired}, query: {format: Schema.StringRequired}}, + url: "/eth/v0/beacon/proof/block/{block_id}", + method: "GET", + req: { + writeReq: ({blockId, descriptor}) => ({params: {block_id: blockId}, query: {format: toHexString(descriptor)}}), + parseReq: ({params, query}) => ({blockId: params.block_id, descriptor: fromHexString(query.format)}), + schema: {params: {block_id: Schema.StringRequired}, query: {format: Schema.StringRequired}}, + }, + resp: { + data: { + toJson: (data) => CompactMultiProofType.toJson(data), + fromJson: (data) => ({...CompactMultiProofType.fromJson(data), type: ProofType.compactMulti}), + serialize: (data) => CompactMultiProofType.serialize(data), + deserialize: (data) => ({...CompactMultiProofType.deserialize(data), type: ProofType.compactMulti}), + }, + meta: VersionCodec, + }, }, }; } - -export function getReturnTypes(): ReturnTypes { - return { - // Just sent the proof JSON as-is - getStateProof: sameType(), - getBlockProof: sameType(), - }; -} diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 1d6b8b80551c..7f704edd542a 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -1,45 +1,47 @@ -import {ContainerType, fromHexString, toHexString, Type} from "@chainsafe/ssz"; -import {ForkName, ForkBlobs, isForkBlobs, isForkExecution, ForkPreBlobs, ForkExecution} from "@lodestar/params"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, fromHexString, toHexString, Type, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; +import {isForkBlobs} from "@lodestar/params"; import { allForks, altair, - BLSPubkey, BLSSignature, CommitteeIndex, Epoch, phase0, - bellatrix, Root, Slot, ssz, - UintNum64, UintBn64, ValidatorIndex, - RootHex, - StringType, - SubcommitteeIndex, - Wei, ProducedBlockSource, + stringType, } from "@lodestar/types"; -import {ApiClientResponse} from "../../interfaces.js"; -import {HttpStatusCode} from "../../utils/client/httpStatusCode.js"; +import {Endpoint, RouteDefinitions, Schema} from "../../utils/index.js"; +import {fromGraffitiHex, toBoolean, toGraffitiHex} from "../../utils/serdes.js"; +import {getBlindedForkTypes, toForkName} from "../../utils/fork.js"; import { - RoutesData, - ReturnTypes, ArrayOf, - Schema, + EmptyMeta, + EmptyMetaCodec, + EmptyResponseCodec, + EmptyResponseData, + JsonOnlyReq, + WithMeta, WithVersion, - WithBlockValues, - reqOnlyBody, - ReqSerializers, - jsonType, - ContainerDataExecutionOptimistic, - ContainerData, - TypeJson, -} from "../../utils/index.js"; -import {fromU64Str, fromGraffitiHex, toU64Str, U64Str, toGraffitiHex} from "../../utils/serdes.js"; -import {allForksBlockContentsResSerializer} from "../../utils/routes.js"; -import {ExecutionOptimistic} from "./beacon/block.js"; +} from "../../utils/codecs.js"; +import { + ExecutionOptimisticAndDependentRootCodec, + ExecutionOptimisticAndDependentRootMeta, + ExecutionOptimisticCodec, + ExecutionOptimisticMeta, + MetaHeader, + VersionCodec, + VersionMeta, + VersionType, +} from "../../utils/metadata.js"; + +// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes export enum BuilderSelection { Default = "default", @@ -52,121 +54,193 @@ export enum BuilderSelection { ExecutionOnly = "executiononly", } -export type ExtraProduceBlockOps = { +/** Lodestar-specific (non-standardized) options */ +export type ExtraProduceBlockOpts = { feeRecipient?: string; builderSelection?: BuilderSelection; - builderBoostFactor?: UintBn64; strictFeeRecipientCheck?: boolean; blindedLocal?: boolean; }; -export type ProduceBlockOrContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & ( - | {data: allForks.BeaconBlock; version: ForkPreBlobs} - | {data: allForks.BlockContents; version: ForkBlobs} +export const ProduceBlockV3MetaType = new ContainerType( + { + ...VersionType.fields, + /** Specifies whether the response contains full or blinded block */ + executionPayloadBlinded: ssz.Boolean, + /** Execution payload value in Wei */ + executionPayloadValue: ssz.UintBn64, + /** Consensus rewards paid to the proposer for this block, in Wei */ + consensusBlockValue: ssz.UintBn64, + }, + {jsonCase: "eth2"} ); -export type ProduceBlindedBlockRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & { - data: allForks.BlindedBeaconBlock; - version: ForkExecution; + +export type ProduceBlockV3Meta = ValueOf & { + /** Lodestar-specific (non-standardized) value */ + executionPayloadSource: ProducedBlockSource; }; -export type ProduceFullOrBlindedBlockOrContentsRes = {executionPayloadSource: ProducedBlockSource} & ( - | (ProduceBlockOrContentsRes & {executionPayloadBlinded: false}) - | (ProduceBlindedBlockRes & {executionPayloadBlinded: true}) +export const BlockContentsType = new ContainerType( + { + block: ssz.deneb.BeaconBlock, + kzgProofs: ssz.deneb.KZGProofs, + blobs: ssz.deneb.Blobs, + }, + {jsonCase: "eth2"} ); -// See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes - -export type BeaconCommitteeSubscription = { - validatorIndex: ValidatorIndex; - committeeIndex: number; - committeesAtSlot: number; - slot: Slot; - isAggregator: boolean; -}; +export const AttesterDutyType = new ContainerType( + { + /** The validator's public key, uniquely identifying them */ + pubkey: ssz.BLSPubkey, + /** Index of validator in validator registry */ + validatorIndex: ssz.ValidatorIndex, + /** Index of the committee */ + committeeIndex: ssz.CommitteeIndex, + /** Number of validators in committee */ + committeeLength: ssz.UintNum64, + /** Number of committees at the provided slot */ + committeesAtSlot: ssz.UintNum64, + /** Index of validator in committee */ + validatorCommitteeIndex: ssz.UintNum64, + /** The slot at which the validator must attest */ + slot: ssz.Slot, + }, + {jsonCase: "eth2"} +); -/** - * From https://github.com/ethereum/beacon-APIs/pull/136 - */ -export type SyncCommitteeSubscription = { - validatorIndex: ValidatorIndex; - syncCommitteeIndices: number[]; - untilEpoch: Epoch; -}; +export const ProposerDutyType = new ContainerType( + { + slot: ssz.Slot, + validatorIndex: ssz.ValidatorIndex, + pubkey: ssz.BLSPubkey, + }, + {jsonCase: "eth2"} +); /** - * The types used here are string instead of ssz based because the use of proposer data - * is just validator --> beacon json api call for `beaconProposerCache` cache update. + * From https://github.com/ethereum/beacon-APIs/pull/134 */ -export type ProposerPreparationData = { - validatorIndex: string; - feeRecipient: string; -}; - -export type ProposerDuty = { - slot: Slot; - validatorIndex: ValidatorIndex; - pubkey: BLSPubkey; -}; +export const SyncDutyType = new ContainerType( + { + pubkey: ssz.BLSPubkey, + /** Index of validator in validator registry. */ + validatorIndex: ssz.ValidatorIndex, + /** The indices of the validator in the sync committee. */ + validatorSyncCommitteeIndices: ArrayOf(ssz.CommitteeIndex), + }, + {jsonCase: "eth2"} +); -export type AttesterDuty = { - // The validator's public key, uniquely identifying them - pubkey: BLSPubkey; - // Index of validator in validator registry - validatorIndex: ValidatorIndex; - committeeIndex: CommitteeIndex; - // Number of validators in committee - committeeLength: UintNum64; - // Number of committees at the provided slot - committeesAtSlot: UintNum64; - // Index of validator in committee - validatorCommitteeIndex: UintNum64; - // The slot at which the validator must attest. - slot: Slot; -}; +export const BeaconCommitteeSubscriptionType = new ContainerType( + { + validatorIndex: ssz.ValidatorIndex, + committeeIndex: ssz.CommitteeIndex, + committeesAtSlot: ssz.Slot, + slot: ssz.Slot, + isAggregator: ssz.Boolean, + }, + {jsonCase: "eth2"} +); /** - * From https://github.com/ethereum/beacon-APIs/pull/134 + * From https://github.com/ethereum/beacon-APIs/pull/136 */ -export type SyncDuty = { - pubkey: BLSPubkey; - /** Index of validator in validator registry. */ - validatorIndex: ValidatorIndex; - /** The indices of the validator in the sync committee. */ - validatorSyncCommitteeIndices: number[]; -}; +export const SyncCommitteeSubscriptionType = new ContainerType( + { + validatorIndex: ssz.ValidatorIndex, + syncCommitteeIndices: ArrayOf(ssz.CommitteeIndex), + untilEpoch: ssz.Epoch, + }, + {jsonCase: "eth2"} +); + +export const ProposerPreparationDataType = new ContainerType( + { + validatorIndex: ssz.ValidatorIndex, + feeRecipient: stringType, + }, + {jsonCase: "eth2"} +); /** * From https://github.com/ethereum/beacon-APIs/pull/224 */ -export type BeaconCommitteeSelection = { - /** Index of the validator */ - validatorIndex: ValidatorIndex; - /** The slot at which a validator is assigned to attest */ - slot: Slot; - /** The `slot_signature` calculated by the validator for the upcoming attestation slot */ - selectionProof: BLSSignature; -}; +export const BeaconCommitteeSelectionType = new ContainerType( + { + /** Index of the validator */ + validatorIndex: ssz.ValidatorIndex, + /** The slot at which a validator is assigned to attest */ + slot: ssz.Slot, + /** The `slot_signature` calculated by the validator for the upcoming attestation slot */ + selectionProof: ssz.BLSSignature, + }, + {jsonCase: "eth2"} +); /** * From https://github.com/ethereum/beacon-APIs/pull/224 */ -export type SyncCommitteeSelection = { - /** Index of the validator */ - validatorIndex: ValidatorIndex; - /** The slot at which validator is assigned to produce a sync committee contribution */ - slot: Slot; - /** SubcommitteeIndex to which the validator is assigned */ - subcommitteeIndex: SubcommitteeIndex; - /** The `slot_signature` calculated by the validator for the upcoming sync committee slot */ - selectionProof: BLSSignature; -}; +export const SyncCommitteeSelectionType = new ContainerType( + { + /** Index of the validator */ + validatorIndex: ssz.ValidatorIndex, + /** The slot at which validator is assigned to produce a sync committee contribution */ + slot: ssz.Slot, + /** SubcommitteeIndex to which the validator is assigned */ + subcommitteeIndex: ssz.SubcommitteeIndex, + /** The `slot_signature` calculated by the validator for the upcoming sync committee slot */ + selectionProof: ssz.BLSSignature, + }, + {jsonCase: "eth2"} +); -export type LivenessResponseData = { - index: ValidatorIndex; - isLive: boolean; -}; +export const LivenessResponseDataType = new ContainerType( + { + index: ssz.ValidatorIndex, + isLive: ssz.Boolean, + }, + {jsonCase: "eth2"} +); -export type Api = { +export const ValidatorIndicesType = ArrayOf(ssz.ValidatorIndex); +export const AttesterDutyListType = ArrayOf(AttesterDutyType); +export const ProposerDutyListType = ArrayOf(ProposerDutyType); +export const SyncDutyListType = ArrayOf(SyncDutyType); +export const SignedAggregateAndProofListType = ArrayOf(ssz.phase0.SignedAggregateAndProof); +export const SignedContributionAndProofListType = ArrayOf(ssz.altair.SignedContributionAndProof); +export const BeaconCommitteeSubscriptionListType = ArrayOf(BeaconCommitteeSubscriptionType); +export const SyncCommitteeSubscriptionListType = ArrayOf(SyncCommitteeSubscriptionType); +export const ProposerPreparationDataListType = ArrayOf(ProposerPreparationDataType); +export const BeaconCommitteeSelectionListType = ArrayOf(BeaconCommitteeSelectionType); +export const SyncCommitteeSelectionListType = ArrayOf(SyncCommitteeSelectionType); +export const LivenessResponseDataListType = ArrayOf(LivenessResponseDataType); +export const SignedValidatorRegistrationV1ListType = ArrayOf(ssz.bellatrix.SignedValidatorRegistrationV1); + +export type ValidatorIndices = ValueOf; +export type AttesterDuty = ValueOf; +export type AttesterDutyList = ValueOf; +export type ProposerDuty = ValueOf; +export type ProposerDutyList = ValueOf; +export type SyncDuty = ValueOf; +export type SyncDutyList = ValueOf; +export type SignedAggregateAndProofList = ValueOf; +export type SignedContributionAndProofList = ValueOf; +export type BeaconCommitteeSubscription = ValueOf; +export type BeaconCommitteeSubscriptionList = ValueOf; +export type SyncCommitteeSubscription = ValueOf; +export type SyncCommitteeSubscriptionList = ValueOf; +export type ProposerPreparationData = ValueOf; +export type ProposerPreparationDataList = ValueOf; +export type BeaconCommitteeSelection = ValueOf; +export type BeaconCommitteeSelectionList = ValueOf; +export type SyncCommitteeSelection = ValueOf; +export type SyncCommitteeSelectionList = ValueOf; +export type LivenessResponseData = ValueOf; +export type LivenessResponseDataList = ValueOf; +export type SignedValidatorRegistrationV1List = ValueOf; + +export type Endpoints = { /** * Get attester duties * Requests the beacon node to provide a set of attestation duties, which should be performed by validators, for a particular epoch. @@ -175,19 +249,18 @@ export type Api = { * - event.current_duty_dependent_root when `compute_epoch_at_slot(event.slot) + 1 == epoch` * - event.block otherwise * The dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch - 1) - 1)` or the genesis block root in the case of underflow. - * @param epoch Should only be allowed 1 epoch ahead - * @param requestBody An array of the validator indices for which to obtain the duties. - * @returns any Success response - * @throws ApiError */ - getAttesterDuties( - epoch: Epoch, - validatorIndices: ValidatorIndex[] - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: AttesterDuty[]; executionOptimistic: ExecutionOptimistic; dependentRoot: RootHex}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + getAttesterDuties: Endpoint< + "POST", + { + /** Should only be allowed 1 epoch ahead */ + epoch: Epoch; + /** An array of the validator indices for which to obtain the duties */ + indices: ValidatorIndices; + }, + {params: {epoch: Epoch}; body: unknown}, + AttesterDutyList, + ExecutionOptimisticAndDependentRootMeta >; /** @@ -197,169 +270,185 @@ export type Api = { * - event.current_duty_dependent_root when `compute_epoch_at_slot(event.slot) == epoch` * - event.block otherwise * The dependent_root value is `get_block_root_at_slot(state, compute_start_slot_at_epoch(epoch) - 1)` or the genesis block root in the case of underflow. - * @param epoch - * @returns any Success response - * @throws ApiError */ - getProposerDuties( - epoch: Epoch - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: ProposerDuty[]; executionOptimistic: ExecutionOptimistic; dependentRoot: RootHex}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + getProposerDuties: Endpoint< + "GET", + {epoch: Epoch}, + {params: {epoch: Epoch}}, + ProposerDutyList, + ExecutionOptimisticAndDependentRootMeta >; - getSyncCommitteeDuties( - epoch: number, - validatorIndices: ValidatorIndex[] - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: SyncDuty[]; executionOptimistic: ExecutionOptimistic}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + getSyncCommitteeDuties: Endpoint< + "POST", + { + epoch: number; + indices: ValidatorIndices; + }, + {params: {epoch: Epoch}; body: unknown}, + SyncDutyList, + ExecutionOptimisticMeta >; /** * Produce a new block, without signature. * Requests a beacon node to produce a valid block, which can then be signed by a validator. - * @param slot The slot for which the block should be proposed. - * @param randaoReveal The validator's randao reveal value. - * @param graffiti Arbitrary data validator wants to include in block. - * @returns any Success response - * @throws ApiError */ - produceBlock( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: allForks.BeaconBlock}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceBlock: Endpoint< + "GET", + { + /** The slot for which the block should be proposed. */ + slot: Slot; + /** The validator's randao reveal value */ + randaoReveal: BLSSignature; + /** Arbitrary data validator wants to include in block */ + graffiti: string; + }, + {params: {slot: number}; query: {randao_reveal: string; graffiti: string}}, + allForks.BeaconBlock, + VersionMeta >; /** * Requests a beacon node to produce a valid block, which can then be signed by a validator. * Metadata in the response indicates the type of block produced, and the supported types of block * will be added to as forks progress. - * @param slot The slot for which the block should be proposed. - * @param randaoReveal The validator's randao reveal value. - * @param graffiti Arbitrary data validator wants to include in block. - * @returns any Success response - * @throws ApiError */ - produceBlockV2( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: ProduceBlockOrContentsRes}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceBlockV2: Endpoint< + "GET", + { + /** The slot for which the block should be proposed */ + slot: Slot; + /** The validator's randao reveal value */ + randaoReveal: BLSSignature; + /** Arbitrary data validator wants to include in block */ + graffiti: string; + } & Omit, + { + params: {slot: number}; + query: { + randao_reveal: string; + graffiti: string; + fee_recipient?: string; + builder_selection?: string; + strict_fee_recipient_check?: boolean; + }; + }, + allForks.BeaconBlockOrContents, + VersionMeta >; /** * Requests a beacon node to produce a valid block, which can then be signed by a validator. * Metadata in the response indicates the type of block produced, and the supported types of block * will be added to as forks progress. - * @param slot The slot for which the block should be proposed. - * @param randaoReveal The validator's randao reveal value. - * @param graffiti Arbitrary data validator wants to include in block. - * @returns any Success response - * @throws ApiError */ - produceBlockV3( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string, - skipRandaoVerification?: boolean, - opts?: ExtraProduceBlockOps - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: ProduceFullOrBlindedBlockOrContentsRes; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceBlockV3: Endpoint< + "GET", + { + /** The slot for which the block should be proposed */ + slot: Slot; + /** The validator's randao reveal value */ + randaoReveal: BLSSignature; + /** Arbitrary data validator wants to include in block */ + graffiti: string; + skipRandaoVerification?: boolean; + builderBoostFactor?: UintBn64; + } & ExtraProduceBlockOpts, + { + params: {slot: number}; + query: { + randao_reveal: string; + graffiti: string; + skip_randao_verification?: string; + fee_recipient?: string; + builder_selection?: string; + builder_boost_factor?: string; + strict_fee_recipient_check?: boolean; + blinded_local?: boolean; + }; + }, + allForks.FullOrBlindedBeaconBlockOrContents, + ProduceBlockV3Meta >; - produceBlindedBlock( - slot: Slot, - randaoReveal: BLSSignature, - graffiti: string - ): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: ProduceBlindedBlockRes; - }, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceBlindedBlock: Endpoint< + "GET", + { + slot: Slot; + randaoReveal: BLSSignature; + graffiti: string; + }, + {params: {slot: number}; query: {randao_reveal: string; graffiti: string}}, + allForks.BlindedBeaconBlock, + VersionMeta >; /** * Produce an attestation data * Requests that the beacon node produce an AttestationData. - * @param slot The slot for which an attestation data should be created. - * @param committeeIndex The committee index for which an attestation data should be created. - * @returns any Success response - * @throws ApiError */ - produceAttestationData( - index: CommitteeIndex, - slot: Slot - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: phase0.AttestationData}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceAttestationData: Endpoint< + "GET", + { + /** The committee index for which an attestation data should be created */ + committeeIndex: CommitteeIndex; + /** The slot for which an attestation data should be created */ + slot: Slot; + }, + {query: {slot: number; committee_index: number}}, + phase0.AttestationData, + EmptyMeta >; - produceSyncCommitteeContribution( - slot: Slot, - subcommitteeIndex: number, - beaconBlockRoot: Root - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: altair.SyncCommitteeContribution}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE - > + produceSyncCommitteeContribution: Endpoint< + "GET", + { + slot: Slot; + subcommitteeIndex: number; + beaconBlockRoot: Root; + }, + {query: {slot: number; subcommittee_index: number; beacon_block_root: string}}, + altair.SyncCommitteeContribution, + EmptyMeta >; /** * Get aggregated attestation * Aggregates all attestations matching given attestation data root and slot - * @param attestationDataRoot HashTreeRoot of AttestationData that validator want's aggregated - * @param slot - * @returns any Returns aggregated `Attestation` object with same `AttestationData` root. - * @throws ApiError + * Returns an aggregated `Attestation` object with same `AttestationData` root. */ - getAggregatedAttestation( - attestationDataRoot: Root, - slot: Slot - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: phase0.Attestation}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_FOUND - > + getAggregatedAttestation: Endpoint< + "GET", + { + /** HashTreeRoot of AttestationData that validator want's aggregated */ + attestationDataRoot: Root; + slot: Slot; + }, + {query: {attestation_data_root: string; slot: number}}, + phase0.Attestation, + EmptyMeta >; /** * Publish multiple aggregate and proofs * Verifies given aggregate and proofs and publishes them on appropriate gossipsub topic. - * @param requestBody - * @returns any Successful response - * @throws ApiError */ - publishAggregateAndProofs( - signedAggregateAndProofs: phase0.SignedAggregateAndProof[] - ): Promise>; + publishAggregateAndProofs: Endpoint< + "POST", + {signedAggregateAndProofs: SignedAggregateAndProofList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; - publishContributionAndProofs( - contributionAndProofs: altair.SignedContributionAndProof[] - ): Promise>; + publishContributionAndProofs: Endpoint< + "POST", + {contributionAndProofs: SignedContributionAndProofList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** * Signal beacon node to prepare for a committee subnet @@ -370,28 +459,33 @@ export type Api = { * - announce subnet topic subscription on gossipsub * - aggregate attestations received on that subnet * - * @param requestBody - * @returns any Slot signature is valid and beacon node has prepared the attestation subnet. + * Returns if slot signature is valid and beacon node has prepared the attestation subnet. * - * Note that, we cannot be certain Beacon node will find peers for that subnet for various reasons," - * - * @throws ApiError + * Note that we cannot be certain the Beacon node will find peers for that subnet for various reasons. */ - prepareBeaconCommitteeSubnet( - subscriptions: BeaconCommitteeSubscription[] - ): Promise< - ApiClientResponse<{[HttpStatusCode.OK]: void}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE> + prepareBeaconCommitteeSubnet: Endpoint< + "POST", + {subscriptions: BeaconCommitteeSubscriptionList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta >; - prepareSyncCommitteeSubnets( - subscriptions: SyncCommitteeSubscription[] - ): Promise< - ApiClientResponse<{[HttpStatusCode.OK]: void}, HttpStatusCode.BAD_REQUEST | HttpStatusCode.SERVICE_UNAVAILABLE> + prepareSyncCommitteeSubnets: Endpoint< + "POST", + {subscriptions: SyncCommitteeSubscriptionList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta >; - prepareBeaconProposer( - proposers: ProposerPreparationData[] - ): Promise>; + prepareBeaconProposer: Endpoint< + "POST", + {proposers: ProposerPreparationDataList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; /** * Determine if a distributed validator has been selected to aggregate attestations @@ -403,17 +497,17 @@ export type Api = { * * Note that this endpoint is not implemented by the beacon node and will return a 501 error * - * @param requestBody An array of partial beacon committee selection proofs - * @returns An array of threshold aggregated beacon committee selection proofs - * @throws ApiError + * Returns an array of threshold aggregated beacon committee selection proofs */ - submitBeaconCommitteeSelections( - selections: BeaconCommitteeSelection[] - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: BeaconCommitteeSelection[]}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_IMPLEMENTED | HttpStatusCode.SERVICE_UNAVAILABLE - > + submitBeaconCommitteeSelections: Endpoint< + "POST", + { + /** An array of partial beacon committee selection proofs */ + selections: BeaconCommitteeSelectionList; + }, + {body: unknown}, + BeaconCommitteeSelectionList, + EmptyMeta >; /** @@ -426,370 +520,481 @@ export type Api = { * * Note that this endpoint is not implemented by the beacon node and will return a 501 error * - * @param requestBody An array of partial sync committee selection proofs - * @returns An array of threshold aggregated sync committee selection proofs - * @throws ApiError + * Returns an array of threshold aggregated sync committee selection proofs */ - submitSyncCommitteeSelections( - selections: SyncCommitteeSelection[] - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: SyncCommitteeSelection[]}}, - HttpStatusCode.BAD_REQUEST | HttpStatusCode.NOT_IMPLEMENTED | HttpStatusCode.SERVICE_UNAVAILABLE - > + submitSyncCommitteeSelections: Endpoint< + "POST", + { + /** An array of partial sync committee selection proofs */ + selections: SyncCommitteeSelectionList; + }, + {body: unknown}, + SyncCommitteeSelectionList, + EmptyMeta >; /** Returns validator indices that have been observed to be active on the network */ - getLiveness( - epoch: Epoch, - validatorIndices: ValidatorIndex[] - ): Promise>; - - registerValidator( - registrations: bellatrix.SignedValidatorRegistrationV1[] - ): Promise>; -}; - -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - getAttesterDuties: {url: "/eth/v1/validator/duties/attester/{epoch}", method: "POST"}, - getProposerDuties: {url: "/eth/v1/validator/duties/proposer/{epoch}", method: "GET"}, - getSyncCommitteeDuties: {url: "/eth/v1/validator/duties/sync/{epoch}", method: "POST"}, - produceBlock: {url: "/eth/v1/validator/blocks/{slot}", method: "GET"}, - produceBlockV2: {url: "/eth/v2/validator/blocks/{slot}", method: "GET"}, - produceBlockV3: {url: "/eth/v3/validator/blocks/{slot}", method: "GET"}, - produceBlindedBlock: {url: "/eth/v1/validator/blinded_blocks/{slot}", method: "GET"}, - produceAttestationData: {url: "/eth/v1/validator/attestation_data", method: "GET"}, - produceSyncCommitteeContribution: {url: "/eth/v1/validator/sync_committee_contribution", method: "GET"}, - getAggregatedAttestation: {url: "/eth/v1/validator/aggregate_attestation", method: "GET"}, - publishAggregateAndProofs: {url: "/eth/v1/validator/aggregate_and_proofs", method: "POST"}, - publishContributionAndProofs: {url: "/eth/v1/validator/contribution_and_proofs", method: "POST"}, - prepareBeaconCommitteeSubnet: {url: "/eth/v1/validator/beacon_committee_subscriptions", method: "POST"}, - prepareSyncCommitteeSubnets: {url: "/eth/v1/validator/sync_committee_subscriptions", method: "POST"}, - prepareBeaconProposer: {url: "/eth/v1/validator/prepare_beacon_proposer", method: "POST"}, - submitBeaconCommitteeSelections: {url: "/eth/v1/validator/beacon_committee_selections", method: "POST"}, - submitSyncCommitteeSelections: {url: "/eth/v1/validator/sync_committee_selections", method: "POST"}, - getLiveness: {url: "/eth/v1/validator/liveness/{epoch}", method: "POST"}, - registerValidator: {url: "/eth/v1/validator/register_validator", method: "POST"}, -}; + getLiveness: Endpoint< + "POST", + { + epoch: Epoch; + indices: ValidatorIndex[]; + }, + {params: {epoch: Epoch}; body: unknown}, + LivenessResponseDataList, + EmptyMeta + >; -/* eslint-disable @typescript-eslint/naming-convention */ -export type ReqTypes = { - getAttesterDuties: {params: {epoch: Epoch}; body: U64Str[]}; - getProposerDuties: {params: {epoch: Epoch}}; - getSyncCommitteeDuties: {params: {epoch: Epoch}; body: U64Str[]}; - produceBlock: {params: {slot: number}; query: {randao_reveal: string; graffiti: string}}; - produceBlockV2: {params: {slot: number}; query: {randao_reveal: string; graffiti: string; fee_recipient?: string}}; - produceBlockV3: { - params: {slot: number}; - query: { - randao_reveal: string; - graffiti: string; - skip_randao_verification?: string; - fee_recipient?: string; - builder_selection?: string; - builder_boost_factor?: string; - strict_fee_recipient_check?: boolean; - blinded_local?: boolean; - }; - }; - produceBlindedBlock: {params: {slot: number}; query: {randao_reveal: string; graffiti: string}}; - produceAttestationData: {query: {slot: number; committee_index: number}}; - produceSyncCommitteeContribution: {query: {slot: number; subcommittee_index: number; beacon_block_root: string}}; - getAggregatedAttestation: {query: {attestation_data_root: string; slot: number}}; - publishAggregateAndProofs: {body: unknown}; - publishContributionAndProofs: {body: unknown}; - prepareBeaconCommitteeSubnet: {body: unknown}; - prepareSyncCommitteeSubnets: {body: unknown}; - prepareBeaconProposer: {body: unknown}; - submitBeaconCommitteeSelections: {body: unknown}; - submitSyncCommitteeSelections: {body: unknown}; - getLiveness: {params: {epoch: Epoch}; body: U64Str[]}; - registerValidator: {body: unknown}; + registerValidator: Endpoint< + "POST", + {registrations: SignedValidatorRegistrationV1List}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; }; -const BeaconCommitteeSelection = new ContainerType( - { - validatorIndex: ssz.ValidatorIndex, - slot: ssz.Slot, - selectionProof: ssz.BLSSignature, - }, - {jsonCase: "eth2"} -); - -const SyncCommitteeSelection = new ContainerType( - { - validatorIndex: ssz.ValidatorIndex, - slot: ssz.Slot, - subcommitteeIndex: ssz.SubcommitteeIndex, - selectionProof: ssz.BLSSignature, - }, - {jsonCase: "eth2"} -); - -export function getReqSerializers(): ReqSerializers { - const BeaconCommitteeSubscription = new ContainerType( - { - validatorIndex: ssz.ValidatorIndex, - committeeIndex: ssz.CommitteeIndex, - committeesAtSlot: ssz.Slot, - slot: ssz.Slot, - isAggregator: ssz.Boolean, +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { + return { + getAttesterDuties: { + url: "/eth/v1/validator/duties/attester/{epoch}", + method: "POST", + req: { + writeReqJson: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.toJson(indices)}), + parseReqJson: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.fromJson(body)}), + writeReqSsz: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.serialize(indices)}), + parseReqSsz: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.deserialize(body)}), + schema: { + params: {epoch: Schema.UintRequired}, + body: Schema.StringArray, + }, + }, + resp: { + data: AttesterDutyListType, + meta: ExecutionOptimisticAndDependentRootCodec, + }, }, - {jsonCase: "eth2"} - ); - - const SyncCommitteeSubscription = new ContainerType( - { - validatorIndex: ssz.ValidatorIndex, - syncCommitteeIndices: ArrayOf(ssz.CommitteeIndex), - untilEpoch: ssz.Epoch, + getProposerDuties: { + url: "/eth/v1/validator/duties/proposer/{epoch}", + method: "GET", + req: { + writeReq: ({epoch}) => ({params: {epoch}}), + parseReq: ({params}) => ({epoch: params.epoch}), + schema: { + params: {epoch: Schema.UintRequired}, + }, + }, + resp: { + data: ProposerDutyListType, + meta: ExecutionOptimisticAndDependentRootCodec, + }, }, - {jsonCase: "eth2"} - ); - - const produceBlockV3: ReqSerializers["produceBlockV3"] = { - writeReq: (slot, randaoReveal, graffiti, skipRandaoVerification, opts) => ({ - params: {slot}, - query: { - randao_reveal: toHexString(randaoReveal), - graffiti: toGraffitiHex(graffiti), - fee_recipient: opts?.feeRecipient, - ...(skipRandaoVerification && {skip_randao_verification: ""}), - builder_selection: opts?.builderSelection, - builder_boost_factor: opts?.builderBoostFactor?.toString(), - strict_fee_recipient_check: opts?.strictFeeRecipientCheck, - blinded_local: opts?.blindedLocal, + getSyncCommitteeDuties: { + url: "/eth/v1/validator/duties/sync/{epoch}", + method: "POST", + req: { + writeReqJson: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.toJson(indices)}), + parseReqJson: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.fromJson(body)}), + writeReqSsz: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.serialize(indices)}), + parseReqSsz: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.deserialize(body)}), + schema: { + params: {epoch: Schema.UintRequired}, + body: Schema.StringArray, + }, }, - }), - parseReq: ({params, query}) => [ - params.slot, - fromHexString(query.randao_reveal), - fromGraffitiHex(query.graffiti), - parseSkipRandaoVerification(query.skip_randao_verification), - { - feeRecipient: query.fee_recipient, - builderSelection: query.builder_selection as BuilderSelection, - builderBoostFactor: parseBuilderBoostFactor(query.builder_boost_factor), - strictFeeRecipientCheck: query.strict_fee_recipient_check, - blindedLocal: query.blinded_local, + resp: { + data: SyncDutyListType, + meta: ExecutionOptimisticCodec, }, - ], - schema: { - params: {slot: Schema.UintRequired}, - query: { - randao_reveal: Schema.StringRequired, - graffiti: Schema.String, - fee_recipient: Schema.String, - skip_randao_verification: Schema.String, - builder_selection: Schema.String, - builder_boost_factor: Schema.String, - strict_fee_recipient_check: Schema.Boolean, - blinded_local: Schema.Boolean, + }, + produceBlock: { + url: "/eth/v1/validator/blocks/{slot}", + method: "GET", + req: { + writeReq: ({slot, randaoReveal, graffiti}) => ({ + params: {slot}, + query: {randao_reveal: toHexString(randaoReveal), graffiti: toGraffitiHex(graffiti)}, + }), + parseReq: ({params, query}) => ({ + slot: params.slot, + randaoReveal: fromHexString(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + }), + schema: { + params: {slot: Schema.UintRequired}, + query: { + randao_reveal: Schema.StringRequired, + graffiti: Schema.String, + }, + }, + }, + resp: { + data: WithVersion((fork) => ssz[fork].BeaconBlock), + meta: VersionCodec, }, }, - }; - - return { - getAttesterDuties: { - writeReq: (epoch, indexes) => ({params: {epoch}, body: indexes.map((i) => toU64Str(i))}), - parseReq: ({params, body}) => [params.epoch, body.map((i) => fromU64Str(i))], - schema: { - params: {epoch: Schema.UintRequired}, - body: Schema.StringArray, + produceBlockV2: { + url: "/eth/v2/validator/blocks/{slot}", + method: "GET", + req: { + writeReq: ({slot, randaoReveal, graffiti, feeRecipient, builderSelection, strictFeeRecipientCheck}) => ({ + params: {slot}, + query: { + randao_reveal: toHexString(randaoReveal), + graffiti: toGraffitiHex(graffiti), + fee_recipient: feeRecipient, + builder_selection: builderSelection, + strict_fee_recipient_check: strictFeeRecipientCheck, + }, + }), + parseReq: ({params, query}) => ({ + slot: params.slot, + randaoReveal: fromHexString(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + feeRecipient: query.fee_recipient, + builderSelection: query.builder_selection as BuilderSelection, + strictFeeRecipientCheck: query.strict_fee_recipient_check, + }), + schema: { + params: {slot: Schema.UintRequired}, + query: { + randao_reveal: Schema.StringRequired, + graffiti: Schema.String, + fee_recipient: Schema.String, + builder_selection: Schema.String, + strict_fee_recipient_check: Schema.Boolean, + }, + }, + }, + resp: { + data: WithVersion( + (fork) => + (isForkBlobs(fork) ? BlockContentsType : ssz[fork].BeaconBlock) as Type + ), + meta: VersionCodec, }, }, - - getProposerDuties: { - writeReq: (epoch) => ({params: {epoch}}), - parseReq: ({params}) => [params.epoch], - schema: { - params: {epoch: Schema.UintRequired}, + produceBlockV3: { + url: "/eth/v3/validator/blocks/{slot}", + method: "GET", + req: { + writeReq: ({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + feeRecipient, + builderSelection, + builderBoostFactor, + strictFeeRecipientCheck, + blindedLocal, + }) => ({ + params: {slot}, + query: { + randao_reveal: toHexString(randaoReveal), + graffiti: toGraffitiHex(graffiti), + skip_randao_verification: writeSkipRandaoVerification(skipRandaoVerification), + fee_recipient: feeRecipient, + builder_selection: builderSelection, + builder_boost_factor: builderBoostFactor?.toString(), + strict_fee_recipient_check: strictFeeRecipientCheck, + blinded_local: blindedLocal, + }, + }), + parseReq: ({params, query}) => ({ + slot: params.slot, + randaoReveal: fromHexString(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + skipRandaoVerification: parseSkipRandaoVerification(query.skip_randao_verification), + feeRecipient: query.fee_recipient, + builderSelection: query.builder_selection as BuilderSelection, + builderBoostFactor: parseBuilderBoostFactor(query.builder_boost_factor), + strictFeeRecipientCheck: query.strict_fee_recipient_check, + blindedLocal: query.blinded_local, + }), + schema: { + params: {slot: Schema.UintRequired}, + query: { + randao_reveal: Schema.StringRequired, + graffiti: Schema.String, + skip_randao_verification: Schema.String, + fee_recipient: Schema.String, + builder_selection: Schema.String, + builder_boost_factor: Schema.String, + strict_fee_recipient_check: Schema.Boolean, + blinded_local: Schema.Boolean, + }, + }, + }, + resp: { + data: WithMeta( + ({version, executionPayloadBlinded}) => + (executionPayloadBlinded + ? getBlindedForkTypes(version).BeaconBlock + : isForkBlobs(version) + ? BlockContentsType + : ssz[version].BeaconBlock) as Type + ), + meta: { + toJson: (meta) => ({ + ...ProduceBlockV3MetaType.toJson(meta), + execution_payload_source: meta.executionPayloadSource, + }), + fromJson: (val) => { + const {executionPayloadBlinded, ...meta} = ProduceBlockV3MetaType.fromJson(val); + + // Extract source from the data and assign defaults in the spec compliant manner if not present + const executionPayloadSource = + (val as {execution_payload_source: ProducedBlockSource}).execution_payload_source ?? + (executionPayloadBlinded === true ? ProducedBlockSource.builder : ProducedBlockSource.engine); + + return {...meta, executionPayloadBlinded, executionPayloadSource}; + }, + toHeadersObject: (meta) => ({ + [MetaHeader.Version]: meta.version, + [MetaHeader.ExecutionPayloadBlinded]: meta.executionPayloadBlinded.toString(), + [MetaHeader.ExecutionPayloadSource]: meta.executionPayloadSource.toString(), + [MetaHeader.ExecutionPayloadValue]: meta.executionPayloadValue.toString(), + [MetaHeader.ConsensusBlockValue]: meta.consensusBlockValue.toString(), + }), + fromHeaders: (headers) => { + const executionPayloadBlinded = toBoolean(headers.getRequired(MetaHeader.ExecutionPayloadBlinded)); + + // Extract source from the headers and assign defaults in a spec compliant manner if not present + const executionPayloadSource = + (headers.get(MetaHeader.ExecutionPayloadSource) as ProducedBlockSource) ?? + (executionPayloadBlinded === true ? ProducedBlockSource.builder : ProducedBlockSource.engine); + + return { + version: toForkName(headers.getRequired(MetaHeader.Version)), + executionPayloadBlinded, + executionPayloadSource, + executionPayloadValue: BigInt(headers.getRequired(MetaHeader.ExecutionPayloadValue)), + consensusBlockValue: BigInt(headers.getRequired(MetaHeader.ConsensusBlockValue)), + }; + }, + }, }, }, - - getSyncCommitteeDuties: { - writeReq: (epoch, indexes) => ({params: {epoch}, body: indexes.map((i) => toU64Str(i))}), - parseReq: ({params, body}) => [params.epoch, body.map((i) => fromU64Str(i))], - schema: { - params: {epoch: Schema.UintRequired}, - body: Schema.StringArray, + produceBlindedBlock: { + url: "/eth/v1/validator/blinded_blocks/{slot}", + method: "GET", + req: { + writeReq: ({slot, randaoReveal, graffiti}) => ({ + params: {slot}, + query: {randao_reveal: toHexString(randaoReveal), graffiti: toGraffitiHex(graffiti)}, + }), + parseReq: ({params, query}) => ({ + slot: params.slot, + randaoReveal: fromHexString(query.randao_reveal), + graffiti: fromGraffitiHex(query.graffiti), + }), + schema: { + params: {slot: Schema.UintRequired}, + query: { + randao_reveal: Schema.StringRequired, + graffiti: Schema.String, + }, + }, + }, + resp: { + data: WithVersion((fork) => getBlindedForkTypes(fork).BeaconBlock), + meta: VersionCodec, }, }, - - produceBlock: produceBlockV3 as ReqSerializers["produceBlock"], - produceBlockV2: produceBlockV3 as ReqSerializers["produceBlockV2"], - produceBlockV3, - produceBlindedBlock: produceBlockV3 as ReqSerializers["produceBlindedBlock"], - produceAttestationData: { - writeReq: (index, slot) => ({query: {slot, committee_index: index}}), - parseReq: ({query}) => [query.committee_index, query.slot], - schema: { - query: {slot: Schema.UintRequired, committee_index: Schema.UintRequired}, + url: "/eth/v1/validator/attestation_data", + method: "GET", + req: { + writeReq: ({committeeIndex, slot}) => ({query: {slot, committee_index: committeeIndex}}), + parseReq: ({query}) => ({committeeIndex: query.committee_index, slot: query.slot}), + schema: { + query: {slot: Schema.UintRequired, committee_index: Schema.UintRequired}, + }, + }, + resp: { + data: ssz.phase0.AttestationData, + meta: EmptyMetaCodec, }, }, - produceSyncCommitteeContribution: { - writeReq: (slot, index, root) => ({ - query: {slot, subcommittee_index: index, beacon_block_root: toHexString(root)}, - }), - parseReq: ({query}) => [query.slot, query.subcommittee_index, fromHexString(query.beacon_block_root)], - schema: { - query: { - slot: Schema.UintRequired, - subcommittee_index: Schema.UintRequired, - beacon_block_root: Schema.StringRequired, + url: "/eth/v1/validator/sync_committee_contribution", + method: "GET", + req: { + writeReq: ({slot, subcommitteeIndex, beaconBlockRoot}) => ({ + query: {slot, subcommittee_index: subcommitteeIndex, beacon_block_root: toHexString(beaconBlockRoot)}, + }), + parseReq: ({query}) => ({ + slot: query.slot, + subcommitteeIndex: query.subcommittee_index, + beaconBlockRoot: fromHexString(query.beacon_block_root), + }), + schema: { + query: { + slot: Schema.UintRequired, + subcommittee_index: Schema.UintRequired, + beacon_block_root: Schema.StringRequired, + }, }, }, + resp: { + data: ssz.altair.SyncCommitteeContribution, + meta: EmptyMetaCodec, + }, }, - getAggregatedAttestation: { - writeReq: (root, slot) => ({query: {attestation_data_root: toHexString(root), slot}}), - parseReq: ({query}) => [fromHexString(query.attestation_data_root), query.slot], - schema: { - query: {attestation_data_root: Schema.StringRequired, slot: Schema.UintRequired}, + url: "/eth/v1/validator/aggregate_attestation", + method: "GET", + req: { + writeReq: ({attestationDataRoot, slot}) => ({ + query: {attestation_data_root: toHexString(attestationDataRoot), slot}, + }), + parseReq: ({query}) => ({attestationDataRoot: fromHexString(query.attestation_data_root), slot: query.slot}), + schema: { + query: {attestation_data_root: Schema.StringRequired, slot: Schema.UintRequired}, + }, + }, + resp: { + data: ssz.phase0.Attestation, + meta: EmptyMetaCodec, }, }, - - publishAggregateAndProofs: reqOnlyBody(ArrayOf(ssz.phase0.SignedAggregateAndProof), Schema.ObjectArray), - publishContributionAndProofs: reqOnlyBody(ArrayOf(ssz.altair.SignedContributionAndProof), Schema.ObjectArray), - prepareBeaconCommitteeSubnet: reqOnlyBody(ArrayOf(BeaconCommitteeSubscription), Schema.ObjectArray), - prepareSyncCommitteeSubnets: reqOnlyBody(ArrayOf(SyncCommitteeSubscription), Schema.ObjectArray), + publishAggregateAndProofs: { + url: "/eth/v1/validator/aggregate_and_proofs", + method: "POST", + req: { + writeReqJson: ({signedAggregateAndProofs}) => ({ + body: SignedAggregateAndProofListType.toJson(signedAggregateAndProofs), + }), + parseReqJson: ({body}) => ({signedAggregateAndProofs: SignedAggregateAndProofListType.fromJson(body)}), + writeReqSsz: ({signedAggregateAndProofs}) => ({ + body: SignedAggregateAndProofListType.serialize(signedAggregateAndProofs), + }), + parseReqSsz: ({body}) => ({signedAggregateAndProofs: SignedAggregateAndProofListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, + }, + publishContributionAndProofs: { + url: "/eth/v1/validator/contribution_and_proofs", + method: "POST", + req: { + writeReqJson: ({contributionAndProofs}) => ({ + body: SignedContributionAndProofListType.toJson(contributionAndProofs), + }), + parseReqJson: ({body}) => ({contributionAndProofs: SignedContributionAndProofListType.fromJson(body)}), + writeReqSsz: ({contributionAndProofs}) => ({ + body: SignedContributionAndProofListType.serialize(contributionAndProofs), + }), + parseReqSsz: ({body}) => ({contributionAndProofs: SignedContributionAndProofListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: EmptyResponseCodec, + }, + prepareBeaconCommitteeSubnet: { + url: "/eth/v1/validator/beacon_committee_subscriptions", + method: "POST", + req: { + writeReqJson: ({subscriptions}) => ({body: BeaconCommitteeSubscriptionListType.toJson(subscriptions)}), + parseReqJson: ({body}) => ({subscriptions: BeaconCommitteeSubscriptionListType.fromJson(body)}), + writeReqSsz: ({subscriptions}) => ({body: BeaconCommitteeSubscriptionListType.serialize(subscriptions)}), + parseReqSsz: ({body}) => ({subscriptions: BeaconCommitteeSubscriptionListType.deserialize(body)}), + schema: {body: Schema.ObjectArray}, + }, + resp: EmptyResponseCodec, + }, + prepareSyncCommitteeSubnets: { + url: "/eth/v1/validator/sync_committee_subscriptions", + method: "POST", + req: { + writeReqJson: ({subscriptions}) => ({body: SyncCommitteeSubscriptionListType.toJson(subscriptions)}), + parseReqJson: ({body}) => ({subscriptions: SyncCommitteeSubscriptionListType.fromJson(body)}), + writeReqSsz: ({subscriptions}) => ({body: SyncCommitteeSubscriptionListType.serialize(subscriptions)}), + parseReqSsz: ({body}) => ({subscriptions: SyncCommitteeSubscriptionListType.deserialize(body)}), + schema: {body: Schema.ObjectArray}, + }, + resp: EmptyResponseCodec, + }, prepareBeaconProposer: { - writeReq: (items: ProposerPreparationData[]) => ({body: items.map((item) => jsonType("snake").toJson(item))}), - parseReq: ({body}) => [ - (body as Record[]).map((item) => jsonType("snake").fromJson(item) as ProposerPreparationData), - ], - schema: {body: Schema.ObjectArray}, + url: "/eth/v1/validator/prepare_beacon_proposer", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({proposers}) => ({body: ProposerPreparationDataListType.toJson(proposers)}), + parseReqJson: ({body}) => ({proposers: ProposerPreparationDataListType.fromJson(body)}), + schema: {body: Schema.ObjectArray}, + }), + resp: EmptyResponseCodec, }, submitBeaconCommitteeSelections: { - writeReq: (items) => ({body: ArrayOf(BeaconCommitteeSelection).toJson(items)}), - parseReq: () => [[]], + url: "/eth/v1/validator/beacon_committee_selections", + method: "POST", + req: { + writeReqJson: ({selections}) => ({body: BeaconCommitteeSelectionListType.toJson(selections)}), + parseReqJson: ({body}) => ({selections: BeaconCommitteeSelectionListType.fromJson(body)}), + writeReqSsz: ({selections}) => ({body: BeaconCommitteeSelectionListType.serialize(selections)}), + parseReqSsz: ({body}) => ({selections: BeaconCommitteeSelectionListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: { + data: BeaconCommitteeSelectionListType, + meta: EmptyMetaCodec, + }, }, submitSyncCommitteeSelections: { - writeReq: (items) => ({body: ArrayOf(SyncCommitteeSelection).toJson(items)}), - parseReq: () => [[]], + url: "/eth/v1/validator/sync_committee_selections", + method: "POST", + req: { + writeReqJson: ({selections}) => ({body: SyncCommitteeSelectionListType.toJson(selections)}), + parseReqJson: ({body}) => ({selections: SyncCommitteeSelectionListType.fromJson(body)}), + writeReqSsz: ({selections}) => ({body: SyncCommitteeSelectionListType.serialize(selections)}), + parseReqSsz: ({body}) => ({selections: SyncCommitteeSelectionListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, + }, + resp: { + data: SyncCommitteeSelectionListType, + meta: EmptyMetaCodec, + }, }, getLiveness: { - writeReq: (epoch, indexes) => ({params: {epoch}, body: indexes.map((i) => toU64Str(i))}), - parseReq: ({params, body}) => [params.epoch, body.map((i) => fromU64Str(i))], - schema: { - params: {epoch: Schema.UintRequired}, - body: Schema.StringArray, + url: "/eth/v1/validator/liveness/{epoch}", + method: "POST", + req: { + writeReqJson: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.toJson(indices)}), + parseReqJson: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.fromJson(body)}), + writeReqSsz: ({epoch, indices}) => ({params: {epoch}, body: ValidatorIndicesType.serialize(indices)}), + parseReqSsz: ({params, body}) => ({epoch: params.epoch, indices: ValidatorIndicesType.deserialize(body)}), + schema: { + params: {epoch: Schema.UintRequired}, + body: Schema.StringArray, + }, }, - }, - registerValidator: reqOnlyBody(ArrayOf(ssz.bellatrix.SignedValidatorRegistrationV1), Schema.ObjectArray), - }; -} - -export function getReturnTypes(): ReturnTypes { - const rootHexType = new StringType(); - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const WithDependentRootExecutionOptimistic = (dataType: Type) => - new ContainerType( - { - executionOptimistic: ssz.Boolean, - data: dataType, - dependentRoot: rootHexType, + resp: { + data: LivenessResponseDataListType, + meta: EmptyMetaCodec, }, - {jsonCase: "eth2"} - ); - - const AttesterDuty = new ContainerType( - { - pubkey: ssz.BLSPubkey, - validatorIndex: ssz.ValidatorIndex, - committeeIndex: ssz.CommitteeIndex, - committeeLength: ssz.UintNum64, - committeesAtSlot: ssz.UintNum64, - validatorCommitteeIndex: ssz.UintNum64, - slot: ssz.Slot, }, - {jsonCase: "eth2"} - ); - - const ProposerDuty = new ContainerType( - { - slot: ssz.Slot, - validatorIndex: ssz.ValidatorIndex, - pubkey: ssz.BLSPubkey, - }, - {jsonCase: "eth2"} - ); - - const SyncDuty = new ContainerType( - { - pubkey: ssz.BLSPubkey, - validatorIndex: ssz.ValidatorIndex, - validatorSyncCommitteeIndices: ArrayOf(ssz.UintNum64), - }, - {jsonCase: "eth2"} - ); - - const produceBlockOrContents = WithBlockValues( - WithVersion((fork: ForkName) => - isForkBlobs(fork) ? allForksBlockContentsResSerializer(fork) : ssz[fork].BeaconBlock - ) - ) as TypeJson; - const produceBlindedBlock = WithBlockValues( - WithVersion( - (fork: ForkName) => ssz.allForksBlinded[isForkExecution(fork) ? fork : ForkName.bellatrix].BeaconBlock - ) - ) as TypeJson; - - return { - getAttesterDuties: WithDependentRootExecutionOptimistic(ArrayOf(AttesterDuty)), - getProposerDuties: WithDependentRootExecutionOptimistic(ArrayOf(ProposerDuty)), - getSyncCommitteeDuties: ContainerDataExecutionOptimistic(ArrayOf(SyncDuty)), - - produceBlock: ContainerData(ssz.phase0.BeaconBlock), - produceBlockV2: produceBlockOrContents, - produceBlockV3: { - toJson: (data) => { - if (data.executionPayloadBlinded) { - return { - execution_payload_blinded: true, - execution_payload_source: data.executionPayloadSource, - ...(produceBlindedBlock.toJson(data) as Record), - }; - } else { - return { - execution_payload_blinded: false, - execution_payload_source: data.executionPayloadSource, - ...(produceBlockOrContents.toJson(data) as Record), - }; - } - }, - fromJson: (data) => { - const executionPayloadBlinded = (data as {execution_payload_blinded: boolean}).execution_payload_blinded; - if (executionPayloadBlinded === undefined) { - throw Error(`Invalid executionPayloadBlinded=${executionPayloadBlinded} for fromJson deserialization`); - } - - // extract source from the data and assign defaults in the spec complaint manner if not present in response - const executionPayloadSource = - (data as {execution_payload_source: ProducedBlockSource}).execution_payload_source ?? - (executionPayloadBlinded ? ProducedBlockSource.builder : ProducedBlockSource.engine); - - if (executionPayloadBlinded) { - return {executionPayloadBlinded, executionPayloadSource, ...produceBlindedBlock.fromJson(data)}; - } else { - return {executionPayloadBlinded, executionPayloadSource, ...produceBlockOrContents.fromJson(data)}; - } + registerValidator: { + url: "/eth/v1/validator/register_validator", + method: "POST", + req: { + writeReqJson: ({registrations}) => ({body: SignedValidatorRegistrationV1ListType.toJson(registrations)}), + parseReqJson: ({body}) => ({registrations: SignedValidatorRegistrationV1ListType.fromJson(body)}), + writeReqSsz: ({registrations}) => ({body: SignedValidatorRegistrationV1ListType.serialize(registrations)}), + parseReqSsz: ({body}) => ({registrations: SignedValidatorRegistrationV1ListType.deserialize(body)}), + schema: { + body: Schema.ObjectArray, + }, }, + resp: EmptyResponseCodec, }, - produceBlindedBlock, - - produceAttestationData: ContainerData(ssz.phase0.AttestationData), - produceSyncCommitteeContribution: ContainerData(ssz.altair.SyncCommitteeContribution), - getAggregatedAttestation: ContainerData(ssz.phase0.Attestation), - submitBeaconCommitteeSelections: ContainerData(ArrayOf(BeaconCommitteeSelection)), - submitSyncCommitteeSelections: ContainerData(ArrayOf(SyncCommitteeSelection)), - getLiveness: jsonType("snake"), }; } @@ -797,6 +1002,10 @@ function parseBuilderBoostFactor(builderBoostFactorInput?: string | number | big return builderBoostFactorInput !== undefined ? BigInt(builderBoostFactorInput) : undefined; } +function writeSkipRandaoVerification(skipRandaoVerification?: boolean): string | undefined { + return skipRandaoVerification === true ? "" : undefined; +} + function parseSkipRandaoVerification(skipRandaoVerification?: string): boolean { return skipRandaoVerification !== undefined && skipRandaoVerification === ""; } diff --git a/packages/api/src/beacon/server/beacon.ts b/packages/api/src/beacon/server/beacon.ts index cd1ed72fb586..7a4407243971 100644 --- a/packages/api/src/beacon/server/beacon.ts +++ b/packages/api/src/beacon/server/beacon.ts @@ -1,65 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ssz} from "@lodestar/types"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/beacon/index.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/beacon/index.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - const reqSerializers = getReqSerializers(config); - const returnTypes = getReturnTypes(); - - // Most of routes return JSON, use a server auto-generator - const serverRoutes = getGenericJsonServer, 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 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - } else { - return returnTypes.getBlock.toJson(response); - } - }, - }, - getBlockV2: { - ...serverRoutes.getBlockV2, - handler: async (req, res) => { - const response = await api.getBlockV2(...reqSerializers.getBlockV2.parseReq(req)); - if (response instanceof Uint8Array) { - const slot = extractSlotFromBlockBytes(response); - const version = config.getForkName(slot); - void res.header("Eth-Consensus-Version", version); - // Fastify 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - } else { - void res.header("Eth-Consensus-Version", response.version); - return returnTypes.getBlockV2.toJson(response); - } - }, - }, - }; -} - -function extractSlotFromBlockBytes(block: Uint8Array): number { - const {signature} = ssz.phase0.SignedBeaconBlock.fields; - /** - * class SignedBeaconBlock(Container): - * message: BeaconBlock [offset - 4 bytes] - * signature: BLSSignature [fixed - 96 bytes] - * - * class BeaconBlock(Container): - * slot: Slot [fixed - 8 bytes] - * ... - */ - const offset = 4 + signature.lengthBytes; - const bytes = block.subarray(offset, offset + ssz.Slot.byteLength); - return ssz.Slot.deserialize(bytes); +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/config.ts b/packages/api/src/beacon/server/config.ts index 26d78ab9a5f1..09b2cbe717bb 100644 --- a/packages/api/src/beacon/server/config.ts +++ b/packages/api/src/beacon/server/config.ts @@ -1,9 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/config.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/config.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - // All routes return JSON, use a server auto-generator - return getGenericJsonServer, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api); +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/debug.ts b/packages/api/src/beacon/server/debug.ts index 553e08afddd0..1508b8ad4178 100644 --- a/packages/api/src/beacon/server/debug.ts +++ b/packages/api/src/beacon/server/debug.ts @@ -1,64 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ssz} from "@lodestar/types"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/debug.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/debug.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - - const serverRoutes = getGenericJsonServer, ReqTypes>( - {routesData, getReturnTypes, getReqSerializers}, - config, - api - ); - - return { - ...serverRoutes, - - // Non-JSON routes. Return JSON or binary depending on "accept" header - getState: { - ...serverRoutes.getState, - handler: async (req) => { - const response = await api.getState(...reqSerializers.getState.parseReq(req)); - if (response instanceof Uint8Array) { - // Fastify 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - } else { - return returnTypes.getState.toJson(response); - } - }, - }, - getStateV2: { - ...serverRoutes.getStateV2, - handler: async (req, res) => { - const response = await api.getStateV2(...reqSerializers.getStateV2.parseReq(req)); - if (response instanceof Uint8Array) { - const slot = extractSlotFromStateBytes(response); - const version = config.getForkName(slot); - void res.header("Eth-Consensus-Version", version); - // Fastify 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - } else { - void res.header("Eth-Consensus-Version", response.version); - return returnTypes.getStateV2.toJson(response); - } - }, - }, - }; -} - -function extractSlotFromStateBytes(state: Uint8Array): number { - const {genesisTime, genesisValidatorsRoot} = ssz.phase0.BeaconState.fields; - /** - * class BeaconState(Container): - * genesisTime: BeaconBlock [offset - 4 bytes] - * genesisValidatorsRoot: BLSSignature [fixed - 96 bytes] - * slot: Slot [fixed - 8 bytes] - * ... - */ - const offset = genesisTime.byteLength + genesisValidatorsRoot.lengthBytes; - const bytes = state.subarray(offset, offset + ssz.Slot.byteLength); - return ssz.Slot.deserialize(bytes); +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/events.ts b/packages/api/src/beacon/server/events.ts index 6d780ed1bf70..cbeae24f6908 100644 --- a/packages/api/src/beacon/server/events.ts +++ b/packages/api/src/beacon/server/events.ts @@ -1,17 +1,15 @@ -import {Api, ReqTypes, routesData, getEventSerdes, eventTypes} from "../routes/events.js"; -import {ApiError, ServerRoutes} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ChainForkConfig} from "@lodestar/config"; +import {ApiError, ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions, eventTypes, getEventSerdes} from "../routes/events.js"; -export function getRoutes(api: ServerApi): ServerRoutes { +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { const eventSerdes = getEventSerdes(); + const serverRoutes = createFastifyRoutes(getDefinitions(config), methods); return { // Non-JSON route. Server Sent Events (SSE) eventstream: { - url: routesData.eventstream.url, - method: routesData.eventstream.method, - id: "eventstream", - + ...serverRoutes.eventstream, handler: async (req, res) => { const validTopics = new Set(Object.values(eventTypes)); for (const topic of req.query.topics) { @@ -40,13 +38,17 @@ export function getRoutes(api: ServerApi): ServerRoutes { res.raw.setHeader("X-Accel-Buffering", "no"); await new Promise((resolve, reject) => { - void api.eventstream(req.query.topics, controller.signal, (event) => { - try { - const data = eventSerdes.toJson(event); - res.raw.write(serializeSSEEvent({event: event.type, data})); - } catch (e) { - reject(e as Error); - } + void methods.eventstream({ + topics: req.query.topics, + signal: controller.signal, + onEvent: (event) => { + try { + const data = eventSerdes.toJson(event); + res.raw.write(serializeSSEEvent({event: event.type, data})); + } catch (e) { + reject(e); + } + }, }); // The stream will never end by the server unless the node is stopped. @@ -62,16 +64,6 @@ export function getRoutes(api: ServerApi): ServerRoutes { controller.abort(); } }, - - // TODO: Bundle this in /routes/events? - schema: { - querystring: { - type: "object", - properties: { - topics: {type: "array", items: {type: "string"}}, - }, - }, - }, }, }; } diff --git a/packages/api/src/beacon/server/index.ts b/packages/api/src/beacon/server/index.ts index da77dfad32af..21c0607d3f14 100644 --- a/packages/api/src/beacon/server/index.ts +++ b/packages/api/src/beacon/server/index.ts @@ -1,8 +1,8 @@ +import type {FastifyInstance} from "fastify"; import {ChainForkConfig} from "@lodestar/config"; -import {Api} from "../routes/index.js"; -import {ApiError, ServerInstance, ServerRoute, RouteConfig, registerRoute} from "../../utils/server/index.js"; +import {ApplicationMethods, FastifyRoute} from "../../utils/server/index.js"; +import {Endpoints} from "../routes/index.js"; -import {ServerApi} from "../../interfaces.js"; import * as beacon from "./beacon.js"; import * as configApi from "./config.js"; import * as debug from "./debug.js"; @@ -13,36 +13,32 @@ import * as node from "./node.js"; import * as proof from "./proof.js"; import * as validator from "./validator.js"; -// Re-export for usage in beacon-node -export {ApiError}; - -// Re-export for convenience -export type {RouteConfig}; +export type BeaconApiMethods = {[K in keyof Endpoints]: ApplicationMethods}; export function registerRoutes( - server: ServerInstance, + server: FastifyInstance, config: ChainForkConfig, - api: {[K in keyof Api]: ServerApi}, - enabledNamespaces: (keyof Api)[] + methods: BeaconApiMethods, + enabledNamespaces: (keyof Endpoints)[] ): void { const routesByNamespace: { - // Enforces that we are declaring routes for every routeId in `Api` - [K in keyof Api]: () => { - // The ReqTypes are enforced in each getRoutes return type + // Enforces that we are declaring routes for every routeId in `Endpoints` + [K in keyof Endpoints]: () => { + // The Endpoints are enforced in each getRoutes return type // eslint-disable-next-line @typescript-eslint/no-explicit-any - [K2 in keyof Api[K]]: ServerRoute; + [K2 in keyof Endpoints[K]]: FastifyRoute; }; } = { // Initializes route types and their definitions - beacon: () => beacon.getRoutes(config, api.beacon), - config: () => configApi.getRoutes(config, api.config), - debug: () => debug.getRoutes(config, api.debug), - events: () => events.getRoutes(api.events), - lightclient: () => lightclient.getRoutes(config, api.lightclient), - lodestar: () => lodestar.getRoutes(config, api.lodestar), - node: () => node.getRoutes(config, api.node), - proof: () => proof.getRoutes(config, api.proof), - validator: () => validator.getRoutes(config, api.validator), + beacon: () => beacon.getRoutes(config, methods.beacon), + config: () => configApi.getRoutes(config, methods.config), + debug: () => debug.getRoutes(config, methods.debug), + events: () => events.getRoutes(config, methods.events), + lightclient: () => lightclient.getRoutes(config, methods.lightclient), + lodestar: () => lodestar.getRoutes(config, methods.lodestar), + node: () => node.getRoutes(config, methods.node), + proof: () => proof.getRoutes(config, methods.proof), + validator: () => validator.getRoutes(config, methods.validator), }; for (const namespace of enabledNamespaces) { @@ -52,7 +48,9 @@ export function registerRoutes( } for (const route of Object.values(routes())) { - registerRoute(server, route, namespace); + // Append the namespace as a tag for downstream consumption + route.schema.tags = [namespace]; + server.route(route); } } } diff --git a/packages/api/src/beacon/server/lightclient.ts b/packages/api/src/beacon/server/lightclient.ts index d587ec29823d..a991ccc7cf9f 100644 --- a/packages/api/src/beacon/server/lightclient.ts +++ b/packages/api/src/beacon/server/lightclient.ts @@ -1,9 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/lightclient.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/lightclient.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - // All routes return JSON, use a server auto-generator - return getGenericJsonServer, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api); +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/lodestar.ts b/packages/api/src/beacon/server/lodestar.ts index b52ed9832f69..ba65c8853997 100644 --- a/packages/api/src/beacon/server/lodestar.ts +++ b/packages/api/src/beacon/server/lodestar.ts @@ -1,9 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/lodestar.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/lodestar.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - // All routes return JSON, use a server auto-generator - return getGenericJsonServer, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api); +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/node.ts b/packages/api/src/beacon/server/node.ts index 8ee3acdb426b..7914bc0a7127 100644 --- a/packages/api/src/beacon/server/node.ts +++ b/packages/api/src/beacon/server/node.ts @@ -1,29 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/node.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/node.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes, ReqTypes> { - const reqSerializers = getReqSerializers(); - - const serverRoutes = getGenericJsonServer, ReqTypes>( - {routesData, getReturnTypes, getReqSerializers}, - config, - api - ); - - return { - ...serverRoutes, - - getHealth: { - ...serverRoutes.getHealth, - handler: async (req, res) => { - const args = reqSerializers.getHealth.parseReq(req); - // Note: This type casting is required as per route definition getHealth - // does not return a value but since the internal API does not have access - // to response object it is required to set the HTTP status code here. - res.statusCode = (await api.getHealth(...args)) as unknown as number; - }, - }, - }; +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/proof.ts b/packages/api/src/beacon/server/proof.ts index bc865626ba72..d4f247fad525 100644 --- a/packages/api/src/beacon/server/proof.ts +++ b/packages/api/src/beacon/server/proof.ts @@ -1,46 +1,7 @@ -import {CompactMultiProof} from "@chainsafe/persistent-merkle-tree"; import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/proof.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/proof.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - const reqSerializers = getReqSerializers(); - const serverRoutes = getGenericJsonServer, ReqTypes>( - {routesData, getReturnTypes, getReqSerializers}, - config, - api - ); - - return { - // Non-JSON routes. Return binary - getStateProof: { - ...serverRoutes.getStateProof, - handler: async (req) => { - const args = reqSerializers.getStateProof.parseReq(req); - const {data} = await api.getStateProof(...args); - const leaves = (data as CompactMultiProof).leaves; - const response = new Uint8Array(32 * leaves.length); - for (let i = 0; i < leaves.length; i++) { - response.set(leaves[i], i * 32); - } - // Fastify 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - }, - }, - getBlockProof: { - ...serverRoutes.getBlockProof, - handler: async (req) => { - const args = reqSerializers.getBlockProof.parseReq(req); - const {data} = await api.getBlockProof(...args); - const leaves = (data as CompactMultiProof).leaves; - const response = new Uint8Array(32 * leaves.length); - for (let i = 0; i < leaves.length; i++) { - response.set(leaves[i], i * 32); - } - // Fastify 4.x.x will automatically add header `Content-Type: application/octet-stream` if TypedArray - return response; - }, - }, - }; +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/beacon/server/validator.ts b/packages/api/src/beacon/server/validator.ts index 5d6c22557060..01e6502857a4 100644 --- a/packages/api/src/beacon/server/validator.ts +++ b/packages/api/src/beacon/server/validator.ts @@ -1,31 +1,7 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes/validator.js"; -import {ServerRoutes, getGenericJsonServer} from "../../utils/server/index.js"; -import {ServerApi} from "../../interfaces.js"; +import {ApplicationMethods, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes/validator.js"; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - - // Most of routes return JSON, use a server auto-generator - const serverRoutes = getGenericJsonServer, ReqTypes>( - {routesData, getReturnTypes, getReqSerializers}, - config, - api - ); - return { - ...serverRoutes, - produceBlockV3: { - ...serverRoutes.produceBlockV3, - handler: async (req, res) => { - const response = await api.produceBlockV3(...reqSerializers.produceBlockV3.parseReq(req)); - void res.header("Eth-Consensus-Version", response.version); - void res.header("Eth-Execution-Payload-Blinded", response.executionPayloadBlinded); - void res.header("Eth-Execution-Payload-Value", response.executionPayloadValue); - void res.header("Eth-Consensus-Block-Value", response.consensusBlockValue); - - return returnTypes.produceBlockV3.toJson(response); - }, - }, - }; +export function getRoutes(config: ChainForkConfig, methods: ApplicationMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } diff --git a/packages/api/src/builder/client.ts b/packages/api/src/builder/client.ts index 381732b81433..f3d12fcb353a 100644 --- a/packages/api/src/builder/client.ts +++ b/packages/api/src/builder/client.ts @@ -1,13 +1,9 @@ import {ChainForkConfig} from "@lodestar/config"; -import {IHttpClient, generateGenericJsonClient, ApiWithExtraOpts} from "../utils/client/index.js"; -import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "./routes.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../utils/client/index.js"; +import {Endpoints, getDefinitions} from "./routes.js"; -/** - * REST HTTP client for builder routes - */ -export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiWithExtraOpts { - const reqSerializers = getReqSerializers(config); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export type ApiClient = ApiClientMethods; + +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/builder/index.ts b/packages/api/src/builder/index.ts index 531ceac1b624..d1d9d31a17ab 100644 --- a/packages/api/src/builder/index.ts +++ b/packages/api/src/builder/index.ts @@ -1,25 +1,25 @@ import {ChainForkConfig} from "@lodestar/config"; -import { - HttpClient, - HttpClientModules, - HttpClientOptions, - IHttpClient, - ApiWithExtraOpts, -} from "../utils/client/index.js"; -import {Api as BuilderApi} from "../builder/routes.js"; +import {HttpClient, HttpClientModules, HttpClientOptions, IHttpClient} from "../utils/client/httpClient.js"; +import {Endpoints} from "./routes.js"; +import type {ApiClient} from "./client.js"; + import * as builder from "./client.js"; // NOTE: Don't export server here so it's not bundled to all consumers -// Note: build API does not have namespaces as routes are declared at the "root" namespace +export type {ApiClient, Endpoints}; + +// Note: builder API does not have namespaces as routes are declared at the "root" namespace -export type Api = ApiWithExtraOpts; type ClientModules = HttpClientModules & { config: ChainForkConfig; httpClient?: IHttpClient; }; -export function getClient(opts: HttpClientOptions, modules: ClientModules): Api { +/** + * REST HTTP client for builder routes + */ +export function getClient(opts: HttpClientOptions, modules: ClientModules): ApiClient { const {config} = modules; const httpClient = modules.httpClient ?? new HttpClient(opts, modules); diff --git a/packages/api/src/builder/routes.ts b/packages/api/src/builder/routes.ts index ca4c81a9fade..d703085583da 100644 --- a/packages/api/src/builder/routes.ts +++ b/packages/api/src/builder/routes.ts @@ -1,99 +1,152 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import {fromHexString, toHexString} from "@chainsafe/ssz"; import {ssz, allForks, bellatrix, Slot, Root, BLSPubkey} from "@lodestar/types"; -import {ForkName, isForkExecution, isForkBlobs} from "@lodestar/params"; +import {ForkName, isForkBlobs} from "@lodestar/params"; import {ChainForkConfig} from "@lodestar/config"; +import {Endpoint, RouteDefinitions, Schema} from "../utils/index.js"; +import {MetaHeader, VersionCodec, VersionMeta} from "../utils/metadata.js"; import { - ReturnTypes, - RoutesData, - Schema, - ReqSerializers, - reqOnlyBody, - reqEmpty, - ReqEmpty, ArrayOf, + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyRequest, + EmptyResponseCodec, + EmptyResponseData, + JsonOnlyReq, WithVersion, -} from "../utils/index.js"; +} from "../utils/codecs.js"; +import {getBlindedForkTypes, getExecutionForkTypes, toForkName} from "../utils/fork.js"; +import {fromHeaders} from "../utils/headers.js"; + // See /packages/api/src/routes/index.ts for reasoning and instructions to add new routes -import {getReqSerializers as getBeaconReqSerializers} from "../beacon/routes/beacon/block.js"; -import {HttpStatusCode} from "../utils/client/httpStatusCode.js"; -import {ApiClientResponse} from "../interfaces.js"; -export type Api = { - status(): Promise>; - registerValidator( - registrations: bellatrix.SignedValidatorRegistrationV1[] - ): Promise>; - getHeader( - slot: Slot, - parentHash: Root, - proposerPubKey: BLSPubkey - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: allForks.SignedBuilderBid; version: ForkName}}, - HttpStatusCode.NOT_FOUND | HttpStatusCode.BAD_REQUEST - > +// Mev-boost might not return any data if there are no bids from builders or min-bid threshold was not reached. +// In this case, we receive a success response (204) which is not handled as an error. The generic response +// handler already checks the status code and will not attempt to parse the body, but it will return no value. +// It is important that this type indicates that there might be no value to ensure it is properly handled downstream. +export type MaybeSignedBuilderBid = allForks.SignedBuilderBid | undefined; + +const RegistrationsType = ArrayOf(ssz.bellatrix.SignedValidatorRegistrationV1); + +export type Endpoints = { + status: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + EmptyResponseData, + EmptyMeta >; - submitBlindedBlock(signedBlock: allForks.SignedBlindedBeaconBlock): Promise< - ApiClientResponse< - { - [HttpStatusCode.OK]: { - data: allForks.ExecutionPayload | allForks.ExecutionPayloadAndBlobsBundle; - version: ForkName; - }; - }, - HttpStatusCode.SERVICE_UNAVAILABLE - > + + registerValidator: Endpoint< + "POST", + {registrations: bellatrix.SignedValidatorRegistrationV1[]}, + {body: unknown}, + EmptyResponseData, + EmptyMeta >; -}; -/** - * Define javascript values for each route - */ -export const routesData: RoutesData = { - status: {url: "/eth/v1/builder/status", method: "GET"}, - registerValidator: {url: "/eth/v1/builder/validators", method: "POST"}, - getHeader: {url: "/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}", method: "GET"}, - submitBlindedBlock: {url: "/eth/v1/builder/blinded_blocks", method: "POST"}, -}; + getHeader: Endpoint< + "GET", + { + slot: Slot; + parentHash: Root; + proposerPubkey: BLSPubkey; + }, + {params: {slot: Slot; parent_hash: string; pubkey: string}}, + MaybeSignedBuilderBid, + VersionMeta + >; -/* eslint-disable @typescript-eslint/naming-convention */ -export type ReqTypes = { - status: ReqEmpty; - registerValidator: {body: unknown}; - getHeader: {params: {slot: Slot; parent_hash: string; pubkey: string}}; - submitBlindedBlock: {body: unknown}; + submitBlindedBlock: Endpoint< + "POST", + {signedBlindedBlock: allForks.SignedBlindedBeaconBlock}, + {body: unknown; headers: {[MetaHeader.Version]: string}}, + allForks.ExecutionPayload | allForks.ExecutionPayloadAndBlobsBundle, + VersionMeta + >; }; -export function getReqSerializers(config: ChainForkConfig): ReqSerializers { +// NOTE: Builder API does not support SSZ as per spec, need to keep routes as JSON-only for now +// See https://github.com/ethereum/builder-specs/issues/53 for more details + +export function getDefinitions(config: ChainForkConfig): RouteDefinitions { return { - status: reqEmpty, - registerValidator: reqOnlyBody(ArrayOf(ssz.bellatrix.SignedValidatorRegistrationV1), Schema.ObjectArray), + status: { + url: "/eth/v1/builder/status", + method: "GET", + req: EmptyRequestCodec, + resp: EmptyResponseCodec, + }, + registerValidator: { + url: "/eth/v1/builder/validators", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({registrations}) => ({body: RegistrationsType.toJson(registrations)}), + parseReqJson: ({body}) => ({registrations: RegistrationsType.fromJson(body)}), + schema: {body: Schema.ObjectArray}, + }), + resp: EmptyResponseCodec, + }, getHeader: { - writeReq: (slot, parentHash, proposerPubKey) => ({ - params: {slot, parent_hash: toHexString(parentHash), pubkey: toHexString(proposerPubKey)}, + url: "/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}", + method: "GET", + req: { + writeReq: ({slot, parentHash, proposerPubkey: proposerPubKey}) => ({ + params: {slot, parent_hash: toHexString(parentHash), pubkey: toHexString(proposerPubKey)}, + }), + parseReq: ({params}) => ({ + slot: params.slot, + parentHash: fromHexString(params.parent_hash), + proposerPubkey: fromHexString(params.pubkey), + }), + schema: { + params: {slot: Schema.UintRequired, parent_hash: Schema.StringRequired, pubkey: Schema.StringRequired}, + }, + }, + resp: { + data: WithVersion( + (fork: ForkName) => getExecutionForkTypes(fork).SignedBuilderBid + ), + meta: VersionCodec, + }, + }, + submitBlindedBlock: { + url: "/eth/v1/builder/blinded_blocks", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({signedBlindedBlock}) => { + const fork = config.getForkName(signedBlindedBlock.message.slot); + return { + body: getBlindedForkTypes(fork).SignedBeaconBlock.toJson(signedBlindedBlock), + headers: { + [MetaHeader.Version]: fork, + }, + }; + }, + parseReqJson: ({body, headers}) => { + const fork = toForkName(fromHeaders(headers, MetaHeader.Version)); + return { + signedBlindedBlock: getBlindedForkTypes(fork).SignedBeaconBlock.fromJson(body), + }; + }, + schema: { + body: Schema.Object, + headers: {[MetaHeader.Version]: Schema.String}, + }, }), - parseReq: ({params}) => [params.slot, fromHexString(params.parent_hash), fromHexString(params.pubkey)], - schema: { - params: {slot: Schema.UintRequired, parent_hash: Schema.StringRequired, pubkey: Schema.StringRequired}, + resp: { + data: WithVersion( + (fork: ForkName) => { + return isForkBlobs(fork) + ? ssz.allForksBlobs[fork].ExecutionPayloadAndBlobsBundle + : getExecutionForkTypes(fork).ExecutionPayload; + } + ), + meta: VersionCodec, }, }, - submitBlindedBlock: getBeaconReqSerializers(config)["publishBlindedBlock"], - }; -} - -export function getReturnTypes(): ReturnTypes { - return { - getHeader: WithVersion((fork: ForkName) => - isForkExecution(fork) ? ssz.allForksExecution[fork].SignedBuilderBid : ssz.bellatrix.SignedBuilderBid - ), - submitBlindedBlock: WithVersion( - (fork: ForkName) => - isForkBlobs(fork) - ? ssz.allForksBlobs[fork].ExecutionPayloadAndBlobsBundle - : isForkExecution(fork) - ? ssz.allForksExecution[fork].ExecutionPayload - : ssz.bellatrix.ExecutionPayload - ), }; } diff --git a/packages/api/src/builder/server/index.ts b/packages/api/src/builder/server/index.ts index 0b51be896258..888d6bb64bec 100644 --- a/packages/api/src/builder/server/index.ts +++ b/packages/api/src/builder/server/index.ts @@ -1,26 +1,19 @@ +import type {FastifyInstance} from "fastify"; import {ChainForkConfig} from "@lodestar/config"; -import {ServerApi} from "../../interfaces.js"; -import { - ServerInstance, - ServerRoutes, - getGenericJsonServer, - registerRoute, - type RouteConfig, -} from "../../utils/server/index.js"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes.js"; +import {ApplicationMethods, FastifyRoute, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes.js"; +import {AnyEndpoint} from "../../utils/codecs.js"; -// Re-export for convenience -export type {RouteConfig}; +export type BuilderApiMethods = ApplicationMethods; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - // All routes return JSON, use a server auto-generator - return getGenericJsonServer, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api); +export function getRoutes(config: ChainForkConfig, methods: BuilderApiMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } -export function registerRoutes(server: ServerInstance, config: ChainForkConfig, api: ServerApi): void { - const routes = getRoutes(config, api); +export function registerRoutes(server: FastifyInstance, config: ChainForkConfig, methods: BuilderApiMethods): void { + const routes = getRoutes(config, methods); for (const route of Object.values(routes)) { - registerRoute(server, route); + server.route(route as FastifyRoute); } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 27ef2ada69d3..a93fc83d4628 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,10 +1,18 @@ // Re-exporting beacon only for backwards compatibility export * from "./beacon/index.js"; -export * from "./interfaces.js"; -export {HttpStatusCode} from "./utils/client/httpStatusCode.js"; -export type {HttpErrorCodes, HttpSuccessCodes} from "./utils/client/httpStatusCode.js"; -export {HttpClient, HttpError, ApiError, FetchError, isFetchError, fetch} from "./utils/client/index.js"; -export type {IHttpClient, HttpClientOptions, HttpClientModules, Metrics} from "./utils/client/index.js"; -export * from "./utils/routes.js"; +export {HttpStatusCode} from "./utils/httpStatusCode.js"; +export {WireFormat} from "./utils/wireFormat.js"; +export type {HttpErrorCodes, HttpSuccessCodes} from "./utils/httpStatusCode.js"; +export {ApiResponse, HttpClient, FetchError, isFetchError, fetch, defaultInit} from "./utils/client/index.js"; +export type {ApiRequestInit} from "./utils/client/request.js"; +export type {Endpoint} from "./utils/types.js"; +export type { + ApiClientMethods, + IHttpClient, + HttpClientOptions, + HttpClientModules, + Metrics, +} from "./utils/client/index.js"; +export {ApiError} from "./utils/client/error.js"; // NOTE: Don't export server here so it's not bundled to all consumers diff --git a/packages/api/src/interfaces.ts b/packages/api/src/interfaces.ts deleted file mode 100644 index 4d57bcbc8934..000000000000 --- a/packages/api/src/interfaces.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {HttpStatusCode, HttpSuccessCodes} from "./utils/client/httpStatusCode.js"; -import {Resolves} from "./utils/types.js"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export type ResponseFormat = "json" | "ssz"; -export type APIClientHandler = (...args: any) => PromiseLike; -export type APIServerHandler = (...args: any) => PromiseLike; - -export type ApiClientSuccessResponse = {ok: true; status: S; response: T; error?: never}; -export type ApiClientErrorResponse> = { - ok: false; - status: S; - response?: never; - error: {code: S; operationId: string; message?: string}; -}; -export type ApiClientResponse< - S extends Partial<{[K in HttpSuccessCodes]: unknown}> = {[K in HttpSuccessCodes]: unknown}, - E extends Exclude = Exclude, -> = - | {[K in keyof S]: ApiClientSuccessResponse}[keyof S] - | {[K in E]: ApiClientErrorResponse}[E] - | ApiClientErrorResponse; - -export type ApiClientResponseData = T extends {ok: true; response: infer R} ? R : never; - -export type GenericOptions = Record; - -export type ServerApi> = { - [K in keyof T]: ( - ...args: [...args: Parameters, opts?: GenericOptions] - ) => Promise>>; -}; - -export type ClientApi> = { - [K in keyof T]: (...args: Parameters) => Promise}>>; -}; diff --git a/packages/api/src/keymanager/client.ts b/packages/api/src/keymanager/client.ts index 4df13d383d7c..f3d12fcb353a 100644 --- a/packages/api/src/keymanager/client.ts +++ b/packages/api/src/keymanager/client.ts @@ -1,10 +1,9 @@ import {ChainForkConfig} from "@lodestar/config"; -import {IHttpClient, generateGenericJsonClient} from "../utils/client/index.js"; -import {Api, ReqTypes, routesData, getReqSerializers, getReturnTypes} from "./routes.js"; +import {ApiClientMethods, IHttpClient, createApiClientMethods} from "../utils/client/index.js"; +import {Endpoints, getDefinitions} from "./routes.js"; -export function getClient(_config: ChainForkConfig, httpClient: IHttpClient): Api { - const reqSerializers = getReqSerializers(); - const returnTypes = getReturnTypes(); - // All routes return JSON, use a client auto-generator - return generateGenericJsonClient(routesData, reqSerializers, returnTypes, httpClient); +export type ApiClient = ApiClientMethods; + +export function getClient(config: ChainForkConfig, httpClient: IHttpClient): ApiClient { + return createApiClientMethods(getDefinitions(config), httpClient); } diff --git a/packages/api/src/keymanager/index.ts b/packages/api/src/keymanager/index.ts index 3232876e2657..1ffcaef897eb 100644 --- a/packages/api/src/keymanager/index.ts +++ b/packages/api/src/keymanager/index.ts @@ -1,13 +1,26 @@ import {ChainForkConfig} from "@lodestar/config"; -import {} from "../beacon/client/index.js"; import {IHttpClient, HttpClient, HttpClientModules, HttpClientOptions} from "../utils/client/index.js"; -import {Api} from "./routes.js"; +import type {ApiClient} from "./client.js"; import * as keymanager from "./client.js"; // NOTE: Don't export server here so it's not bundled to all consumers export {ImportStatus, DeletionStatus, ImportRemoteKeyStatus, DeleteRemoteKeyStatus} from "./routes.js"; -export type {ResponseStatus, SignerDefinition, KeystoreStr, SlashingProtectionData, PubkeyHex, Api} from "./routes.js"; +export type { + ResponseStatus, + SignerDefinition, + RemoteSignerDefinition, + KeystoreStr, + SlashingProtectionData, + PubkeyHex, + Endpoints, + FeeRecipientData, + GraffitiData, + GasLimitData, + BuilderBoostFactorData, +} from "./routes.js"; + +export type {ApiClient}; type ClientModules = HttpClientModules & { config: ChainForkConfig; @@ -15,9 +28,9 @@ type ClientModules = HttpClientModules & { }; /** - * REST HTTP client for all keymanager routes + * REST HTTP client for keymanager routes */ -export function getClient(opts: HttpClientOptions, modules: ClientModules): Api { +export function getClient(opts: HttpClientOptions, modules: ClientModules): ApiClient { const {config} = modules; const httpClient = modules.httpClient ?? new HttpClient(opts, modules); diff --git a/packages/api/src/keymanager/routes.ts b/packages/api/src/keymanager/routes.ts index 48f928e86100..b6abe8928d77 100644 --- a/packages/api/src/keymanager/routes.ts +++ b/packages/api/src/keymanager/routes.ts @@ -1,17 +1,20 @@ -import {ContainerType} from "@chainsafe/ssz"; +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ChainForkConfig} from "@lodestar/config"; import {Epoch, phase0, ssz, stringType} from "@lodestar/types"; -import {ApiClientResponse} from "../interfaces.js"; -import {HttpStatusCode} from "../utils/client/httpStatusCode.js"; +import {Schema, Endpoint, RouteDefinitions} from "../utils/index.js"; +import {WireFormat} from "../utils/wireFormat.js"; import { - ReturnTypes, - RoutesData, - Schema, - reqEmpty, - ReqSerializers, - ReqEmpty, - jsonType, - ContainerData, -} from "../utils/index.js"; + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyMetaCodec, + EmptyRequest, + EmptyResponseCodec, + EmptyResponseData, + JsonOnlyReq, + JsonOnlyResponseCodec, +} from "../utils/codecs.js"; export enum ImportStatus { /** Keystore successfully decrypted and imported to keymanager permanent storage */ @@ -60,22 +63,39 @@ export type ResponseStatus = { message?: string; }; -export type FeeRecipientData = { - pubkey: string; - ethaddress: string; -}; -export type GraffitiData = { - pubkey: string; - graffiti: string; -}; -export type GasLimitData = { - pubkey: string; - gasLimit: number; -}; -export type BuilderBoostFactorData = { - pubkey: string; - builderBoostFactor: bigint; -}; +export const FeeRecipientDataType = new ContainerType( + { + pubkey: stringType, + ethaddress: stringType, + }, + {jsonCase: "eth2"} +); +export const GraffitiDataType = new ContainerType( + { + pubkey: stringType, + graffiti: stringType, + }, + {jsonCase: "eth2"} +); +export const GasLimitDataType = new ContainerType( + { + pubkey: stringType, + gasLimit: ssz.UintNum64, + }, + {jsonCase: "eth2"} +); +export const BuilderBoostFactorDataType = new ContainerType( + { + pubkey: stringType, + builderBoostFactor: ssz.UintBn64, + }, + {jsonCase: "eth2"} +); + +export type FeeRecipientData = ValueOf; +export type GraffitiData = ValueOf; +export type GasLimitData = ValueOf; +export type BuilderBoostFactorData = ValueOf; export type SignerDefinition = { pubkey: PubkeyHex; @@ -88,6 +108,8 @@ export type SignerDefinition = { readonly: boolean; }; +export type RemoteSignerDefinition = Pick; + /** * JSON serialized representation of a single keystore in EIP-2335: BLS12-381 Keystore format. * ``` @@ -112,24 +134,40 @@ export type SlashingProtectionData = string; */ export type PubkeyHex = string; -export type Api = { +/** + * An address on the execution (Ethereum 1) network. + * ``` + * "0xAbcF8e0d4e9587369b2301D0790347320302cc09" + * ``` + */ +export type EthAddress = string; + +/** + * Arbitrary data to set in the graffiti field of BeaconBlockBody + * ``` + * "plain text value" + * ``` + */ +export type Graffiti = string; + +export type Endpoints = { /** * List all validating pubkeys known to and decrypted by this keymanager binary * * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml */ - listKeys(): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: { - data: { - validatingPubkey: PubkeyHex; - /** The derivation path (if present in the imported keystore) */ - derivationPath?: string; - /** The key associated with this pubkey cannot be deleted from the API */ - readonly?: boolean; - }[]; - }; - }> + listKeys: Endpoint< + "GET", + EmptyArgs, + EmptyRequest, + { + validatingPubkey: PubkeyHex; + /** The derivation path (if present in the imported keystore) */ + derivationPath?: string; + /** The key associated with this pubkey cannot be deleted from the API */ + readonly?: boolean; + }[], + EmptyMeta >; /** @@ -138,18 +176,24 @@ export type Api = { * Users SHOULD send slashing_protection data associated with the imported pubkeys. MUST follow the format defined in * EIP-3076: Slashing Protection Interchange Format. * - * @param keystores JSON-encoded keystore files generated with the Launchpad - * @param passwords Passwords to unlock imported keystore files. `passwords[i]` must unlock `keystores[i]` - * @param slashingProtection Slashing protection data for some of the keys of `keystores` - * @returns Status result of each `request.keystores` with same length and order of `request.keystores` + * Returns status result of each `request.keystores` with same length and order of `request.keystores` * * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml */ - importKeystores( - keystoresStr: KeystoreStr[], - passwords: string[], - slashingProtectionStr?: SlashingProtectionData - ): Promise[]}}>>; + importKeystores: Endpoint< + "POST", + { + /** JSON-encoded keystore files generated with the Launchpad */ + keystores: KeystoreStr[]; + /** Passwords to unlock imported keystore files. `passwords[i]` must unlock `keystores[i]` */ + passwords: string[]; + /** Slashing protection data for some of the keys of `keystores` */ + slashingProtection?: SlashingProtectionData; + }, + {body: {keystores: KeystoreStr[]; passwords: string[]; slashing_protection?: SlashingProtectionData}}, + ResponseStatus[], + EmptyMeta + >; /** * DELETE must delete all keys from `request.pubkeys` that are known to the keymanager and exist in its @@ -167,109 +211,150 @@ export type Api = { * Slashing protection data must only be returned for keys from `request.pubkeys` for which a * `deleted` or `not_active` status is returned. * - * @param pubkeys List of public keys to delete. - * @returns Deletion status of all keys in `request.pubkeys` in the same order. + * Returns deletion status of all keys in `request.pubkeys` in the same order. * * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml */ - deleteKeys(pubkeysHex: string[]): Promise< - ApiClientResponse<{ - [HttpStatusCode.OK]: {data: ResponseStatus[]; slashingProtection: SlashingProtectionData}; - }> + deleteKeys: Endpoint< + "DELETE", + { + /** List of public keys to delete */ + pubkeys: PubkeyHex[]; + }, + {body: {pubkeys: string[]}}, + {statuses: ResponseStatus[]; slashingProtection: SlashingProtectionData}, + EmptyMeta >; /** * List all remote validating pubkeys known to this validator client binary */ - listRemoteKeys(): Promise>; + listRemoteKeys: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + SignerDefinition[], + EmptyMeta + >; /** * Import remote keys for the validator client to request duties for */ - importRemoteKeys( - remoteSigners: Pick[] - ): Promise[]}}>>; - - deleteRemoteKeys( - pubkeys: PubkeyHex[] - ): Promise[]}}>>; - - listFeeRecipient(pubkey: string): Promise>; - setFeeRecipient( - pubkey: string, - ethaddress: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + importRemoteKeys: Endpoint< + "POST", + {remoteSigners: RemoteSignerDefinition[]}, + {body: {remote_keys: RemoteSignerDefinition[]}}, + ResponseStatus[], + EmptyMeta + >; + + /** + * DELETE must delete all keys from `request.pubkeys` that are known to the validator client and exist in its + * persistent storage. + * + * DELETE should never return a 404 response, even if all pubkeys from `request.pubkeys` have no existing keystores. + */ + deleteRemoteKeys: Endpoint< + "DELETE", + {pubkeys: PubkeyHex[]}, + {body: {pubkeys: string[]}}, + ResponseStatus[], + EmptyMeta + >; + + listFeeRecipient: Endpoint< + // ⏎ + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + FeeRecipientData, + EmptyMeta + >; + setFeeRecipient: Endpoint< + "POST", + {pubkey: PubkeyHex; ethaddress: EthAddress}, + {params: {pubkey: string}; body: {ethaddress: string}}, + EmptyResponseData, + EmptyMeta >; - deleteFeeRecipient( - pubkey: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + deleteFeeRecipient: Endpoint< + // ⏎ + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta >; - listGraffiti(pubkey: string): Promise>; - setGraffiti( - pubkey: string, - graffiti: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + getGraffiti: Endpoint< + // ⏎ + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + GraffitiData, + EmptyMeta >; - deleteGraffiti( - pubkey: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + setGraffiti: Endpoint< + "POST", + {pubkey: PubkeyHex; graffiti: Graffiti}, + {params: {pubkey: string}; body: {graffiti: string}}, + EmptyResponseData, + EmptyMeta + >; + deleteGraffiti: Endpoint< + // ⏎ + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta >; - getGasLimit(pubkey: string): Promise>; - setGasLimit( - pubkey: string, - gasLimit: number - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + getGasLimit: Endpoint< + // ⏎ + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + GasLimitData, + EmptyMeta + >; + setGasLimit: Endpoint< + "POST", + {pubkey: PubkeyHex; gasLimit: number}, + {params: {pubkey: string}; body: {gas_limit: string}}, + EmptyResponseData, + EmptyMeta >; - deleteGasLimit( - pubkey: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + deleteGasLimit: Endpoint< + // ⏎ + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta >; - getBuilderBoostFactor( - pubkey: string - ): Promise>; - setBuilderBoostFactor( - pubkey: string, - builderBoostFactor: bigint - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + getBuilderBoostFactor: Endpoint< + "GET", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + BuilderBoostFactorData, + EmptyMeta + >; + setBuilderBoostFactor: Endpoint< + "POST", + {pubkey: PubkeyHex; builderBoostFactor: bigint}, + {params: {pubkey: string}; body: {builder_boost_factor: string}}, + EmptyResponseData, + EmptyMeta >; - deleteBuilderBoostFactor( - pubkey: string - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: void; [HttpStatusCode.NO_CONTENT]: void}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + deleteBuilderBoostFactor: Endpoint< + "DELETE", + {pubkey: PubkeyHex}, + {params: {pubkey: string}}, + EmptyResponseData, + EmptyMeta >; /** @@ -277,257 +362,299 @@ export type Api = { * client. This endpoint returns a `SignedVoluntaryExit` object, which can be used to initiate voluntary exit via the * beacon node's [submitPoolVoluntaryExit](https://ethereum.github.io/beacon-APIs/#/Beacon/submitPoolVoluntaryExit) endpoint. * - * @param pubkey Public key of an active validator known to the validator client - * @param epoch Minimum epoch for processing exit. Defaults to the current epoch if not set - * @returns Signed voluntary exit message + * Returns the signed voluntary exit message * * https://github.com/ethereum/keymanager-APIs/blob/7105e749e11dd78032ea275cc09bf62ecd548fca/keymanager-oapi.yaml */ - signVoluntaryExit( - pubkey: PubkeyHex, - epoch?: Epoch - ): Promise< - ApiClientResponse< - {[HttpStatusCode.OK]: {data: phase0.SignedVoluntaryExit}}, - HttpStatusCode.UNAUTHORIZED | HttpStatusCode.FORBIDDEN | HttpStatusCode.NOT_FOUND - > + signVoluntaryExit: Endpoint< + "POST", + { + /** Public key of an active validator known to the validator client */ + pubkey: PubkeyHex; + /** Minimum epoch for processing exit. Defaults to the current epoch if not set */ + epoch?: Epoch; + }, + {params: {pubkey: string}; query: {epoch?: number}}, + phase0.SignedVoluntaryExit, + EmptyMeta >; }; -export const routesData: RoutesData = { - listKeys: {url: "/eth/v1/keystores", method: "GET"}, - importKeystores: {url: "/eth/v1/keystores", method: "POST"}, - deleteKeys: {url: "/eth/v1/keystores", method: "DELETE"}, - - listRemoteKeys: {url: "/eth/v1/remotekeys", method: "GET"}, - importRemoteKeys: {url: "/eth/v1/remotekeys", method: "POST"}, - deleteRemoteKeys: {url: "/eth/v1/remotekeys", method: "DELETE"}, - - listFeeRecipient: {url: "/eth/v1/validator/{pubkey}/feerecipient", method: "GET"}, - setFeeRecipient: {url: "/eth/v1/validator/{pubkey}/feerecipient", method: "POST", statusOk: 202}, - deleteFeeRecipient: {url: "/eth/v1/validator/{pubkey}/feerecipient", method: "DELETE", statusOk: 204}, - - listGraffiti: {url: "/eth/v1/validator/{pubkey}/graffiti", method: "GET"}, - setGraffiti: {url: "/eth/v1/validator/{pubkey}/graffiti", method: "POST", statusOk: 202}, - deleteGraffiti: {url: "/eth/v1/validator/{pubkey}/graffiti", method: "DELETE", statusOk: 204}, - - getGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "GET"}, - setGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "POST", statusOk: 202}, - deleteGasLimit: {url: "/eth/v1/validator/{pubkey}/gas_limit", method: "DELETE", statusOk: 204}, - - getBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "GET"}, - setBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "POST", statusOk: 202}, - deleteBuilderBoostFactor: {url: "/eth/v1/validator/{pubkey}/builder_boost_factor", method: "DELETE", statusOk: 204}, - - signVoluntaryExit: {url: "/eth/v1/validator/{pubkey}/voluntary_exit", method: "POST"}, -}; - -/* eslint-disable @typescript-eslint/naming-convention */ - -export type ReqTypes = { - listKeys: ReqEmpty; - importKeystores: { - body: { - keystores: KeystoreStr[]; - passwords: string[]; - slashing_protection?: SlashingProtectionData; - }; - }; - deleteKeys: {body: {pubkeys: string[]}}; - - listRemoteKeys: ReqEmpty; - importRemoteKeys: { - body: { - remote_keys: Pick[]; - }; - }; - deleteRemoteKeys: {body: {pubkeys: string[]}}; - - listFeeRecipient: {params: {pubkey: string}}; - setFeeRecipient: {params: {pubkey: string}; body: {ethaddress: string}}; - deleteFeeRecipient: {params: {pubkey: string}}; - - listGraffiti: {params: {pubkey: string}}; - setGraffiti: {params: {pubkey: string}; body: {graffiti: string}}; - deleteGraffiti: {params: {pubkey: string}}; - - getGasLimit: {params: {pubkey: string}}; - setGasLimit: {params: {pubkey: string}; body: {gas_limit: string}}; - deleteGasLimit: {params: {pubkey: string}}; - - getBuilderBoostFactor: {params: {pubkey: string}}; - setBuilderBoostFactor: {params: {pubkey: string}; body: {builder_boost_factor: string}}; - deleteBuilderBoostFactor: {params: {pubkey: string}}; - - signVoluntaryExit: {params: {pubkey: string}; query: {epoch?: number}}; -}; - -export function getReqSerializers(): ReqSerializers { +export function getDefinitions(_config: ChainForkConfig): RouteDefinitions { return { - listKeys: reqEmpty, + listKeys: { + url: "/eth/v1/keystores", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, importKeystores: { - writeReq: (keystores, passwords, slashing_protection) => ({body: {keystores, passwords, slashing_protection}}), - parseReq: ({body: {keystores, passwords, slashing_protection}}) => [keystores, passwords, slashing_protection], - schema: {body: Schema.Object}, + url: "/eth/v1/keystores", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({keystores, passwords, slashingProtection}) => ({ + body: {keystores, passwords, slashing_protection: slashingProtection}, + }), + parseReqJson: ({body: {keystores, passwords, slashing_protection}}) => ({ + keystores, + passwords, + slashingProtection: slashing_protection, + }), + schema: {body: Schema.Object}, + }), + resp: JsonOnlyResponseCodec, }, deleteKeys: { - writeReq: (pubkeys) => ({body: {pubkeys}}), - parseReq: ({body: {pubkeys}}) => [pubkeys], - schema: {body: Schema.Object}, + url: "/eth/v1/keystores", + method: "DELETE", + req: JsonOnlyReq({ + writeReqJson: ({pubkeys}) => ({body: {pubkeys}}), + parseReqJson: ({body: {pubkeys}}) => ({pubkeys}), + schema: {body: Schema.Object}, + }), + resp: { + onlySupport: WireFormat.json, + data: JsonOnlyResponseCodec.data, + meta: EmptyMetaCodec, + transform: { + toResponse: (data) => { + const {statuses, slashing_protection} = data as { + statuses: ResponseStatus[]; + slashing_protection: SlashingProtectionData; + }; + return {data: statuses, slashing_protection}; + }, + fromResponse: (resp) => { + const {data, slashing_protection} = resp as { + data: ResponseStatus[]; + slashing_protection: SlashingProtectionData; + }; + return {data: {statuses: data, slashingProtection: slashing_protection}}; + }, + }, + }, }, - listRemoteKeys: reqEmpty, + listRemoteKeys: { + url: "/eth/v1/remotekeys", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, importRemoteKeys: { - writeReq: (remote_keys) => ({body: {remote_keys}}), - parseReq: ({body: {remote_keys}}) => [remote_keys], - schema: {body: Schema.Object}, + url: "/eth/v1/remotekeys", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({remoteSigners}) => ({body: {remote_keys: remoteSigners}}), + parseReqJson: ({body: {remote_keys}}) => ({remoteSigners: remote_keys}), + schema: {body: Schema.Object}, + }), + resp: JsonOnlyResponseCodec, }, deleteRemoteKeys: { - writeReq: (pubkeys) => ({body: {pubkeys}}), - parseReq: ({body: {pubkeys}}) => [pubkeys], - schema: {body: Schema.Object}, + url: "/eth/v1/remotekeys", + method: "DELETE", + req: JsonOnlyReq({ + writeReqJson: ({pubkeys}) => ({body: {pubkeys}}), + parseReqJson: ({body: {pubkeys}}) => ({pubkeys}), + schema: {body: Schema.Object}, + }), + resp: JsonOnlyResponseCodec, }, listFeeRecipient: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/feerecipient", + method: "GET", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: FeeRecipientDataType, + meta: EmptyMetaCodec, }, }, setFeeRecipient: { - writeReq: (pubkey, ethaddress) => ({params: {pubkey}, body: {ethaddress}}), - parseReq: ({params: {pubkey}, body: {ethaddress}}) => [pubkey, ethaddress], - schema: { - params: {pubkey: Schema.StringRequired}, - body: Schema.Object, - }, + url: "/eth/v1/validator/{pubkey}/feerecipient", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({pubkey, ethaddress}) => ({params: {pubkey}, body: {ethaddress}}), + parseReqJson: ({params: {pubkey}, body: {ethaddress}}) => ({pubkey, ethaddress}), + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, + }), + resp: EmptyResponseCodec, }, deleteFeeRecipient: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/feerecipient", + method: "DELETE", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, }, + resp: EmptyResponseCodec, }, - listGraffiti: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + getGraffiti: { + url: "/eth/v1/validator/{pubkey}/graffiti", + method: "GET", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: GraffitiDataType, + meta: EmptyMetaCodec, }, }, setGraffiti: { - writeReq: (pubkey, graffiti) => ({params: {pubkey}, body: {graffiti}}), - parseReq: ({params: {pubkey}, body: {graffiti}}) => [pubkey, graffiti], - schema: { - params: {pubkey: Schema.StringRequired}, - body: Schema.Object, - }, + url: "/eth/v1/validator/{pubkey}/graffiti", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({pubkey, graffiti}) => ({params: {pubkey}, body: {graffiti}}), + parseReqJson: ({params: {pubkey}, body: {graffiti}}) => ({pubkey, graffiti}), + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, + }), + resp: EmptyResponseCodec, }, deleteGraffiti: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/graffiti", + method: "DELETE", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, }, + resp: EmptyResponseCodec, }, getGasLimit: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/gas_limit", + method: "GET", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: GasLimitDataType, + meta: EmptyMetaCodec, }, }, setGasLimit: { - writeReq: (pubkey, gasLimit) => ({params: {pubkey}, body: {gas_limit: gasLimit.toString(10)}}), - parseReq: ({params: {pubkey}, body: {gas_limit}}) => [pubkey, parseGasLimit(gas_limit)], - schema: { - params: {pubkey: Schema.StringRequired}, - body: Schema.Object, - }, + url: "/eth/v1/validator/{pubkey}/gas_limit", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({pubkey, gasLimit}) => ({params: {pubkey}, body: {gas_limit: gasLimit.toString(10)}}), + parseReqJson: ({params: {pubkey}, body: {gas_limit}}) => ({pubkey, gasLimit: parseGasLimit(gas_limit)}), + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, + }), + resp: EmptyResponseCodec, }, deleteGasLimit: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/gas_limit", + method: "DELETE", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, }, + resp: EmptyResponseCodec, }, getBuilderBoostFactor: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/builder_boost_factor", + method: "GET", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, + }, + resp: { + onlySupport: WireFormat.json, + data: BuilderBoostFactorDataType, + meta: EmptyMetaCodec, }, }, setBuilderBoostFactor: { - writeReq: (pubkey, builderBoostFactor) => ({ - params: {pubkey}, - body: {builder_boost_factor: builderBoostFactor.toString(10)}, + url: "/eth/v1/validator/{pubkey}/builder_boost_factor", + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({pubkey, builderBoostFactor}) => ({ + params: {pubkey}, + body: {builder_boost_factor: builderBoostFactor.toString(10)}, + }), + parseReqJson: ({params: {pubkey}, body: {builder_boost_factor}}) => ({ + pubkey, + builderBoostFactor: BigInt(builder_boost_factor), + }), + schema: { + params: {pubkey: Schema.StringRequired}, + body: Schema.Object, + }, }), - parseReq: ({params: {pubkey}, body: {builder_boost_factor}}) => [pubkey, BigInt(builder_boost_factor)], - schema: { - params: {pubkey: Schema.StringRequired}, - body: Schema.Object, - }, + resp: EmptyResponseCodec, }, deleteBuilderBoostFactor: { - writeReq: (pubkey) => ({params: {pubkey}}), - parseReq: ({params: {pubkey}}) => [pubkey], - schema: { - params: {pubkey: Schema.StringRequired}, + url: "/eth/v1/validator/{pubkey}/builder_boost_factor", + method: "DELETE", + req: { + writeReq: ({pubkey}) => ({params: {pubkey}}), + parseReq: ({params: {pubkey}}) => ({pubkey}), + schema: { + params: {pubkey: Schema.StringRequired}, + }, }, + resp: EmptyResponseCodec, }, signVoluntaryExit: { - writeReq: (pubkey, epoch) => ({params: {pubkey}, query: epoch !== undefined ? {epoch} : {}}), - parseReq: ({params: {pubkey}, query: {epoch}}) => [pubkey, epoch], - schema: { - params: {pubkey: Schema.StringRequired}, - query: {epoch: Schema.Uint}, + url: "/eth/v1/validator/{pubkey}/voluntary_exit", + method: "POST", + req: { + writeReq: ({pubkey, epoch}) => ({params: {pubkey}, query: {epoch}}), + parseReq: ({params: {pubkey}, query: {epoch}}) => ({pubkey, epoch}), + schema: { + params: {pubkey: Schema.StringRequired}, + query: {epoch: Schema.Uint}, + }, + }, + resp: { + data: ssz.phase0.SignedVoluntaryExit, + meta: EmptyMetaCodec, }, }, }; } -export function getReturnTypes(): ReturnTypes { - return { - listKeys: jsonType("snake"), - importKeystores: jsonType("snake"), - deleteKeys: jsonType("snake"), - - listRemoteKeys: jsonType("snake"), - importRemoteKeys: jsonType("snake"), - deleteRemoteKeys: jsonType("snake"), - - listFeeRecipient: jsonType("snake"), - listGraffiti: jsonType("snake"), - getGasLimit: ContainerData( - new ContainerType( - { - pubkey: stringType, - gasLimit: ssz.UintNum64, - }, - {jsonCase: "eth2"} - ) - ), - getBuilderBoostFactor: ContainerData( - new ContainerType( - { - pubkey: stringType, - builderBoostFactor: ssz.UintBn64, - }, - {jsonCase: "eth2"} - ) - ), - signVoluntaryExit: ContainerData(ssz.phase0.SignedVoluntaryExit), - }; -} - function parseGasLimit(gasLimitInput: string | number): number { if ((typeof gasLimitInput !== "string" && typeof gasLimitInput !== "number") || `${gasLimitInput}`.trim() === "") { throw Error("Not valid Gas Limit"); diff --git a/packages/api/src/keymanager/server/index.ts b/packages/api/src/keymanager/server/index.ts index 0b51be896258..f4d0e75f971e 100644 --- a/packages/api/src/keymanager/server/index.ts +++ b/packages/api/src/keymanager/server/index.ts @@ -1,26 +1,19 @@ +import type {FastifyInstance} from "fastify"; import {ChainForkConfig} from "@lodestar/config"; -import {ServerApi} from "../../interfaces.js"; -import { - ServerInstance, - ServerRoutes, - getGenericJsonServer, - registerRoute, - type RouteConfig, -} from "../../utils/server/index.js"; -import {Api, ReqTypes, routesData, getReturnTypes, getReqSerializers} from "../routes.js"; +import {ApplicationMethods, FastifyRoute, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js"; +import {Endpoints, getDefinitions} from "../routes.js"; +import {AnyEndpoint} from "../../utils/codecs.js"; -// Re-export for convenience -export type {RouteConfig}; +export type KeymanagerApiMethods = ApplicationMethods; -export function getRoutes(config: ChainForkConfig, api: ServerApi): ServerRoutes { - // All routes return JSON, use a server auto-generator - return getGenericJsonServer, ReqTypes>({routesData, getReturnTypes, getReqSerializers}, config, api); +export function getRoutes(config: ChainForkConfig, methods: KeymanagerApiMethods): FastifyRoutes { + return createFastifyRoutes(getDefinitions(config), methods); } -export function registerRoutes(server: ServerInstance, config: ChainForkConfig, api: ServerApi): void { - const routes = getRoutes(config, api); +export function registerRoutes(server: FastifyInstance, config: ChainForkConfig, methods: KeymanagerApiMethods): void { + const routes = getRoutes(config, methods); for (const route of Object.values(routes)) { - registerRoute(server, route); + server.route(route as FastifyRoute); } } diff --git a/packages/api/src/server/index.ts b/packages/api/src/server/index.ts new file mode 100644 index 000000000000..ca5151d86a16 --- /dev/null +++ b/packages/api/src/server/index.ts @@ -0,0 +1,2 @@ +// Server specific code to be exported from /server subpath +export * from "../utils/server/index.js"; diff --git a/packages/api/src/utils/acceptHeader.ts b/packages/api/src/utils/acceptHeader.ts deleted file mode 100644 index dfe858cd3cba..000000000000 --- a/packages/api/src/utils/acceptHeader.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {ResponseFormat} from "../interfaces.js"; - -enum MediaType { - json = "application/json", - ssz = "application/octet-stream", -} - -const MEDIA_TYPES: { - [K in ResponseFormat]: MediaType; -} = { - json: MediaType.json, - ssz: MediaType.ssz, -}; - -function responseFormatFromMediaType(mediaType: MediaType): ResponseFormat { - switch (mediaType) { - default: - case MediaType.json: - return "json"; - case MediaType.ssz: - return "ssz"; - } -} - -export function writeAcceptHeader(format?: ResponseFormat): MediaType { - return format === undefined ? MEDIA_TYPES["json"] : MEDIA_TYPES[format]; -} - -export function parseAcceptHeader(accept?: string): ResponseFormat { - // Use json by default. - if (!accept) { - return "json"; - } - - const mediaTypes = Object.values(MediaType); - - // Respect Quality Values per RFC-9110 - // Acceptable mime-types are comma separated with optional whitespace - return responseFormatFromMediaType( - accept - .toLowerCase() - .split(",") - .map((x) => x.trim()) - .reduce( - (best: [number, MediaType], current: string): [number, MediaType] => { - // An optional `;` delimiter is used to separate the mime-type from the weight - // Normalize here, using 1 as the default qvalue - const quality = current.includes(";") ? current.split(";") : [current, "q=1"]; - - const mediaType = quality[0].trim() as MediaType; - - // If the mime type isn't acceptable, move on to the next entry - if (!mediaTypes.includes(mediaType)) { - return best; - } - - // Otherwise, the portion after the semicolon has optional whitespace and the constant prefix "q=" - const weight = quality[1].trim(); - if (!weight.startsWith("q=")) { - // If the format is invalid simply move on to the next entry - return best; - } - - const qvalue = +weight.replace("q=", ""); - if (isNaN(qvalue) || qvalue > 1 || qvalue <= 0) { - // If we can't convert the qvalue to a valid number, move on - return best; - } - - if (qvalue < best[0]) { - // This mime type is not preferred - return best; - } - - // This mime type is preferred - return [qvalue, mediaType]; - }, - [0, MediaType.json] - )[1] - ); -} diff --git a/packages/api/src/utils/client/client.ts b/packages/api/src/utils/client/client.ts deleted file mode 100644 index 59a06aa024a2..000000000000 --- a/packages/api/src/utils/client/client.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {TimeoutError, mapValues} from "@lodestar/utils"; -import {compileRouteUrlFormater} from "../urlFormat.js"; -import {RouteDef, ReqGeneric, ReturnTypes, TypeJson, ReqSerializer, ReqSerializers, RoutesData} from "../types.js"; -import {APIClientHandler} from "../../interfaces.js"; -import {FetchOpts, HttpError, IHttpClient} from "./httpClient.js"; -import {HttpStatusCode} from "./httpStatusCode.js"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -type ExtraOpts = {retries?: number}; -type ParametersWithOptionalExtraOpts any> = [...Parameters, ExtraOpts] | Parameters; - -export type ApiWithExtraOpts> = { - [K in keyof T]: (...args: ParametersWithOptionalExtraOpts) => ReturnType; -}; - -// See /packages/api/src/routes/index.ts for reasoning - -/** - * Format FetchFn opts from Fn arguments given a route definition and request serializer. - * For routes that return only JSOn use @see getGenericJsonClient - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function getFetchOptsSerializer any, ReqType extends ReqGeneric>( - routeDef: RouteDef, - reqSerializer: ReqSerializer, - routeId: string -) { - const urlFormater = compileRouteUrlFormater(routeDef.url); - - return function getFetchOpts(...args: Parameters): FetchOpts { - const req = reqSerializer.writeReq(...args); - return { - url: urlFormater(req.params ?? {}), - method: routeDef.method, - query: Object.keys(req.query ?? {}).length ? req.query : undefined, - body: req.body as unknown, - headers: req.headers, - routeId, - }; - }; -} - -/** - * Generate `getFetchOptsSerializer()` functions for all routes in `Api` - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function getFetchOptsSerializers< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, ->(routesData: RoutesData, reqSerializers: ReqSerializers) { - return mapValues(routesData, (routeDef, routeId) => - getFetchOptsSerializer(routeDef, reqSerializers[routeId], routeId as string) - ); -} - -/** - * Get a generic JSON client from route definition, request serializer and return types. - */ -export function generateGenericJsonClient< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, ->( - routesData: RoutesData, - reqSerializers: ReqSerializers, - returnTypes: ReturnTypes, - fetchFn: IHttpClient -): ApiWithExtraOpts { - return mapValues(routesData, (routeDef, routeId) => { - const fetchOptsSerializer = getFetchOptsSerializer(routeDef, reqSerializers[routeId], routeId as string); - const returnType = returnTypes[routeId as keyof ReturnTypes] as TypeJson | null; - - return async function request( - ...args: ParametersWithOptionalExtraOpts - ): Promise> { - try { - // extract the extraOpts if provided - // - const argLen = (args as any[])?.length ?? 0; - const lastArg = (args as any[])[argLen] as ExtraOpts | undefined; - const retries = lastArg?.retries; - const extraOpts = {retries}; - - if (returnType) { - // open extraOpts first if some serializer wants to add some overriding param - const res = await fetchFn.json({ - ...extraOpts, - ...fetchOptsSerializer(...(args as Parameters)), - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/return-await - return {ok: true, response: returnType.fromJson(res.body), status: res.status} as ReturnType; - } else { - // We need to avoid parsing the response as the servers might just - // response status 200 and close the request instead of writing an - // empty json response. We return the status code. - const res = await fetchFn.request({ - ...extraOpts, - ...fetchOptsSerializer(...(args as Parameters)), - }); - - // eslint-disable-next-line @typescript-eslint/return-await - return {ok: true, response: undefined, status: res.status} as ReturnType; - } - } catch (err) { - if (err instanceof HttpError) { - return { - ok: false, - status: err.status, - error: {code: err.status, message: err.message, operationId: routeId}, - } as ReturnType; - } - - if (err instanceof TimeoutError) { - return { - ok: false, - status: HttpStatusCode.INTERNAL_SERVER_ERROR, - error: {code: HttpStatusCode.INTERNAL_SERVER_ERROR, message: err.message, operationId: routeId}, - } as ReturnType; - } - - throw err; - } - }; - }) as unknown as ApiWithExtraOpts; -} diff --git a/packages/api/src/utils/client/error.ts b/packages/api/src/utils/client/error.ts new file mode 100644 index 000000000000..b5e5da52aaea --- /dev/null +++ b/packages/api/src/utils/client/error.ts @@ -0,0 +1,10 @@ +export class ApiError extends Error { + status: number; + operationId: string; + + constructor(message: string, status: number, operationId: string) { + super(`${operationId} failed with status ${status}: ${message}`); + this.status = status; + this.operationId = operationId; + } +} diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index 9cbadde1cca5..eeccd59d2032 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -1,14 +1,37 @@ -import {ErrorAborted, Logger, TimeoutError, isValidHttpUrl, toBase64, retry} from "@lodestar/utils"; -import {ReqGeneric, RouteDef} from "../index.js"; -import {ApiClientResponse, ApiClientSuccessResponse} from "../../interfaces.js"; +import {ErrorAborted, Logger, MapDef, TimeoutError, isValidHttpUrl, retry} from "@lodestar/utils"; +import {mergeHeaders} from "../headers.js"; +import {Endpoint} from "../types.js"; +import {WireFormat} from "../wireFormat.js"; +import {HttpStatusCode} from "../httpStatusCode.js"; +import { + ApiRequestInit, + ApiRequestInitRequired, + ExtraRequestInit, + RouteDefinitionExtra, + UrlInit, + UrlInitRequired, + createApiRequest, +} from "./request.js"; +import {ApiResponse} from "./response.js"; +import {Metrics} from "./metrics.js"; import {fetch, isFetchError} from "./fetch.js"; -import {stringifyQuery, urlJoin} from "./format.js"; -import type {Metrics} from "./metrics.js"; -import {HttpStatusCode} from "./httpStatusCode.js"; -/** A higher default timeout, validator will sets its own shorter timeoutMs */ +/** A higher default timeout, validator will set its own shorter timeoutMs */ const DEFAULT_TIMEOUT_MS = 60_000; -const DEFAULT_ROUTE_ID = "unknown"; +const DEFAULT_RETRIES = 0; +const DEFAULT_RETRY_DELAY = 200; +/** + * Default to JSON to ensure compatibility with other clients, can be overridden + * per route in case spec states that SSZ requests must be supported by server. + * Alternatively, can be configured via CLI flag to use SSZ for all routes. + */ +const DEFAULT_REQUEST_WIRE_FORMAT = WireFormat.json; +/** + * For responses, it is possible to default to SSZ without breaking compatibility with + * other clients as we will just be stating a preference to receive a SSZ response from + * the server but will still accept a JSON response in case the server does not support it. + */ +const DEFAULT_RESPONSE_WIRE_FORMAT = WireFormat.ssz; const URL_SCORE_DELTA_SUCCESS = 1; /** Require 2 success to recover from 1 failed request */ @@ -17,75 +40,26 @@ const URL_SCORE_DELTA_ERROR = 2 * URL_SCORE_DELTA_SUCCESS; const URL_SCORE_MAX = 10 * URL_SCORE_DELTA_SUCCESS; const URL_SCORE_MIN = 0; -export class HttpError extends Error { - status: number; - url: string; - - constructor(message: string, status: number, url: string) { - super(message); - this.status = status; - this.url = url; - } -} - -export class ApiError extends Error { - status: number; - operationId: string; - - constructor(message: string, status: number, operationId: string) { - super(message); - this.status = status; - this.operationId = operationId; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static assert(res: ApiClientResponse, message?: string): asserts res is ApiClientSuccessResponse { - if (!res.ok) { - throw new ApiError( - [message, res.error.message].filter(Boolean).join(" - "), - res.error.code, - res.error.operationId - ); - } - } - - toString(): string { - return `${this.message} (status=${this.status}, operationId=${this.operationId})`; - } -} - -export interface URLOpts { - baseUrl: string; - timeoutMs?: number; - bearerToken?: string; - extraHeaders?: Record; -} - -export type FetchOpts = { - url: RouteDef["url"]; - method: RouteDef["method"]; - query?: ReqGeneric["query"]; - body?: ReqGeneric["body"]; - headers?: ReqGeneric["headers"]; - /** Optional, for metrics */ - routeId?: string; - timeoutMs?: number; - retries?: number; +export const defaultInit: Required = { + timeoutMs: DEFAULT_TIMEOUT_MS, + retries: DEFAULT_RETRIES, + retryDelay: DEFAULT_RETRY_DELAY, + requestWireFormat: DEFAULT_REQUEST_WIRE_FORMAT, + responseWireFormat: DEFAULT_RESPONSE_WIRE_FORMAT, }; export interface IHttpClient { - baseUrl: string; - json(opts: FetchOpts): Promise<{status: HttpStatusCode; body: T}>; - request(opts: FetchOpts): Promise<{status: HttpStatusCode; body: void}>; - arrayBuffer(opts: FetchOpts): Promise<{status: HttpStatusCode; body: ArrayBuffer}>; + readonly baseUrl: string; + + request( + definition: RouteDefinitionExtra, + args: E["args"], + localInit?: ApiRequestInit + ): Promise>; } -export type HttpClientOptions = ({baseUrl: string} | {urls: (string | URLOpts)[]}) & { - timeoutMs?: number; - bearerToken?: string; - extraHeaders?: Record; - /** Return an AbortSignal to be attached to all requests */ - getAbortSignal?: () => AbortSignal | undefined; +export type HttpClientOptions = ({baseUrl: string} | {urls: (string | UrlInit)[]}) & { + globalInit?: ApiRequestInit; /** Override fetch function */ fetch?: typeof fetch; }; @@ -95,65 +69,63 @@ export type HttpClientModules = { metrics?: Metrics; }; -export type {Metrics}; - export class HttpClient implements IHttpClient { - private readonly globalTimeoutMs: number; - private readonly globalBearerToken: string | null; - private readonly globalExtraHeaders: Record | null; - private readonly getAbortSignal?: () => AbortSignal | undefined; + readonly urlsInits: UrlInitRequired[] = []; + + private readonly signal: null | AbortSignal; private readonly fetch: typeof fetch; private readonly metrics: null | Metrics; private readonly logger: null | Logger; - private readonly urlsOpts: URLOpts[] = []; private readonly urlsScore: number[]; + /** + * Cache to keep track of routes per server that do not support SSZ. This cache will only be + * populated if we receive a 415 error response from the server after sending a SSZ request body. + * The request will be retried using a JSON body and all subsequent requests will only use JSON. + */ + private readonly sszNotSupportedByRouteIdByUrlIndex = new MapDef>(() => new Map()); + get baseUrl(): string { - return this.urlsOpts[0].baseUrl; + return this.urlsInits[0].baseUrl; } - /** - * timeoutMs = config.params.SECONDS_PER_SLOT * 1000 - */ constructor(opts: HttpClientOptions, {logger, metrics}: HttpClientModules = {}) { // Cast to all types optional since they are defined with syntax `HttpClientOptions = A | B` - const {baseUrl, urls = []} = opts as {baseUrl?: string; urls?: (string | URLOpts)[]}; - - // Append to Partial object to not fill urlOpts with properties with value undefined - const allUrlOpts: Partial = {}; - if (opts.bearerToken) allUrlOpts.bearerToken = opts.bearerToken; - if (opts.timeoutMs !== undefined) allUrlOpts.timeoutMs = opts.timeoutMs; - if (opts.extraHeaders) allUrlOpts.extraHeaders = opts.extraHeaders; + const {baseUrl, urls = []} = opts as {baseUrl?: string; urls?: (string | UrlInit)[]}; + // Do not merge global signal into url inits + const {signal, ...globalInit} = opts.globalInit ?? {}; // opts.baseUrl is equivalent to `urls: [{baseUrl}]` // unshift opts.baseUrl to urls, without mutating opts.urls - for (const [i, urlOrOpts] of [...(baseUrl ? [baseUrl] : []), ...urls].entries()) { - const urlOpts: URLOpts = typeof urlOrOpts === "string" ? {baseUrl: urlOrOpts, ...allUrlOpts} : urlOrOpts; - - if (!urlOpts.baseUrl) { - throw Error(`HttpClient.urls[${i}] is empty or undefined: ${urlOpts.baseUrl}`); + for (const [i, urlOrInit] of [...(baseUrl ? [baseUrl] : []), ...urls].entries()) { + const init = typeof urlOrInit === "string" ? {baseUrl: urlOrInit} : urlOrInit; + const urlInit: UrlInit = { + ...globalInit, + ...init, + headers: mergeHeaders(globalInit.headers, init.headers), + }; + + if (!urlInit.baseUrl) { + throw Error(`HttpClient.urls[${i}] is empty or undefined: ${urlInit.baseUrl}`); } - if (!isValidHttpUrl(urlOpts.baseUrl)) { - throw Error(`HttpClient.urls[${i}] must be a valid URL: ${urlOpts.baseUrl}`); + if (!isValidHttpUrl(urlInit.baseUrl)) { + throw Error(`HttpClient.urls[${i}] must be a valid URL: ${urlInit.baseUrl}`); } // De-duplicate by baseUrl, having two baseUrls with different token or timeouts does not make sense - if (!this.urlsOpts.some((opt) => opt.baseUrl === urlOpts.baseUrl)) { - this.urlsOpts.push(urlOpts); + if (!this.urlsInits.some((opt) => opt.baseUrl === urlInit.baseUrl)) { + this.urlsInits.push({...urlInit, urlIndex: i} as UrlInitRequired); } } - if (this.urlsOpts.length === 0) { + if (this.urlsInits.length === 0) { throw Error("Must set at least 1 URL in HttpClient opts"); } // Initialize scores to max value to only query first URL on start - this.urlsScore = this.urlsOpts.map(() => URL_SCORE_MAX); + this.urlsScore = this.urlsInits.map(() => URL_SCORE_MAX); - this.globalTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - this.globalBearerToken = opts.bearerToken ?? null; - this.globalExtraHeaders = opts.extraHeaders ?? null; - this.getAbortSignal = opts.getAbortSignal; + this.signal = signal ?? null; this.fetch = opts.fetch ?? fetch; this.metrics = metrics ?? null; this.logger = logger ?? null; @@ -161,58 +133,38 @@ export class HttpClient implements IHttpClient { if (metrics) { metrics.urlsScore.addCollect(() => { for (let i = 0; i < this.urlsScore.length; i++) { - metrics.urlsScore.set({urlIndex: i, baseUrl: this.urlsOpts[i].baseUrl}, this.urlsScore[i]); + metrics.urlsScore.set({urlIndex: i, baseUrl: this.urlsInits[i].baseUrl}, this.urlsScore[i]); } }); } } - async json(opts: FetchOpts): Promise<{status: HttpStatusCode; body: T}> { - return this.requestWithBodyWithRetries(opts, (res) => res.json() as Promise); - } - - async request(opts: FetchOpts): Promise<{status: HttpStatusCode; body: void}> { - return this.requestWithBodyWithRetries(opts, async () => undefined); - } + async request( + definition: RouteDefinitionExtra, + args: E["args"], + localInit: ApiRequestInit = {} + ): Promise> { + if (this.urlsInits.length === 1) { + const init = mergeInits(definition, this.urlsInits[0], localInit); - async arrayBuffer(opts: FetchOpts): Promise<{status: HttpStatusCode; body: ArrayBuffer}> { - return this.requestWithBodyWithRetries(opts, (res) => res.arrayBuffer()); - } - - private async requestWithBodyWithRetries( - opts: FetchOpts, - getBody: (res: Response) => Promise - ): Promise<{status: HttpStatusCode; body: T}> { - if (opts.retries !== undefined) { - const routeId = opts.routeId ?? DEFAULT_ROUTE_ID; - - return retry( - async (_attempt) => { - return this.requestWithBodyWithFallbacks(opts, getBody); - }, - { - retries: opts.retries, - retryDelay: 200, - signal: this.getAbortSignal?.(), - onRetry: (e, attempt) => { - this.logger?.debug("Retrying request", {routeId, attempt, lastError: e.message}); - }, - } - ); + if (init.retries > 0) { + return this.requestWithRetries(definition, args, init); + } else { + return this.requestFallbackToJson(definition, args, init); + } } else { - return this.requestWithBodyWithFallbacks(opts, getBody); + return this.requestWithFallbacks(definition, args, localInit); } } - private async requestWithBodyWithFallbacks( - opts: FetchOpts, - getBody: (res: Response) => Promise - ): Promise<{status: HttpStatusCode; body: T}> { - // Early return when no fallback URLs are setup - if (this.urlsOpts.length === 1) { - return this.requestWithBody(this.urlsOpts[0], opts, getBody); - } - + /** + * Send request to primary server first, retry failed requests on fallbacks + */ + private async requestWithFallbacks( + definition: RouteDefinitionExtra, + args: E["args"], + localInit: ApiRequestInit + ): Promise> { let i = 0; // Goals: @@ -221,9 +173,9 @@ export class HttpClient implements IHttpClient { // - until first server is shown to be reliable again, contact all servers // First loop: retry in sequence, query next URL only after previous errors - for (; i < this.urlsOpts.length; i++) { + for (; i < this.urlsInits.length; i++) { try { - return await new Promise<{status: HttpStatusCode; body: T}>((resolve, reject) => { + const res = await new Promise>((resolve, reject) => { let requestCount = 0; let errorCount = 0; @@ -231,10 +183,9 @@ export class HttpClient implements IHttpClient { // Score each URL available: // - If url[0] is good, only send to 0 // - If url[0] has recently errored, send to both 0, 1, etc until url[0] does not error for some time - for (; i < this.urlsOpts.length; i++) { - const urlOpts = this.urlsOpts[i]; - const {baseUrl} = urlOpts; - const routeId = opts.routeId ?? DEFAULT_ROUTE_ID; + for (; i < this.urlsInits.length; i++) { + const baseUrl = this.urlsInits[i].baseUrl; + const routeId = definition.operationId; if (i > 0) { this.metrics?.requestToFallbacks.inc({routeId, baseUrl}); @@ -242,13 +193,32 @@ export class HttpClient implements IHttpClient { } // eslint-disable-next-line @typescript-eslint/naming-convention - const i_ = i; // Keep local copy of i variable to index urlScore after requestWithBody() resolves + const i_ = i; // Keep local copy of i variable to index urlScore after requestMethod() resolves + + const urlInit = this.urlsInits[i]; + if (urlInit === undefined) { + throw Error(`Url at index ${i} does not exist`); + } + const init = mergeInits(definition, urlInit, localInit); - this.requestWithBody(urlOpts, opts, getBody).then( - (res) => { - this.urlsScore[i_] = Math.min(URL_SCORE_MAX, this.urlsScore[i_] + URL_SCORE_DELTA_SUCCESS); - // Resolve immediately on success - resolve(res); + const requestMethod = (init.retries > 0 ? this.requestWithRetries : this.requestFallbackToJson).bind(this); + + requestMethod(definition, args, init).then( + async (res) => { + if (res.ok) { + this.urlsScore[i_] = Math.min(URL_SCORE_MAX, this.urlsScore[i_] + URL_SCORE_DELTA_SUCCESS); + // Resolve immediately on success + resolve(res); + } else { + this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR); + + // Resolve failed response only when all queried URLs have errored + if (++errorCount >= requestCount) { + resolve(res); + } else { + this.logger?.debug("Request error, retrying", {routeId, baseUrl}, res.error() as Error); + } + } }, (err) => { this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR); @@ -271,8 +241,17 @@ export class HttpClient implements IHttpClient { } } }); + if (res.ok) { + return res; + } else { + if (i >= this.urlsInits.length - 1) { + return res; + } else { + this.logger?.debug("Request error, retrying", {}, res.error() as Error); + } + } } catch (e) { - if (i >= this.urlsOpts.length - 1) { + if (i >= this.urlsInits.length - 1) { throw e; } else { this.logger?.debug("Request error, retrying", {}, e as Error); @@ -283,75 +262,125 @@ export class HttpClient implements IHttpClient { throw Error("loop ended without return or rejection"); } - private async requestWithBody( - urlOpts: URLOpts, - opts: FetchOpts, - getBody: (res: Response) => Promise - ): Promise<{status: HttpStatusCode; body: T}> { - const baseUrl = urlOpts.baseUrl; - const bearerToken = urlOpts.bearerToken ?? this.globalBearerToken; - const extraHeaders = urlOpts.extraHeaders ?? this.globalExtraHeaders; - const timeoutMs = opts.timeoutMs ?? urlOpts.timeoutMs ?? this.globalTimeoutMs; + /** + * Send request to single URL, retry failed requests on same server + */ + private async requestWithRetries( + definition: RouteDefinitionExtra, + args: E["args"], + init: ApiRequestInitRequired + ): Promise> { + const {retries, retryDelay, signal} = init; + const routeId = definition.operationId; + + return retry( + async (attempt) => { + const res = await this.requestFallbackToJson(definition, args, init); + if (!res.ok && attempt <= retries) { + throw res.error(); + } + return res; + }, + { + retries, + retryDelay, + // Local signal takes precedence over global signal + signal: signal ?? this.signal ?? undefined, + onRetry: (e, attempt) => { + this.logger?.debug("Retrying request", {routeId, attempt, lastError: e.message}); + }, + } + ); + } - // Implement fetch timeout - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs ?? this.globalTimeoutMs); + /** + * Send request to single URL, SSZ requests will be retried using JSON + * if a 415 error response is returned by the server. All subsequent requests + * to this server for the route will always be sent as JSON afterwards. + */ + private async requestFallbackToJson( + definition: RouteDefinitionExtra, + args: E["args"], + init: ApiRequestInitRequired + ): Promise> { + const {urlIndex} = init; + const routeId = definition.operationId; + + const sszNotSupportedByRouteId = this.sszNotSupportedByRouteIdByUrlIndex.getOrDefault(urlIndex); + if (sszNotSupportedByRouteId.has(routeId)) { + init.requestWireFormat = WireFormat.json; + } - // Attach global signal to this request's controller - const onGlobalSignalAbort = (): void => controller.abort(); - const signalGlobal = this.getAbortSignal?.(); - signalGlobal?.addEventListener("abort", onGlobalSignalAbort); + const res = await this._request(definition, args, init); - const routeId = opts.routeId ?? DEFAULT_ROUTE_ID; - const timer = this.metrics?.requestTime.startTimer({routeId}); + if (res.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE && init.requestWireFormat === WireFormat.ssz) { + this.logger?.debug("SSZ request failed with status 415, retrying using JSON", {routeId, urlIndex}); - try { - const url = new URL(urlJoin(baseUrl, opts.url) + (opts.query ? "?" + stringifyQuery(opts.query) : "")); + sszNotSupportedByRouteId.set(routeId, true); + init.requestWireFormat = WireFormat.json; - const headers = - extraHeaders && opts.headers ? {...extraHeaders, ...opts.headers} : opts.headers || extraHeaders || {}; - if (opts.body && headers["Content-Type"] === undefined) { - headers["Content-Type"] = "application/json"; - } - if (bearerToken && headers["Authorization"] === undefined) { - headers["Authorization"] = `Bearer ${bearerToken}`; - } - if (url.username || url.password) { - if (headers["Authorization"] === undefined) { - headers["Authorization"] = `Basic ${toBase64(decodeURIComponent(`${url.username}:${url.password}`))}`; - } - // Remove the username and password from the URL - url.username = ""; - url.password = ""; - } + return this._request(definition, args, init); + } - this.logger?.debug("HttpClient request", {routeId}); + return res; + } - const res = await this.fetch(url, { - method: opts.method, - headers: headers as Record, - body: opts.body ? JSON.stringify(opts.body) : undefined, - signal: controller.signal, - }); + /** + * Send request to single URL + */ + private async _request( + definition: RouteDefinitionExtra, + args: E["args"], + init: ApiRequestInitRequired + ): Promise> { + const abortSignals = [this.signal, init.signal]; - if (!res.ok) { - const errBody = await res.text(); - throw new HttpError(`${res.statusText}: ${getErrorMessage(errBody)}`, res.status, url.toString()); + // Implement fetch timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), init.timeoutMs); + init.signal = controller.signal; + + // Attach global/local signal to this request's controller + const onSignalAbort = (): void => controller.abort(); + abortSignals.forEach((s) => s?.addEventListener("abort", onSignalAbort)); + + const routeId = definition.operationId; + const {baseUrl, requestWireFormat, responseWireFormat} = init; + const timer = this.metrics?.requestTime.startTimer({routeId}); + + try { + this.logger?.debug("API request", {routeId, requestWireFormat, responseWireFormat}); + const request = createApiRequest(definition, args, init); + const response = await this.fetch(request.url, request); + const apiResponse = new ApiResponse(definition, response.body, response); + + if (!apiResponse.ok) { + await apiResponse.errorBody(); + this.logger?.debug("API response error", {routeId, status: apiResponse.status}); + this.metrics?.requestErrors.inc({routeId, baseUrl}); + return apiResponse; } const streamTimer = this.metrics?.streamTime.startTimer({routeId}); - const body = await getBody(res); - streamTimer?.(); - this.logger?.debug("HttpClient response", {routeId}); - return {status: res.status, body}; + try { + await apiResponse.rawBody(); + this.logger?.debug("API response success", { + routeId, + status: apiResponse.status, + wireFormat: apiResponse.wireFormat(), + }); + return apiResponse; + } finally { + streamTimer?.(); + } } catch (e) { this.metrics?.requestErrors.inc({routeId, baseUrl}); - if (isAbortedError(e as Error)) { - if (signalGlobal?.aborted) { - throw new ErrorAborted("REST client"); + if (isAbortedError(e)) { + if (abortSignals.some((s) => s?.aborted)) { + throw new ErrorAborted(`${routeId} request`); } else if (controller.signal.aborted) { - throw new TimeoutError("request"); + throw new TimeoutError(`${routeId} request`); } else { throw Error("Unknown aborted error"); } @@ -362,24 +391,32 @@ export class HttpClient implements IHttpClient { timer?.(); clearTimeout(timeout); - signalGlobal?.removeEventListener("abort", onGlobalSignalAbort); + abortSignals.forEach((s) => s?.removeEventListener("abort", onSignalAbort)); } } } -function isAbortedError(e: Error): boolean { - return isFetchError(e) && e.type === "aborted"; +function mergeInits( + definition: RouteDefinitionExtra, + urlInit: UrlInitRequired, + localInit: ApiRequestInit +): ApiRequestInitRequired { + return { + ...defaultInit, + ...definition.init, + // Sanitize user provided values + ...removeUndefined(urlInit), + ...removeUndefined(localInit), + headers: mergeHeaders(urlInit.headers, localInit.headers), + }; } -function getErrorMessage(errBody: string): string { - try { - const errJson = JSON.parse(errBody) as {message: string}; - if (errJson.message) { - return errJson.message; - } else { - return errBody; - } - } catch (e) { - return errBody; - } +function removeUndefined(obj: T): {[K in keyof T]: Exclude} { + return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as { + [K in keyof T]: Exclude; + }; +} + +function isAbortedError(e: unknown): boolean { + return isFetchError(e) && e.type === "aborted"; } diff --git a/packages/api/src/utils/client/index.ts b/packages/api/src/utils/client/index.ts index 7198c22ab89b..f07a7bad5a64 100644 --- a/packages/api/src/utils/client/index.ts +++ b/packages/api/src/utils/client/index.ts @@ -1,3 +1,7 @@ -export * from "./client.js"; -export * from "./httpClient.js"; export * from "./fetch.js"; +export * from "./httpClient.js"; +export * from "./method.js"; +export * from "./metrics.js"; +export * from "./request.js"; +export * from "./response.js"; +export * from "./error.js"; diff --git a/packages/api/src/utils/client/method.ts b/packages/api/src/utils/client/method.ts new file mode 100644 index 000000000000..6d450f75201b --- /dev/null +++ b/packages/api/src/utils/client/method.ts @@ -0,0 +1,50 @@ +import {mapValues} from "@lodestar/utils"; +import {Endpoint, HasOnlyOptionalProps, RouteDefinition, RouteDefinitions} from "../types.js"; +import {compileRouteUrlFormatter} from "../urlFormat.js"; +import {IHttpClient} from "./httpClient.js"; +import {ApiRequestInit} from "./request.js"; +import {ApiResponse} from "./response.js"; + +export type ApiClientMethod = E["args"] extends void + ? (init?: ApiRequestInit) => Promise> + : HasOnlyOptionalProps extends true + ? (args?: E["args"], init?: ApiRequestInit) => Promise> + : (args: E["args"], init?: ApiRequestInit) => Promise>; + +export type ApiClientMethods> = {[K in keyof Es]: ApiClientMethod}; + +export function createApiClientMethod( + definition: RouteDefinition, + client: IHttpClient, + operationId: string +): ApiClientMethod { + const urlFormatter = compileRouteUrlFormatter(definition.url); + const definitionExtended = { + ...definition, + urlFormatter, + operationId, + }; + + // If the request args is void, then completely remove the args parameter + if ( + definition.req.schema.body === undefined && + definition.req.schema.params === undefined && + definition.req.schema.query === undefined + ) { + return (async (init?: ApiRequestInit) => { + return client.request(definitionExtended, undefined, init); + }) as ApiClientMethod; + } + return async (args?: E["args"], init?: ApiRequestInit) => { + return client.request(definitionExtended, args ?? {}, init); + }; +} + +export function createApiClientMethods>( + definitions: RouteDefinitions, + client: IHttpClient +): ApiClientMethods { + return mapValues(definitions, (definition, operationId) => { + return createApiClientMethod(definition, client, operationId as string); + }) as unknown as ApiClientMethods; +} diff --git a/packages/api/src/utils/client/request.ts b/packages/api/src/utils/client/request.ts new file mode 100644 index 000000000000..f8b41ca4d706 --- /dev/null +++ b/packages/api/src/utils/client/request.ts @@ -0,0 +1,108 @@ +import {HttpHeader, MediaType, mergeHeaders, setAuthorizationHeader} from "../headers.js"; +import { + Endpoint, + JsonRequestMethods, + RequestWithBodyCodec, + RouteDefinition, + SszRequestMethods, + isRequestWithoutBody, +} from "../types.js"; +import {WireFormat} from "../wireFormat.js"; +import {stringifyQuery, urlJoin} from "./format.js"; + +export type ExtraRequestInit = { + /** Wire format to use in HTTP requests to server */ + requestWireFormat?: `${WireFormat}`; + /** Preferred wire format for HTTP responses from server */ + responseWireFormat?: `${WireFormat}`; + /** Timeout of requests in milliseconds */ + timeoutMs?: number; + /** Number of retries per request */ + retries?: number; + /** Retry delay, only relevant if retries > 0 */ + retryDelay?: number; +}; + +export type OptionalRequestInit = { + bearerToken?: string; +}; + +export type UrlInit = ApiRequestInit & {baseUrl?: string}; +export type UrlInitRequired = ApiRequestInit & {urlIndex: number; baseUrl: string}; +export type ApiRequestInit = ExtraRequestInit & OptionalRequestInit & RequestInit; +export type ApiRequestInitRequired = Required & UrlInitRequired; + +/** Route definition with computed extra properties */ +export type RouteDefinitionExtra = RouteDefinition & { + operationId: string; + urlFormatter: (args: Record) => string; +}; + +export function createApiRequest( + definition: RouteDefinitionExtra, + args: E["args"], + init: ApiRequestInitRequired +): Request { + const headers = new Headers(init.headers); + + let req: E["request"]; + + if (isRequestWithoutBody(definition)) { + req = definition.req.writeReq(args); + } else { + const requestWireFormat = (definition.req as RequestWithBodyCodec).onlySupport ?? init.requestWireFormat; + switch (requestWireFormat) { + case WireFormat.json: + req = (definition.req as JsonRequestMethods).writeReqJson(args); + if (req.body) { + req.body = JSON.stringify(req.body); + headers.set(HttpHeader.ContentType, MediaType.json); + } + break; + case WireFormat.ssz: + req = (definition.req as SszRequestMethods).writeReqSsz(args); + if (req.body) { + headers.set(HttpHeader.ContentType, MediaType.ssz); + } + break; + default: + throw Error(`Invalid requestWireFormat: ${requestWireFormat}`); + } + } + const queryString = req.query ? stringifyQuery(req.query) : ""; + const url = new URL( + urlJoin(init.baseUrl, definition.urlFormatter(req.params ?? {})) + (queryString ? `?${queryString}` : "") + ); + setAuthorizationHeader(url, headers, init); + + if (definition.resp.isEmpty) { + // Do not set Accept header + } else if (definition.resp.onlySupport !== undefined) { + switch (definition.resp.onlySupport) { + case WireFormat.json: + headers.set(HttpHeader.Accept, MediaType.json); + break; + case WireFormat.ssz: + headers.set(HttpHeader.Accept, MediaType.ssz); + break; + } + } else { + switch (init.responseWireFormat) { + case WireFormat.json: + headers.set(HttpHeader.Accept, `${MediaType.json};q=1,${MediaType.ssz};q=0.9`); + break; + case WireFormat.ssz: + headers.set(HttpHeader.Accept, `${MediaType.ssz};q=1,${MediaType.json};q=0.9`); + break; + default: + throw Error(`Invalid responseWireFormat: ${init.responseWireFormat}`); + } + } + + return new Request(url, { + ...init, + method: definition.method, + headers: mergeHeaders(headers, req.headers), + body: req.body as BodyInit, + }); +} diff --git a/packages/api/src/utils/client/response.ts b/packages/api/src/utils/client/response.ts new file mode 100644 index 000000000000..b7039f4865ae --- /dev/null +++ b/packages/api/src/utils/client/response.ts @@ -0,0 +1,201 @@ +import {HeadersExtra, HttpHeader, parseContentTypeHeader} from "../headers.js"; +import {HttpStatusCode} from "../httpStatusCode.js"; +import {Endpoint} from "../types.js"; +import {WireFormat, getWireFormat} from "../wireFormat.js"; +import {ApiError} from "./error.js"; +import {RouteDefinitionExtra} from "./request.js"; + +export type RawBody = + | {type: WireFormat.json; value: unknown} + | {type: WireFormat.ssz; value: Uint8Array} + | {type?: never; value?: never}; + +export class ApiResponse extends Response { + private definition: RouteDefinitionExtra; + private _wireFormat?: WireFormat | null; + private _rawBody?: RawBody; + private _errorBody?: string; + private _meta?: E["meta"]; + private _value?: E["return"]; + + constructor(definition: RouteDefinitionExtra, body?: BodyInit | null, init?: ResponseInit) { + super(body, init); + this.definition = definition; + } + + wireFormat(): WireFormat | null { + if (this._wireFormat === undefined) { + if (this.definition.resp.isEmpty) { + return (this._wireFormat = null); + } + + const contentType = this.headers.get(HttpHeader.ContentType); + if (contentType === null) { + if (this.status === HttpStatusCode.NO_CONTENT) { + return (this._wireFormat = null); + } else { + throw Error("Content-Type header is required in response"); + } + } + + const mediaType = parseContentTypeHeader(contentType); + if (mediaType === null) { + throw Error(`Unsupported response media type: ${contentType.split(";", 1)[0]}`); + } + + const wireFormat = getWireFormat(mediaType); + + const {onlySupport} = this.definition.resp; + if (onlySupport !== undefined && wireFormat !== onlySupport) { + throw Error(`Method only supports ${onlySupport.toUpperCase()} responses`); + } + + this._wireFormat = wireFormat; + } + return this._wireFormat; + } + + async rawBody(): Promise { + this.assertOk(); + + if (!this._rawBody) { + switch (this.wireFormat()) { + case WireFormat.json: + this._rawBody = { + type: WireFormat.json, + value: await super.json(), + }; + break; + case WireFormat.ssz: + this._rawBody = { + type: WireFormat.ssz, + value: new Uint8Array(await this.arrayBuffer()), + }; + break; + default: + this._rawBody = {}; + } + } + return this._rawBody; + } + + meta(): E["meta"] { + this.assertOk(); + + if (!this._meta) { + switch (this.wireFormat()) { + case WireFormat.json: { + const rawBody = this.resolvedRawBody(); + const metaJson = this.definition.resp.transform + ? this.definition.resp.transform.fromResponse(rawBody.value).meta + : rawBody.value; + this._meta = this.definition.resp.meta.fromJson(metaJson); + break; + } + case WireFormat.ssz: + this._meta = this.definition.resp.meta.fromHeaders(new HeadersExtra(this.headers)); + break; + } + } + return this._meta; + } + + value(): E["return"] { + this.assertOk(); + + if (!this._value) { + const rawBody = this.resolvedRawBody(); + const meta = this.meta(); + switch (rawBody.type) { + case WireFormat.json: { + const dataJson = this.definition.resp.transform + ? this.definition.resp.transform.fromResponse(rawBody.value).data + : (rawBody.value as Record)?.data; + this._value = this.definition.resp.data.fromJson(dataJson, meta); + break; + } + case WireFormat.ssz: + this._value = this.definition.resp.data.deserialize(rawBody.value, meta); + break; + } + } + return this._value; + } + + ssz(): Uint8Array { + this.assertOk(); + + const rawBody = this.resolvedRawBody(); + switch (rawBody.type) { + case WireFormat.json: + return this.definition.resp.data.serialize(this.value(), this.meta()); + case WireFormat.ssz: + return rawBody.value; + default: + return new Uint8Array(); + } + } + + json(): Awaited> { + this.assertOk(); + + const rawBody = this.resolvedRawBody(); + switch (rawBody.type) { + case WireFormat.json: + return rawBody.value; + case WireFormat.ssz: + return this.definition.resp.data.toJson(this.value(), this.meta()); + default: + return {}; + } + } + + assertOk(): void { + if (!this.ok) { + throw this.error(); + } + } + + error(): ApiError | null { + if (this.ok) { + return null; + } + + return new ApiError(getErrorMessage(this.resolvedErrorBody()), this.status, this.definition.operationId); + } + + async errorBody(): Promise { + if (!this._errorBody) { + this._errorBody = await this.text(); + } + return this._errorBody; + } + + private resolvedRawBody(): RawBody { + if (!this._rawBody) { + throw Error("rawBody() must be called first"); + } + return this._rawBody; + } + + private resolvedErrorBody(): string { + if (!this._errorBody) { + throw Error("errorBody() must be called first"); + } + + return this._errorBody; + } +} + +function getErrorMessage(errBody: string): string { + try { + const errJson = JSON.parse(errBody) as {message?: string}; + if (errJson.message) { + return errJson.message; + } else { + return errBody; + } + } catch (e) { + return errBody; + } +} diff --git a/packages/api/src/utils/codecs.ts b/packages/api/src/utils/codecs.ts new file mode 100644 index 000000000000..36d905583098 --- /dev/null +++ b/packages/api/src/utils/codecs.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ArrayType, ListBasicType, ListCompositeType, Type, isBasicType, isCompositeType} from "@chainsafe/ssz"; +import {ForkName} from "@lodestar/params"; +import {objectToExpectedCase} from "@lodestar/utils"; +import { + RequestWithoutBodyCodec, + RequestWithBodyCodec, + ResponseCodec, + ResponseDataCodec, + ResponseMetadataCodec, + Endpoint, + SszRequestMethods, +} from "./types.js"; +import {WireFormat} from "./wireFormat.js"; + +// Utility types / codecs + +export type EmptyArgs = void; +export type EmptyRequest = Record; +export type EmptyResponseData = void; +export type EmptyMeta = void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyEndpoint = Endpoint; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EmptyRequestEndpoint = Endpoint; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EmptyResponseEndpoint = Endpoint; + +/** Shortcut for routes that have no params, query */ +export const EmptyRequestCodec: RequestWithoutBodyCodec = { + writeReq: () => ({}), + parseReq: () => {}, + schema: {}, +}; + +export function JsonOnlyReq( + req: Omit, keyof SszRequestMethods> +): RequestWithBodyCodec { + return { + ...req, + writeReqSsz: () => { + throw Error("Not implemented"); + }, + parseReqSsz: () => { + throw Error("Not implemented"); + }, + onlySupport: WireFormat.json, + }; +} + +export const EmptyResponseDataCodec: ResponseDataCodec = { + toJson: () => {}, + fromJson: () => {}, + serialize: () => new Uint8Array(), + deserialize: () => {}, +}; + +export const EmptyMetaCodec: ResponseMetadataCodec = { + toJson: () => {}, + fromJson: () => {}, + toHeadersObject: () => ({}), + fromHeaders: () => {}, +}; + +export const EmptyResponseCodec: ResponseCodec = { + data: EmptyResponseDataCodec, + meta: EmptyMetaCodec, + isEmpty: true, +}; + +export function ArrayOf(elementType: Type, limit = Infinity): ArrayType, unknown, unknown> { + if (isCompositeType(elementType)) { + return new ListCompositeType(elementType, limit) as unknown as ArrayType, unknown, unknown>; + } else if (isBasicType(elementType)) { + return new ListBasicType(elementType, limit) as unknown as ArrayType, unknown, unknown>; + } else { + throw Error(`Unknown type ${elementType.typeName}`); + } +} + +export function WithMeta(getType: (m: M) => Type): ResponseDataCodec { + return { + toJson: (data, meta: M) => getType(meta).toJson(data), + fromJson: (data, meta: M) => getType(meta).fromJson(data), + serialize: (data, meta: M) => getType(meta).serialize(data), + deserialize: (data, meta: M) => getType(meta).deserialize(data), + }; +} + +export function WithVersion( + getType: (v: ForkName) => Type +): ResponseDataCodec { + return { + toJson: (data, meta: M) => getType(meta.version).toJson(data), + fromJson: (data, meta: M) => getType(meta.version).fromJson(data), + serialize: (data, meta: M) => getType(meta.version).serialize(data), + deserialize: (data, meta: M) => getType(meta.version).deserialize(data), + }; +} + +export function JsonOnlyResp( + resp: Omit, "data"> & { + data: Omit["data"], "serialize" | "deserialize">; + } +): ResponseCodec { + return { + ...resp, + data: { + ...resp.data, + serialize: () => { + throw Error("Not implemented"); + }, + deserialize: () => { + throw Error("Not implemented"); + }, + }, + onlySupport: WireFormat.json, + }; +} + +export const JsonOnlyResponseCodec: ResponseCodec = { + data: { + toJson: (data: Record) => { + // JSON fields use snake case across all existing routes + return objectToExpectedCase(data, "snake"); + }, + fromJson: (data) => { + if (typeof data !== "object" || data === null) { + throw Error("JSON must be of type object"); + } + // All JSON inside the JS code must be camel case + return objectToExpectedCase(data as Record, "camel"); + }, + serialize: () => { + throw Error("Not implemented"); + }, + deserialize: () => { + throw Error("Not implemented"); + }, + }, + meta: EmptyMetaCodec, + onlySupport: WireFormat.json, +}; diff --git a/packages/api/src/utils/fork.ts b/packages/api/src/utils/fork.ts new file mode 100644 index 000000000000..290147e7e47a --- /dev/null +++ b/packages/api/src/utils/fork.ts @@ -0,0 +1,40 @@ +import {ForkName, isForkBlobs, isForkExecution, isForkLightClient} from "@lodestar/params"; +import {allForks, ssz} from "@lodestar/types"; + +export function toForkName(version: string): ForkName { + // Teku returns fork as UPPERCASE + version = version.toLowerCase(); + + // Un-safe external data, validate version is known ForkName value + if (!(version in ForkName)) throw Error(`Invalid version ${version}`); + + return version as ForkName; +} + +export function getLightClientForkTypes(fork: ForkName): allForks.AllForksLightClientSSZTypes { + if (!isForkLightClient(fork)) { + throw Error(`Invalid fork=${fork} for lightclient fork types`); + } + return ssz.allForksLightClient[fork]; +} + +export function getExecutionForkTypes(fork: ForkName): allForks.AllForksExecutionSSZTypes { + if (!isForkExecution(fork)) { + throw Error(`Invalid fork=${fork} for execution fork types`); + } + return ssz.allForksExecution[fork]; +} + +export function getBlindedForkTypes(fork: ForkName): allForks.AllForksBlindedSSZTypes { + if (!isForkExecution(fork)) { + throw Error(`Invalid fork=${fork} for blinded fork types`); + } + return ssz.allForksBlinded[fork] as allForks.AllForksBlindedSSZTypes; +} + +export function getBlobsForkTypes(fork: ForkName): allForks.AllForksBlobsSSZTypes { + if (!isForkBlobs(fork)) { + throw Error(`Invalid fork=${fork} for blobs fork types`); + } + return ssz.allForksBlobs[fork]; +} diff --git a/packages/api/src/utils/headers.ts b/packages/api/src/utils/headers.ts new file mode 100644 index 000000000000..5e39e5765958 --- /dev/null +++ b/packages/api/src/utils/headers.ts @@ -0,0 +1,161 @@ +import {toBase64} from "@lodestar/utils"; + +export enum HttpHeader { + ContentType = "content-type", + Accept = "accept", + Authorization = "authorization", +} + +export enum MediaType { + json = "application/json", + ssz = "application/octet-stream", +} + +export const SUPPORTED_MEDIA_TYPES = Object.values(MediaType); + +function isSupportedMediaType(mediaType: string | null, supported: MediaType[]): mediaType is MediaType { + return mediaType !== null && supported.includes(mediaType as MediaType); +} + +export function parseContentTypeHeader(contentType?: string): MediaType | null { + if (!contentType) { + return null; + } + + const mediaType = contentType.split(";", 1)[0].trim().toLowerCase(); + + return isSupportedMediaType(mediaType, SUPPORTED_MEDIA_TYPES) ? mediaType : null; +} + +export function parseAcceptHeader(accept?: string, supported = SUPPORTED_MEDIA_TYPES): MediaType | null { + if (!accept) { + return null; + } + + // Respect Quality Values per RFC-9110 + // Acceptable mime-types are comma separated with optional whitespace + return accept + .toLowerCase() + .split(",") + .map((x) => x.trim()) + .reduce( + (best: [number, MediaType | null], current: string): [number, MediaType | null] => { + // An optional `;` delimiter is used to separate the mime-type from the weight + // Normalize here, using 1 as the default qvalue + const quality = current.includes(";") ? current.split(";") : [current, "q=1"]; + + const mediaType = quality[0].trim(); + + // If the mime type isn't acceptable, move on to the next entry + if (!isSupportedMediaType(mediaType, supported)) { + return best; + } + + // Otherwise, the portion after the semicolon has optional whitespace and the constant prefix "q=" + const weight = quality[1].trim(); + if (!weight.startsWith("q=")) { + // If the format is invalid simply move on to the next entry + return best; + } + + const qvalue = +weight.replace("q=", ""); + if (isNaN(qvalue) || qvalue > 1 || qvalue <= 0) { + // If we can't convert the qvalue to a valid number, move on + return best; + } + + if (qvalue < best[0]) { + // This mime type is not preferred + return best; + } + + // This mime type is preferred + return [qvalue, mediaType]; + }, + [0, null] + )[1]; +} + +export function setAuthorizationHeader(url: URL, headers: Headers, {bearerToken}: {bearerToken?: string}): void { + if (bearerToken && !headers.has(HttpHeader.Authorization)) { + headers.set(HttpHeader.Authorization, `Bearer ${bearerToken}`); + } + if (url.username || url.password) { + if (!headers.has(HttpHeader.Authorization)) { + headers.set(HttpHeader.Authorization, `Basic ${toBase64(decodeURIComponent(`${url.username}:${url.password}`))}`); + } + // Remove the username and password from the URL + url.username = ""; + url.password = ""; + } +} + +export function mergeHeaders(a: HeadersInit | undefined, b: HeadersInit | undefined): Headers { + if (!a) { + return new Headers(b); + } + const headers = new Headers(a); + if (!b) { + return headers; + } + if (Array.isArray(b)) { + for (const [key, value] of b) { + headers.set(key, value); + } + } else if (b instanceof Headers) { + for (const [key, value] of b as unknown as Iterable<[string, string]>) { + headers.set(key, value); + } + } else { + for (const [key, value] of Object.entries(b)) { + headers.set(key, value); + } + } + return headers; +} + +/** + * Get header from request headers, by default an error will be thrown if the header + * is not present. The header can be marked as optional in which case the return value + * might be `undefined` but no error will be thrown if header is missing. + */ +export function fromHeaders, R extends boolean = true>( + headers: T, + name: Extract, + required: R = true as R +): R extends true ? string : string | undefined { + // Fastify converts all headers to lower case + const header = headers[name.toLowerCase()]; + + if (header === undefined && required) { + throw Error(`${name} header is required`); + } + + return header as R extends true ? string : string | undefined; +} + +/** + * Extension of Headers object returned by Fetch API + */ +export class HeadersExtra extends Headers { + /** + * Get required header from response headers + */ + getRequired(name: string): string { + const header = this.get(name); + + if (header === null) { + throw Error(`${name} header is required in response`); + } + + return header; + } + + /** + * Get optional header from response headers. + * Return default value if it does not exist + */ + getOrDefault(name: string, defaultValue: string): string { + return this.get(name) ?? defaultValue; + } +} diff --git a/packages/api/src/utils/client/httpStatusCode.ts b/packages/api/src/utils/httpStatusCode.ts similarity index 100% rename from packages/api/src/utils/client/httpStatusCode.ts rename to packages/api/src/utils/httpStatusCode.ts diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index e87a2272d637..73d46e497c03 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -1,3 +1,3 @@ export * from "./schema.js"; export * from "./types.js"; -export {compileRouteUrlFormater, toColonNotationPath} from "./urlFormat.js"; +export {compileRouteUrlFormatter, toColonNotationPath} from "./urlFormat.js"; diff --git a/packages/api/src/utils/metadata.ts b/packages/api/src/utils/metadata.ts new file mode 100644 index 000000000000..8376113efec8 --- /dev/null +++ b/packages/api/src/utils/metadata.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ContainerType, ValueOf} from "@chainsafe/ssz"; +import {ForkName} from "@lodestar/params"; +import {StringType, ssz, stringType} from "@lodestar/types"; +import {ResponseMetadataCodec} from "./types.js"; +import {toBoolean} from "./serdes.js"; +import {toForkName} from "./fork.js"; + +export const VersionType = new ContainerType({ + /** + * Fork code name + */ + version: new StringType(), +}); +VersionType.fields.version.fromJson = (json) => { + return toForkName(stringType.fromJson(json)); +}; + +export const ExecutionOptimisticType = new ContainerType( + { + /** + * True if the response references an unverified execution payload. + * Optimistic information may be invalidated at a later time. + */ + executionOptimistic: ssz.Boolean, + }, + {jsonCase: "eth2"} +); + +export const ExecutionOptimisticAndFinalizedType = new ContainerType( + { + ...ExecutionOptimisticType.fields, + /** + * True if the response references the finalized history of the chain, as determined by fork choice + */ + finalized: ssz.Boolean, + }, + {jsonCase: "eth2"} +); + +export const ExecutionOptimisticAndVersionType = new ContainerType( + { + ...ExecutionOptimisticType.fields, + ...VersionType.fields, + }, + {jsonCase: "eth2"} +); + +export const ExecutionOptimisticFinalizedAndVersionType = new ContainerType( + { + ...ExecutionOptimisticAndFinalizedType.fields, + ...VersionType.fields, + }, + {jsonCase: "eth2"} +); + +export const ExecutionOptimisticAndDependentRootType = new ContainerType( + { + ...ExecutionOptimisticType.fields, + /** + * The block root that this response is dependent on + */ + dependentRoot: stringType, + }, + {jsonCase: "eth2"} +); + +export type VersionMeta = ValueOf; +export type ExecutionOptimisticMeta = ValueOf; +export type ExecutionOptimisticAndFinalizedMeta = ValueOf; +export type ExecutionOptimisticAndVersionMeta = ValueOf; +export type ExecutionOptimisticFinalizedAndVersionMeta = ValueOf; +export type ExecutionOptimisticAndDependentRootMeta = ValueOf; + +export enum MetaHeader { + Version = "Eth-Consensus-Version", + ConsensusBlockValue = "Eth-Consensus-Block-Value", + ExecutionPayloadBlinded = "Eth-Execution-Payload-Blinded", + ExecutionPayloadValue = "Eth-Execution-Payload-Value", + + /* Lodestar-specific (non-standardized) headers */ + Finalized = "Eth-Consensus-Finalized", + DependentRoot = "Eth-Consensus-Dependent-Root", + ExecutionOptimistic = "Eth-Execution-Optimistic", + ExecutionPayloadSource = "Eth-Execution-Payload-Source", +} + +export const ExecutionOptimisticCodec: ResponseMetadataCodec = { + toJson: (val) => ExecutionOptimisticType.toJson(val), + fromJson: (val) => ExecutionOptimisticType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.ExecutionOptimistic]: val.executionOptimistic.toString(), + }), + fromHeaders: (headers) => ({ + executionOptimistic: toBoolean(headers.getOrDefault(MetaHeader.ExecutionOptimistic, "false")), + }), +}; + +export const VersionCodec: ResponseMetadataCodec = { + toJson: (val) => VersionType.toJson(val), + fromJson: (val) => VersionType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.Version]: val.version, + }), + fromHeaders: (headers) => ({ + version: toForkName(headers.getRequired(MetaHeader.Version)), + }), +}; + +export const ExecutionOptimisticAndVersionCodec: ResponseMetadataCodec = { + toJson: (val) => ExecutionOptimisticAndVersionType.toJson(val), + fromJson: (val) => ExecutionOptimisticAndVersionType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.ExecutionOptimistic]: val.executionOptimistic.toString(), + [MetaHeader.Version]: val.version, + }), + fromHeaders: (headers) => ({ + executionOptimistic: toBoolean(headers.getOrDefault(MetaHeader.ExecutionOptimistic, "false")), + version: toForkName(headers.getRequired(MetaHeader.Version)), + }), +}; + +export const ExecutionOptimisticAndFinalizedCodec: ResponseMetadataCodec = { + toJson: (val) => ExecutionOptimisticAndFinalizedType.toJson(val), + fromJson: (val) => ExecutionOptimisticAndFinalizedType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.ExecutionOptimistic]: val.executionOptimistic.toString(), + [MetaHeader.Finalized]: val.finalized.toString(), + }), + fromHeaders: (headers) => ({ + executionOptimistic: toBoolean(headers.getOrDefault(MetaHeader.ExecutionOptimistic, "false")), + finalized: toBoolean(headers.getOrDefault(MetaHeader.Finalized, "false")), + }), +}; + +export const ExecutionOptimisticFinalizedAndVersionCodec: ResponseMetadataCodec = + { + toJson: (val) => ExecutionOptimisticFinalizedAndVersionType.toJson(val), + fromJson: (val) => ExecutionOptimisticFinalizedAndVersionType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.ExecutionOptimistic]: val.executionOptimistic.toString(), + [MetaHeader.Finalized]: val.finalized.toString(), + [MetaHeader.Version]: val.version, + }), + fromHeaders: (headers) => ({ + executionOptimistic: toBoolean(headers.getOrDefault(MetaHeader.ExecutionOptimistic, "false")), + finalized: toBoolean(headers.getOrDefault(MetaHeader.Finalized, "false")), + version: toForkName(headers.getRequired(MetaHeader.Version)), + }), + }; + +export const ExecutionOptimisticAndDependentRootCodec: ResponseMetadataCodec = + { + toJson: (val) => ExecutionOptimisticAndDependentRootType.toJson(val), + fromJson: (val) => ExecutionOptimisticAndDependentRootType.fromJson(val), + toHeadersObject: (val) => ({ + [MetaHeader.ExecutionOptimistic]: val.executionOptimistic.toString(), + [MetaHeader.DependentRoot]: val.dependentRoot, + }), + fromHeaders: (headers) => ({ + executionOptimistic: toBoolean(headers.getOrDefault(MetaHeader.ExecutionOptimistic, "false")), + dependentRoot: headers.getRequired(MetaHeader.DependentRoot), + }), + }; diff --git a/packages/api/src/utils/routes.ts b/packages/api/src/utils/routes.ts deleted file mode 100644 index 77d177f7b24c..000000000000 --- a/packages/api/src/utils/routes.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {allForks, ssz} from "@lodestar/types"; -import {ForkBlobs} from "@lodestar/params"; - -import {TypeJson} from "./types.js"; - -/* eslint-disable @typescript-eslint/naming-convention */ - -export function allForksSignedBlockContentsReqSerializer( - blockSerializer: (data: allForks.SignedBeaconBlock) => TypeJson -): TypeJson { - return { - toJson: (data) => ({ - signed_block: blockSerializer(data.signedBlock).toJson(data.signedBlock), - kzg_proofs: ssz.deneb.KZGProofs.toJson(data.kzgProofs), - blobs: ssz.deneb.Blobs.toJson(data.blobs), - }), - - fromJson: (data: {signed_block: unknown; kzg_proofs: unknown; blobs: unknown}) => ({ - signedBlock: blockSerializer(data.signed_block as allForks.SignedBeaconBlock).fromJson(data.signed_block), - kzgProofs: ssz.deneb.KZGProofs.fromJson(data.kzg_proofs), - blobs: ssz.deneb.Blobs.fromJson(data.blobs), - }), - }; -} - -export function allForksBlockContentsResSerializer(fork: ForkBlobs): TypeJson { - return { - toJson: (data) => ({ - block: (ssz.allForks[fork].BeaconBlock as allForks.AllForksSSZTypes["BeaconBlock"]).toJson(data.block), - kzg_proofs: ssz.deneb.KZGProofs.toJson(data.kzgProofs), - blobs: ssz.deneb.Blobs.toJson(data.blobs), - }), - fromJson: (data: {block: unknown; blob_sidecars: unknown; kzg_proofs: unknown; blobs: unknown}) => ({ - block: ssz.allForks[fork].BeaconBlock.fromJson(data.block), - kzgProofs: ssz.deneb.KZGProofs.fromJson(data.kzg_proofs), - blobs: ssz.deneb.Blobs.fromJson(data.blobs), - }), - }; -} diff --git a/packages/api/src/utils/schema.ts b/packages/api/src/utils/schema.ts index 6b08f27bdbab..2d086fd8dfa9 100644 --- a/packages/api/src/utils/schema.ts +++ b/packages/api/src/utils/schema.ts @@ -1,26 +1,23 @@ -import {ReqGeneric} from "./types.js"; +import {Endpoint, HeaderParams, PathParams, QueryParams} from "./types.js"; // Reasoning: Allows to declare JSON schemas for server routes in a succinct typesafe way. // The enums exposed here are very feature incomplete but cover the minimum necessary for // the existing routes. Since the arguments for Ethereum Consensus server routes are very simple it suffice. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type JsonSchema = Record; +type JsonSchema = Record; type JsonSchemaObj = { type: "object"; required: string[]; properties: Record; }; +type RequireSchema = {[K in keyof T]-?: Schema}; -export type SchemaDefinition = { - params?: { - [K in keyof ReqType["params"]]: Schema; - }; - query?: { - [K in keyof ReqType["query"]]: Schema; - }; - body?: Schema; -}; +export type SchemaDefinition = (ReqType["params"] extends PathParams + ? {params: RequireSchema} + : {params?: never}) & + (ReqType["query"] extends QueryParams ? {query: RequireSchema} : {query?: never}) & + (ReqType["headers"] extends HeaderParams ? {headers: RequireSchema} : {headers?: never}) & + (ReqType extends {body: unknown} ? {body: Schema} : {body?: never}); export enum Schema { Uint, @@ -29,6 +26,7 @@ export enum Schema { String, StringRequired, StringArray, + StringArrayRequired, UintOrStringRequired, UintOrStringArray, Object, @@ -54,6 +52,7 @@ function getJsonSchemaItem(schema: Schema): JsonSchema { return {type: "string"}; case Schema.StringArray: + case Schema.StringArrayRequired: return {type: "array", items: {type: "string"}}; case Schema.UintOrStringRequired: @@ -80,6 +79,7 @@ function isRequired(schema: Schema): boolean { case Schema.UintRequired: case Schema.StringRequired: case Schema.UintOrStringRequired: + case Schema.StringArrayRequired: return true; default: @@ -87,34 +87,45 @@ function isRequired(schema: Schema): boolean { } } -export function getFastifySchema(schemaDef: SchemaDefinition): JsonSchema { - const schema: {params?: JsonSchemaObj; querystring?: JsonSchemaObj; body?: JsonSchema} = {}; +export function getFastifySchema(schemaDef: SchemaDefinition): JsonSchema { + const schema: {params?: JsonSchemaObj; querystring?: JsonSchemaObj; headers?: JsonSchemaObj; body?: JsonSchema} = {}; if (schemaDef.body != null) { schema.body = getJsonSchemaItem(schemaDef.body); } if (schemaDef.params) { - schema.params = {type: "object", required: [] as string[], properties: {}}; + schema.params = {type: "object", required: [], properties: {}}; - for (const [key, def] of Object.entries(schemaDef.params)) { - schema.params.properties[key] = getJsonSchemaItem(def as Schema); - if (isRequired(def as Schema)) { + for (const [key, def] of Object.entries(schemaDef.params)) { + schema.params.properties[key] = getJsonSchemaItem(def); + if (isRequired(def)) { schema.params.required.push(key); } } } if (schemaDef.query) { - schema.querystring = {type: "object", required: [] as string[], properties: {}}; + schema.querystring = {type: "object", required: [], properties: {}}; - for (const [key, def] of Object.entries(schemaDef.query)) { - schema.querystring.properties[key] = getJsonSchemaItem(def as Schema); - if (isRequired(def as Schema)) { + for (const [key, def] of Object.entries(schemaDef.query)) { + schema.querystring.properties[key] = getJsonSchemaItem(def); + if (isRequired(def)) { schema.querystring.required.push(key); } } } + if (schemaDef.headers) { + schema.headers = {type: "object", required: [], properties: {}}; + + for (const [key, def] of Object.entries(schemaDef.headers)) { + schema.headers.properties[key] = getJsonSchemaItem(def); + if (isRequired(def)) { + schema.headers.required.push(key); + } + } + } + return schema; } diff --git a/packages/api/src/utils/serdes.ts b/packages/api/src/utils/serdes.ts index 44e52a93382e..73196c917a66 100644 --- a/packages/api/src/utils/serdes.ts +++ b/packages/api/src/utils/serdes.ts @@ -67,6 +67,14 @@ export function toU64StrOpt(u64: U64 | undefined): U64Str | undefined { return u64 !== undefined ? toU64Str(u64) : undefined; } +export function toValidatorIdsStr(ids?: (string | number)[]): string[] | undefined { + return ids?.map((id) => (typeof id === "string" ? id : toU64Str(id))); +} + +export function fromValidatorIdsStr(ids?: string[]): (string | number)[] | undefined { + return ids?.map((id) => (typeof id === "string" && id.startsWith("0x") ? id : fromU64Str(id))); +} + const GRAFFITI_HEX_LENGTH = 66; export function toGraffitiHex(utf8: string): string { @@ -93,3 +101,13 @@ export function fromGraffitiHex(hex: string): string { return hex; } } + +export function toBoolean(value: string): boolean { + value = value.toLowerCase(); + + if (value !== "true" && value !== "false") { + throw Error(`Invalid boolean ${value}`); + } + + return value === "true"; +} diff --git a/packages/api/src/utils/server/errors.ts b/packages/api/src/utils/server/error.ts similarity index 76% rename from packages/api/src/utils/server/errors.ts rename to packages/api/src/utils/server/error.ts index ea075678f4f6..b57a4c402a90 100644 --- a/packages/api/src/utils/server/errors.ts +++ b/packages/api/src/utils/server/error.ts @@ -1,4 +1,4 @@ -import {HttpErrorCodes} from "../client/httpStatusCode.js"; +import {HttpErrorCodes} from "../httpStatusCode.js"; export class ApiError extends Error { statusCode: HttpErrorCodes; diff --git a/packages/api/src/utils/server/genericJsonServer.ts b/packages/api/src/utils/server/genericJsonServer.ts deleted file mode 100644 index ebda05ccc859..000000000000 --- a/packages/api/src/utils/server/genericJsonServer.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type {FastifyInstance} from "fastify"; -import {mapValues} from "@lodestar/utils"; -import {ChainForkConfig} from "@lodestar/config"; -import {ReqGeneric, TypeJson, Resolves, RouteGroupDefinition} from "../types.js"; -import {getFastifySchema} from "../schema.js"; -import {toColonNotationPath} from "../urlFormat.js"; -import {APIServerHandler} from "../../interfaces.js"; -import {ServerRoute} from "./types.js"; - -// See /packages/api/src/routes/index.ts for reasoning - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export type ServerRoutes< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, -> = { - [K in keyof Api]: ServerRoute; -}; - -export function getGenericJsonServer< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, ->( - {routesData, getReqSerializers, getReturnTypes}: RouteGroupDefinition, - config: ChainForkConfig, - api: Api -): ServerRoutes { - const reqSerializers = getReqSerializers(config); - const returnTypes = getReturnTypes(config); - - return mapValues(routesData, (routeDef, routeId) => { - const routeSerdes = reqSerializers[routeId]; - const returnType = returnTypes[routeId as keyof typeof returnTypes] as TypeJson | null; - - return { - // Convert '/states/{state_id}' into '/states/:state_id' - url: toColonNotationPath(routeDef.url), - method: routeDef.method, - id: routeId as string, - schema: routeSerdes.schema && getFastifySchema(routeSerdes.schema), - - handler: async function handler(this: FastifyInstance, req, resp): Promise { - const args: any[] = routeSerdes.parseReq(req as ReqGeneric as ReqTypes[keyof Api]); - const data = (await api[routeId](...args)) as Resolves; - - if (routeDef.statusOk !== undefined) { - resp.statusCode = routeDef.statusOk; - } - - if (returnType) { - return returnType.toJson(data); - } else { - return {}; - } - }, - }; - }); -} diff --git a/packages/api/src/utils/server/handler.ts b/packages/api/src/utils/server/handler.ts new file mode 100644 index 000000000000..162f68f25c05 --- /dev/null +++ b/packages/api/src/utils/server/handler.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type * as fastify from "fastify"; +import {HttpHeader, MediaType, SUPPORTED_MEDIA_TYPES, parseAcceptHeader, parseContentTypeHeader} from "../headers.js"; +import { + Endpoint, + RequestData, + JsonRequestData, + JsonRequestMethods, + RequestWithBodyCodec, + RouteDefinition, + SszRequestData, + SszRequestMethods, + isRequestWithoutBody, +} from "../types.js"; +import {WireFormat, fromWireFormat, getWireFormat} from "../wireFormat.js"; +import {ApiError} from "./error.js"; +import {ApplicationMethod, ApplicationResponse} from "./method.js"; + +export type FastifyHandler = fastify.RouteHandlerMethod< + fastify.RawServerDefault, + fastify.RawRequestDefaultExpression, + fastify.RawReplyDefaultExpression, + { + Body: E["request"] extends JsonRequestData ? E["request"]["body"] : undefined; + Querystring: E["request"]["query"]; + Params: E["request"]["params"]; + Headers: E["request"]["headers"]; + }, + fastify.ContextConfigDefault +>; + +export function createFastifyHandler( + definition: RouteDefinition, + method: ApplicationMethod, + _operationId: string +): FastifyHandler { + return async (req, resp) => { + // Determine response wire format first to inform application method + // about the preferable return type to avoid unnecessary serialization + let responseMediaType: MediaType | null; + + const acceptHeader = req.headers.accept; + if (definition.resp.isEmpty) { + // Ignore Accept header, the response will be sent without body + responseMediaType = null; + } else if (acceptHeader === undefined || acceptHeader === "*/*") { + // Default to json to not force user to set header, e.g. when using curl + responseMediaType = MediaType.json; + } else { + const {onlySupport} = definition.resp; + const supportedMediaTypes = onlySupport !== undefined ? [fromWireFormat(onlySupport)] : SUPPORTED_MEDIA_TYPES; + responseMediaType = parseAcceptHeader(acceptHeader, supportedMediaTypes); + + if (responseMediaType === null) { + throw new ApiError(406, `Accepted media types not supported: ${acceptHeader}`); + } + } + const responseWireFormat = responseMediaType !== null ? getWireFormat(responseMediaType) : null; + + let response: ApplicationResponse; + try { + if (isRequestWithoutBody(definition)) { + response = await method(definition.req.parseReq(req as RequestData), { + sszBytes: null, + returnBytes: responseWireFormat === WireFormat.ssz, + }); + } else { + const contentType = req.headers[HttpHeader.ContentType]; + if (contentType === undefined) { + throw new ApiError(400, "Content-Type header is required"); + } + const requestMediaType = parseContentTypeHeader(contentType); + if (requestMediaType === null) { + throw new ApiError(415, `Unsupported media type: ${contentType.split(";", 1)[0]}`); + } + const requestWireFormat = getWireFormat(requestMediaType); + + const {onlySupport} = definition.req as RequestWithBodyCodec; + if (onlySupport !== undefined && onlySupport !== requestWireFormat) { + throw new ApiError(415, `Endpoint only supports ${onlySupport.toUpperCase()} requests`); + } + + switch (requestWireFormat) { + case WireFormat.json: + response = await method((definition.req as JsonRequestMethods).parseReqJson(req as JsonRequestData), { + sszBytes: null, + returnBytes: responseWireFormat === WireFormat.ssz, + }); + break; + case WireFormat.ssz: + response = await method( + (definition.req as SszRequestMethods).parseReqSsz(req as SszRequestData), + { + sszBytes: req.body as Uint8Array, + returnBytes: responseWireFormat === WireFormat.ssz, + } + ); + break; + } + } + } catch (e) { + if (e instanceof ApiError) throw e; + // Errors related to parsing should return 400 status code + throw new ApiError(400, (e as Error).message); + } + + if (response?.status !== undefined) { + resp.statusCode = response.status; + } + + switch (responseWireFormat) { + case WireFormat.json: { + const metaHeaders = definition.resp.meta.toHeadersObject(response?.meta); + metaHeaders[HttpHeader.ContentType] = MediaType.json; + void resp.headers(metaHeaders); + const data = + response?.data instanceof Uint8Array + ? definition.resp.data.toJson(definition.resp.data.deserialize(response.data, response.meta), response.meta) + : definition.resp.data.toJson(response?.data, response?.meta); + const metaJson = definition.resp.meta.toJson(response?.meta); + if (definition.resp.transform) { + return definition.resp.transform.toResponse(data, metaJson); + } + return { + data, + ...(metaJson as object), + }; + } + case WireFormat.ssz: { + const metaHeaders = definition.resp.meta.toHeadersObject(response?.meta); + metaHeaders[HttpHeader.ContentType] = MediaType.ssz; + void resp.headers(metaHeaders); + const data = + response?.data instanceof Uint8Array + ? response.data + : definition.resp.data.serialize(response?.data, response?.meta); + // Fastify supports returning `Uint8Array` from handler and will efficiently + // convert it to a `Buffer` internally without copying the underlying `ArrayBuffer` + return data; + } + case null: + // Send response without body + return; + } + }; +} diff --git a/packages/api/src/utils/server/index.ts b/packages/api/src/utils/server/index.ts index 2e17e9ee2a6f..3f3b705b5bcf 100644 --- a/packages/api/src/utils/server/index.ts +++ b/packages/api/src/utils/server/index.ts @@ -1,4 +1,5 @@ -export * from "./genericJsonServer.js"; -export * from "./registerRoute.js"; -export * from "./errors.js"; -export * from "./types.js"; +export * from "./error.js"; +export * from "./handler.js"; +export * from "./method.js"; +export * from "./parser.js"; +export * from "./route.js"; diff --git a/packages/api/src/utils/server/method.ts b/packages/api/src/utils/server/method.ts new file mode 100644 index 000000000000..080c78869798 --- /dev/null +++ b/packages/api/src/utils/server/method.ts @@ -0,0 +1,39 @@ +import {EmptyMeta, EmptyResponseData} from "../codecs.js"; +import {HttpSuccessCodes} from "../httpStatusCode.js"; +import {Endpoint, HasOnlyOptionalProps} from "../types.js"; + +type ApplicationResponseObject = { + /** + * Set non-200 success status code + */ + status?: HttpSuccessCodes; +} & (E["return"] extends EmptyResponseData + ? {data?: never} + : {data: E["return"] | (E["return"] extends undefined ? undefined : Uint8Array)}) & + (E["meta"] extends EmptyMeta ? {meta?: never} : {meta: E["meta"]}); + +export type ApplicationResponse = + HasOnlyOptionalProps> extends true + ? ApplicationResponseObject | void + : ApplicationResponseObject; + +export type ApiContext = { + /** + * Raw ssz bytes from request payload, only available for ssz requests + */ + sszBytes?: Uint8Array | null; + /** + * Informs application method about preferable return type to avoid unnecessary serialization + */ + returnBytes?: boolean; +}; + +type GenericOptions = Record; + +export type ApplicationMethod = ( + args: E["args"], + context?: ApiContext, + opts?: GenericOptions +) => Promise>; + +export type ApplicationMethods> = {[K in keyof Es]: ApplicationMethod}; diff --git a/packages/api/src/utils/server/parser.ts b/packages/api/src/utils/server/parser.ts new file mode 100644 index 000000000000..fd668b63757e --- /dev/null +++ b/packages/api/src/utils/server/parser.ts @@ -0,0 +1,27 @@ +import type * as fastify from "fastify"; +import {MediaType} from "../headers.js"; + +export function addSszContentTypeParser(server: fastify.FastifyInstance): void { + // Cache body schema symbol, does not change per request + let bodySchemaSymbol: symbol | undefined; + + server.addContentTypeParser( + MediaType.ssz, + {parseAs: "buffer"}, + async (request: fastify.FastifyRequest, payload: Buffer) => { + if (bodySchemaSymbol === undefined) { + // Get body schema symbol to be able to access validation function + // https://github.com/fastify/fastify/blob/af2ccb5ff681c1d0ac22eb7314c6fa803f73c873/lib/symbols.js#L25 + bodySchemaSymbol = Object.getOwnPropertySymbols(request.context).find((s) => s.description === "body-schema"); + } + // JSON schema validation will be applied to `Buffer` object, it is required to override validation function + // See https://github.com/fastify/help/issues/1012, it is not possible right now to define a schema per content type + (request.context as unknown as Record)[bodySchemaSymbol as symbol] = () => true; + + // We could just return the `Buffer` here which is a subclass of `Uint8Array` but downstream code does not require it + // and it's better to convert it here to avoid unexpected behavior such as `Buffer.prototype.slice` not copying memory + // See https://github.com/nodejs/node/issues/41588#issuecomment-1016269584 + return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength); + } + ); +} diff --git a/packages/api/src/utils/server/registerRoute.ts b/packages/api/src/utils/server/registerRoute.ts deleted file mode 100644 index 9d9bd9016a3d..000000000000 --- a/packages/api/src/utils/server/registerRoute.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ServerInstance, RouteConfig, ServerRoute} from "./types.js"; - -export function registerRoute( - server: ServerInstance, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - route: ServerRoute, - namespace?: string -): void { - server.route({ - url: route.url, - method: route.method, - handler: route.handler, - // append the namespace as a tag for downstream consumption of our API schema, eg: for swagger UI - schema: {...route.schema, ...(namespace ? {tags: [namespace]} : undefined), operationId: route.id}, - config: {operationId: route.id} as RouteConfig, - }); -} diff --git a/packages/api/src/utils/server/route.ts b/packages/api/src/utils/server/route.ts new file mode 100644 index 000000000000..aa04a1f11cf7 --- /dev/null +++ b/packages/api/src/utils/server/route.ts @@ -0,0 +1,45 @@ +import type * as fastify from "fastify"; +import {mapValues} from "@lodestar/utils"; +import {getFastifySchema} from "../schema.js"; +import {Endpoint, RouteDefinition, RouteDefinitions} from "../types.js"; +import {toColonNotationPath} from "../urlFormat.js"; +import {FastifyHandler, createFastifyHandler} from "./handler.js"; +import {ApplicationMethod, ApplicationMethods} from "./method.js"; + +export type FastifySchema = fastify.FastifySchema & { + operationId: string; + tags?: string[]; +}; + +export type FastifyRoute = { + url: string; + method: fastify.HTTPMethods; + handler: FastifyHandler; + schema: FastifySchema; +}; +export type FastifyRoutes> = {[K in keyof Es]: FastifyRoute}; + +export function createFastifyRoute( + definition: RouteDefinition, + method: ApplicationMethod, + operationId: string +): FastifyRoute { + return { + url: toColonNotationPath(definition.url), + method: definition.method, + handler: createFastifyHandler(definition, method, operationId), + schema: { + ...getFastifySchema(definition.req.schema), + operationId, + }, + }; +} + +export function createFastifyRoutes>( + definitions: RouteDefinitions, + methods: ApplicationMethods +): FastifyRoutes { + return mapValues(definitions, (definition, operationId) => + createFastifyRoute(definition, methods?.[operationId]?.bind(methods), operationId as string) + ); +} diff --git a/packages/api/src/utils/server/types.ts b/packages/api/src/utils/server/types.ts deleted file mode 100644 index ba5f54cc96a3..000000000000 --- a/packages/api/src/utils/server/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {FastifyInstance, FastifyContextConfig} from "fastify"; -import type * as fastify from "fastify"; -import {ReqGeneric} from "../types.js"; - -export type ServerInstance = FastifyInstance; - -export type RouteConfig = FastifyContextConfig & { - operationId: ServerRoute["id"]; -}; - -export type ServerRoute = { - url: string; - method: fastify.HTTPMethods; - handler: FastifyHandler; - schema?: fastify.FastifySchema; - /** OperationId as defined in https://github.com/ethereum/beacon-APIs/blob/v2.1.0/apis/beacon/blocks/attestations.yaml#L2 */ - id: string; -}; - -/* eslint-disable @typescript-eslint/naming-convention */ - -/** Adaptor for Fastify v3.x.x route type which has a ton of arguments */ -export type FastifyHandler = fastify.RouteHandlerMethod< - fastify.RawServerDefault, - fastify.RawRequestDefaultExpression, - fastify.RawReplyDefaultExpression, - { - Body: Req["body"]; - Querystring: Req["query"]; - Params: Req["params"]; - }, - fastify.ContextConfigDefault ->; diff --git a/packages/api/src/utils/types.ts b/packages/api/src/utils/types.ts index 999fd93bdc81..abe2f358320d 100644 --- a/packages/api/src/utils/types.ts +++ b/packages/api/src/utils/types.ts @@ -1,253 +1,161 @@ -import {isBasicType, ListBasicType, Type, isCompositeType, ListCompositeType, ArrayType} from "@chainsafe/ssz"; -import {ForkName} from "@lodestar/params"; -import {ChainForkConfig} from "@lodestar/config"; -import {objectToExpectedCase} from "@lodestar/utils"; -import {APIClientHandler, ApiClientResponseData, APIServerHandler, ClientApi} from "../interfaces.js"; -import {Schema, SchemaDefinition} from "./schema.js"; - -// See /packages/api/src/routes/index.ts for reasoning - -/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */ - -/** All JSON inside the JS code must be camel case */ -const codeCase = "camel" as const; - -export type RouteGroupDefinition< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, +import {ExtraRequestInit} from "./client/request.js"; +import {EmptyMeta} from "./codecs.js"; +import {HeadersExtra} from "./headers.js"; +import {SchemaDefinition} from "./schema.js"; +import {WireFormat} from "./wireFormat.js"; + +export type HasOnlyOptionalProps = { + [K in keyof T]-?: object extends Pick ? never : K; +} extends {[_ in keyof T]: never} + ? true + : false; + +export type PathParams = Record; +export type QueryParams = Record; +export type HeaderParams = Record; + +export type RequestData< + P extends PathParams = PathParams, + Q extends QueryParams = QueryParams, + H extends HeaderParams = HeaderParams, > = { - routesData: RoutesData; - getReqSerializers: (config: ChainForkConfig) => ReqSerializers; - getReturnTypes: (config: ChainForkConfig) => ReturnTypes>; + params?: P; + query?: Q; + headers?: H; }; -export type RouteDef = { - url: string; - method: "GET" | "POST" | "DELETE"; - statusOk?: number; +export type JsonRequestData< + B = unknown, + P extends PathParams = PathParams, + Q extends QueryParams = QueryParams, + H extends HeaderParams = HeaderParams, +> = RequestData & { + body?: B; }; -export type ReqGeneric = { - params?: Record; - query?: Record; - body?: any; - headers?: Record; -}; +export type SszRequestData

= Omit & + ("body" extends keyof P ? (P["body"] extends void ? {body?: never} : {body: Uint8Array}) : {body?: never}); -export type ReqEmpty = ReqGeneric; -export type Resolves any> = Awaited>; +export type HttpMethod = "GET" | "POST" | "DELETE"; -export type TypeJson = { - toJson(val: T): unknown; - fromJson(json: unknown): T; +/** + * This type describes the general shape of a route + * + * This includes both http and application-level shape + * - The http method + * - Used to more strictly enforce the shape of the request + * - The application-level parameters + * - this enforces the shape of the input data passed by the client and to the route handler + * - The http request + * - this enforces the shape of the querystring, url params, request body + * - The application-level return data + * - this enforces the shape of the output data passed back to the client and returned by the route handler + * - The application-level return metadata + * - this enforces the shape of the returned metadata, used informationally and to help decode the return data + */ +export type Endpoint< + Method extends HttpMethod = HttpMethod, + ArgsType = unknown, + RequestType extends Method extends "GET" ? RequestData : JsonRequestData = JsonRequestData, + ReturnType = unknown, + Meta = unknown, +> = { + method: Method; + /** The parameters the client passes / server app code ingests */ + args: ArgsType; + /** The parameters in the http request */ + request: RequestType; + /** The return data */ + return: ReturnType; + /** The return metadata */ + meta: Meta; }; -// -// REQ -// +// Request codec -export type ReqSerializer any, ReqType extends ReqGeneric> = { - writeReq: (...args: Parameters) => ReqType; - parseReq: (arg: ReqType) => Parameters; - schema?: SchemaDefinition; +/** Encode / decode requests to & from function params, as well as schema definitions */ +export type RequestWithoutBodyCodec = { + writeReq: (p: E["args"]) => E["request"]; // client + parseReq: (r: E["request"]) => E["args"]; // server + schema: SchemaDefinition; }; -export type ReqSerializers< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, -> = { - [K in keyof Api]: ReqSerializer; +export type JsonRequestMethods = { + writeReqJson: (p: E["args"]) => E["request"]; // client + parseReqJson: (r: E["request"]) => E["args"]; // server }; -/** Curried definition to infer only one of the two generic types */ -export type ReqGenArg any, ReqType extends ReqGeneric> = ReqSerializer; - -// -// Helpers -// - -/** Shortcut for routes that have no params, query nor body */ -export const reqEmpty: ReqSerializer<() => void, ReqEmpty> = { - writeReq: () => ({}), - parseReq: () => [] as [], +export type SszRequestMethods = { + writeReqSsz: (p: E["args"]) => SszRequestData; // client + parseReqSsz: (r: SszRequestData) => E["args"]; // server }; -/** Shortcut for routes that have only body */ -export const reqOnlyBody = ( - type: TypeJson, - bodySchema: Schema -): ReqGenArg<(arg: T) => Promise, {body: unknown}> => ({ - writeReq: (items) => ({body: type.toJson(items)}), - parseReq: ({body}) => [type.fromJson(body)], - schema: {body: bodySchema}, -}); - -/** SSZ factory helper + typed. limit = 1e6 as a big enough random number */ -export function ArrayOf(elementType: Type): ArrayType, unknown, unknown> { - if (isCompositeType(elementType)) { - return new ListCompositeType(elementType, Infinity) as unknown as ArrayType, unknown, unknown>; - } else if (isBasicType(elementType)) { - return new ListBasicType(elementType, Infinity) as unknown as ArrayType, unknown, unknown>; - } else { - throw Error(`Unknown type ${elementType.typeName}`); - } -} - -/** - * SSZ factory helper + typed to return responses of type - * ``` - * data: T - * ``` - */ -export function ContainerData(dataType: TypeJson): TypeJson<{data: T}> { - return { - toJson: ({data}) => ({ - data: dataType.toJson(data), - }), - fromJson: ({data}: {data: unknown}) => { - return { - data: dataType.fromJson(data), - }; - }, +export type RequestWithBodyCodec = JsonRequestMethods & + SszRequestMethods & { + schema: SchemaDefinition; + /** Support ssz-only or json-only requests */ + onlySupport?: WireFormat; }; -} /** - * SSZ factory helper + typed to return responses of type `{data: T; executionOptimistic: boolean}` + * Handles translation between `Endpoint["args"]` and `Endpoint["request"]` */ -export function ContainerDataExecutionOptimistic( - dataType: TypeJson -): TypeJson<{data: T; executionOptimistic: boolean}> { - return { - toJson: ({data, executionOptimistic}) => ({ - data: dataType.toJson(data), - execution_optimistic: executionOptimistic, - }), - fromJson: ({data, execution_optimistic}: {data: unknown; execution_optimistic: boolean}) => { - return { - data: dataType.fromJson(data), - executionOptimistic: execution_optimistic, - }; - }, - }; +export type RequestCodec = E["method"] extends "GET" + ? RequestWithoutBodyCodec + : "body" extends keyof E["request"] + ? RequestWithBodyCodec + : RequestWithoutBodyCodec; + +export function isRequestWithoutBody( + definition: RouteDefinition +): definition is RouteDefinition & {req: RequestWithoutBodyCodec} { + return definition.method === "GET" || definition.req.schema.body === undefined; } -/** - * SSZ factory helper + typed to return responses of type - * ``` - * data: T - * version: ForkName - * ``` - */ -export function WithVersion(getType: (fork: ForkName) => TypeJson): TypeJson<{data: T; version: ForkName}> { - return { - toJson: ({data, version}) => ({ - data: getType(version ?? ForkName.phase0).toJson(data), - version, - }), - fromJson: ({data, version}: {data: unknown; version: string}) => { - // Teku returns fork as UPPERCASE - version = version.toLowerCase(); - - // Un-safe external data, validate version is known ForkName value - if (!(version in ForkName)) throw Error(`Invalid version ${version}`); - - return { - data: getType(version as ForkName).fromJson(data), - version: version as ForkName, - }; - }, - }; -} +// Response codec -/** - * SSZ factory helper to wrap an existing type with `{executionOptimistic: boolean}` - */ -export function WithExecutionOptimistic( - type: TypeJson -): TypeJson { - return { - toJson: ({executionOptimistic, ...data}) => ({ - ...(type.toJson(data as unknown as T) as Record), - execution_optimistic: executionOptimistic, - }), - fromJson: ({execution_optimistic, ...data}: T & {execution_optimistic: boolean}) => ({ - ...type.fromJson(data), - executionOptimistic: execution_optimistic, - }), - }; -} +export type ResponseDataCodec = { + toJson: (data: T, meta: M) => unknown; // server + fromJson: (data: unknown, meta: M) => T; // client + serialize: (data: T, meta: M) => Uint8Array; // server + deserialize: (data: Uint8Array, meta: M) => T; // client +}; -/** - * SSZ factory helper to wrap an existing type with `{finalized: boolean}` - */ -export function WithFinalized(type: TypeJson): TypeJson { - return { - toJson: ({finalized, ...data}) => ({ - ...(type.toJson(data as unknown as T) as Record), - finalized, - }), - fromJson: ({finalized, ...data}: T & {finalized: boolean}) => ({ - ...type.fromJson(data), - finalized, - }), +export type ResponseMetadataCodec = { + toJson: (val: T) => unknown; // server + fromJson: (val: unknown) => T; // client + toHeadersObject: (val: T) => Record; // server + fromHeaders: (headers: HeadersExtra) => T; // server +}; + +export type ResponseCodec = { + data: ResponseDataCodec; + meta: ResponseMetadataCodec; + /** Occasionally, json responses require an extra transformation to separate the data from metadata */ + transform?: { + toResponse: (data: unknown, meta: unknown) => unknown; + fromResponse: (resp: unknown) => { + data: E["return"]; + } & (E["meta"] extends EmptyMeta ? {meta?: never} : {meta: E["meta"]}); }; -} + /** Support ssz-only or json-only responses */ + onlySupport?: WireFormat; + /** Indicator used to handle empty responses */ + isEmpty?: true; +}; /** - * SSZ factory helper to wrap an existing type with `{executionPayloadValue: Wei, consensusBlockValue: Wei}` + * Top-level definition of a route used by both the client and server + * - url and method + * - request and response codec + * - request json schema */ -export function WithBlockValues( - type: TypeJson -): TypeJson { - return { - toJson: ({executionPayloadValue, consensusBlockValue, ...data}) => ({ - ...(type.toJson(data as unknown as T) as Record), - execution_payload_value: executionPayloadValue.toString(), - consensus_block_value: consensusBlockValue.toString(), - }), - fromJson: ({ - execution_payload_value, - consensus_block_value, - ...data - }: T & {execution_payload_value: string; consensus_block_value: string}) => ({ - ...type.fromJson(data), - // For cross client usage where beacon or validator are of separate clients, executionPayloadValue could be missing - executionPayloadValue: BigInt(execution_payload_value ?? "0"), - consensusBlockValue: BigInt(consensus_block_value ?? "0"), - }), - }; -} - -type JsonCase = "snake" | "constant" | "camel" | "param" | "header" | "pascal" | "dot" | "notransform"; - -/** Helper to only translate casing */ -export function jsonType | Record[] | unknown[]>( - jsonCase: JsonCase -): TypeJson { - return { - toJson: (val: T) => objectToExpectedCase(val as Record, jsonCase), - fromJson: (json) => objectToExpectedCase(json as Record, codeCase) as T, - }; -} - -/** Helper to not do any transformation with the type */ -export function sameType(): TypeJson { - return { - toJson: (val) => val as unknown, - fromJson: (json) => json as T, - }; -} - -// -// RETURN -// -export type KeysOfNonVoidResolveValues> = { - [K in keyof Api]: ApiClientResponseData> extends void ? never : K; -}[keyof Api]; - -export type ReturnTypes> = { - [K in keyof Pick>]: TypeJson>>; +export type RouteDefinition = { + url: string; + method: E["method"]; + req: RequestCodec; + resp: ResponseCodec; + init?: ExtraRequestInit; }; -export type RoutesData> = {[K in keyof Api]: RouteDef}; +export type RouteDefinitions> = {[K in keyof Es]: RouteDefinition}; diff --git a/packages/api/src/utils/urlFormat.ts b/packages/api/src/utils/urlFormat.ts index efbbb1d6be39..5aac4aebb156 100644 --- a/packages/api/src/utils/urlFormat.ts +++ b/packages/api/src/utils/urlFormat.ts @@ -53,7 +53,7 @@ export function urlToTokens(path: string): Token[] { } /** - * Compile a route URL formater with syntax `/path/{var1}/{var2}`. + * Compile a route URL formatter with syntax `/path/{var1}/{var2}`. * Returns a function that expects an object `{var1: 1, var2: 2}`, and returns`/path/1/2`. * * It's cheap enough to be negligible. For the sample input below it costs: @@ -62,7 +62,7 @@ export function urlToTokens(path: string): Token[] { * - execute with template literal: 12 ns / op * @param path `/eth/v1/validator/:name/attester/:epoch` */ -export function compileRouteUrlFormater(path: string): (arg: Args) => string { +export function compileRouteUrlFormatter(path: string): (arg: Args) => string { const tokens = urlToTokens(path); // Return a faster function if there's not ':' token @@ -82,7 +82,7 @@ export function compileRouteUrlFormater(path: string): (arg: Args) => string { } }); - return function urlFormater(args: Args) { + return function urlFormatter(args: Args) { // Don't use .map() or .join(), it's x3 slower let s = ""; for (const fn of fns) s += fn(args); diff --git a/packages/api/src/utils/wireFormat.ts b/packages/api/src/utils/wireFormat.ts new file mode 100644 index 000000000000..cd8fd05e0cb2 --- /dev/null +++ b/packages/api/src/utils/wireFormat.ts @@ -0,0 +1,24 @@ +import {MediaType} from "./headers.js"; + +export enum WireFormat { + json = "json", + ssz = "ssz", +} + +export function getWireFormat(mediaType: MediaType): WireFormat { + switch (mediaType) { + case MediaType.json: + return WireFormat.json; + case MediaType.ssz: + return WireFormat.ssz; + } +} + +export function fromWireFormat(wireFormat: WireFormat): MediaType { + switch (wireFormat) { + case WireFormat.json: + return MediaType.json; + case WireFormat.ssz: + return MediaType.ssz; + } +} diff --git a/packages/api/test/perf/compileRouteUrlFormater.test.ts b/packages/api/test/perf/compileRouteUrlFormater.test.ts index 5abdc2d03a30..ab16e1a5d14e 100644 --- a/packages/api/test/perf/compileRouteUrlFormater.test.ts +++ b/packages/api/test/perf/compileRouteUrlFormater.test.ts @@ -1,19 +1,19 @@ -import {compileRouteUrlFormater} from "../../src/utils/urlFormat.js"; +import {compileRouteUrlFormatter} from "../../src/utils/urlFormat.js"; /* eslint-disable no-console */ describe("route parse", () => { - it.skip("Benchmark compileRouteUrlFormater", () => { + it.skip("Benchmark compileRouteUrlFormatter", () => { const path = "/eth/v1/validator/:name/attester/:epoch"; const args = {epoch: 5, name: "HEAD"}; console.time("compile"); for (let i = 0; i < 1e6; i++) { - compileRouteUrlFormater(path); + compileRouteUrlFormatter(path); } console.timeEnd("compile"); - const fn = compileRouteUrlFormater(path); + const fn = compileRouteUrlFormatter(path); console.log(fn(args)); diff --git a/packages/api/test/unit/beacon/genericServerTest/beacon.test.ts b/packages/api/test/unit/beacon/genericServerTest/beacon.test.ts index 7972e4bfca65..a722e72a4c27 100644 --- a/packages/api/test/unit/beacon/genericServerTest/beacon.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/beacon.test.ts @@ -1,13 +1,13 @@ import {describe} from "vitest"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; -import {Api, ReqTypes} from "../../../../src/beacon/routes/beacon/index.js"; +import {Endpoints} from "../../../../src/beacon/routes/beacon/index.js"; import {getClient} from "../../../../src/beacon/client/beacon.js"; import {getRoutes} from "../../../../src/beacon/server/beacon.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {testData} from "../testData/beacon.js"; describe("beacon / beacon", () => { - runGenericServerTest( + runGenericServerTest( // eslint-disable-next-line @typescript-eslint/naming-convention createChainForkConfig({...defaultChainConfig, ALTAIR_FORK_EPOCH: 1, BELLATRIX_FORK_EPOCH: 2}), getClient, diff --git a/packages/api/test/unit/beacon/genericServerTest/config.test.ts b/packages/api/test/unit/beacon/genericServerTest/config.test.ts index 3e9c001bffbf..8b924cf07693 100644 --- a/packages/api/test/unit/beacon/genericServerTest/config.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/config.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes, getReturnTypes} from "../../../../src/beacon/routes/config.js"; +import {Endpoints, getDefinitions} from "../../../../src/beacon/routes/config.js"; import {getClient} from "../../../../src/beacon/client/config.js"; import {getRoutes} from "../../../../src/beacon/server/config.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; @@ -9,10 +9,10 @@ import {testData} from "../testData/config.js"; /* eslint-disable @typescript-eslint/naming-convention */ describe("beacon / config", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); it("Serialize Partial Spec object", () => { - const returnTypes = getReturnTypes(); + const {getSpec} = getDefinitions(config); const partialJsonSpec: Record = { PRESET_BASE: "mainnet", @@ -22,9 +22,9 @@ describe("beacon / config", () => { MIN_GENESIS_TIME: "1606824000", }; - const jsonRes = returnTypes.getSpec.toJson({data: partialJsonSpec}); - const specRes = returnTypes.getSpec.fromJson(jsonRes); + const jsonRes = getSpec.resp.data.toJson(partialJsonSpec); + const specRes = getSpec.resp.data.fromJson(jsonRes); - expect(specRes).toEqual({data: partialJsonSpec}); + expect(specRes).toEqual(partialJsonSpec); }); }); diff --git a/packages/api/test/unit/beacon/genericServerTest/debug.test.ts b/packages/api/test/unit/beacon/genericServerTest/debug.test.ts index 3a8ccd0afe25..54158cda392c 100644 --- a/packages/api/test/unit/beacon/genericServerTest/debug.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/debug.test.ts @@ -1,27 +1,30 @@ -import {describe, it, expect, MockInstance, beforeAll, afterAll, vi} from "vitest"; +import {describe, it, expect, beforeAll, afterAll, vi} from "vitest"; import {toHexString} from "@chainsafe/ssz"; import {FastifyInstance} from "fastify"; +import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes, routesData} from "../../../../src/beacon/routes/debug.js"; +import {Endpoints, getDefinitions} from "../../../../src/beacon/routes/debug.js"; import {getClient} from "../../../../src/beacon/client/debug.js"; import {getRoutes} from "../../../../src/beacon/server/debug.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {getMockApi, getTestServer} from "../../../utils/utils.js"; -import {registerRoute} from "../../../../src/utils/server/registerRoute.js"; import {HttpClient} from "../../../../src/utils/client/httpClient.js"; import {testData} from "../testData/debug.js"; +import {FastifyRoute} from "../../../../src/utils/server/index.js"; +import {AnyEndpoint} from "../../../../src/utils/codecs.js"; +import {WireFormat} from "../../../../src/utils/wireFormat.js"; describe("beacon / debug", () => { // Extend timeout since states are very big vi.setConfig({testTimeout: 30_000}); - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); // Get state by SSZ describe("getState() in SSZ format", () => { - const mockApi = getMockApi(routesData); + const mockApi = getMockApi(getDefinitions(config)); let baseUrl: string; let server: FastifyInstance; @@ -29,7 +32,7 @@ describe("beacon / debug", () => { const res = getTestServer(); server = res.server; for (const route of Object.values(getRoutes(config, mockApi))) { - registerRoute(server, route); + server.route(route as FastifyRoute); } baseUrl = await res.start(); }); @@ -42,17 +45,20 @@ describe("beacon / debug", () => { it(method, async () => { const state = ssz.phase0.BeaconState.defaultValue(); const stateSerialized = ssz.phase0.BeaconState.serialize(state); - (mockApi[method] as MockInstance).mockResolvedValue(stateSerialized); + mockApi[method].mockResolvedValue({ + data: stateSerialized, + meta: {version: ForkName.phase0, executionOptimistic: false, finalized: false}, + }); const httpClient = new HttpClient({baseUrl}); const client = getClient(config, httpClient); - const res = await client[method]("head", "ssz"); + const res = await client[method]({stateId: "head"}, {responseWireFormat: WireFormat.ssz}); expect(res.ok).toBe(true); if (res.ok) { - expect(toHexString(res.response)).toBe(toHexString(stateSerialized)); + expect(toHexString(res.ssz())).toBe(toHexString(stateSerialized)); } }); } diff --git a/packages/api/test/unit/beacon/genericServerTest/events.test.ts b/packages/api/test/unit/beacon/genericServerTest/events.test.ts index 2d6c9462c1fd..16123ec624e8 100644 --- a/packages/api/test/unit/beacon/genericServerTest/events.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/events.test.ts @@ -1,23 +1,23 @@ import {describe, it, expect, beforeEach, afterEach, beforeAll, afterAll} from "vitest"; import {FastifyInstance} from "fastify"; import {sleep} from "@lodestar/utils"; -import {Api, routesData, EventType, BeaconEvent} from "../../../../src/beacon/routes/events.js"; +import {config} from "@lodestar/config/default"; +import {Endpoints, getDefinitions, EventType, BeaconEvent} from "../../../../src/beacon/routes/events.js"; import {getClient} from "../../../../src/beacon/client/events.js"; import {getRoutes} from "../../../../src/beacon/server/events.js"; -import {registerRoute} from "../../../../src/utils/server/registerRoute.js"; import {getMockApi, getTestServer} from "../../../utils/utils.js"; import {eventTestData} from "../testData/events.js"; describe("beacon / events", () => { - const mockApi = getMockApi(routesData); + const mockApi = getMockApi(getDefinitions(config)); let server: FastifyInstance; let baseUrl: string; beforeAll(async () => { const res = getTestServer(); server = res.server; - for (const route of Object.values(getRoutes(mockApi))) { - registerRoute(server, route); + for (const route of Object.values(getRoutes(config, mockApi))) { + server.route(route); } baseUrl = await res.start(); @@ -52,7 +52,7 @@ describe("beacon / events", () => { const eventsReceived: BeaconEvent[] = []; await new Promise((resolve, reject) => { - mockApi.eventstream.mockImplementation(async (topics, signal, onEvent) => { + mockApi.eventstream.mockImplementation(async ({topics, onEvent}) => { try { expect(topics).toEqual(topicsToRequest); for (const event of eventsToSend) { @@ -60,15 +60,19 @@ describe("beacon / events", () => { await sleep(5); } } catch (e) { - reject(e as Error); + reject(e); } }); // Capture them on the client - const client = getClient(baseUrl); - void client.eventstream(topicsToRequest, controller.signal, (event) => { - eventsReceived.push(event); - if (eventsReceived.length >= eventsToSend.length) resolve(); + const client = getClient(config, baseUrl); + void client.eventstream({ + topics: topicsToRequest, + signal: controller.signal, + onEvent: (event) => { + eventsReceived.push(event); + if (eventsReceived.length >= eventsToSend.length) resolve(); + }, }); }); diff --git a/packages/api/test/unit/beacon/genericServerTest/lightclient.test.ts b/packages/api/test/unit/beacon/genericServerTest/lightclient.test.ts index 10031a150490..5bf8b7827161 100644 --- a/packages/api/test/unit/beacon/genericServerTest/lightclient.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/lightclient.test.ts @@ -1,11 +1,11 @@ import {describe} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes} from "../../../../src/beacon/routes/lightclient.js"; +import {Endpoints} from "../../../../src/beacon/routes/lightclient.js"; import {getClient} from "../../../../src/beacon/client/lightclient.js"; import {getRoutes} from "../../../../src/beacon/server/lightclient.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {testData} from "../testData/lightclient.js"; describe("beacon / lightclient", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); }); diff --git a/packages/api/test/unit/beacon/genericServerTest/node.test.ts b/packages/api/test/unit/beacon/genericServerTest/node.test.ts index 059bd4ca2c88..0affaf014c4a 100644 --- a/packages/api/test/unit/beacon/genericServerTest/node.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/node.test.ts @@ -1,11 +1,11 @@ import {describe} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes} from "../../../../src/beacon/routes/node.js"; +import {Endpoints} from "../../../../src/beacon/routes/node.js"; import {getClient} from "../../../../src/beacon/client/node.js"; import {getRoutes} from "../../../../src/beacon/server/node.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {testData} from "../testData/node.js"; describe("beacon / node", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); }); diff --git a/packages/api/test/unit/beacon/genericServerTest/proofs.test.ts b/packages/api/test/unit/beacon/genericServerTest/proofs.test.ts index 4619d20d989f..d31137886e2f 100644 --- a/packages/api/test/unit/beacon/genericServerTest/proofs.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/proofs.test.ts @@ -1,11 +1,11 @@ import {describe} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes} from "../../../../src/beacon/routes/proof.js"; +import {Endpoints} from "../../../../src/beacon/routes/proof.js"; import {getClient} from "../../../../src/beacon/client/proof.js"; import {getRoutes} from "../../../../src/beacon/server/proof.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {testData} from "../testData/proofs.js"; describe("beacon / proofs", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); }); diff --git a/packages/api/test/unit/beacon/genericServerTest/validator.test.ts b/packages/api/test/unit/beacon/genericServerTest/validator.test.ts index 5a87ea9eee5f..0aed65cd6f17 100644 --- a/packages/api/test/unit/beacon/genericServerTest/validator.test.ts +++ b/packages/api/test/unit/beacon/genericServerTest/validator.test.ts @@ -1,13 +1,13 @@ import {describe} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes} from "../../../../src/beacon/routes/validator.js"; +import {Endpoints} from "../../../../src/beacon/routes/validator.js"; import {getClient} from "../../../../src/beacon/client/validator.js"; import {getRoutes} from "../../../../src/beacon/server/validator.js"; import {runGenericServerTest} from "../../../utils/genericServerTest.js"; import {testData} from "../testData/validator.js"; describe("beacon / validator", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); // TODO: Extra tests to implement maybe diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 1bffbb486ff7..6b238bf8cd36 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -4,8 +4,6 @@ import {describe, it, beforeAll, expect} from "vitest"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {OpenApiFile} from "../../utils/parseOpenApiSpec.js"; import {routes} from "../../../src/beacon/index.js"; -import {ReqSerializers} from "../../../src/utils/types.js"; -import {Schema} from "../../../src/utils/schema.js"; import {IgnoredProperty, runTestCheckAgainstSpec} from "../../utils/checkAgainstSpec.js"; import {fetchOpenApiSpec} from "../../utils/fetchOpenApiSpec.js"; // Import all testData and merge below @@ -30,47 +28,18 @@ const openApiFile: OpenApiFile = { version: RegExp(version), }; -const routesData = { - ...routes.beacon.routesData, - ...routes.config.routesData, - ...routes.debug.routesData, - ...routes.events.routesData, - ...routes.lightclient.routesData, - ...routes.node.routesData, - ...routes.proof.routesData, - ...routes.validator.routesData, -}; - -// Additional definition not used in production -const getEventsReqSerializers = (): ReqSerializers => ({ - eventstream: { - writeReq: (topics) => ({query: {topics}}), - parseReq: ({query}) => [query.topics, null as any, null as any], - schema: {query: {topics: Schema.StringArray}}, - }, -}); - // eslint-disable-next-line @typescript-eslint/naming-convention const config = createChainForkConfig({...defaultChainConfig, ALTAIR_FORK_EPOCH: 1, BELLATRIX_FORK_EPOCH: 2}); -const reqSerializers = { - ...routes.beacon.getReqSerializers(config), - ...routes.config.getReqSerializers(), - ...routes.debug.getReqSerializers(), - ...getEventsReqSerializers(), - ...routes.lightclient.getReqSerializers(), - ...routes.node.getReqSerializers(), - ...routes.proof.getReqSerializers(), - ...routes.validator.getReqSerializers(), -}; -const returnTypes = { - ...routes.beacon.getReturnTypes(), - ...routes.config.getReturnTypes(), - ...routes.debug.getReturnTypes(), - ...routes.lightclient.getReturnTypes(), - ...routes.node.getReturnTypes(), - ...routes.proof.getReturnTypes(), - ...routes.validator.getReturnTypes(), +const definitions = { + ...routes.beacon.getDefinitions(config), + ...routes.config.getDefinitions(config), + ...routes.debug.getDefinitions(config), + ...routes.events.getDefinitions(config), + ...routes.lightclient.getDefinitions(config), + ...routes.node.getDefinitions(config), + ...routes.proof.getDefinitions(config), + ...routes.validator.getDefinitions(config), }; const testDatas = { @@ -90,13 +59,8 @@ const ignoredOperations = [ "getBlindedBlock", // https://github.com/ChainSafe/lodestar/issues/5699 "getNextWithdrawals", // https://github.com/ChainSafe/lodestar/issues/5696 "getDebugForkChoice", // https://github.com/ChainSafe/lodestar/issues/5700 - /* Ensure operationId matches spec value, blocked by https://github.com/ChainSafe/lodestar/pull/6080 */ - "getLightClientBootstrap", - "getLightClientUpdatesByRange", - "getLightClientFinalityUpdate", - "getLightClientOptimisticUpdate", - "getPoolBLSToExecutionChanges", - "submitPoolBLSToExecutionChange", + /* Must support ssz response body */ + "getLightClientUpdatesByRange", // https://github.com/ChainSafe/lodestar/issues/6841 ]; const ignoredProperties: Record = { @@ -115,15 +79,7 @@ const ignoredProperties: Record = { }; const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec( - openApiJson, - routesData, - reqSerializers, - returnTypes, - testDatas, - ignoredOperations, - ignoredProperties -); +runTestCheckAgainstSpec(openApiJson, definitions, testDatas, ignoredOperations, ignoredProperties); const ignoredTopics = [ /* @@ -145,7 +101,7 @@ describe("eventstream event data", () => { // "value": "event: head\ndata: {\"slot\":\"10\", \"block\":\"0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf\", \"state\":\"0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9\", \"epoch_transition\":false, \"previous_duty_dependent_root\":\"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91\", \"current_duty_dependent_root\":\"0x5e0043f107cb57913498fbf2f99ff55e730bf1e151f02f221e977c91a90a0e91\", \"execution_optimistic\": false}\n" // }, ... } const eventstreamExamples = - openApiJson.paths["/eth/v1/events"]["get"].responses["200"].content?.["text/event-stream"].examples; + openApiJson.paths["/eth/v1/events"]["get"].responses["200"]?.content?.["text/event-stream"].examples; beforeAll(() => { if (!eventstreamExamples) { diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 82e2ae1af421..9dad5f0079a2 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -1,10 +1,10 @@ import {toHexString} from "@chainsafe/ssz"; import {ForkName} from "@lodestar/params"; -import {ssz, Slot, allForks} from "@lodestar/types"; +import {Slot, allForks, ssz} from "@lodestar/types"; import { - Api, BlockHeaderResponse, BroadcastValidation, + Endpoints, ValidatorResponse, } from "../../../../src/beacon/routes/beacon/index.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; @@ -28,168 +28,172 @@ const validatorResponse: ValidatorResponse = { validator: ssz.phase0.Validator.defaultValue(), }; -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { // block getBlock: { - args: ["head", "json"], + args: {blockId: "head"}, res: {data: ssz.phase0.SignedBeaconBlock.defaultValue()}, }, getBlockV2: { - args: ["head", "json"], + args: {blockId: "head"}, res: { - executionOptimistic: true, - finalized: false, data: ssz.bellatrix.SignedBeaconBlock.defaultValue(), - version: ForkName.bellatrix, + meta: {executionOptimistic: true, finalized: false, version: ForkName.bellatrix}, }, }, getBlockAttestations: { - args: ["head"], - res: {executionOptimistic: true, finalized: false, data: [ssz.phase0.Attestation.defaultValue()]}, + args: {blockId: "head"}, + res: {data: [ssz.phase0.Attestation.defaultValue()], meta: {executionOptimistic: true, finalized: false}}, }, getBlockHeader: { - args: ["head"], - res: {executionOptimistic: true, finalized: false, data: blockHeaderResponse}, + args: {blockId: "head"}, + res: {data: blockHeaderResponse, meta: {executionOptimistic: true, finalized: false}}, }, getBlockHeaders: { - args: [{slot: 1, parentRoot: toHexString(root)}], - res: {executionOptimistic: true, finalized: false, data: [blockHeaderResponse]}, + args: {slot: 1, parentRoot: toHexString(root)}, + res: {data: [blockHeaderResponse], meta: {executionOptimistic: true, finalized: false}}, }, getBlockRoot: { - args: ["head"], - res: {executionOptimistic: true, finalized: false, data: {root}}, + args: {blockId: "head"}, + res: {data: {root}, meta: {executionOptimistic: true, finalized: false}}, }, publishBlock: { - args: [ssz.phase0.SignedBeaconBlock.defaultValue()], + args: {signedBlockOrContents: ssz.phase0.SignedBeaconBlock.defaultValue()}, res: undefined, }, publishBlockV2: { - args: [ssz.phase0.SignedBeaconBlock.defaultValue(), {broadcastValidation: BroadcastValidation.consensus}], + args: { + signedBlockOrContents: ssz.phase0.SignedBeaconBlock.defaultValue(), + broadcastValidation: BroadcastValidation.consensus, + }, res: undefined, }, publishBlindedBlock: { - args: [getDefaultBlindedBlock(64)], + args: {signedBlindedBlock: getDefaultBlindedBlock(64)}, res: undefined, }, publishBlindedBlockV2: { - args: [getDefaultBlindedBlock(64), {broadcastValidation: BroadcastValidation.consensus}], + args: {signedBlindedBlock: getDefaultBlindedBlock(64), broadcastValidation: BroadcastValidation.consensus}, res: undefined, }, getBlobSidecars: { - args: ["head", [0]], - res: {executionOptimistic: true, finalized: false, data: ssz.deneb.BlobSidecars.defaultValue()}, + args: {blockId: "head", indices: [0]}, + res: { + data: [ssz.deneb.BlobSidecar.defaultValue()], + meta: {executionOptimistic: true, finalized: false, version: ForkName.deneb}, + }, }, // pool getPoolAttestations: { - args: [{slot: 1, committeeIndex: 2}], + args: {slot: 1, committeeIndex: 2}, res: {data: [ssz.phase0.Attestation.defaultValue()]}, }, getPoolAttesterSlashings: { - args: [], + args: undefined, res: {data: [ssz.phase0.AttesterSlashing.defaultValue()]}, }, getPoolProposerSlashings: { - args: [], + args: undefined, res: {data: [ssz.phase0.ProposerSlashing.defaultValue()]}, }, getPoolVoluntaryExits: { - args: [], + args: undefined, res: {data: [ssz.phase0.SignedVoluntaryExit.defaultValue()]}, }, - getPoolBlsToExecutionChanges: { - args: [], + getPoolBLSToExecutionChanges: { + args: undefined, res: {data: [ssz.capella.SignedBLSToExecutionChange.defaultValue()]}, }, submitPoolAttestations: { - args: [[ssz.phase0.Attestation.defaultValue()]], + args: {signedAttestations: [ssz.phase0.Attestation.defaultValue()]}, res: undefined, }, submitPoolAttesterSlashings: { - args: [ssz.phase0.AttesterSlashing.defaultValue()], + args: {attesterSlashing: ssz.phase0.AttesterSlashing.defaultValue()}, res: undefined, }, submitPoolProposerSlashings: { - args: [ssz.phase0.ProposerSlashing.defaultValue()], + args: {proposerSlashing: ssz.phase0.ProposerSlashing.defaultValue()}, res: undefined, }, submitPoolVoluntaryExit: { - args: [ssz.phase0.SignedVoluntaryExit.defaultValue()], + args: {signedVoluntaryExit: ssz.phase0.SignedVoluntaryExit.defaultValue()}, res: undefined, }, - submitPoolBlsToExecutionChange: { - args: [[ssz.capella.SignedBLSToExecutionChange.defaultValue()]], + submitPoolBLSToExecutionChange: { + args: {blsToExecutionChanges: [ssz.capella.SignedBLSToExecutionChange.defaultValue()]}, res: undefined, }, submitPoolSyncCommitteeSignatures: { - args: [[ssz.altair.SyncCommitteeMessage.defaultValue()]], + args: {signatures: [ssz.altair.SyncCommitteeMessage.defaultValue()]}, res: undefined, }, // state getStateRoot: { - args: ["head"], - res: {executionOptimistic: true, finalized: false, data: {root}}, + args: {stateId: "head"}, + res: {data: {root}, meta: {executionOptimistic: true, finalized: false}}, }, getStateFork: { - args: ["head"], - res: {executionOptimistic: true, finalized: false, data: ssz.phase0.Fork.defaultValue()}, + args: {stateId: "head"}, + res: {data: ssz.phase0.Fork.defaultValue(), meta: {executionOptimistic: true, finalized: false}}, }, getStateRandao: { - args: ["head", 1], - res: {executionOptimistic: true, finalized: false, data: {randao}}, + args: {stateId: "head", epoch: 1}, + res: {data: {randao}, meta: {executionOptimistic: true, finalized: false}}, }, getStateFinalityCheckpoints: { - args: ["head"], + args: {stateId: "head"}, res: { - executionOptimistic: true, - finalized: false, data: { previousJustified: ssz.phase0.Checkpoint.defaultValue(), currentJustified: ssz.phase0.Checkpoint.defaultValue(), finalized: ssz.phase0.Checkpoint.defaultValue(), }, + meta: {executionOptimistic: true, finalized: false}, }, }, getStateValidators: { - args: ["head", {id: [pubkeyHex, "1300"], status: ["active_ongoing"]}], - res: {executionOptimistic: true, finalized: false, data: [validatorResponse]}, + args: {stateId: "head", validatorIds: [pubkeyHex, "1300"], statuses: ["active_ongoing"]}, + res: {data: [validatorResponse], meta: {executionOptimistic: true, finalized: false}}, }, postStateValidators: { - args: ["head", {id: [pubkeyHex, 1300], status: ["active_ongoing"]}], - res: {executionOptimistic: true, finalized: false, data: [validatorResponse]}, + args: {stateId: "head", validatorIds: [pubkeyHex, 1300], statuses: ["active_ongoing"]}, + res: {data: [validatorResponse], meta: {executionOptimistic: true, finalized: false}}, }, getStateValidator: { - args: ["head", pubkeyHex], - res: {executionOptimistic: true, finalized: false, data: validatorResponse}, + args: {stateId: "head", validatorId: pubkeyHex}, + res: {data: validatorResponse, meta: {executionOptimistic: true, finalized: false}}, }, getStateValidatorBalances: { - args: ["head", ["1300"]], - res: {executionOptimistic: true, finalized: false, data: [{index: 1300, balance}]}, + args: {stateId: "head", validatorIds: ["1300"]}, + res: {data: [{index: 1300, balance}], meta: {executionOptimistic: true, finalized: false}}, }, postStateValidatorBalances: { - args: ["head", [1300]], - res: {executionOptimistic: true, finalized: false, data: [{index: 1300, balance}]}, + args: {stateId: "head", validatorIds: [1300]}, + res: {data: [{index: 1300, balance}], meta: {executionOptimistic: true, finalized: false}}, }, getEpochCommittees: { - args: ["head", {index: 1, slot: 2, epoch: 3}], - res: {executionOptimistic: true, finalized: false, data: [{index: 1, slot: 2, validators: [1300]}]}, + args: {stateId: "head", index: 1, slot: 2, epoch: 3}, + res: {data: [{index: 1, slot: 2, validators: [1300]}], meta: {executionOptimistic: true, finalized: false}}, }, getEpochSyncCommittees: { - args: ["head", 1], - res: {executionOptimistic: true, finalized: false, data: {validators: [1300], validatorAggregates: [[1300]]}}, + args: {stateId: "head", epoch: 1}, + res: { + data: {validators: [1300], validatorAggregates: [[1300]]}, + meta: {executionOptimistic: true, finalized: false}, + }, }, - // reward + // rewards getBlockRewards: { - args: ["head"], + args: {blockId: "head"}, res: { - executionOptimistic: true, - finalized: false, data: { proposerIndex: 0, total: 15, @@ -198,18 +202,12 @@ export const testData: GenericServerTestCases = { proposerSlashings: 2, attesterSlashings: 1, }, + meta: {executionOptimistic: true, finalized: false}, }, }, - getSyncCommitteeRewards: { - args: ["head", ["1300"]], - res: {executionOptimistic: true, finalized: false, data: [{validatorIndex: 1300, reward}]}, - }, - getAttestationsRewards: { - args: [10, ["1300"]], + args: {epoch: 10, validatorIds: [1300]}, res: { - executionOptimistic: true, - finalized: false, data: { idealRewards: [ { @@ -232,13 +230,18 @@ export const testData: GenericServerTestCases = { }, ], }, + meta: {executionOptimistic: true, finalized: false}, }, }, + getSyncCommitteeRewards: { + args: {blockId: "head", validatorIds: [1300]}, + res: {data: [{validatorIndex: 1300, reward}], meta: {executionOptimistic: true, finalized: false}}, + }, // - getGenesis: { - args: [], + args: undefined, res: {data: ssz.phase0.Genesis.defaultValue()}, }, }; diff --git a/packages/api/test/unit/beacon/testData/config.ts b/packages/api/test/unit/beacon/testData/config.ts index 642ed5e7e224..dddc90ae6b25 100644 --- a/packages/api/test/unit/beacon/testData/config.ts +++ b/packages/api/test/unit/beacon/testData/config.ts @@ -2,16 +2,16 @@ import {ssz} from "@lodestar/types"; import {chainConfigToJson} from "@lodestar/config"; import {chainConfig} from "@lodestar/config/default"; import {activePreset, presetToJson} from "@lodestar/params"; -import {Api} from "../../../../src/beacon/routes/config.js"; +import {Endpoints} from "../../../../src/beacon/routes/config.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const configJson = chainConfigToJson(chainConfig); const presetJson = presetToJson(activePreset); const jsonSpec = {...configJson, ...presetJson}; -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { getDepositContract: { - args: [], + args: undefined, res: { data: { chainId: 1, @@ -20,11 +20,11 @@ export const testData: GenericServerTestCases = { }, }, getForkSchedule: { - args: [], + args: undefined, res: {data: [ssz.phase0.Fork.defaultValue()]}, }, getSpec: { - args: [], + args: undefined, res: {data: jsonSpec}, }, }; diff --git a/packages/api/test/unit/beacon/testData/debug.ts b/packages/api/test/unit/beacon/testData/debug.ts index aa595046b8ba..386443c76f3d 100644 --- a/packages/api/test/unit/beacon/testData/debug.ts +++ b/packages/api/test/unit/beacon/testData/debug.ts @@ -1,22 +1,22 @@ import {toHexString} from "@chainsafe/ssz"; import {ForkName} from "@lodestar/params"; import {ssz} from "@lodestar/types"; -import {Api} from "../../../../src/beacon/routes/debug.js"; +import {Endpoints} from "../../../../src/beacon/routes/debug.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const rootHex = toHexString(Buffer.alloc(32, 1)); -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { getDebugChainHeads: { - args: [], + args: undefined, res: {data: [{slot: 1, root: rootHex}]}, }, getDebugChainHeadsV2: { - args: [], + args: undefined, res: {data: [{slot: 1, root: rootHex, executionOptimistic: true}]}, }, getProtoArrayNodes: { - args: [], + args: undefined, res: { data: [ { @@ -46,16 +46,14 @@ export const testData: GenericServerTestCases = { }, }, getState: { - args: ["head", "json"], - res: {executionOptimistic: true, finalized: false, data: ssz.phase0.BeaconState.defaultValue()}, + args: {stateId: "head"}, + res: {data: ssz.phase0.BeaconState.defaultValue(), meta: {executionOptimistic: true, finalized: false}}, }, getStateV2: { - args: ["head", "json"], + args: {stateId: "head"}, res: { - executionOptimistic: true, - finalized: false, data: ssz.altair.BeaconState.defaultValue(), - version: ForkName.altair, + meta: {executionOptimistic: true, finalized: false, version: ForkName.altair}, }, }, }; diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 1ac101f32f4d..8a7610a26836 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -1,15 +1,15 @@ import {ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; -import {Api, EventData, EventType, blobSidecarSSE} from "../../../../src/beacon/routes/events.js"; +import {Endpoints, EventData, EventType, blobSidecarSSE} from "../../../../src/beacon/routes/events.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const abortController = new AbortController(); /* eslint-disable @typescript-eslint/naming-convention */ -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { eventstream: { - args: [[EventType.head, EventType.chainReorg], abortController.signal, function onEvent() {}], + args: {topics: [EventType.head, EventType.chainReorg], signal: abortController.signal, onEvent: () => {}}, res: undefined, }, }; diff --git a/packages/api/test/unit/beacon/testData/lightclient.ts b/packages/api/test/unit/beacon/testData/lightclient.ts index 13e08e365987..b86e1f338329 100644 --- a/packages/api/test/unit/beacon/testData/lightclient.ts +++ b/packages/api/test/unit/beacon/testData/lightclient.ts @@ -1,29 +1,28 @@ import {toHexString} from "@chainsafe/ssz"; import {ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; -import {Api} from "../../../../src/beacon/routes/lightclient.js"; +import {Endpoints} from "../../../../src/beacon/routes/lightclient.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; -const root = Uint8Array.from(Buffer.alloc(32, 1)); +const root = new Uint8Array(32).fill(1); const lightClientUpdate = ssz.altair.LightClientUpdate.defaultValue(); const syncAggregate = ssz.altair.SyncAggregate.defaultValue(); const header = ssz.altair.LightClientHeader.defaultValue(); const signatureSlot = ssz.Slot.defaultValue(); -export const testData: GenericServerTestCases = { - getUpdates: { - args: [1, 2], - res: [{version: ForkName.bellatrix, data: lightClientUpdate}], +export const testData: GenericServerTestCases = { + getLightClientUpdatesByRange: { + args: {startPeriod: 1, count: 2}, + res: {data: [lightClientUpdate], meta: {versions: [ForkName.bellatrix]}}, }, - getOptimisticUpdate: { - args: [], - res: {version: ForkName.bellatrix, data: {syncAggregate, attestedHeader: header, signatureSlot}}, + getLightClientOptimisticUpdate: { + args: undefined, + res: {data: {syncAggregate, attestedHeader: header, signatureSlot}, meta: {version: ForkName.bellatrix}}, }, - getFinalityUpdate: { - args: [], + getLightClientFinalityUpdate: { + args: undefined, res: { - version: ForkName.bellatrix, data: { syncAggregate, attestedHeader: header, @@ -31,21 +30,22 @@ export const testData: GenericServerTestCases = { finalityBranch: lightClientUpdate.finalityBranch, signatureSlot: lightClientUpdate.attestedHeader.beacon.slot + 1, }, + meta: {version: ForkName.bellatrix}, }, }, - getBootstrap: { - args: [toHexString(root)], + getLightClientBootstrap: { + args: {blockRoot: toHexString(root)}, res: { - version: ForkName.bellatrix, data: { header, currentSyncCommittee: lightClientUpdate.nextSyncCommittee, currentSyncCommitteeBranch: [root, root, root, root, root], // Vector(Root, 5) }, + meta: {version: ForkName.bellatrix}, }, }, - getCommitteeRoot: { - args: [1, 2], - res: {data: [Uint8Array.from(Buffer.alloc(32, 0)), Uint8Array.from(Buffer.alloc(32, 1))]}, + getLightClientCommitteeRoot: { + args: {startPeriod: 1, count: 2}, + res: {data: [new Uint8Array(32), new Uint8Array(32).fill(1)]}, }, }; diff --git a/packages/api/test/unit/beacon/testData/node.ts b/packages/api/test/unit/beacon/testData/node.ts index 2243f37fc4c7..48efc4a728bc 100644 --- a/packages/api/test/unit/beacon/testData/node.ts +++ b/packages/api/test/unit/beacon/testData/node.ts @@ -1,5 +1,5 @@ import {ssz} from "@lodestar/types"; -import {Api, NodePeer} from "../../../../src/beacon/routes/node.js"; +import {Endpoints, NodePeer} from "../../../../src/beacon/routes/node.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const peerIdStr = "peerId"; @@ -11,9 +11,9 @@ const nodePeer: NodePeer = { direction: "inbound", }; -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { getNetworkIdentity: { - args: [], + args: undefined, res: { data: { peerId: peerIdStr, @@ -25,15 +25,15 @@ export const testData: GenericServerTestCases = { }, }, getPeers: { - args: [{state: ["connected", "disconnected"], direction: ["inbound"]}], + args: {state: ["connected", "disconnected"], direction: ["inbound"]}, res: {data: [nodePeer], meta: {count: 1}}, }, getPeer: { - args: [peerIdStr], + args: {peerId: peerIdStr}, res: {data: nodePeer}, }, getPeerCount: { - args: [], + args: undefined, res: { data: { disconnected: 1, @@ -44,15 +44,15 @@ export const testData: GenericServerTestCases = { }, }, getNodeVersion: { - args: [], + args: undefined, res: {data: {version: "Lodestar/v0.20.0"}}, }, getSyncingStatus: { - args: [], + args: undefined, res: {data: {headSlot: "1", syncDistance: "2", isSyncing: false, isOptimistic: true, elOffline: false}}, }, getHealth: { - args: [{syncingStatus: 206}], + args: {syncingStatus: 206}, res: undefined, }, }; diff --git a/packages/api/test/unit/beacon/testData/proofs.ts b/packages/api/test/unit/beacon/testData/proofs.ts index 3af25fe7b5d0..2e24d1c18a71 100644 --- a/packages/api/test/unit/beacon/testData/proofs.ts +++ b/packages/api/test/unit/beacon/testData/proofs.ts @@ -1,35 +1,32 @@ import {ProofType} from "@chainsafe/persistent-merkle-tree"; -import {Api} from "../../../../src/beacon/routes/proof.js"; +import {ForkName} from "@lodestar/params"; +import {Endpoints} from "../../../../src/beacon/routes/proof.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; -const root = Uint8Array.from(Buffer.alloc(32, 1)); +const root = new Uint8Array(32).fill(1); const descriptor = Uint8Array.from([0, 0, 0, 0]); -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { getStateProof: { - args: ["head", descriptor], + args: {stateId: "head", descriptor}, res: { data: { type: ProofType.compactMulti, descriptor, leaves: [root, root, root, root], }, - }, - query: { - format: "0x00000000", + meta: {version: ForkName.altair}, }, }, getBlockProof: { - args: ["head", descriptor], + args: {blockId: "head", descriptor}, res: { data: { type: ProofType.compactMulti, descriptor, leaves: [root, root, root, root], }, - }, - query: { - format: "0x00000000", + meta: {version: ForkName.altair}, }, }, }; diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index 6b410b724e4f..3a92beb7ad27 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -1,6 +1,6 @@ import {ForkName} from "@lodestar/params"; import {ssz, ProducedBlockSource} from "@lodestar/types"; -import {Api} from "../../../../src/beacon/routes/validator.js"; +import {BuilderSelection, Endpoints} from "../../../../src/beacon/routes/validator.js"; import {GenericServerTestCases} from "../../../utils/genericServerTest.js"; const ZERO_HASH = new Uint8Array(32); @@ -10,11 +10,10 @@ const selectionProof = new Uint8Array(96).fill(1); const graffiti = "a".repeat(32); const feeRecipient = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { getAttesterDuties: { - args: [1000, [1, 2, 3]], + args: {epoch: 1000, indices: [1, 2, 3]}, res: { - executionOptimistic: true, data: [ { pubkey: new Uint8Array(48).fill(1), @@ -26,151 +25,121 @@ export const testData: GenericServerTestCases = { slot: 7, }, ], - dependentRoot: ZERO_HASH_HEX, + meta: {executionOptimistic: true, dependentRoot: ZERO_HASH_HEX}, }, }, getProposerDuties: { - args: [1000], + args: {epoch: 1000}, res: { - executionOptimistic: true, data: [{slot: 1, validatorIndex: 2, pubkey: new Uint8Array(48).fill(3)}], - dependentRoot: ZERO_HASH_HEX, + meta: {executionOptimistic: true, dependentRoot: ZERO_HASH_HEX}, }, }, getSyncCommitteeDuties: { - args: [1000, [1, 2, 3]], + args: {epoch: 1000, indices: [1, 2, 3]}, res: { - executionOptimistic: true, - data: [{pubkey: Uint8Array.from(Buffer.alloc(48, 1)), validatorIndex: 2, validatorSyncCommitteeIndices: [3]}], + data: [{pubkey: new Uint8Array(48).fill(1), validatorIndex: 2, validatorSyncCommitteeIndices: [3]}], + meta: {executionOptimistic: true}, }, }, produceBlock: { - args: [ - 32000, - randaoReveal, - graffiti, - false, - { - feeRecipient, - builderSelection: undefined, - strictFeeRecipientCheck: undefined, - blindedLocal: undefined, - builderBoostFactor: 100n, - }, - ] as unknown as GenericServerTestCases["produceBlock"]["args"], - res: {data: ssz.phase0.BeaconBlock.defaultValue()}, + args: {slot: 32000, randaoReveal, graffiti}, + res: {data: ssz.phase0.BeaconBlock.defaultValue(), meta: {version: ForkName.phase0}}, }, produceBlockV2: { - args: [ - 32000, + args: { + slot: 32000, randaoReveal, graffiti, - false, - { - feeRecipient, - builderSelection: undefined, - strictFeeRecipientCheck: undefined, - blindedLocal: undefined, - builderBoostFactor: 100n, - }, - ] as unknown as GenericServerTestCases["produceBlockV2"]["args"], + feeRecipient, + builderSelection: BuilderSelection.ExecutionAlways, + strictFeeRecipientCheck: true, + }, res: { data: ssz.altair.BeaconBlock.defaultValue(), - version: ForkName.altair, - executionPayloadValue: ssz.Wei.defaultValue(), - consensusBlockValue: ssz.Wei.defaultValue(), + meta: { + version: ForkName.altair, + }, }, }, produceBlockV3: { - args: [ - 32000, + args: { + slot: 32000, randaoReveal, graffiti, - true, - { - feeRecipient, - builderSelection: undefined, - strictFeeRecipientCheck: undefined, - blindedLocal: undefined, - builderBoostFactor: 100n, - }, - ], + skipRandaoVerification: true, + builderBoostFactor: 0n, + feeRecipient, + builderSelection: BuilderSelection.ExecutionAlways, + strictFeeRecipientCheck: true, + blindedLocal: false, + }, res: { data: ssz.altair.BeaconBlock.defaultValue(), - version: ForkName.altair, - executionPayloadValue: ssz.Wei.defaultValue(), - consensusBlockValue: ssz.Wei.defaultValue(), - executionPayloadBlinded: false, - executionPayloadSource: ProducedBlockSource.engine, + meta: { + version: ForkName.altair, + executionPayloadValue: ssz.Wei.defaultValue(), + consensusBlockValue: ssz.Wei.defaultValue(), + executionPayloadBlinded: false, + executionPayloadSource: ProducedBlockSource.engine, + }, }, }, produceBlindedBlock: { - args: [ - 32000, - randaoReveal, - graffiti, - false, - { - feeRecipient, - builderSelection: undefined, - strictFeeRecipientCheck: undefined, - blindedLocal: undefined, - builderBoostFactor: 100n, - }, - ] as unknown as GenericServerTestCases["produceBlindedBlock"]["args"], + args: {slot: 32000, randaoReveal, graffiti}, res: { data: ssz.bellatrix.BlindedBeaconBlock.defaultValue(), - version: ForkName.bellatrix, - executionPayloadValue: ssz.Wei.defaultValue(), - consensusBlockValue: ssz.Wei.defaultValue(), + meta: { + version: ForkName.bellatrix, + }, }, }, produceAttestationData: { - args: [2, 32000], + args: {committeeIndex: 2, slot: 32000}, res: {data: ssz.phase0.AttestationData.defaultValue()}, }, produceSyncCommitteeContribution: { - args: [32000, 2, ZERO_HASH], + args: {slot: 32000, subcommitteeIndex: 2, beaconBlockRoot: ZERO_HASH}, res: {data: ssz.altair.SyncCommitteeContribution.defaultValue()}, }, getAggregatedAttestation: { - args: [ZERO_HASH, 32000], + args: {attestationDataRoot: ZERO_HASH, slot: 32000}, res: {data: ssz.phase0.Attestation.defaultValue()}, }, publishAggregateAndProofs: { - args: [[ssz.phase0.SignedAggregateAndProof.defaultValue()]], + args: {signedAggregateAndProofs: [ssz.phase0.SignedAggregateAndProof.defaultValue()]}, res: undefined, }, publishContributionAndProofs: { - args: [[ssz.altair.SignedContributionAndProof.defaultValue()]], + args: {contributionAndProofs: [ssz.altair.SignedContributionAndProof.defaultValue()]}, res: undefined, }, prepareBeaconCommitteeSubnet: { - args: [[{validatorIndex: 1, committeeIndex: 2, committeesAtSlot: 3, slot: 4, isAggregator: true}]], + args: {subscriptions: [{validatorIndex: 1, committeeIndex: 2, committeesAtSlot: 3, slot: 4, isAggregator: true}]}, res: undefined, }, prepareSyncCommitteeSubnets: { - args: [[{validatorIndex: 1, syncCommitteeIndices: [2], untilEpoch: 3}]], + args: {subscriptions: [{validatorIndex: 1, syncCommitteeIndices: [2], untilEpoch: 3}]}, res: undefined, }, prepareBeaconProposer: { - args: [[{validatorIndex: "1", feeRecipient: "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"}]], + args: {proposers: [{validatorIndex: 1, feeRecipient: "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"}]}, res: undefined, }, submitBeaconCommitteeSelections: { - args: [[]], + args: {selections: []}, res: {data: [{validatorIndex: 1, slot: 2, selectionProof}]}, }, submitSyncCommitteeSelections: { - args: [[]], + args: {selections: []}, res: {data: [{validatorIndex: 1, slot: 2, subcommitteeIndex: 3, selectionProof}]}, }, getLiveness: { - args: [0, [0]], + args: {epoch: 0, indices: [0]}, res: {data: []}, }, registerValidator: { - args: [[ssz.bellatrix.SignedValidatorRegistrationV1.defaultValue()]], + args: {registrations: [ssz.bellatrix.SignedValidatorRegistrationV1.defaultValue()]}, res: undefined, }, }; diff --git a/packages/api/test/unit/builder/builder.test.ts b/packages/api/test/unit/builder/builder.test.ts index 56b8eee45ea5..045a66496b6f 100644 --- a/packages/api/test/unit/builder/builder.test.ts +++ b/packages/api/test/unit/builder/builder.test.ts @@ -1,13 +1,13 @@ import {describe} from "vitest"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; -import {Api, ReqTypes} from "../../../src/builder/routes.js"; +import {Endpoints} from "../../../src/builder/routes.js"; import {getClient} from "../../../src/builder/client.js"; import {getRoutes} from "../../../src/builder/server/index.js"; import {runGenericServerTest} from "../../utils/genericServerTest.js"; import {testData} from "./testData.js"; describe("builder", () => { - runGenericServerTest( + runGenericServerTest( createChainForkConfig({ ...defaultChainConfig, /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/packages/api/test/unit/builder/oapiSpec.test.ts b/packages/api/test/unit/builder/oapiSpec.test.ts index 10fb1c62085f..60168f71d8d8 100644 --- a/packages/api/test/unit/builder/oapiSpec.test.ts +++ b/packages/api/test/unit/builder/oapiSpec.test.ts @@ -3,7 +3,7 @@ import {fileURLToPath} from "node:url"; import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; import {OpenApiFile} from "../../utils/parseOpenApiSpec.js"; -import {routesData, getReqSerializers, getReturnTypes} from "../../../src/builder/routes.js"; +import {getDefinitions} from "../../../src/builder/routes.js"; import {runTestCheckAgainstSpec} from "../../utils/checkAgainstSpec.js"; import {fetchOpenApiSpec} from "../../utils/fetchOpenApiSpec.js"; import {testData} from "./testData.js"; @@ -22,11 +22,10 @@ const openApiFile: OpenApiFile = { version: RegExp(/.*/), }; -const reqSerializers = getReqSerializers( +const definitions = getDefinitions( // eslint-disable-next-line @typescript-eslint/naming-convention createChainForkConfig({...defaultChainConfig, ALTAIR_FORK_EPOCH: 0, BELLATRIX_FORK_EPOCH: 0}) ); -const returnTypes = getReturnTypes(); const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec(openApiJson, routesData, reqSerializers, returnTypes, testData); +runTestCheckAgainstSpec(openApiJson, definitions, testData); diff --git a/packages/api/test/unit/builder/testData.ts b/packages/api/test/unit/builder/testData.ts index e198e6971905..a23823702b6d 100644 --- a/packages/api/test/unit/builder/testData.ts +++ b/packages/api/test/unit/builder/testData.ts @@ -2,28 +2,28 @@ import {fromHexString} from "@chainsafe/ssz"; import {ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; -import {Api} from "../../../src/builder/routes.js"; +import {Endpoints} from "../../../src/builder/routes.js"; import {GenericServerTestCases} from "../../utils/genericServerTest.js"; // randomly pregenerated pubkey const pubkeyRand = "0x84105a985058fc8740a48bf1ede9d223ef09e8c6b1735ba0a55cf4a9ff2ff92376b778798365e488dab07a652eb04576"; const root = new Uint8Array(32).fill(1); -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { status: { - args: [], + args: undefined, res: undefined, }, registerValidator: { - args: [[ssz.bellatrix.SignedValidatorRegistrationV1.defaultValue()]], + args: {registrations: [ssz.bellatrix.SignedValidatorRegistrationV1.defaultValue()]}, res: undefined, }, getHeader: { - args: [1, root, fromHexString(pubkeyRand)], - res: {version: ForkName.bellatrix, data: ssz.bellatrix.SignedBuilderBid.defaultValue()}, + args: {slot: 1, parentHash: root, proposerPubkey: fromHexString(pubkeyRand)}, + res: {data: ssz.bellatrix.SignedBuilderBid.defaultValue(), meta: {version: ForkName.bellatrix}}, }, submitBlindedBlock: { - args: [ssz.deneb.SignedBlindedBeaconBlock.defaultValue()], - res: {version: ForkName.bellatrix, data: ssz.bellatrix.ExecutionPayload.defaultValue()}, + args: {signedBlindedBlock: ssz.deneb.SignedBlindedBeaconBlock.defaultValue()}, + res: {data: ssz.bellatrix.ExecutionPayload.defaultValue(), meta: {version: ForkName.bellatrix}}, }, }; diff --git a/packages/api/test/unit/client/httpClient.test.ts b/packages/api/test/unit/client/httpClient.test.ts index c82b879bdcf5..577b860c4cca 100644 --- a/packages/api/test/unit/client/httpClient.test.ts +++ b/packages/api/test/unit/client/httpClient.test.ts @@ -1,17 +1,29 @@ import {IncomingMessage} from "node:http"; -import {describe, it, afterEach, expect} from "vitest"; +import {describe, it, afterEach, expect, vi} from "vitest"; import {RouteOptions, fastify} from "fastify"; +import {BooleanType, ContainerType, UintNumberType, ValueOf} from "@chainsafe/ssz"; import {ErrorAborted, TimeoutError, toBase64} from "@lodestar/utils"; -import {HttpClient, HttpError} from "../../../src/utils/client/index.js"; -import {HttpStatusCode} from "../../../src/utils/client/httpStatusCode.js"; +import {HttpClient, RouteDefinitionExtra} from "../../../src/utils/client/index.js"; +import {HttpStatusCode} from "../../../src/utils/httpStatusCode.js"; +import { + AnyEndpoint, + EmptyArgs, + EmptyRequestCodec, + EmptyMeta, + EmptyRequest, + EmptyResponseCodec, + JsonOnlyReq, + JsonOnlyResponseCodec, + EmptyResponseData, +} from "../../../src/utils/codecs.js"; +import {compileRouteUrlFormatter} from "../../../src/utils/urlFormat.js"; +import {Endpoint, Schema} from "../../../src/utils/index.js"; +import {WireFormat} from "../../../src/index.js"; +import {HttpHeader, MediaType} from "../../../src/utils/headers.js"; +import {addSszContentTypeParser} from "../../../src/server/index.js"; /* eslint-disable @typescript-eslint/return-await */ -type User = { - id?: number; - name: string; -}; - describe("httpClient json client", () => { const afterEachCallbacks: (() => Promise | any)[] = []; afterEach(async () => { @@ -22,11 +34,21 @@ describe("httpClient json client", () => { }); const testRoute = {url: "/test-route", method: "GET" as const}; + const testDefinition: RouteDefinitionExtra = { + url: testRoute.url, + method: testRoute.method, + req: EmptyRequestCodec, + resp: EmptyResponseCodec, + operationId: "testRoute", + urlFormatter: compileRouteUrlFormatter(testRoute.url), + }; async function getServer(opts: RouteOptions): Promise<{baseUrl: string}> { const server = fastify({logger: false}); server.route(opts); + addSszContentTypeParser(server); + const reqs = new Set(); server.addHook("onRequest", async (req) => reqs.add(req.raw)); afterEachCallbacks.push(async () => { @@ -43,20 +65,46 @@ describe("httpClient json client", () => { } it("should handle successful GET request correctly", async () => { + type TestGetEndpoint = Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + {test: number}, + EmptyMeta + >; + const url = "/test-get"; + const testGetDefinition: RouteDefinitionExtra = { + url, + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + operationId: "testGet", + urlFormatter: compileRouteUrlFormatter(url), + }; + const httpClient = await getServerWithClient({ url, method: "GET", - handler: async () => ({test: 1}), + handler: async () => ({data: {test: 1}}), }); - const {body: resBody, status} = await httpClient.json({url, method: "GET"}); + const res = await httpClient.request(testGetDefinition, undefined); - expect(status).toBe(HttpStatusCode.OK); - expect(resBody).toEqual({test: 1}); + expect(res.status).toBe(HttpStatusCode.OK); + expect(res.value()).toEqual({test: 1}); }); it("should handle successful POST request correctly", async () => { + type TestPostEndpoint = Endpoint< + "POST", + {a: string; b: string[]; c: number}, + {query: {a: string; b: string[]}; body: {c: number}}, + {test: number}, + EmptyMeta + >; + const query = {a: "a", b: ["b1", "b2"]}; const body = {c: 4}; const resBody = {test: 1}; @@ -64,20 +112,36 @@ describe("httpClient json client", () => { let bodyReceived: any; const url = "/test-post"; + const testPostDefinition: RouteDefinitionExtra = { + url, + method: "POST", + req: JsonOnlyReq({ + writeReqJson: ({a, b, c}) => ({query: {a, b}, body: {c}}), + parseReqJson: ({query, body}) => ({a: query.a, b: query.b, c: body.c}), + schema: { + query: {a: Schema.String, b: Schema.StringArray}, + body: Schema.Object, + }, + }), + resp: JsonOnlyResponseCodec, + operationId: "testPost", + urlFormatter: compileRouteUrlFormatter(url), + }; + const httpClient = await getServerWithClient({ url, method: "POST", handler: async (req) => { queryReceived = req.query; bodyReceived = req.body; - return resBody; + return {data: resBody}; }, }); - const {body: resBodyReceived, status} = await httpClient.json({url, method: "POST", query, body}); + const res = await httpClient.request(testPostDefinition, {...query, ...body}); - expect(status).toBe(HttpStatusCode.OK); - expect(resBodyReceived).toEqual(resBody); + expect(res.status).toBe(HttpStatusCode.OK); + expect(res.value()).toEqual(resBody); expect(queryReceived).toEqual(query); expect(bodyReceived).toEqual(body); }); @@ -89,14 +153,79 @@ describe("httpClient json client", () => { handler: async () => ({}), }); - try { - await httpClient.json(testRoute); - return Promise.reject(Error("did not throw")); // So it doesn't gets catch {} - } catch (e) { - if (!(e instanceof HttpError)) throw Error(`Not an HttpError: ${(e as Error).message}`); - expect(e.message).toBe("Not Found: Route GET:/test-route not found"); - expect(e.status).toBe(404); - } + const res = await httpClient.request(testDefinition, {}); + + expect(res.ok).toBe(false); + expect(res.status).toBe(404); + + expect(res.error()?.message).toBe("testRoute failed with status 404: Route GET:/test-route not found"); + }); + + it("should handle http status code 415 correctly", async () => { + const container = new ContainerType({ + a: new BooleanType(), + b: new UintNumberType(1), + }); + + type TestEndpoint = Endpoint< + "POST", + {payload: ValueOf}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; + + const url = "/test-unsupported-media-type"; + const routeId = "testUnsupportedMediaType"; + const testPostDefinition: RouteDefinitionExtra = { + url, + method: "POST", + req: { + writeReqJson: ({payload}) => ({body: container.toJson(payload)}), + parseReqJson: ({body}) => ({payload: container.fromJson(body)}), + writeReqSsz: ({payload}) => ({body: container.serialize(payload)}), + parseReqSsz: ({body}) => ({payload: container.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + operationId: routeId, + urlFormatter: compileRouteUrlFormatter(url), + }; + + const httpClient = await getServerWithClient({ + url, + method: "POST", + handler: async (req, res) => { + if (req.headers[HttpHeader.ContentType] !== MediaType.json) { + void res.status(415); + } + }, + }); + const fetchSpy = vi.spyOn(httpClient, "fetch" as any); + const sszNotSupportedCache = httpClient["sszNotSupportedByRouteIdByUrlIndex"].getOrDefault(0); + + const res1 = await httpClient.request( + testPostDefinition, + {payload: {a: true, b: 1}}, + {requestWireFormat: WireFormat.ssz} + ); + + expect(res1.ok).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(sszNotSupportedCache?.has(routeId)).toBe(true); + + // Subsequent requests should always use JSON + const res2 = await httpClient.request( + testPostDefinition, + {payload: {a: true, b: 1}}, + {requestWireFormat: WireFormat.ssz} + ); + + expect(res2.ok).toBe(true); + // Call count should only be incremented by 1, no retry + expect(fetchSpy).toHaveBeenCalledTimes(3); }); it("should handle http status code 500 correctly", async () => { @@ -107,32 +236,122 @@ describe("httpClient json client", () => { }, }); - try { - await httpClient.json(testRoute); - return Promise.reject(Error("did not throw")); - } catch (e) { - if (!(e instanceof HttpError)) throw Error(`Not an HttpError: ${(e as Error).message}`); - expect(e.message).toBe("Internal Server Error: Test error"); - expect(e.status).toBe(500); - } + const res = await httpClient.request(testDefinition, {}); + + expect(res.ok).toBe(false); + expect(res.status).toBe(500); + + expect(res.error()?.message).toBe("testRoute failed with status 500: Test error"); }); it("should handle http status with custom code 503", async () => { const httpClient = await getServerWithClient({ ...testRoute, - handler: async (req, res) => { + handler: async (_req, res) => { return res.code(503).send("Node is syncing"); }, }); - try { - await httpClient.json(testRoute); - return Promise.reject(Error("did not throw")); - } catch (e) { - if (!(e instanceof HttpError)) throw Error(`Not an HttpError: ${(e as Error).message}`); - expect(e.message).toBe("Service Unavailable: Node is syncing"); - expect(e.status).toBe(503); - } + const res = await httpClient.request(testDefinition, {}); + + expect(res.ok).toBe(false); + expect(res.status).toBe(503); + + expect(res.error()?.message).toBe("testRoute failed with status 503: Node is syncing"); + }); + + it("should send a SSZ-serialized request if configured as wire format", async () => { + const container = new ContainerType({ + a: new BooleanType(), + b: new UintNumberType(1), + }); + + type TestEndpoint = Endpoint< + "POST", + {payload: ValueOf}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; + + const url = "/test-ssz-wire-format"; + const routeId = "testSszWireFormat"; + const testPostDefinition: RouteDefinitionExtra = { + url, + method: "POST", + req: { + writeReqJson: ({payload}) => ({body: container.toJson(payload)}), + parseReqJson: ({body}) => ({payload: container.fromJson(body)}), + writeReqSsz: ({payload}) => ({body: container.serialize(payload)}), + parseReqSsz: ({body}) => ({payload: container.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + operationId: routeId, + urlFormatter: compileRouteUrlFormatter(url), + }; + const payload = {a: true, b: 1}; + + const httpClient = await getServerWithClient({ + url, + method: "POST", + handler: async (req) => { + expect(req.headers[HttpHeader.ContentType]).toBe(MediaType.ssz); + expect(req.body).toBeInstanceOf(Uint8Array); + expect(container.deserialize(req.body as Uint8Array)).toEqual(payload); + }, + }); + + (await httpClient.request(testPostDefinition, {payload}, {requestWireFormat: WireFormat.ssz})).assertOk(); + }); + + it("should send a JSON-serialized request if configured as wire format", async () => { + const container = new ContainerType({ + a: new BooleanType(), + b: new UintNumberType(1), + }); + + type TestEndpoint = Endpoint< + "POST", + {payload: ValueOf}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; + + const url = "/test-json-wire-format"; + const routeId = "testJsonWireFormat"; + const testPostDefinition: RouteDefinitionExtra = { + url, + method: "POST", + req: { + writeReqJson: ({payload}) => ({body: container.toJson(payload)}), + parseReqJson: ({body}) => ({payload: container.fromJson(body)}), + writeReqSsz: ({payload}) => ({body: container.serialize(payload)}), + parseReqSsz: ({body}) => ({payload: container.deserialize(body)}), + schema: { + body: Schema.Object, + }, + }, + resp: EmptyResponseCodec, + operationId: routeId, + urlFormatter: compileRouteUrlFormatter(url), + }; + const payload = {a: true, b: 1}; + + const httpClient = await getServerWithClient({ + url, + method: "POST", + handler: async (req) => { + expect(req.headers[HttpHeader.ContentType]).toBe(MediaType.json); + expect(req.body).toBeInstanceOf(Object); + expect(container.fromJson(req.body)).toEqual(payload); + }, + }); + + (await httpClient.request(testPostDefinition, {payload}, {requestWireFormat: WireFormat.json})).assertOk(); }); it("should set user credentials in URL as Authorization header", async () => { @@ -148,7 +367,7 @@ describe("httpClient json client", () => { url.password = "password"; const httpClient = new HttpClient({baseUrl: url.toString()}); - await httpClient.json(testRoute); + (await httpClient.request(testDefinition, {})).assertOk(); }); it("should not URI-encode user credentials in Authorization header", async () => { @@ -171,7 +390,7 @@ describe("httpClient json client", () => { const httpClient = new HttpClient({baseUrl: baseUrl}); - await httpClient.json(testRoute); + (await httpClient.request(testDefinition, {})).assertOk(); }); it("should handle aborting request with timeout", async () => { @@ -180,10 +399,10 @@ describe("httpClient json client", () => { handler: async () => new Promise((r) => setTimeout(r, 1000)), }); - const httpClient = new HttpClient({baseUrl, timeoutMs: 10}); + const httpClient = new HttpClient({baseUrl, globalInit: {timeoutMs: 10}}); try { - await httpClient.json(testRoute); + await httpClient.request(testDefinition, {}); return Promise.reject(Error("did not throw")); } catch (e) { if (!(e instanceof TimeoutError)) throw Error(`Not an TimeoutError: ${(e as Error).message}`); @@ -197,13 +416,12 @@ describe("httpClient json client", () => { }); const controller = new AbortController(); - const signal = controller.signal; - const httpClient = new HttpClient({baseUrl, getAbortSignal: () => signal}); + const httpClient = new HttpClient({baseUrl, globalInit: {signal: controller.signal}}); setTimeout(() => controller.abort(), 10); try { - await httpClient.json(testRoute); + await httpClient.request(testDefinition, {}); return Promise.reject(Error("did not throw")); } catch (e) { if (!(e instanceof ErrorAborted)) throw Error(`Not an ErrorAborted: ${(e as Error).message}`); diff --git a/packages/api/test/unit/client/httpClientFallback.test.ts b/packages/api/test/unit/client/httpClientFallback.test.ts index e51119741d3c..10f4a60c678a 100644 --- a/packages/api/test/unit/client/httpClientFallback.test.ts +++ b/packages/api/test/unit/client/httpClientFallback.test.ts @@ -1,8 +1,18 @@ -import {describe, it, beforeEach, afterEach, expect, vi} from "vitest"; -import {HttpClient, fetch} from "../../../src/utils/client/index.js"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import {HttpClient, RouteDefinitionExtra, fetch} from "../../../src/utils/client/index.js"; +import {AnyEndpoint, EmptyRequestCodec, EmptyResponseCodec} from "../../../src/utils/codecs.js"; +import {compileRouteUrlFormatter} from "../../../src/utils/urlFormat.js"; describe("httpClient fallback", () => { - const testRoute = {url: "/test-route", method: "GET" as const}; + const url = "/test-route"; + const testDefinition: RouteDefinitionExtra = { + url, + method: "GET", + req: EmptyRequestCodec, + resp: EmptyResponseCodec, + operationId: "testRoute", + urlFormatter: compileRouteUrlFormatter(url), + }; const DEBUG_LOGS = Boolean(process.env.DEBUG); // Using fetchSub instead of actually setting up servers because there are some strange @@ -41,9 +51,16 @@ describe("httpClient fallback", () => { await new Promise((r) => setTimeout(r, 10)); const i = getServerIndex(url); if (serverErrors.get(i)) { - throw Error(`test_error_server_${i}`); + if (i === 1) { + // Simulate one of the servers returning a HTTP error + // which is handled separately from network errors + // but the fallback logic should be the same + return new Response(null, {status: 500}); + } else { + throw Error(`test_error_server_${i}`); + } } else { - return {ok: true} as Response; + return new Response(null, {status: 200}); } }); }); @@ -69,7 +86,7 @@ describe("httpClient fallback", () => { } async function requestTestRoute(): Promise { - await httpClient.request(testRoute); + await httpClient.request(testDefinition, {}, {}); } it("Should only call server 0", async () => { diff --git a/packages/api/test/unit/client/httpClientOptions.test.ts b/packages/api/test/unit/client/httpClientOptions.test.ts index af0968777219..9bc7ab2bfaf8 100644 --- a/packages/api/test/unit/client/httpClientOptions.test.ts +++ b/packages/api/test/unit/client/httpClientOptions.test.ts @@ -8,21 +8,28 @@ describe("HTTPClient options", () => { const bearerToken2 = "token-2"; it("Single root baseUrl option", () => { - const httpClient = new HttpClient({baseUrl: baseUrl1, bearerToken: bearerToken1}); + const httpClient = new HttpClient({baseUrl: baseUrl1, globalInit: {bearerToken: bearerToken1}}); - expect(httpClient["urlsOpts"]).toEqual([{baseUrl: baseUrl1, bearerToken: bearerToken1}]); + const [urlInit] = httpClient["urlsInits"]; + + expect(urlInit.baseUrl).toBe(baseUrl1); + expect(urlInit.bearerToken).toBe(bearerToken1); }); it("Multiple urls option with common bearerToken", () => { const httpClient = new HttpClient({ urls: [baseUrl1, baseUrl2], - bearerToken: bearerToken1, + globalInit: { + bearerToken: bearerToken1, + }, }); - expect(httpClient["urlsOpts"]).toEqual([ - {baseUrl: baseUrl1, bearerToken: bearerToken1}, - {baseUrl: baseUrl2, bearerToken: bearerToken1}, - ]); + const [urlInit1, urlInit2] = httpClient["urlsInits"]; + + expect(urlInit1.baseUrl).toBe(baseUrl1); + expect(urlInit1.bearerToken).toBe(bearerToken1); + expect(urlInit2.baseUrl).toBe(baseUrl2); + expect(urlInit2.bearerToken).toBe(bearerToken1); }); it("Multiple urls as object option", () => { @@ -33,39 +40,47 @@ describe("HTTPClient options", () => { ], }); - expect(httpClient["urlsOpts"]).toEqual([ - {baseUrl: baseUrl1, bearerToken: bearerToken1}, - {baseUrl: baseUrl2, bearerToken: bearerToken2}, - ]); + const [urlInit1, urlInit2] = httpClient["urlsInits"]; + + expect(urlInit1.baseUrl).toBe(baseUrl1); + expect(urlInit1.bearerToken).toBe(bearerToken1); + expect(urlInit2.baseUrl).toBe(baseUrl2); + expect(urlInit2.bearerToken).toBe(bearerToken2); }); it("baseUrl and urls option", () => { const httpClient = new HttpClient({ baseUrl: baseUrl1, - bearerToken: bearerToken1, + globalInit: {bearerToken: bearerToken1}, urls: [{baseUrl: baseUrl2, bearerToken: bearerToken2}], }); - expect(httpClient["urlsOpts"]).toEqual([ - {baseUrl: baseUrl1, bearerToken: bearerToken1}, - {baseUrl: baseUrl2, bearerToken: bearerToken2}, - ]); + const [urlInit1, urlInit2] = httpClient["urlsInits"]; + + expect(urlInit1.baseUrl).toBe(baseUrl1); + expect(urlInit1.bearerToken).toBe(bearerToken1); + expect(urlInit2.baseUrl).toBe(baseUrl2); + expect(urlInit2.bearerToken).toBe(bearerToken2); }); it("de-duplicate urls", () => { const httpClient = new HttpClient({ baseUrl: baseUrl1, - bearerToken: bearerToken1, + globalInit: {bearerToken: bearerToken1}, urls: [ {baseUrl: baseUrl2, bearerToken: bearerToken2}, {baseUrl: baseUrl1, bearerToken: bearerToken1}, {baseUrl: baseUrl2, bearerToken: bearerToken2}, ], }); - expect(httpClient["urlsOpts"]).toEqual([ - {baseUrl: baseUrl1, bearerToken: bearerToken1}, - {baseUrl: baseUrl2, bearerToken: bearerToken2}, - ]); + + const [urlInit1, urlInit2, urlInit3] = httpClient["urlsInits"]; + + expect(urlInit1.baseUrl).toBe(baseUrl1); + expect(urlInit1.bearerToken).toBe(bearerToken1); + expect(urlInit2.baseUrl).toBe(baseUrl2); + expect(urlInit2.bearerToken).toBe(bearerToken2); + expect(urlInit3).toBeUndefined(); }); it("Throw if empty baseUrl", () => { diff --git a/packages/api/test/unit/client/urlFormat.test.ts b/packages/api/test/unit/client/urlFormat.test.ts index 5b8e1f294976..dc86e2674e13 100644 --- a/packages/api/test/unit/client/urlFormat.test.ts +++ b/packages/api/test/unit/client/urlFormat.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect} from "vitest"; import { - compileRouteUrlFormater, + compileRouteUrlFormatter, toColonNotationPath, Token, TokenType, @@ -59,10 +59,10 @@ describe("utils / urlFormat", () => { expect(toColonNotationPath(urlTemplate)).toBe(colonNotation); - const utlFormater = compileRouteUrlFormater(urlTemplate); + const urlFormatter = compileRouteUrlFormatter(urlTemplate); for (const [_, {args, url}] of cases.entries()) { - expect(utlFormater(args)).toBe(url); + expect(urlFormatter(args)).toBe(url); } }); } diff --git a/packages/api/test/unit/keymanager/keymanager.test.ts b/packages/api/test/unit/keymanager/keymanager.test.ts index 1adf5b1e44da..42e6a3dbf511 100644 --- a/packages/api/test/unit/keymanager/keymanager.test.ts +++ b/packages/api/test/unit/keymanager/keymanager.test.ts @@ -1,11 +1,11 @@ import {describe} from "vitest"; import {config} from "@lodestar/config/default"; -import {Api, ReqTypes} from "../../../src/keymanager/routes.js"; +import {Endpoints} from "../../../src/keymanager/routes.js"; import {getClient} from "../../../src/keymanager/client.js"; import {getRoutes} from "../../../src/keymanager/server/index.js"; import {runGenericServerTest} from "../../utils/genericServerTest.js"; import {testData} from "./testData.js"; describe("keymanager", () => { - runGenericServerTest(config, getClient, getRoutes, testData); + runGenericServerTest(config, getClient, getRoutes, testData); }); diff --git a/packages/api/test/unit/keymanager/oapiSpec.test.ts b/packages/api/test/unit/keymanager/oapiSpec.test.ts index aec70bc64858..10c29f0cea55 100644 --- a/packages/api/test/unit/keymanager/oapiSpec.test.ts +++ b/packages/api/test/unit/keymanager/oapiSpec.test.ts @@ -1,7 +1,8 @@ import path from "node:path"; import {fileURLToPath} from "node:url"; +import {config} from "@lodestar/config/default"; import {OpenApiFile} from "../../utils/parseOpenApiSpec.js"; -import {routesData, getReqSerializers, getReturnTypes} from "../../../src/keymanager/routes.js"; +import {getDefinitions} from "../../../src/keymanager/routes.js"; import {runTestCheckAgainstSpec} from "../../utils/checkAgainstSpec.js"; import {fetchOpenApiSpec} from "../../utils/fetchOpenApiSpec.js"; import {testData} from "./testData.js"; @@ -18,9 +19,5 @@ const openApiFile: OpenApiFile = { version: RegExp(version), }; -// TODO: un-skip in follow-up PR, this PR only adds basic infra for spec testing -const reqSerializers = getReqSerializers(); -const returnTypes = getReturnTypes(); - const openApiJson = await fetchOpenApiSpec(openApiFile); -runTestCheckAgainstSpec(openApiJson, routesData, reqSerializers, returnTypes, testData); +runTestCheckAgainstSpec(openApiJson, getDefinitions(config), testData); diff --git a/packages/api/test/unit/keymanager/testData.ts b/packages/api/test/unit/keymanager/testData.ts index 2c66610c8733..bd37798c4685 100644 --- a/packages/api/test/unit/keymanager/testData.ts +++ b/packages/api/test/unit/keymanager/testData.ts @@ -1,8 +1,8 @@ import {ssz} from "@lodestar/types"; import { - Api, DeleteRemoteKeyStatus, DeletionStatus, + Endpoints, ImportRemoteKeyStatus, ImportStatus, } from "../../../src/keymanager/routes.js"; @@ -15,9 +15,9 @@ const graffitiRandUtf8 = "636861696e736166652f6c6f64657374"; const gasLimitRand = 30_000_000; const builderBoostFactorRand = BigInt(100); -export const testData: GenericServerTestCases = { +export const testData: GenericServerTestCases = { listKeys: { - args: [], + args: undefined, res: { data: [ { @@ -29,16 +29,16 @@ export const testData: GenericServerTestCases = { }, }, importKeystores: { - args: [[pubkeyRand], ["pass1"], "slash_protection"], + args: {keystores: ["keystore"], passwords: ["pass1"], slashingProtection: "slash_protection"}, res: {data: [{status: ImportStatus.imported}]}, }, deleteKeys: { - args: [[pubkeyRand]], - res: {data: [{status: DeletionStatus.deleted}], slashingProtection: "slash_protection"}, + args: {pubkeys: [pubkeyRand]}, + res: {data: {statuses: [{status: DeletionStatus.deleted}], slashingProtection: "slash_protection"}}, }, listRemoteKeys: { - args: [], + args: undefined, res: { data: [ { @@ -50,66 +50,66 @@ export const testData: GenericServerTestCases = { }, }, importRemoteKeys: { - args: [[{pubkey: pubkeyRand, url: "https://sign.er"}]], + args: {remoteSigners: [{pubkey: pubkeyRand, url: "https://sign.er"}]}, res: {data: [{status: ImportRemoteKeyStatus.imported}]}, }, deleteRemoteKeys: { - args: [[pubkeyRand]], + args: {pubkeys: [pubkeyRand]}, res: {data: [{status: DeleteRemoteKeyStatus.deleted}]}, }, listFeeRecipient: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: {data: {pubkey: pubkeyRand, ethaddress: ethaddressRand}}, }, setFeeRecipient: { - args: [pubkeyRand, ethaddressRand], + args: {pubkey: pubkeyRand, ethaddress: ethaddressRand}, res: undefined, }, deleteFeeRecipient: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: undefined, }, - listGraffiti: { - args: [pubkeyRand], + getGraffiti: { + args: {pubkey: pubkeyRand}, res: {data: {pubkey: pubkeyRand, graffiti: graffitiRandUtf8}}, }, setGraffiti: { - args: [pubkeyRand, graffitiRandUtf8], + args: {pubkey: pubkeyRand, graffiti: graffitiRandUtf8}, res: undefined, }, deleteGraffiti: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: undefined, }, getGasLimit: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: {data: {pubkey: pubkeyRand, gasLimit: gasLimitRand}}, }, setGasLimit: { - args: [pubkeyRand, gasLimitRand], + args: {pubkey: pubkeyRand, gasLimit: gasLimitRand}, res: undefined, }, deleteGasLimit: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: undefined, }, signVoluntaryExit: { - args: [pubkeyRand, 1], + args: {pubkey: pubkeyRand, epoch: 1}, res: {data: ssz.phase0.SignedVoluntaryExit.defaultValue()}, }, getBuilderBoostFactor: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: {data: {pubkey: pubkeyRand, builderBoostFactor: builderBoostFactorRand}}, }, setBuilderBoostFactor: { - args: [pubkeyRand, builderBoostFactorRand], + args: {pubkey: pubkeyRand, builderBoostFactor: builderBoostFactorRand}, res: undefined, }, deleteBuilderBoostFactor: { - args: [pubkeyRand], + args: {pubkey: pubkeyRand}, res: undefined, }, }; diff --git a/packages/api/test/unit/utils/acceptHeader.test.ts b/packages/api/test/unit/utils/headers.test.ts similarity index 56% rename from packages/api/test/unit/utils/acceptHeader.test.ts rename to packages/api/test/unit/utils/headers.test.ts index b93f07ba286d..c909fe59499b 100644 --- a/packages/api/test/unit/utils/acceptHeader.test.ts +++ b/packages/api/test/unit/utils/headers.test.ts @@ -1,35 +1,35 @@ import {describe, it, expect} from "vitest"; -import {parseAcceptHeader} from "../../../src/utils/acceptHeader.js"; -import {ResponseFormat} from "../../../src/interfaces.js"; +import {MediaType, SUPPORTED_MEDIA_TYPES, parseAcceptHeader} from "../../../src/utils/headers.js"; -describe("utils / acceptHeader", () => { +describe("utils / headers", () => { describe("parseAcceptHeader", () => { - const testCases: {header: string | undefined; expected: ResponseFormat}[] = [ - {header: undefined, expected: "json"}, - {header: "application/json", expected: "json"}, - {header: "application/octet-stream", expected: "ssz"}, - {header: "application/invalid", expected: "json"}, - {header: "application/invalid;q=1,application/octet-stream;q=0.1", expected: "ssz"}, - {header: "application/octet-stream;q=0.5,application/json;q=1", expected: "json"}, - {header: "application/octet-stream;q=1,application/json;q=0.1", expected: "ssz"}, - {header: "application/octet-stream,application/json;q=0.1", expected: "ssz"}, - {header: "application/octet-stream;,application/json;q=0.1", expected: "json"}, - {header: "application/octet-stream;q=2,application/json;q=0.1", expected: "json"}, - {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: "json"}, - {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: "json"}, - {header: "application/octet-stream ; q=0.5 , application/json ; q=1", expected: "json"}, - {header: "application/octet-stream ; q=1 , application/json ; q=0.1", expected: "ssz"}, - {header: "application/octet-stream;q=1,application/json;q=0.1", expected: "ssz"}, + const testCases: {header: string | undefined; expected: MediaType | null}[] = [ + {header: undefined, expected: null}, + {header: "*/*", expected: null}, + {header: "application/json", expected: MediaType.json}, + {header: "application/octet-stream", expected: MediaType.ssz}, + {header: "application/invalid", expected: null}, + {header: "application/invalid;q=1,application/octet-stream;q=0.1", expected: MediaType.ssz}, + {header: "application/octet-stream;q=0.5,application/json;q=1", expected: MediaType.json}, + {header: "application/octet-stream;q=1,application/json;q=0.1", expected: MediaType.ssz}, + {header: "application/octet-stream,application/json;q=0.1", expected: MediaType.ssz}, + {header: "application/octet-stream;,application/json;q=0.1", expected: MediaType.json}, + {header: "application/octet-stream;q=2,application/json;q=0.1", expected: MediaType.json}, + {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: MediaType.json}, + {header: "application/octet-stream;q=invalid,application/json;q=0.1", expected: MediaType.json}, + {header: "application/octet-stream ; q=0.5 , application/json ; q=1", expected: MediaType.json}, + {header: "application/octet-stream ; q=1 , application/json ; q=0.1", expected: MediaType.ssz}, + {header: "application/octet-stream;q=1,application/json;q=0.1", expected: MediaType.ssz}, // The implementation is order dependent, however, RFC-9110 doesn't specify a preference. // The following tests serve to document the behavior at the time of implementation- not a // specific requirement from the spec. In this case, last wins. - {header: "application/octet-stream;q=1,application/json;q=1", expected: "json"}, - {header: "application/json;q=1,application/octet-stream;q=1", expected: "ssz"}, + {header: "application/octet-stream;q=1,application/json;q=1", expected: MediaType.json}, + {header: "application/json;q=1,application/octet-stream;q=1", expected: MediaType.ssz}, ]; it.each(testCases)("should correctly parse the header $header", ({header, expected}) => { - expect(parseAcceptHeader(header)).toBe(expected); + expect(parseAcceptHeader(header, SUPPORTED_MEDIA_TYPES)).toBe(expected); }); }); }); diff --git a/packages/api/test/utils/checkAgainstSpec.ts b/packages/api/test/utils/checkAgainstSpec.ts index 354ae53b2358..5e339efcb822 100644 --- a/packages/api/test/utils/checkAgainstSpec.ts +++ b/packages/api/test/utils/checkAgainstSpec.ts @@ -1,6 +1,7 @@ import Ajv, {ErrorObject} from "ajv"; import {expect, describe, beforeAll, it} from "vitest"; -import {ReqGeneric, ReqSerializer, ReturnTypes, RouteDef} from "../../src/utils/types.js"; +import {WireFormat} from "../../src/utils/wireFormat.js"; +import {Endpoint, RequestWithBodyCodec, RouteDefinitions, isRequestWithoutBody} from "../../src/utils/types.js"; import {applyRecursively, JsonSchema, OpenApiJson, parseOpenApiSpec} from "./parseOpenApiSpec.js"; import {GenericServerTestCases} from "./genericServerTest.js"; @@ -60,12 +61,10 @@ function deleteNested(schema: JsonSchema | undefined, property: string): void { } } -export function runTestCheckAgainstSpec( +export function runTestCheckAgainstSpec>( openApiJson: OpenApiJson, - routesData: Record, - reqSerializers: Record>, - returnTypes: Record[string]>, - testDatas: Record[string]>, + definitions: RouteDefinitions, + testCases: GenericServerTestCases, ignoredOperations: string[] = [], ignoredProperties: Record = {} ): void { @@ -82,12 +81,12 @@ export function runTestCheckAgainstSpec( describe(operationId, () => { const {requestSchema, responseOkSchema} = routeSpec; const routeId = operationId; - const testData = testDatas[routeId]; - const routeData = routesData[routeId]; + const testData = testCases[routeId]; + const routeDef = definitions[routeId]; beforeAll(() => { - if (routeData == null) { - throw Error(`No routeData for ${routeId}`); + if (routeDef == null) { + throw Error(`No routeDef for ${routeId}`); } if (testData == null) { throw Error(`No testData for ${routeId}`); @@ -95,18 +94,19 @@ export function runTestCheckAgainstSpec( }); it(`${operationId}_route`, function () { - expect(routeData.method.toLowerCase()).to.equal(routeSpec.method.toLowerCase(), "Wrong method"); - expect(routeData.url).to.equal(routeSpec.url, "Wrong url"); + expect(routeDef.method.toLowerCase()).toBe(routeSpec.method.toLowerCase()); + expect(routeDef.url).toBe(routeSpec.url); }); if (requestSchema != null) { it(`${operationId}_request`, function () { - const reqJson = reqSerializers[routeId].writeReq(...(testData.args as [never])) as unknown; + const reqJson = isRequestWithoutBody(routeDef) + ? routeDef.req.writeReq(testData.args) + : (routeDef.req as RequestWithBodyCodec).writeReqJson(testData.args); // Stringify param and query to simulate rendering in HTTP query - // TODO: Review conversions in fastify and other servers - stringifyProperties((reqJson as ReqGeneric).params ?? {}); - stringifyProperties((reqJson as ReqGeneric).query ?? {}); + stringifyProperties(reqJson.params ?? {}); + stringifyProperties(reqJson.query ?? {}); const ignoredProperties = ignoredProperty?.request; if (ignoredProperties) { @@ -118,12 +118,37 @@ export function runTestCheckAgainstSpec( // Validate request validateSchema(routeSpec.requestSchema, reqJson, "request"); + + // Verify that request supports ssz if required by spec + if (routeSpec.requestSszRequired) { + try { + const reqCodec = routeDef.req as RequestWithBodyCodec; + const reqSsz = reqCodec.writeReqSsz(testData.args); + + expect(reqSsz.body).toBeInstanceOf(Uint8Array); + expect(reqCodec.onlySupport).not.toBe(WireFormat.json); + } catch { + throw Error("Must support ssz request body"); + } + } }); } if (responseOkSchema) { it(`${operationId}_response`, function () { - const resJson = returnTypes[operationId].toJson(testData.res as any); + const data = routeDef.resp.data.toJson(testData.res?.data, testData.res?.meta); + const metaJson = routeDef.resp.meta.toJson(testData.res?.meta); + const headers = parseHeaders(routeDef.resp.meta.toHeadersObject(testData.res?.meta)); + + let resJson: unknown; + if (routeDef.resp.transform) { + resJson = routeDef.resp.transform.toResponse(data, metaJson); + } else { + resJson = { + data, + ...(metaJson as object), + }; + } const ignoredProperties = ignoredProperty?.response; if (ignoredProperties) { @@ -133,7 +158,19 @@ export function runTestCheckAgainstSpec( } } // Validate response - validateSchema(responseOkSchema, resJson, "response"); + validateSchema(responseOkSchema, {headers, body: resJson}, "response"); + + // Verify that response supports ssz if required by spec + if (routeSpec.responseSszRequired) { + try { + const sszBytes = routeDef.resp.data.serialize(testData.res?.data, testData.res?.meta); + + expect(sszBytes).toBeInstanceOf(Uint8Array); + expect(routeDef.resp.onlySupport).not.toBe(WireFormat.json); + } catch { + throw Error("Must support ssz response body"); + } + } }); } }); @@ -196,3 +233,16 @@ function stringifyProperties(obj: Record): Record): Record { + const parsed: Record = {}; + for (const key of Object.keys(headers)) { + const value = headers[key]; + parsed[key] = /true|false/.test(value) ? value === "true" : value; + } + return parsed; +} diff --git a/packages/api/test/utils/genericServerTest.ts b/packages/api/test/utils/genericServerTest.ts index 4cd0263aaea8..1c01bb449306 100644 --- a/packages/api/test/utils/genericServerTest.ts +++ b/packages/api/test/utils/genericServerTest.ts @@ -1,49 +1,41 @@ -import {it, expect, MockInstance, describe, beforeAll, afterAll} from "vitest"; +import {it, expect, describe, beforeAll, afterAll, MockInstance} from "vitest"; import {FastifyInstance} from "fastify"; import {ChainForkConfig} from "@lodestar/config"; -import {ReqGeneric, Resolves} from "../../src/utils/index.js"; -import {FetchOpts, HttpClient, IHttpClient} from "../../src/utils/client/index.js"; -import {ServerRoutes} from "../../src/utils/server/genericJsonServer.js"; -import {registerRoute} from "../../src/utils/server/registerRoute.js"; -import {HttpStatusCode} from "../../src/utils/client/httpStatusCode.js"; -import {APIClientHandler, ApiClientResponseData, ServerApi} from "../../src/interfaces.js"; +import {Endpoint} from "../../src/utils/index.js"; +import {WireFormat} from "../../src/utils/wireFormat.js"; +import {ApplicationMethods, ApplicationResponse, FastifyRoutes} from "../../src/utils/server/index.js"; +import {ApiClientMethods, ApiRequestInit, HttpClient, IHttpClient} from "../../src/utils/client/index.js"; import {getMockApi, getTestServer} from "./utils.js"; -type IgnoreVoid = T extends void ? undefined : T; - -export type GenericServerTestCases> = { - [K in keyof Api]: { - args: Parameters; - res: IgnoreVoid>>; - query?: FetchOpts["query"]; +export type GenericServerTestCases> = { + [K in keyof Es]: { + args: Es[K]["args"]; + res: ApplicationResponse; }; }; -export function runGenericServerTest< - Api extends Record, - ReqTypes extends {[K in keyof Api]: ReqGeneric}, ->( +export function runGenericServerTest>( config: ChainForkConfig, - getClient: (config: ChainForkConfig, https: IHttpClient) => Api, - getRoutes: (config: ChainForkConfig, api: ServerApi) => ServerRoutes, ReqTypes>, - testCases: GenericServerTestCases + getClient: (config: ChainForkConfig, http: IHttpClient) => ApiClientMethods, + getRoutes: (config: ChainForkConfig, methods: ApplicationMethods) => FastifyRoutes, + testCases: GenericServerTestCases ): void { - const mockApi = getMockApi(testCases); + const mockApi = getMockApi(testCases); let server: FastifyInstance; - let client: Api; - let httpClient: HttpClientSpy; + let client: ApiClientMethods; + let httpClient: HttpClient; beforeAll(async () => { const res = getTestServer(); server = res.server; for (const route of Object.values(getRoutes(config, mockApi))) { - registerRoute(server, route); + server.route(route); } const baseUrl = await res.start(); - httpClient = new HttpClientSpy({baseUrl}); + httpClient = new HttpClient({baseUrl}); client = getClient(config, httpClient); }); @@ -52,49 +44,30 @@ export function runGenericServerTest< }); describe("run generic server tests", () => { - it.each(Object.keys(testCases))("%s", async (key) => { - const routeId = key as keyof Api; - const testCase = testCases[routeId]; - - // Register mock data for this route - // TODO: Look for the type error - (mockApi[routeId] as MockInstance).mockResolvedValue(testCases[routeId].res); - - // Do the call - const res = await client[routeId](...(testCase.args as any[])); - - // Use spy to assert argument serialization - if (testCase.query) { - expect(httpClient.opts?.query).toEqual(testCase.query); - } - - // Assert server handler called with correct args - expect(mockApi[routeId] as MockInstance).toHaveBeenCalledTimes(1); - - // if mock api args are > testcase args, there may be some undefined extra args parsed towards the end - // to obtain a match, ignore the extra args - expect(mockApi[routeId] as MockInstance).toHaveBeenNthCalledWith(1, ...(testCase.args as any[])); - - // Assert returned value is correct - expect(res.response).toEqual(testCase.res); + describe.each(Object.keys(testCases))("%s", (key) => { + it.each(Object.values(WireFormat))("%s", async (format) => { + const wireFormat = format as WireFormat; + const localInit: ApiRequestInit = { + requestWireFormat: wireFormat, + responseWireFormat: wireFormat, + }; + const routeId = key as keyof Es; + const testCase = testCases[routeId]; + + // Register mock data for this route + (mockApi[routeId] as MockInstance).mockResolvedValue(testCases[routeId].res); + + // Do the call + const res = await client[routeId](testCase.args ?? localInit, localInit); + + // Assert server handler called with correct args + expect(mockApi[routeId]).toHaveBeenCalledTimes(1); + expect(mockApi[routeId]).toHaveBeenCalledWith(testCase.args, expect.any(Object)); + + // Assert returned value and metadata is correct + expect(res.value()).toEqual(testCase.res?.data); + expect(res.meta()).toEqual(testCase.res?.meta); + }); }); }); } - -class HttpClientSpy extends HttpClient { - opts: FetchOpts | null = null; - - async json(opts: FetchOpts): Promise<{status: HttpStatusCode; body: T}> { - this.opts = opts; - return super.json(opts); - } - async arrayBuffer(opts: FetchOpts): Promise<{status: HttpStatusCode; body: ArrayBuffer}> { - this.opts = opts; - return super.arrayBuffer(opts); - } - - async request(opts: FetchOpts): Promise<{status: HttpStatusCode; body: void}> { - this.opts = opts; - return super.request(opts); - } -} diff --git a/packages/api/test/utils/parseOpenApiSpec.ts b/packages/api/test/utils/parseOpenApiSpec.ts index 2672b381eea6..7527fb61abaa 100644 --- a/packages/api/test/utils/parseOpenApiSpec.ts +++ b/packages/api/test/utils/parseOpenApiSpec.ts @@ -47,14 +47,17 @@ type RouteDefinition = { operationId: string; parameters: { name: string; - in: "path" | "query"; + in: "path" | "query" | "header"; schema: JsonSchema; }[]; responses: { /** `"200"` | `"500"` */ - [statusCode: string]: { - content?: Content; - }; + [statusCode: string]: + | { + headers?: Record; + content?: Content; + } + | undefined; }; requestBody?: { content?: Content; @@ -65,12 +68,20 @@ export type RouteSpec = { url: RouteUrl; method: HttpMethod; responseOkSchema: JsonSchema | undefined; + responseSszRequired: boolean; requestSchema: JsonSchema; + requestSszRequired: boolean; }; export type ReqSchema = { params?: JsonSchema; query?: JsonSchema; + headers?: JsonSchema; + body?: JsonSchema; +}; + +export type RespSchema = { + headers?: JsonSchema; body?: JsonSchema; }; @@ -80,6 +91,7 @@ enum StatusCode { enum ContentType { json = "application/json", + ssz = "application/octet-stream", } export function parseOpenApiSpec(openApiJson: OpenApiJson): Map { @@ -87,27 +99,30 @@ export function parseOpenApiSpec(openApiJson: OpenApiJson): Map void) } function buildReqSchema(routeDefinition: RouteDefinition): JsonSchema { - const reqSchemas: ReqSchema = {}; + const reqSchema: ReqSchema = {}; // "parameters": [{ // "name": "block_id", @@ -182,30 +197,69 @@ function buildReqSchema(routeDefinition: RouteDefinition): JsonSchema { for (const parameter of routeDefinition.parameters ?? []) { switch (parameter.in) { case "path": - if (!reqSchemas.params) reqSchemas.params = {type: "object", properties: {}}; - if (!reqSchemas.params.properties) reqSchemas.params.properties = {}; - reqSchemas.params.properties[parameter.name] = parameter.schema; + if (!reqSchema.params) reqSchema.params = {type: "object", properties: {}}; + if (!reqSchema.params.properties) reqSchema.params.properties = {}; + reqSchema.params.properties[parameter.name] = parameter.schema; break; case "query": - if (!reqSchemas.query) reqSchemas.query = {type: "object", properties: {}}; - if (!reqSchemas.query.properties) reqSchemas.query.properties = {}; - reqSchemas.query.properties[parameter.name] = parameter.schema; + if (!reqSchema.query) reqSchema.query = {type: "object", properties: {}}; + if (!reqSchema.query.properties) reqSchema.query.properties = {}; + reqSchema.query.properties[parameter.name] = parameter.schema; break; - // case "header" + case "header": + if (!reqSchema.headers) reqSchema.headers = {type: "object", properties: {}}; + if (!reqSchema.headers.properties) reqSchema.headers.properties = {}; + reqSchema.headers.properties[parameter.name] = parameter.schema; + break; } } - const requestJsonSchema = routeDefinition.requestBody?.content?.[ContentType.json].schema; + const requestJsonSchema = routeDefinition.requestBody?.content?.[ContentType.json]?.schema; if (requestJsonSchema) { - reqSchemas.body = requestJsonSchema; + reqSchema.body = requestJsonSchema; + } + + return { + type: "object", + properties: reqSchema, + }; +} + +function buildRespSchema(routeDefinition: RouteDefinition): JsonSchema { + const respSchema: RespSchema = {}; + + const responseOk = routeDefinition.responses[StatusCode.ok]; + + // "headers": { + // "Eth-Consensus-Version": { + // "required": true, + // "schema": { + // "type": "string", + // "enum": ["phase0", "altair", "bellatrix", "capella", "deneb"], + // "example": "phase0", + // }, + // }, + // }, + + if (responseOk?.headers) { + Object.entries(responseOk.headers).map(([header, {schema}]) => { + if (!respSchema.headers) respSchema.headers = {type: "object", properties: {}}; + if (!respSchema.headers.properties) respSchema.headers.properties = {}; + respSchema.headers.properties[header] = schema; + }); + } + + const responseJsonSchema = responseOk?.content?.[ContentType.json]?.schema; + if (responseJsonSchema) { + respSchema.body = responseJsonSchema; } return { type: "object", - properties: reqSchemas as Record, + properties: respSchema, }; } @@ -213,5 +267,6 @@ function buildReqSchema(routeDefinition: RouteDefinition): JsonSchema { // - Correct URL // - Correct method // - Correct query? +// - Correct headers? // - Correct body? // - Correct return type diff --git a/packages/api/test/utils/utils.ts b/packages/api/test/utils/utils.ts index b261ae54920f..d78378e57e19 100644 --- a/packages/api/test/utils/utils.ts +++ b/packages/api/test/utils/utils.ts @@ -2,7 +2,8 @@ import {MockedObject, vi} from "vitest"; import {parse as parseQueryString} from "qs"; import {FastifyInstance, fastify} from "fastify"; import {mapValues} from "@lodestar/utils"; -import {ServerApi} from "../../src/interfaces.js"; +import {Endpoint} from "../../src/utils/index.js"; +import {ApplicationMethods, addSszContentTypeParser} from "../../src/utils/server/index.js"; export function getTestServer(): {server: FastifyInstance; start: () => Promise} { const server = fastify({ @@ -10,7 +11,9 @@ export function getTestServer(): {server: FastifyInstance; start: () => Promise< querystringParser: (str) => parseQueryString(str, {comma: true, parseArrays: false}), }); - server.addHook("onError", (request, reply, error, done) => { + addSszContentTypeParser(server); + + server.addHook("onError", (_request, _reply, error, done) => { // eslint-disable-next-line no-console console.log(`onError: ${error.toString()}`); done(); @@ -30,8 +33,8 @@ export function getTestServer(): {server: FastifyInstance; start: () => Promise< return {start, server}; } -export function getMockApi>( +export function getMockApi>( routeIds: Record -): MockedObject> & ServerApi { - return mapValues(routeIds, () => vi.fn()) as MockedObject> & ServerApi; +): MockedObject> & ApplicationMethods { + return mapValues(routeIds, () => vi.fn()) as MockedObject> & ApplicationMethods; } diff --git a/packages/beacon-node/src/api/impl/api.ts b/packages/beacon-node/src/api/impl/api.ts index d962d310b5ba..6ec7180cf0f4 100644 --- a/packages/beacon-node/src/api/impl/api.ts +++ b/packages/beacon-node/src/api/impl/api.ts @@ -1,4 +1,4 @@ -import {Api, ServerApi} from "@lodestar/api"; +import {BeaconApiMethods} from "@lodestar/api/beacon/server"; import {ApiOptions} from "../options.js"; import {ApiModules} from "./types.js"; import {getBeaconApi} from "./beacon/index.js"; @@ -11,7 +11,7 @@ import {getNodeApi} from "./node/index.js"; import {getProofApi} from "./proof/index.js"; import {getValidatorApi} from "./validator/index.js"; -export function getApi(opts: ApiOptions, modules: ApiModules): {[K in keyof Api]: ServerApi} { +export function getApi(opts: ApiOptions, modules: ApiModules): BeaconApiMethods { return { beacon: getBeaconApi(modules), config: getConfigApi(modules), diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index ed0224fc9cb6..c7c34d45971a 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -1,8 +1,8 @@ -import {fromHexString, toHexString} from "@chainsafe/ssz"; -import {routes, ServerApi, ResponseFormat} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {computeEpochAtSlot, computeTimeAtSlot, reconstructFullBlockOrContents} from "@lodestar/state-transition"; import {SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; -import {sleep, toHex} from "@lodestar/utils"; +import {sleep, fromHex, toHex} from "@lodestar/utils"; import {allForks, deneb, isSignedBlockContents, ProducedBlockSource} from "@lodestar/types"; import {BlockSource, getBlockInput, ImportBlockOpts, BlockInput, BlobsSource} from "../../../../chain/blocks/types.js"; import {promiseAllMaybeAsync} from "../../../../util/promises.js"; @@ -17,7 +17,7 @@ import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; import {BeaconChain} from "../../../../chain/chain.js"; import {resolveBlockId, toBeaconHeaderResponse} from "./utils.js"; -type PublishBlockOpts = ImportBlockOpts & {broadcastValidation?: routes.beacon.BroadcastValidation}; +type PublishBlockOpts = ImportBlockOpts; /** * Validator clock may be advanced from beacon's clock. If the validator requests a resource in a @@ -36,9 +36,13 @@ export function getBeaconBlockApi({ metrics, network, db, -}: Pick): ServerApi { - const publishBlock: ServerApi["publishBlock"] = async ( - signedBlockOrContents, +}: Pick< + ApiModules, + "chain" | "config" | "metrics" | "network" | "db" +>): ApplicationMethods { + const publishBlock: ApplicationMethods["publishBlockV2"] = async ( + {signedBlockOrContents, broadcastValidation}, + context, opts: PublishBlockOpts = {} ) => { const seenTimestampSec = Date.now() / 1000; @@ -60,13 +64,12 @@ export function getBeaconBlockApi({ } else { signedBlock = signedBlockOrContents; blobSidecars = []; - // TODO: Once API supports submitting data as SSZ, replace null with blockBytes - blockForImport = getBlockInput.preDeneb(config, signedBlock, BlockSource.api, null); + blockForImport = getBlockInput.preDeneb(config, signedBlock, BlockSource.api, context?.sszBytes ?? null); } // check what validations have been requested before broadcasting and publishing the block // TODO: add validation time to metrics - const broadcastValidation = opts.broadcastValidation ?? routes.beacon.BroadcastValidation.gossip; + broadcastValidation = broadcastValidation ?? routes.beacon.BroadcastValidation.gossip; // if block is locally produced, full or blinded, it already is 'consensus' validated as it went through // state transition to produce the stateRoot const slot = signedBlock.message.slot; @@ -121,7 +124,7 @@ export function getBeaconBlockApi({ ); throw new BlockError(signedBlock, { code: BlockErrorCode.PARENT_UNKNOWN, - parentRoot: toHexString(signedBlock.message.parentRoot), + parentRoot: toHex(signedBlock.message.parentRoot), }); } @@ -211,8 +214,9 @@ export function getBeaconBlockApi({ await promiseAllMaybeAsync(publishPromises); }; - const publishBlindedBlock: ServerApi["publishBlindedBlock"] = async ( - signedBlindedBlock, + const publishBlindedBlock: ApplicationMethods["publishBlindedBlock"] = async ( + {signedBlindedBlock}, + context, opts: PublishBlockOpts = {} ) => { const slot = signedBlindedBlock.message.slot; @@ -236,7 +240,7 @@ export function getBeaconBlockApi({ const signedBlockOrContents = reconstructFullBlockOrContents(signedBlindedBlock, {executionPayload, contents}); chain.logger.info("Publishing assembled block", {slot, blockRoot, source}); - return publishBlock(signedBlockOrContents, opts); + return publishBlock({signedBlockOrContents}, {...context, sszBytes: null}, opts); } else { const source = ProducedBlockSource.builder; chain.logger.debug("Reconstructing signedBlockOrContents", {slot, blockRoot, source}); @@ -248,12 +252,12 @@ export function getBeaconBlockApi({ // // see: https://github.com/ChainSafe/lodestar/issues/5404 chain.logger.info("Publishing assembled block", {slot, blockRoot, source}); - return publishBlock(signedBlockOrContents, {...opts, ignoreIfKnown: true}); + return publishBlock({signedBlockOrContents}, {...context, sszBytes: null}, {...opts, ignoreIfKnown: true}); } }; return { - async getBlockHeaders(filters) { + async getBlockHeaders({slot, parentRoot}) { // TODO - SLOW CODE: This code seems like it could be improved // If one block in the response contains an optimistic block, mark the entire response as optimistic @@ -262,16 +266,15 @@ export function getBeaconBlockApi({ let finalized = true; const result: routes.beacon.BlockHeaderResponse[] = []; - if (filters.parentRoot) { - const parentRoot = filters.parentRoot; - const finalizedBlock = await db.blockArchive.getByParentRoot(fromHexString(parentRoot)); + if (parentRoot) { + const finalizedBlock = await db.blockArchive.getByParentRoot(fromHex(parentRoot)); if (finalizedBlock) { result.push(toBeaconHeaderResponse(config, finalizedBlock, true)); } const nonFinalizedBlocks = chain.forkChoice.getBlockSummariesByParentRoot(parentRoot); await Promise.all( nonFinalizedBlocks.map(async (summary) => { - const block = await db.block.get(fromHexString(summary.blockRoot)); + const block = await db.block.get(fromHex(summary.blockRoot)); if (block) { const canonical = chain.forkChoice.getCanonicalBlockAtSlot(block.message.slot); if (canonical) { @@ -286,31 +289,30 @@ export function getBeaconBlockApi({ }) ); return { - executionOptimistic, - finalized, data: result.filter( (item) => // skip if no slot filter - !(filters.slot !== undefined && filters.slot !== 0) || item.header.message.slot === filters.slot + !(slot !== undefined && slot !== 0) || item.header.message.slot === slot ), + meta: {executionOptimistic, finalized}, }; } const headSlot = chain.forkChoice.getHead().slot; - if (!filters.parentRoot && filters.slot === undefined) { - filters.slot = headSlot; + if (!parentRoot && slot === undefined) { + slot = headSlot; } - if (filters.slot !== undefined) { + if (slot !== undefined) { // future slot - if (filters.slot > headSlot) { - return {executionOptimistic: false, finalized: false, data: []}; + if (slot > headSlot) { + return {data: [], meta: {executionOptimistic: false, finalized: false}}; } - const canonicalBlock = await chain.getCanonicalBlockAtSlot(filters.slot); + const canonicalBlock = await chain.getCanonicalBlockAtSlot(slot); // skip slot if (!canonicalBlock) { - return {executionOptimistic: false, finalized: false, data: []}; + return {data: [], meta: {executionOptimistic: false, finalized: false}}; } const canonicalRoot = config .getForkTypes(canonicalBlock.block.message.slot) @@ -323,14 +325,14 @@ export function getBeaconBlockApi({ // fork blocks // TODO: What is this logic? await Promise.all( - chain.forkChoice.getBlockSummariesAtSlot(filters.slot).map(async (summary) => { + chain.forkChoice.getBlockSummariesAtSlot(slot).map(async (summary) => { if (isOptimisticBlock(summary)) { executionOptimistic = true; } finalized = false; - if (summary.blockRoot !== toHexString(canonicalRoot)) { - const block = await db.block.get(fromHexString(summary.blockRoot)); + if (summary.blockRoot !== toHex(canonicalRoot)) { + const block = await db.block.get(fromHex(summary.blockRoot)); if (block) { result.push(toBeaconHeaderResponse(config, block)); } @@ -340,54 +342,45 @@ export function getBeaconBlockApi({ } return { - executionOptimistic, - finalized, data: result, + meta: {executionOptimistic, finalized}, }; }, - async getBlockHeader(blockId) { + async getBlockHeader({blockId}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); return { - executionOptimistic, - finalized, data: toBeaconHeaderResponse(config, block, true), + meta: {executionOptimistic, finalized}, }; }, - async getBlock(blockId, format?: ResponseFormat) { + async getBlock({blockId}) { const {block} = await resolveBlockId(chain, blockId); - if (format === "ssz") { - return config.getForkTypes(block.message.slot).SignedBeaconBlock.serialize(block); - } - return { - data: block, - }; + return {data: block}; }, - async getBlockV2(blockId, format?: ResponseFormat) { + async getBlockV2({blockId}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); - if (format === "ssz") { - return config.getForkTypes(block.message.slot).SignedBeaconBlock.serialize(block); - } return { - executionOptimistic, - finalized, data: block, - version: config.getForkName(block.message.slot), + meta: { + executionOptimistic, + finalized, + version: config.getForkName(block.message.slot), + }, }; }, - async getBlockAttestations(blockId) { + async getBlockAttestations({blockId}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); return { - executionOptimistic, - finalized, data: Array.from(block.message.body.attestations), + meta: {executionOptimistic, finalized}, }; }, - async getBlockRoot(blockId) { + async getBlockRoot({blockId}) { // Fast path: From head state already available in memory get historical blockRoot const slot = typeof blockId === "string" ? parseInt(blockId) : blockId; if (!Number.isNaN(slot)) { @@ -395,50 +388,49 @@ export function getBeaconBlockApi({ if (slot === head.slot) { return { - executionOptimistic: isOptimisticBlock(head), - finalized: false, - data: {root: fromHexString(head.blockRoot)}, + data: {root: fromHex(head.blockRoot)}, + meta: {executionOptimistic: isOptimisticBlock(head), finalized: false}, }; } if (slot < head.slot && head.slot <= slot + SLOTS_PER_HISTORICAL_ROOT) { const state = chain.getHeadState(); return { - executionOptimistic: isOptimisticBlock(head), - finalized: computeEpochAtSlot(slot) <= chain.forkChoice.getFinalizedCheckpoint().epoch, data: {root: state.blockRoots.get(slot % SLOTS_PER_HISTORICAL_ROOT)}, + meta: { + executionOptimistic: isOptimisticBlock(head), + finalized: computeEpochAtSlot(slot) <= chain.forkChoice.getFinalizedCheckpoint().epoch, + }, }; } } else if (blockId === "head") { const head = chain.forkChoice.getHead(); return { - executionOptimistic: isOptimisticBlock(head), - finalized: false, - data: {root: fromHexString(head.blockRoot)}, + data: {root: fromHex(head.blockRoot)}, + meta: {executionOptimistic: isOptimisticBlock(head), finalized: false}, }; } // Slow path const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); return { - executionOptimistic, - finalized, data: {root: config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message)}, + meta: {executionOptimistic, finalized}, }; }, publishBlock, publishBlindedBlock, - async publishBlindedBlockV2(signedBlindedBlockOrContents, opts) { - await publishBlindedBlock(signedBlindedBlockOrContents, opts); + async publishBlindedBlockV2(args, context, opts) { + await publishBlindedBlock(args, context, opts); }, - async publishBlockV2(signedBlockOrContents, opts) { - await publishBlock(signedBlockOrContents, opts); + async publishBlockV2(args, context, opts) { + await publishBlock(args, context, opts); }, - async getBlobSidecars(blockId, indices) { + async getBlobSidecars({blockId, indices}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); const blockRoot = config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message); @@ -448,13 +440,16 @@ export function getBeaconBlockApi({ } if (!blobSidecars) { - throw Error(`blobSidecars not found in db for slot=${block.message.slot} root=${toHexString(blockRoot)}`); + throw Error(`blobSidecars not found in db for slot=${block.message.slot} root=${toHex(blockRoot)}`); } return { - executionOptimistic, - finalized, data: indices ? blobSidecars.filter(({index}) => indices.includes(index)) : blobSidecars, + meta: { + executionOptimistic, + finalized, + version: config.getForkName(block.message.slot), + }, }; }, }; @@ -466,7 +461,7 @@ async function reconstructBuilderBlockOrContents( ): Promise { const executionBuilder = chain.executionBuilder; if (!executionBuilder) { - throw Error("exeutionBuilder required to publish SignedBlindedBeaconBlock"); + throw Error("executionBuilder required to publish SignedBlindedBeaconBlock"); } const signedBlockOrContents = await executionBuilder.submitBlindedBlock(signedBlindedBlock); diff --git a/packages/beacon-node/src/api/impl/beacon/index.ts b/packages/beacon-node/src/api/impl/beacon/index.ts index 492e2f8ff8b1..2e75dc76782c 100644 --- a/packages/beacon-node/src/api/impl/beacon/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/index.ts @@ -1,4 +1,5 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {ApiModules} from "../types.js"; import {getBeaconBlockApi} from "./blocks/index.js"; import {getBeaconPoolApi} from "./pool/index.js"; @@ -7,7 +8,7 @@ import {getBeaconRewardsApi} from "./rewards/index.js"; export function getBeaconApi( modules: Pick -): ServerApi { +): ApplicationMethods { const block = getBeaconBlockApi(modules); const pool = getBeaconPoolApi(modules); const state = getBeaconStateApi(modules); diff --git a/packages/beacon-node/src/api/impl/beacon/pool/index.ts b/packages/beacon-node/src/api/impl/beacon/pool/index.ts index 09a66eba4d15..8372b84db3b1 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -1,4 +1,5 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {Epoch, ssz} from "@lodestar/types"; import {SYNC_COMMITTEE_SUBNET_SIZE} from "@lodestar/params"; import {validateApiAttestation} from "../../../../chain/validation/index.js"; @@ -21,14 +22,14 @@ export function getBeaconPoolApi({ logger, metrics, network, -}: Pick): ServerApi { +}: Pick): ApplicationMethods { return { - async getPoolAttestations(filters) { + async getPoolAttestations({slot, committeeIndex}) { // Already filtered by slot - let attestations = chain.aggregatedAttestationPool.getAll(filters?.slot); + let attestations = chain.aggregatedAttestationPool.getAll(slot); - if (filters?.committeeIndex !== undefined) { - attestations = attestations.filter((attestation) => filters.committeeIndex === attestation.data.index); + if (committeeIndex !== undefined) { + attestations = attestations.filter((attestation) => committeeIndex === attestation.data.index); } return {data: attestations}; @@ -46,16 +47,16 @@ export function getBeaconPoolApi({ return {data: chain.opPool.getAllVoluntaryExits()}; }, - async getPoolBlsToExecutionChanges() { + async getPoolBLSToExecutionChanges() { return {data: chain.opPool.getAllBlsToExecutionChanges().map(({data}) => data)}; }, - async submitPoolAttestations(attestations) { + async submitPoolAttestations({signedAttestations}) { const seenTimestampSec = Date.now() / 1000; const errors: Error[] = []; await Promise.all( - attestations.map(async (attestation, i) => { + signedAttestations.map(async (attestation, i) => { try { const fork = chain.config.getForkName(chain.clock.currentSlot); // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -107,26 +108,26 @@ export function getBeaconPoolApi({ } }, - async submitPoolAttesterSlashings(attesterSlashing) { + async submitPoolAttesterSlashings({attesterSlashing}) { await validateApiAttesterSlashing(chain, attesterSlashing); chain.opPool.insertAttesterSlashing(attesterSlashing); await network.publishAttesterSlashing(attesterSlashing); }, - async submitPoolProposerSlashings(proposerSlashing) { + async submitPoolProposerSlashings({proposerSlashing}) { await validateApiProposerSlashing(chain, proposerSlashing); chain.opPool.insertProposerSlashing(proposerSlashing); await network.publishProposerSlashing(proposerSlashing); }, - async submitPoolVoluntaryExit(voluntaryExit) { - await validateApiVoluntaryExit(chain, voluntaryExit); - chain.opPool.insertVoluntaryExit(voluntaryExit); - chain.emitter.emit(routes.events.EventType.voluntaryExit, voluntaryExit); - await network.publishVoluntaryExit(voluntaryExit); + async submitPoolVoluntaryExit({signedVoluntaryExit}) { + await validateApiVoluntaryExit(chain, signedVoluntaryExit); + chain.opPool.insertVoluntaryExit(signedVoluntaryExit); + chain.emitter.emit(routes.events.EventType.voluntaryExit, signedVoluntaryExit); + await network.publishVoluntaryExit(signedVoluntaryExit); }, - async submitPoolBlsToExecutionChange(blsToExecutionChanges) { + async submitPoolBLSToExecutionChange({blsToExecutionChanges}) { const errors: Error[] = []; await Promise.all( @@ -145,7 +146,7 @@ export function getBeaconPoolApi({ } catch (e) { errors.push(e as Error); logger.error( - `Error on submitPoolBlsToExecutionChange [${i}]`, + `Error on submitPoolBLSToExecutionChange [${i}]`, {validatorIndex: blsToExecutionChange.message.validatorIndex}, e as Error ); @@ -154,7 +155,7 @@ export function getBeaconPoolApi({ ); if (errors.length > 1) { - throw Error("Multiple errors on submitPoolBlsToExecutionChange\n" + errors.map((e) => e.message).join("\n")); + throw Error("Multiple errors on submitPoolBLSToExecutionChange\n" + errors.map((e) => e.message).join("\n")); } else if (errors.length === 1) { throw errors[0]; } @@ -170,7 +171,7 @@ export function getBeaconPoolApi({ * * https://github.com/ethereum/beacon-APIs/pull/135 */ - async submitPoolSyncCommitteeSignatures(signatures) { + async submitPoolSyncCommitteeSignatures({signatures}) { // Fetch states for all slots of the `signatures` const slots = new Set(); for (const signature of signatures) { diff --git a/packages/beacon-node/src/api/impl/beacon/rewards/index.ts b/packages/beacon-node/src/api/impl/beacon/rewards/index.ts index f1c7d3eb6b8e..96399db27b4f 100644 --- a/packages/beacon-node/src/api/impl/beacon/rewards/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/rewards/index.ts @@ -1,22 +1,25 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {ApiModules} from "../../types.js"; import {resolveBlockId} from "../blocks/utils.js"; -export function getBeaconRewardsApi({chain}: Pick): ServerApi { +export function getBeaconRewardsApi({ + chain, +}: Pick): ApplicationMethods { return { - async getBlockRewards(blockId) { + async getBlockRewards({blockId}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); const data = await chain.getBlockRewards(block.message); - return {data, executionOptimistic, finalized}; + return {data, meta: {executionOptimistic, finalized}}; }, - async getAttestationsRewards(epoch, validatorIds) { + async getAttestationsRewards({epoch, validatorIds}) { const {rewards, executionOptimistic, finalized} = await chain.getAttestationsRewards(epoch, validatorIds); - return {data: rewards, executionOptimistic, finalized}; + return {data: rewards, meta: {executionOptimistic, finalized}}; }, - async getSyncCommitteeRewards(blockId, validatorIds) { + async getSyncCommitteeRewards({blockId, validatorIds}) { const {block, executionOptimistic, finalized} = await resolveBlockId(chain, blockId); const data = await chain.getSyncCommitteeRewards(block.message, validatorIds); - return {data, executionOptimistic, finalized}; + return {data, meta: {executionOptimistic, finalized}}; }, }; } diff --git a/packages/beacon-node/src/api/impl/beacon/state/index.ts b/packages/beacon-node/src/api/impl/beacon/state/index.ts index d61d36bf83b6..e5592d461ff7 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/index.ts @@ -1,4 +1,5 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import { BeaconStateAllForks, CachedBeaconStateAltair, @@ -21,7 +22,7 @@ import { export function getBeaconStateApi({ chain, config, -}: Pick): ServerApi { +}: Pick): ApplicationMethods { async function getState( stateId: routes.beacon.StateId ): Promise<{state: BeaconStateAllForks; executionOptimistic: boolean; finalized: boolean}> { @@ -29,25 +30,23 @@ export function getBeaconStateApi({ } return { - async getStateRoot(stateId) { + async getStateRoot({stateId}) { const {state, executionOptimistic, finalized} = await getState(stateId); return { - executionOptimistic, - finalized, data: {root: state.hashTreeRoot()}, + meta: {executionOptimistic, finalized}, }; }, - async getStateFork(stateId) { + async getStateFork({stateId}) { const {state, executionOptimistic, finalized} = await getState(stateId); return { - executionOptimistic, - finalized, data: state.fork, + meta: {executionOptimistic, finalized}, }; }, - async getStateRandao(stateId, epoch) { + async getStateRandao({stateId, epoch}) { const {state, executionOptimistic, finalized} = await getState(stateId); const stateEpoch = computeEpochAtSlot(state.slot); const usedEpoch = epoch ?? stateEpoch; @@ -59,41 +58,37 @@ export function getBeaconStateApi({ const randao = getRandaoMix(state, usedEpoch); return { - executionOptimistic, - finalized, - data: { - randao, - }, + data: {randao}, + meta: {executionOptimistic, finalized}, }; }, - async getStateFinalityCheckpoints(stateId) { + async getStateFinalityCheckpoints({stateId}) { const {state, executionOptimistic, finalized} = await getState(stateId); return { - executionOptimistic, - finalized, data: { currentJustified: state.currentJustifiedCheckpoint, previousJustified: state.previousJustifiedCheckpoint, finalized: state.finalizedCheckpoint, }, + meta: {executionOptimistic, finalized}, }; }, - async getStateValidators(stateId, filters) { + async getStateValidators({stateId, validatorIds, statuses}) { const {state, executionOptimistic, finalized} = await resolveStateId(chain, stateId); const currentEpoch = getCurrentEpoch(state); const {validators, balances} = state; // Get the validators sub tree once for all the loop const {pubkey2index} = chain.getHeadState().epochCtx; const validatorResponses: routes.beacon.ValidatorResponse[] = []; - if (filters?.id) { - for (const id of filters.id) { + if (validatorIds) { + for (const id of validatorIds) { const resp = getStateValidatorIndex(id, state, pubkey2index); if (resp.valid) { const validatorIndex = resp.validatorIndex; const validator = validators.getReadonly(validatorIndex); - if (filters.status && !filters.status.includes(getValidatorStatus(validator, currentEpoch))) { + if (statuses && !statuses.includes(getValidatorStatus(validator, currentEpoch))) { continue; } const validatorResponse = toValidatorResponse( @@ -106,16 +101,14 @@ export function getBeaconStateApi({ } } return { - executionOptimistic, - finalized, data: validatorResponses, + meta: {executionOptimistic, finalized}, }; - } else if (filters?.status) { - const validatorsByStatus = filterStateValidatorsByStatus(filters.status, state, pubkey2index, currentEpoch); + } else if (statuses) { + const validatorsByStatus = filterStateValidatorsByStatus(statuses, state, pubkey2index, currentEpoch); return { - executionOptimistic, - finalized, data: validatorsByStatus, + meta: {executionOptimistic, finalized}, }; } @@ -128,17 +121,16 @@ export function getBeaconStateApi({ } return { - executionOptimistic, - finalized, data: resp, + meta: {executionOptimistic, finalized}, }; }, - async postStateValidators(stateId, filters) { - return this.getStateValidators(stateId, filters); + async postStateValidators(args, context) { + return this.getStateValidators(args, context); }, - async getStateValidator(stateId, validatorId) { + async getStateValidator({stateId, validatorId}) { const {state, executionOptimistic, finalized} = await resolveStateId(chain, stateId); const {pubkey2index} = chain.getHeadState().epochCtx; @@ -149,24 +141,23 @@ export function getBeaconStateApi({ const validatorIndex = resp.validatorIndex; return { - executionOptimistic, - finalized, data: toValidatorResponse( validatorIndex, state.validators.getReadonly(validatorIndex), state.balances.get(validatorIndex), getCurrentEpoch(state) ), + meta: {executionOptimistic, finalized}, }; }, - async getStateValidatorBalances(stateId, indices) { + async getStateValidatorBalances({stateId, validatorIds}) { const {state, executionOptimistic, finalized} = await resolveStateId(chain, stateId); - if (indices) { + if (validatorIds) { const headState = chain.getHeadState(); const balances: routes.beacon.ValidatorBalance[] = []; - for (const id of indices) { + for (const id of validatorIds) { if (typeof id === "number") { if (state.validators.length <= id) { continue; @@ -180,9 +171,8 @@ export function getBeaconStateApi({ } } return { - executionOptimistic, - finalized, data: balances, + meta: {executionOptimistic, finalized}, }; } @@ -193,17 +183,16 @@ export function getBeaconStateApi({ resp.push({index: i, balance: balancesArr[i]}); } return { - executionOptimistic, - finalized, data: resp, + meta: {executionOptimistic, finalized}, }; }, - async postStateValidatorBalances(stateId, indices) { - return this.getStateValidatorBalances(stateId, indices); + async postStateValidatorBalances(args, context) { + return this.getStateValidatorBalances(args, context); }, - async getEpochCommittees(stateId, filters) { + async getEpochCommittees({stateId, ...filters}) { const {state, executionOptimistic, finalized} = await resolveStateId(chain, stateId); const stateCached = state as CachedBeaconStateAltair; @@ -211,33 +200,32 @@ export function getBeaconStateApi({ throw new ApiError(400, `No cached state available for stateId: ${stateId}`); } - const epoch = filters?.epoch ?? computeEpochAtSlot(state.slot); + const epoch = filters.epoch ?? computeEpochAtSlot(state.slot); const startSlot = computeStartSlotAtEpoch(epoch); const shuffling = stateCached.epochCtx.getShufflingAtEpoch(epoch); const committees = shuffling.committees; const committeesFlat = committees.flatMap((slotCommittees, slotInEpoch) => { const slot = startSlot + slotInEpoch; - if (filters?.slot !== undefined && filters.slot !== slot) { + if (filters.slot !== undefined && filters.slot !== slot) { return []; } return slotCommittees.flatMap((committee, committeeIndex) => { - if (filters?.index !== undefined && filters.index !== committeeIndex) { + if (filters.index !== undefined && filters.index !== committeeIndex) { return []; } return [ { index: committeeIndex, slot, - validators: committee, + validators: Array.from(committee), }, ]; }); }); return { - executionOptimistic, - finalized, data: committeesFlat, + meta: {executionOptimistic, finalized}, }; }, @@ -245,7 +233,7 @@ export function getBeaconStateApi({ * Retrieves the sync committees for the given state. * @param epoch Fetch sync committees for the given epoch. If not present then the sync committees for the epoch of the state will be obtained. */ - async getEpochSyncCommittees(stateId, epoch) { + async getEpochSyncCommittees({stateId, epoch}) { // TODO: Should pick a state with the provided epoch too const {state, executionOptimistic, finalized} = await resolveStateId(chain, stateId); @@ -264,13 +252,12 @@ export function getBeaconStateApi({ const syncCommitteeCache = stateCached.epochCtx.getIndexedSyncCommitteeAtEpoch(epoch ?? stateEpoch); return { - executionOptimistic, - finalized, data: { validators: syncCommitteeCache.validatorIndices, // TODO: This is not used by the validator and will be deprecated soon validatorAggregates: [], }, + meta: {executionOptimistic, finalized}, }; }, }; diff --git a/packages/beacon-node/src/api/impl/beacon/state/utils.ts b/packages/beacon-node/src/api/impl/beacon/state/utils.ts index 5ed624e9eedf..73f7134e1530 100644 --- a/packages/beacon-node/src/api/impl/beacon/state/utils.ts +++ b/packages/beacon-node/src/api/impl/beacon/state/utils.ts @@ -1,9 +1,8 @@ -import {fromHexString} from "@chainsafe/ssz"; import {routes} from "@lodestar/api"; import {FAR_FUTURE_EPOCH, GENESIS_SLOT} from "@lodestar/params"; import {BeaconStateAllForks, PubkeyIndexMap} from "@lodestar/state-transition"; -import {BLSPubkey, phase0} from "@lodestar/types"; -import {Epoch, ValidatorIndex} from "@lodestar/types"; +import {BLSPubkey, Epoch, phase0, ValidatorIndex} from "@lodestar/types"; +import {fromHex} from "@lodestar/utils"; import {IBeaconChain, StateGetOpts} from "../../../../chain/index.js"; import {ApiError, ValidationError} from "../../errors.js"; import {isOptimisticBlock} from "../../../../util/forkChoice.js"; @@ -142,7 +141,7 @@ export function getStateValidatorIndex( // mutate `id` and fallthrough to below if (id.startsWith("0x")) { try { - id = fromHexString(id); + id = fromHex(id); } catch (e) { return {valid: false, code: 400, reason: "Invalid pubkey hex encoding"}; } diff --git a/packages/beacon-node/src/api/impl/config/index.ts b/packages/beacon-node/src/api/impl/config/index.ts index 209906fd415c..9b8694aa5914 100644 --- a/packages/beacon-node/src/api/impl/config/index.ts +++ b/packages/beacon-node/src/api/impl/config/index.ts @@ -1,4 +1,5 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {chainConfigToJson, ChainConfig, specValuesToJson} from "@lodestar/config"; import {activePreset, presetToJson} from "@lodestar/params"; import {ApiModules} from "../types.js"; @@ -21,7 +22,7 @@ export function renderJsonSpec(config: ChainConfig): Record { return {...configJson, ...presetJson, ...constantsJson}; } -export function getConfigApi({config}: Pick): ServerApi { +export function getConfigApi({config}: Pick): ApplicationMethods { return { async getForkSchedule() { const forkInfos = Object.values(config.forks); diff --git a/packages/beacon-node/src/api/impl/debug/index.ts b/packages/beacon-node/src/api/impl/debug/index.ts index a03c00bd7150..c5c0eeda03a3 100644 --- a/packages/beacon-node/src/api/impl/debug/index.ts +++ b/packages/beacon-node/src/api/impl/debug/index.ts @@ -1,9 +1,14 @@ -import {routes, ServerApi, ResponseFormat} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; +import {phase0} from "@lodestar/types"; import {resolveStateId} from "../beacon/state/utils.js"; import {ApiModules} from "../types.js"; import {isOptimisticBlock} from "../../../util/forkChoice.js"; -export function getDebugApi({chain, config}: Pick): ServerApi { +export function getDebugApi({ + chain, + config, +}: Pick): ApplicationMethods { return { async getDebugChainHeads() { const heads = chain.forkChoice.getHeads(); @@ -36,22 +41,24 @@ export function getDebugApi({chain, config}: Pick): ServerApi { +export function getEventsApi({ + chain, +}: Pick): ApplicationMethods { return { - async eventstream(topics, signal, onEvent) { + async eventstream({topics, signal, onEvent}) { const onAbortFns: (() => void)[] = []; for (const topic of topics) { diff --git a/packages/beacon-node/src/api/impl/lightclient/index.ts b/packages/beacon-node/src/api/impl/lightclient/index.ts index 83052630f343..13deb16a9cb0 100644 --- a/packages/beacon-node/src/api/impl/lightclient/index.ts +++ b/packages/beacon-node/src/api/impl/lightclient/index.ts @@ -1,6 +1,6 @@ -import {fromHexString} from "@chainsafe/ssz"; -import {routes, ServerApi} from "@lodestar/api"; -import {SyncPeriod} from "@lodestar/types"; +import {fromHex} from "@lodestar/utils"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {MAX_REQUEST_LIGHT_CLIENT_UPDATES, MAX_REQUEST_LIGHT_CLIENT_COMMITTEE_HASHES} from "@lodestar/params"; import {ApiModules} from "../types.js"; @@ -9,40 +9,40 @@ import {ApiModules} from "../types.js"; export function getLightclientApi({ chain, config, -}: Pick): ServerApi { +}: Pick): ApplicationMethods { return { - async getUpdates(startPeriod: SyncPeriod, count: number) { + async getLightClientUpdatesByRange({startPeriod, count}) { const maxAllowedCount = Math.min(MAX_REQUEST_LIGHT_CLIENT_UPDATES, count); const periods = Array.from({length: maxAllowedCount}, (_ignored, i) => i + startPeriod); const updates = await Promise.all(periods.map((period) => chain.lightClientServer.getUpdate(period))); - return updates.map((update) => ({ - version: config.getForkName(update.attestedHeader.beacon.slot), - data: update, - })); + return { + data: updates, + meta: {versions: updates.map((update) => config.getForkName(update.attestedHeader.beacon.slot))}, + }; }, - async getOptimisticUpdate() { - const data = chain.lightClientServer.getOptimisticUpdate(); - if (data === null) { + async getLightClientOptimisticUpdate() { + const update = chain.lightClientServer.getOptimisticUpdate(); + if (update === null) { throw Error("No optimistic update available"); } - return {version: config.getForkName(data.attestedHeader.beacon.slot), data}; + return {data: update, meta: {version: config.getForkName(update.attestedHeader.beacon.slot)}}; }, - async getFinalityUpdate() { - const data = chain.lightClientServer.getFinalityUpdate(); - if (data === null) { + async getLightClientFinalityUpdate() { + const update = chain.lightClientServer.getFinalityUpdate(); + if (update === null) { throw Error("No finality update available"); } - return {version: config.getForkName(data.attestedHeader.beacon.slot), data}; + return {data: update, meta: {version: config.getForkName(update.attestedHeader.beacon.slot)}}; }, - async getBootstrap(blockRoot) { - const bootstrapProof = await chain.lightClientServer.getBootstrap(fromHexString(blockRoot)); - return {version: config.getForkName(bootstrapProof.header.beacon.slot), data: bootstrapProof}; + async getLightClientBootstrap({blockRoot}) { + const bootstrapProof = await chain.lightClientServer.getBootstrap(fromHex(blockRoot)); + return {data: bootstrapProof, meta: {version: config.getForkName(bootstrapProof.header.beacon.slot)}}; }, - async getCommitteeRoot(startPeriod: SyncPeriod, count: number) { + async getLightClientCommitteeRoot({startPeriod, count}) { const maxAllowedCount = Math.min(MAX_REQUEST_LIGHT_CLIENT_COMMITTEE_HASHES, count); const periods = Array.from({length: maxAllowedCount}, (_ignored, i) => i + startPeriod); const committeeHashes = await Promise.all( diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index 8048e668662b..d3083dda8e9c 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -1,13 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import {toHexString} from "@chainsafe/ssz"; -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {Repository} from "@lodestar/db"; import {toHex} from "@lodestar/utils"; import {getLatestWeakSubjectivityCheckpointEpoch} from "@lodestar/state-transition"; import {ChainForkConfig} from "@lodestar/config"; import {ssz} from "@lodestar/types"; -import {LodestarThreadType} from "@lodestar/api/lib/beacon/routes/lodestar.js"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {BeaconChain} from "../../../chain/index.js"; import {QueuedStateRegenerator, RegenRequest} from "../../../chain/regen/index.js"; @@ -22,13 +21,13 @@ export function getLodestarApi({ db, network, sync, -}: Pick): ServerApi { +}: Pick): ApplicationMethods { let writingHeapdump = false; let writingProfile = false; const defaultProfileMs = SLOTS_PER_EPOCH * config.SECONDS_PER_SLOT * 1000; return { - async writeHeapdump(thread = "main", dirpath = ".") { + async writeHeapdump({thread = "main", dirpath = "."}) { if (writingHeapdump) { throw Error("Already writing heapdump"); } @@ -54,7 +53,7 @@ export function getLodestarApi({ } }, - async writeProfile(thread: LodestarThreadType = "network", durationMs = defaultProfileMs, dirpath = ".") { + async writeProfile({thread = "network", duration = defaultProfileMs, dirpath = "."}) { if (writingProfile) { throw Error("Already writing network profile"); } @@ -65,14 +64,14 @@ export function getLodestarApi({ let profile: string; switch (thread) { case "network": - filepath = await network.writeNetworkThreadProfile(durationMs, dirpath); + filepath = await network.writeNetworkThreadProfile(duration, dirpath); break; case "discv5": - filepath = await network.writeDiscv5Profile(durationMs, dirpath); + filepath = await network.writeDiscv5Profile(duration, dirpath); break; default: // main thread - profile = await profileNodeJS(durationMs); + profile = await profileNodeJS(duration); filepath = path.join(dirpath, `main_thread_${new Date().toISOString()}.cpuprofile`); fs.writeFileSync(filepath, profile); break; @@ -92,7 +91,7 @@ export function getLodestarApi({ return {data: sync.getSyncChainsDebugState()}; }, - async getGossipQueueItems(gossipType: GossipType | string) { + async getGossipQueueItems({gossipType}) { return { data: await network.dumpGossipQueue(gossipType as GossipType), }; @@ -144,16 +143,15 @@ export function getLodestarApi({ chain.regen.dropCache(); }, - async connectPeer(peerIdStr, multiaddrStrs) { - await network.connectToPeer(peerIdStr, multiaddrStrs); + async connectPeer({peerId, multiaddrs}) { + await network.connectToPeer(peerId, multiaddrs); }, - async disconnectPeer(peerIdStr) { - await network.disconnectPeer(peerIdStr); + async disconnectPeer({peerId}) { + await network.disconnectPeer(peerId); }, - async getPeers(filters) { - const {state, direction} = filters || {}; + async getPeers({state, direction}) { const peers = (await network.dumpPeers()).filter( (nodePeer) => (!state || state.length === 0 || state.includes(nodePeer.state)) && @@ -172,16 +170,16 @@ export function getLodestarApi({ }; }, - async dumpDbBucketKeys(bucketReq) { + async dumpDbBucketKeys({bucket}) { for (const repo of Object.values(db) as IBeaconDb[keyof IBeaconDb][]) { if (repo instanceof Repository) { - if (String(repo["bucket"]) === bucketReq || repo["bucketId"] === bucketReq) { + if (String(repo["bucket"]) === bucket || repo["bucketId"] === bucket) { return {data: stringifyKeys(await repo.keys())}; } } } - throw Error(`Unknown Bucket '${bucketReq}'`); + throw Error(`Unknown Bucket '${bucket}'`); }, async dumpDbStateIndex() { @@ -204,7 +202,7 @@ function regenRequestToJson(config: ChainForkConfig, regenRequest: RegenRequest) case "getPreState": { const slot = regenRequest.args[0].slot; return { - root: toHexString(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(regenRequest.args[0])), + root: toHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(regenRequest.args[0])), slot, }; } diff --git a/packages/beacon-node/src/api/impl/node/index.ts b/packages/beacon-node/src/api/impl/node/index.ts index 26b7f827d1fd..103552ffb5b9 100644 --- a/packages/beacon-node/src/api/impl/node/index.ts +++ b/packages/beacon-node/src/api/impl/node/index.ts @@ -1,4 +1,5 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {ApiError} from "../errors.js"; import {ApiModules} from "../types.js"; import {ApiOptions} from "../../options.js"; @@ -6,7 +7,7 @@ import {ApiOptions} from "../../options.js"; export function getNodeApi( opts: ApiOptions, {network, sync}: Pick -): ServerApi { +): ApplicationMethods { return { async getNetworkIdentity() { return { @@ -14,16 +15,15 @@ export function getNodeApi( }; }, - async getPeer(peerIdStr) { - const peer = await network.dumpPeer(peerIdStr); + async getPeer({peerId}) { + const peer = await network.dumpPeer(peerId); if (!peer) { throw new ApiError(404, "Node has not seen this peer"); } return {data: peer}; }, - async getPeers(filters) { - const {state, direction} = filters || {}; + async getPeers({state, direction}) { const peers = (await network.dumpPeers()).filter( (nodePeer) => (!state || state.length === 0 || state.includes(nodePeer.state)) && @@ -66,29 +66,22 @@ export function getNodeApi( return {data: sync.getSyncStatus()}; }, - async getHealth(options) { - const syncingStatus = options?.syncingStatus; - + async getHealth({syncingStatus}) { if (syncingStatus != null && (syncingStatus < 100 || syncingStatus > 599)) { throw new ApiError(400, `Invalid syncing status code: ${syncingStatus}`); } - let healthStatus: number; - if (sync.getSyncStatus().isSyncing) { // 206: Node is syncing but can serve incomplete data - healthStatus = syncingStatus ?? routes.node.NodeHealth.SYNCING; + return {status: syncingStatus ?? routes.node.NodeHealth.SYNCING}; } else { // 200: Node is ready - healthStatus = routes.node.NodeHealth.READY; + return {status: routes.node.NodeHealth.READY}; } // else { // 503: Node not initialized or having issues // NOTE: Lodestar does not start its API until fully initialized, so this status can never be served // } - - // Health status is returned to allow route handler to set it as HTTP status code - return healthStatus as unknown as void; }, }; } diff --git a/packages/beacon-node/src/api/impl/proof/index.ts b/packages/beacon-node/src/api/impl/proof/index.ts index 09f691c4c453..a581fb8eed59 100644 --- a/packages/beacon-node/src/api/impl/proof/index.ts +++ b/packages/beacon-node/src/api/impl/proof/index.ts @@ -1,5 +1,6 @@ -import {createProof, ProofType} from "@chainsafe/persistent-merkle-tree"; -import {routes, ServerApi} from "@lodestar/api"; +import {CompactMultiProof, createProof, ProofType} from "@chainsafe/persistent-merkle-tree"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {ApiModules} from "../types.js"; import {resolveStateId} from "../beacon/state/utils.js"; import {resolveBlockId} from "../beacon/blocks/utils.js"; @@ -8,13 +9,13 @@ import {ApiOptions} from "../../options.js"; export function getProofApi( opts: ApiOptions, {chain, config}: Pick -): ServerApi { +): ApplicationMethods { // It's currently possible to request gigantic proofs (eg: a proof of the entire beacon state) // We want some some sort of resistance against this DoS vector. const maxGindicesInProof = opts.maxGindicesInProof ?? 512; return { - async getStateProof(stateId, descriptor) { + async getStateProof({stateId, descriptor}) { // descriptor.length / 2 is a rough approximation of # of gindices if (descriptor.length / 2 > maxGindicesInProof) { throw new Error("Requested proof is too large."); @@ -26,11 +27,14 @@ export function getProofApi( state.commit(); const stateNode = state.node; - const data = createProof(stateNode, {type: ProofType.compactMulti, descriptor}); + const proof = createProof(stateNode, {type: ProofType.compactMulti, descriptor}); - return {data}; + return { + data: proof as CompactMultiProof, + meta: {version: config.getForkName(state.slot)}, + }; }, - async getBlockProof(blockId, descriptor) { + async getBlockProof({blockId, descriptor}) { // descriptor.length / 2 is a rough approximation of # of gindices if (descriptor.length / 2 > maxGindicesInProof) { throw new Error("Requested proof is too large."); @@ -41,9 +45,12 @@ export function getProofApi( // Commit any changes before computing the state root. In normal cases the state should have no changes here const blockNode = config.getForkTypes(block.message.slot).BeaconBlock.toView(block.message).node; - const data = createProof(blockNode, {type: ProofType.compactMulti, descriptor}); + const proof = createProof(blockNode, {type: ProofType.compactMulti, descriptor}); - return {data}; + return { + data: proof as CompactMultiProof, + meta: {version: config.getForkName(block.message.slot)}, + }; }, }; } diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 5a10b8337e9c..0b8f4d9cce36 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1,5 +1,5 @@ -import {fromHexString, toHexString} from "@chainsafe/ssz"; -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import { CachedBeaconStateAllForks, computeStartSlotAtEpoch, @@ -18,6 +18,9 @@ import { isForkBlobs, isForkExecution, ForkSeq, + ForkPreBlobs, + ForkBlobs, + ForkExecution, } from "@lodestar/params"; import {MAX_BUILDER_BOOST_FACTOR} from "@lodestar/validator"; import { @@ -33,9 +36,10 @@ import { isBlindedBeaconBlock, isBlockContents, phase0, + Wei, } from "@lodestar/types"; import {ExecutionStatus} from "@lodestar/fork-choice"; -import {toHex, resolveOrRacePromises, prettyWeiToEth} from "@lodestar/utils"; +import {fromHex, toHex, resolveOrRacePromises, prettyWeiToEth} from "@lodestar/utils"; import { AttestationError, AttestationErrorCode, @@ -86,6 +90,20 @@ const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000; /** Overall timeout for execution and block production apis */ const BLOCK_PRODUCTION_RACE_TIMEOUT_MS = 12_000; +type ProduceBlockOrContentsRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & ( + | {data: allForks.BeaconBlock; version: ForkPreBlobs} + | {data: allForks.BlockContents; version: ForkBlobs} +); +type ProduceBlindedBlockRes = {executionPayloadValue: Wei; consensusBlockValue: Wei} & { + data: allForks.BlindedBeaconBlock; + version: ForkExecution; +}; + +type ProduceFullOrBlindedBlockOrContentsRes = {executionPayloadSource: ProducedBlockSource} & ( + | (ProduceBlockOrContentsRes & {executionPayloadBlinded: false}) + | (ProduceBlindedBlockRes & {executionPayloadBlinded: true}) +); + /** * Server implementation for handling validator duties. * See `@lodestar/validator/src/api` for the client implementation). @@ -97,7 +115,7 @@ export function getValidatorApi({ metrics, network, sync, -}: ApiModules): ServerApi { +}: ApiModules): ApplicationMethods { let genesisBlockRoot: Root | null = null; /** @@ -225,7 +243,7 @@ export function getValidatorApi({ } const cp = { epoch: cpHex.epoch, - root: fromHexString(cpHex.rootHex), + root: fromHex(cpHex.rootHex), }; const slot0 = computeStartSlotAtEpoch(cp.epoch); // if not, wait for ChainEvent.checkpoint event until slot 1 of epoch @@ -315,7 +333,7 @@ export function getValidatorApi({ ); } - const produceBuilderBlindedBlock = async function produceBuilderBlindedBlock( + async function produceBuilderBlindedBlock( slot: Slot, randaoReveal: BLSSignature, graffiti: string, @@ -324,7 +342,7 @@ export function getValidatorApi({ skipHeadChecksAndUpdate, commonBlockBody, parentBlockRoot: inParentBlockRoot, - }: Omit & + }: Omit & ( | { skipHeadChecksAndUpdate: true; @@ -337,7 +355,7 @@ export function getValidatorApi({ parentBlockRoot?: undefined; } ) = {} - ): Promise { + ): Promise { const version = config.getForkName(slot); if (!isForkExecution(version)) { throw Error(`Invalid fork=${version} for produceBuilderBlindedBlock`); @@ -363,7 +381,7 @@ export function getValidatorApi({ // forkChoice.updateTime() might have already been called by the onSlot clock // handler, in which case this should just return. chain.forkChoice.updateTime(slot); - parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); + parentBlockRoot = fromHex(chain.getProposerHead(slot).blockRoot); } else { parentBlockRoot = inParentBlockRoot; } @@ -385,7 +403,7 @@ export function getValidatorApi({ slot, executionPayloadValue, consensusBlockValue, - root: toHexString(config.getBlindedForkTypes(slot).BeaconBlock.hashTreeRoot(block)), + root: toHex(config.getBlindedForkTypes(slot).BeaconBlock.hashTreeRoot(block)), }); if (chain.opts.persistProducedBlocks) { @@ -396,9 +414,9 @@ export function getValidatorApi({ } finally { if (timer) timer({source}); } - }; + } - const produceEngineFullBlockOrContents = async function produceEngineFullBlockOrContents( + async function produceEngineFullBlockOrContents( slot: Slot, randaoReveal: BLSSignature, graffiti: string, @@ -408,7 +426,7 @@ export function getValidatorApi({ skipHeadChecksAndUpdate, commonBlockBody, parentBlockRoot: inParentBlockRoot, - }: Omit & + }: Omit & ( | { skipHeadChecksAndUpdate: true; @@ -417,7 +435,7 @@ export function getValidatorApi({ } | {skipHeadChecksAndUpdate?: false | undefined; commonBlockBody?: undefined; parentBlockRoot?: undefined} ) = {} - ): Promise { + ): Promise { const source = ProducedBlockSource.engine; metrics?.blockProductionRequests.inc({source}); @@ -430,7 +448,7 @@ export function getValidatorApi({ // forkChoice.updateTime() might have already been called by the onSlot clock // handler, in which case this should just return. chain.forkChoice.updateTime(slot); - parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); + parentBlockRoot = fromHex(chain.getProposerHead(slot).blockRoot); } else { parentBlockRoot = inParentBlockRoot; } @@ -448,7 +466,7 @@ export function getValidatorApi({ }); const version = config.getForkName(block.slot); if (strictFeeRecipientCheck && feeRecipient && isForkExecution(version)) { - const blockFeeRecipient = toHexString((block as bellatrix.BeaconBlock).body.executionPayload.feeRecipient); + const blockFeeRecipient = toHex((block as bellatrix.BeaconBlock).body.executionPayload.feeRecipient); if (blockFeeRecipient !== feeRecipient) { throw Error(`Invalid feeRecipient set in engine block expected=${feeRecipient} actual=${blockFeeRecipient}`); } @@ -460,7 +478,7 @@ export function getValidatorApi({ slot, executionPayloadValue, consensusBlockValue, - root: toHexString(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block)), + root: toHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block)), }); if (chain.opts.persistProducedBlocks) { void chain.persistBlock(block, "produced_engine_block"); @@ -485,329 +503,298 @@ export function getValidatorApi({ } finally { if (timer) timer({source}); } - }; + } - const produceEngineOrBuilderBlock: ServerApi["produceBlockV3"] = - async function produceEngineOrBuilderBlock( + async function produceEngineOrBuilderBlock( + slot: Slot, + randaoReveal: BLSSignature, + graffiti: string, + // TODO deneb: skip randao verification + _skipRandaoVerification?: boolean, + builderBoostFactor?: bigint, + {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOpts = {} + ): Promise { + notWhileSyncing(); + await waitForSlot(slot); // Must never request for a future slot > currentSlot + + // Process the queued attestations in the forkchoice for correct head estimation + // forkChoice.updateTime() might have already been called by the onSlot clock + // handler, in which case this should just return. + chain.forkChoice.updateTime(slot); + const parentBlockRoot = fromHex(chain.getProposerHead(slot).blockRoot); + + const fork = config.getForkName(slot); + // set some sensible opts + // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled + // and the actual selection will be determined using builderBoostFactor passed by the validator + builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; + builderBoostFactor = builderBoostFactor ?? BigInt(100); + if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { + throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); + } + + const isBuilderEnabled = + ForkSeq[fork] >= ForkSeq.bellatrix && + chain.executionBuilder !== undefined && + builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; + + // At any point either the builder or execution or both flows should be active. + // + // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager + // configurations could cause a validator pubkey to have builder disabled with builder selection builder only + // (TODO: independently make sure such an options update is not successful for a validator pubkey) + // + // So if builder is disabled ignore builder selection of builder only if caused by user mistake + // https://github.com/ChainSafe/lodestar/issues/6338 + const isEngineEnabled = !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly; + + if (!isEngineEnabled && !isBuilderEnabled) { + throw Error( + `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` + ); + } + + const loggerContext = { slot, - randaoReveal, - graffiti, - // TODO deneb: skip randao verification - _skipRandaoVerification?: boolean, - { - feeRecipient, - builderSelection, - builderBoostFactor, - strictFeeRecipientCheck, - }: routes.validator.ExtraProduceBlockOps = {} - ) { - notWhileSyncing(); - await waitForSlot(slot); // Must never request for a future slot > currentSlot + fork, + builderSelection, + isBuilderEnabled, + isEngineEnabled, + strictFeeRecipientCheck, + // winston logger doesn't like bigint + builderBoostFactor: `${builderBoostFactor}`, + }; - // Process the queued attestations in the forkchoice for correct head estimation - // forkChoice.updateTime() might have already been called by the onSlot clock - // handler, in which case this should just return. - chain.forkChoice.updateTime(slot); - const parentBlockRoot = fromHexString(chain.getProposerHead(slot).blockRoot); - - const fork = config.getForkName(slot); - // set some sensible opts - // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled - // and the actual selection will be determined using builderBoostFactor passed by the validator - builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit; - builderBoostFactor = builderBoostFactor ?? BigInt(100); - if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) { - throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`); - } + logger.verbose("Assembling block with produceEngineOrBuilderBlock", loggerContext); + const commonBlockBody = await chain.produceCommonBlockBody({ + slot, + parentBlockRoot, + randaoReveal, + graffiti: toGraffitiBuffer(graffiti || ""), + }); + logger.debug("Produced common block body", loggerContext); + + logger.verbose("Block production race (builder vs execution) starting", { + ...loggerContext, + cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, + timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + }); + + // use abort controller to stop waiting for both block sources + const controller = new AbortController(); + + // Start calls for building execution and builder blocks + + const builderPromise = isBuilderEnabled + ? produceBuilderBlindedBlock(slot, randaoReveal, graffiti, { + feeRecipient, + // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now + strictFeeRecipientCheck: false, + // skip checking and recomputing head in these individual produce calls + skipHeadChecksAndUpdate: true, + commonBlockBody, + parentBlockRoot, + }) + : Promise.reject(new Error("Builder disabled")); + + const enginePromise = isEngineEnabled + ? produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { + feeRecipient, + strictFeeRecipientCheck, + // skip checking and recomputing head in these individual produce calls + skipHeadChecksAndUpdate: true, + commonBlockBody, + parentBlockRoot, + }).then((engineBlock) => { + // Once the engine returns a block, in the event of either: + // - suspected builder censorship + // - builder boost factor set to 0 or builder selection `executionalways` + // we don't need to wait for builder block as engine block will always be selected + if ( + engineBlock.shouldOverrideBuilder || + builderBoostFactor === BigInt(0) || + builderSelection === routes.validator.BuilderSelection.ExecutionAlways + ) { + controller.abort(); + } + return engineBlock; + }) + : Promise.reject(new Error("Engine disabled")); - const isBuilderEnabled = - ForkSeq[fork] >= ForkSeq.bellatrix && - chain.executionBuilder !== undefined && - builderSelection !== routes.validator.BuilderSelection.ExecutionOnly; + const [builder, engine] = await resolveOrRacePromises([builderPromise, enginePromise], { + resolveTimeoutMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, + raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + signal: controller.signal, + }); - // At any point either the builder or execution or both flows should be active. - // - // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager - // configurations could cause a validator pubkey to have builder disabled with builder selection builder only - // (TODO: independently make sure such an options update is not successful for a validator pubkey) - // - // So if builder is disabled ignore builder selection of builder only if caused by user mistake - // https://github.com/ChainSafe/lodestar/issues/6338 - const isEngineEnabled = !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly; + if (builder.status === "pending" && engine.status === "pending") { + throw Error("Builder and engine both failed to produce the block within timeout"); + } - if (!isEngineEnabled && !isBuilderEnabled) { - throw Error( - `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}` - ); - } + if (engine.status === "rejected" && isEngineEnabled) { + logger.warn( + "Engine failed to produce the block", + { + ...loggerContext, + durationMs: engine.durationMs, + }, + engine.reason + ); + } - const loggerContext = { - slot, - fork, - builderSelection, - isBuilderEnabled, - isEngineEnabled, - strictFeeRecipientCheck, - // winston logger doesn't like bigint - builderBoostFactor: `${builderBoostFactor}`, - }; + if (builder.status === "rejected" && isBuilderEnabled) { + logger.warn( + "Builder failed to produce the block", + { + ...loggerContext, + durationMs: builder.durationMs, + }, + builder.reason + ); + } - logger.verbose("Assembling block with produceEngineOrBuilderBlock", loggerContext); - const commonBlockBody = await chain.produceCommonBlockBody({ - slot, - parentBlockRoot, - randaoReveal, - graffiti: toGraffitiBuffer(graffiti || ""), - }); - logger.debug("Produced common block body", loggerContext); + if (builder.status === "rejected" && engine.status === "rejected") { + throw Error( + `${isBuilderEnabled && isEngineEnabled ? "Builder and engine both" : isBuilderEnabled ? "Builder" : "Engine"} failed to produce the block` + ); + } - logger.verbose("Block production race (builder vs execution) starting", { + // handle shouldOverrideBuilder separately + if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { + logger.info("Selected engine block: censorship suspected in builder blocks", { ...loggerContext, - cutoffMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, - timeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, + durationMs: engine.durationMs, + shouldOverrideBuilder: engine.value.shouldOverrideBuilder, + ...getBlockValueLogInfo(engine.value), }); - // use abort controller to stop waiting for both block sources - const controller = new AbortController(); - - // Start calls for building execution and builder blocks - - const builderPromise = isBuilderEnabled - ? produceBuilderBlindedBlock(slot, randaoReveal, graffiti, { - feeRecipient, - // can't do fee recipient checks as builder bid doesn't return feeRecipient as of now - strictFeeRecipientCheck: false, - // skip checking and recomputing head in these individual produce calls - skipHeadChecksAndUpdate: true, - commonBlockBody, - parentBlockRoot, - }) - : Promise.reject(new Error("Builder disabled")); - - const enginePromise = isEngineEnabled - ? produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, { - feeRecipient, - strictFeeRecipientCheck, - // skip checking and recomputing head in these individual produce calls - skipHeadChecksAndUpdate: true, - commonBlockBody, - parentBlockRoot, - }).then((engineBlock) => { - // Once the engine returns a block, in the event of either: - // - suspected builder censorship - // - builder boost factor set to 0 or builder selection `executionalways` - // we don't need to wait for builder block as engine block will always be selected - if ( - engineBlock.shouldOverrideBuilder || - builderBoostFactor === BigInt(0) || - builderSelection === routes.validator.BuilderSelection.ExecutionAlways - ) { - controller.abort(); - } - return engineBlock; - }) - : Promise.reject(new Error("Engine disabled")); - - const [builder, engine] = await resolveOrRacePromises([builderPromise, enginePromise], { - resolveTimeoutMs: BLOCK_PRODUCTION_RACE_CUTOFF_MS, - raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS, - signal: controller.signal, + return {...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine}; + } + + if (builder.status === "fulfilled" && engine.status !== "fulfilled") { + logger.info("Selected builder block: no engine block produced", { + ...loggerContext, + durationMs: builder.durationMs, + ...getBlockValueLogInfo(builder.value), }); - if (builder.status === "pending" && engine.status === "pending") { - throw Error("Builder and engine both failed to produce the block within timeout"); - } + return {...builder.value, executionPayloadBlinded: true, executionPayloadSource: ProducedBlockSource.builder}; + } - if (engine.status === "rejected" && isEngineEnabled) { - logger.warn( - "Engine failed to produce the block", - { - ...loggerContext, - durationMs: engine.durationMs, - }, - engine.reason - ); - } + if (engine.status === "fulfilled" && builder.status !== "fulfilled") { + logger.info("Selected engine block: no builder block produced", { + ...loggerContext, + durationMs: engine.durationMs, + ...getBlockValueLogInfo(engine.value), + }); - if (builder.status === "rejected" && isBuilderEnabled) { - logger.warn( - "Builder failed to produce the block", - { - ...loggerContext, - durationMs: builder.durationMs, - }, - builder.reason - ); - } + return {...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine}; + } - if (builder.status === "rejected" && engine.status === "rejected") { - throw Error( - `${isBuilderEnabled && isEngineEnabled ? "Builder and engine both" : isBuilderEnabled ? "Builder" : "Engine"} failed to produce the block` - ); - } + if (engine.status === "fulfilled" && builder.status === "fulfilled") { + const executionPayloadSource = selectBlockProductionSource({ + builderBlockValue: builder.value.executionPayloadValue + builder.value.consensusBlockValue, + engineBlockValue: engine.value.executionPayloadValue + engine.value.consensusBlockValue, + builderBoostFactor, + builderSelection, + }); - // handle shouldOverrideBuilder separately - if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) { - logger.info("Selected engine block: censorship suspected in builder blocks", { - ...loggerContext, - durationMs: engine.durationMs, - shouldOverrideBuilder: engine.value.shouldOverrideBuilder, - ...getBlockValueLogInfo(engine.value), - }); + logger.info(`Selected ${executionPayloadSource} block`, { + ...loggerContext, + engineDurationMs: engine.durationMs, + ...getBlockValueLogInfo(engine.value, ProducedBlockSource.engine), + builderDurationMs: builder.durationMs, + ...getBlockValueLogInfo(builder.value, ProducedBlockSource.builder), + }); - return {...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine}; + if (executionPayloadSource === ProducedBlockSource.engine) { + return { + ...engine.value, + executionPayloadBlinded: false, + executionPayloadSource, + }; + } else { + return { + ...builder.value, + executionPayloadBlinded: true, + executionPayloadSource, + }; } + } - if (builder.status === "fulfilled" && engine.status !== "fulfilled") { - logger.info("Selected builder block: no engine block produced", { - ...loggerContext, - durationMs: builder.durationMs, - ...getBlockValueLogInfo(builder.value), - }); + throw Error("Unreachable error occurred during the builder and execution block production"); + } - return {...builder.value, executionPayloadBlinded: true, executionPayloadSource: ProducedBlockSource.builder}; + return { + async produceBlock({slot, randaoReveal, graffiti}) { + const {data, ...meta} = await produceEngineFullBlockOrContents(slot, randaoReveal, graffiti); + if (isForkBlobs(meta.version)) { + throw Error(`Invalid call to produceBlock for deneb+ fork=${meta.version}`); + } else { + // TODO: need to figure out why typescript requires typecasting here + // by typing of produceFullBlockOrContents respose it should have figured this out itself + return {data: data as allForks.BeaconBlock, meta}; } + }, - if (engine.status === "fulfilled" && builder.status !== "fulfilled") { - logger.info("Selected engine block: no builder block produced", { - ...loggerContext, - durationMs: engine.durationMs, - ...getBlockValueLogInfo(engine.value), - }); - - return {...engine.value, executionPayloadBlinded: false, executionPayloadSource: ProducedBlockSource.engine}; - } + async produceBlockV2({slot, randaoReveal, graffiti, ...opts}) { + const {data, ...meta} = await produceEngineFullBlockOrContents(slot, randaoReveal, graffiti, opts); + return {data, meta}; + }, - if (engine.status === "fulfilled" && builder.status === "fulfilled") { - const executionPayloadSource = selectBlockProductionSource({ - builderBlockValue: builder.value.executionPayloadValue + builder.value.consensusBlockValue, - engineBlockValue: engine.value.executionPayloadValue + engine.value.consensusBlockValue, - builderBoostFactor, - builderSelection, - }); + async produceBlockV3({slot, randaoReveal, graffiti, skipRandaoVerification, builderBoostFactor, ...opts}) { + const {data, ...meta} = await produceEngineOrBuilderBlock( + slot, + randaoReveal, + graffiti, + skipRandaoVerification, + builderBoostFactor, + opts + ); - logger.info(`Selected ${executionPayloadSource} block`, { - ...loggerContext, - engineDurationMs: engine.durationMs, - ...getBlockValueLogInfo(engine.value, ProducedBlockSource.engine), - builderDurationMs: builder.durationMs, - ...getBlockValueLogInfo(builder.value, ProducedBlockSource.builder), - }); - - if (executionPayloadSource === ProducedBlockSource.engine) { - return { - ...engine.value, - executionPayloadBlinded: false, - executionPayloadSource, - }; + if (opts.blindedLocal === true && ForkSeq[meta.version] >= ForkSeq.bellatrix) { + if (meta.executionPayloadBlinded) { + return {data, meta}; } else { - return { - ...builder.value, - executionPayloadBlinded: true, - executionPayloadSource, - }; + if (isBlockContents(data)) { + const {block} = data; + const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); + return { + data: blindedBlock, + meta: {...meta, executionPayloadBlinded: true}, + }; + } else { + const blindedBlock = beaconBlockToBlinded(config, data as allForks.AllForksExecution["BeaconBlock"]); + return { + data: blindedBlock, + meta: {...meta, executionPayloadBlinded: true}, + }; + } } + } else { + return {data, meta}; } + }, - throw Error("Unreachable error occurred during the builder and execution block production"); - }; - - const produceBlock: ServerApi["produceBlock"] = async function produceBlock( - slot, - randaoReveal, - graffiti - ) { - const producedData = await produceEngineFullBlockOrContents(slot, randaoReveal, graffiti); - if (isForkBlobs(producedData.version)) { - throw Error(`Invalid call to produceBlock for deneb+ fork=${producedData.version}`); - } else { - // TODO: need to figure out why typescript requires typecasting here - // by typing of produceFullBlockOrContents respose it should have figured this out itself - return producedData as {data: allForks.BeaconBlock}; - } - }; - - const produceEngineOrBuilderBlindedBlock: ServerApi["produceBlindedBlock"] = - async function produceEngineOrBuilderBlindedBlock(slot, randaoReveal, graffiti) { - const {data, executionPayloadValue, consensusBlockValue, version} = await produceEngineOrBuilderBlock( - slot, - randaoReveal, - graffiti - ); + async produceBlindedBlock({slot, randaoReveal, graffiti}) { + const {data, version} = await produceEngineOrBuilderBlock(slot, randaoReveal, graffiti); if (!isForkExecution(version)) { - throw Error(`Invalid fork=${version} for produceEngineOrBuilderBlindedBlock`); + throw Error(`Invalid fork=${version} for produceBlindedBlock`); } - const executionPayloadBlinded = true; if (isBlockContents(data)) { const {block} = data; const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); - return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; + return {data: blindedBlock, meta: {version}}; } else if (isBlindedBeaconBlock(data)) { - return {executionPayloadValue, consensusBlockValue, data, executionPayloadBlinded, version}; + return {data, meta: {version}}; } else { const blindedBlock = beaconBlockToBlinded(config, data as allForks.AllForksExecution["BeaconBlock"]); - return {executionPayloadValue, consensusBlockValue, data: blindedBlock, executionPayloadBlinded, version}; + return {data: blindedBlock, meta: {version}}; } - }; - - const produceBlockV3: ServerApi["produceBlockV3"] = async function produceBlockV3( - slot, - randaoReveal, - graffiti, - skipRandaoVerification?: boolean, - opts: routes.validator.ExtraProduceBlockOps = {} - ) { - const produceBlockEngineOrBuilderRes = await produceEngineOrBuilderBlock( - slot, - randaoReveal, - graffiti, - skipRandaoVerification, - opts - ); - - if (opts.blindedLocal === true && ForkSeq[produceBlockEngineOrBuilderRes.version] >= ForkSeq.bellatrix) { - if (produceBlockEngineOrBuilderRes.executionPayloadBlinded) { - return produceBlockEngineOrBuilderRes; - } else { - if (isBlockContents(produceBlockEngineOrBuilderRes.data)) { - const {block} = produceBlockEngineOrBuilderRes.data; - const blindedBlock = beaconBlockToBlinded(config, block as allForks.AllForksExecution["BeaconBlock"]); - return { - ...produceBlockEngineOrBuilderRes, - data: blindedBlock, - executionPayloadBlinded: true, - } as routes.validator.ProduceBlindedBlockRes & { - executionPayloadBlinded: true; - executionPayloadSource: ProducedBlockSource; - }; - } else { - const blindedBlock = beaconBlockToBlinded( - config, - produceBlockEngineOrBuilderRes.data as allForks.AllForksExecution["BeaconBlock"] - ); - return { - ...produceBlockEngineOrBuilderRes, - data: blindedBlock, - executionPayloadBlinded: true, - } as routes.validator.ProduceBlindedBlockRes & { - executionPayloadBlinded: true; - executionPayloadSource: ProducedBlockSource; - }; - } - } - } else { - return produceBlockEngineOrBuilderRes; - } - }; - - return { - produceBlock, - produceBlockV2: produceEngineFullBlockOrContents, - produceBlockV3, - produceBlindedBlock: produceEngineOrBuilderBlindedBlock, + }, - async produceAttestationData(committeeIndex, slot) { + async produceAttestationData({committeeIndex, slot}) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot @@ -818,7 +805,7 @@ export function getValidatorApi({ const headSlot = headState.slot; const attEpoch = computeEpochAtSlot(slot); const headBlockRootHex = chain.forkChoice.getHead().blockRoot; - const headBlockRoot = fromHexString(headBlockRootHex); + const headBlockRoot = fromHex(headBlockRootHex); const beaconBlockRoot = slot >= headSlot @@ -865,12 +852,12 @@ export function getValidatorApi({ * @param subcommitteeIndex The subcommittee index for which to produce the contribution. * @param beaconBlockRoot The block root for which to produce the contribution. */ - async produceSyncCommitteeContribution(slot, subcommitteeIndex, beaconBlockRoot) { + async produceSyncCommitteeContribution({slot, subcommitteeIndex, beaconBlockRoot}) { // when a validator is configured with multiple beacon node urls, this beaconBlockRoot may come from another beacon node // and it hasn't been in our forkchoice since we haven't seen / processing that block // see https://github.com/ChainSafe/lodestar/issues/5063 if (!chain.forkChoice.hasBlock(beaconBlockRoot)) { - const rootHex = toHexString(beaconBlockRoot); + const rootHex = toHex(beaconBlockRoot); network.searchUnknownSlotRoot({slot, root: rootHex}); // if result of this call is false, i.e. block hasn't seen after 1 slot then the below notOnOptimisticBlockRoot call will throw error await chain.waitForBlock(slot, rootHex); @@ -889,7 +876,7 @@ export function getValidatorApi({ return {data: contribution}; }, - async getProposerDuties(epoch) { + async getProposerDuties({epoch}) { notWhileSyncing(); // Early check that epoch is within [current_epoch, current_epoch + 1], or allow for pre-genesis @@ -953,15 +940,17 @@ export function getValidatorApi({ return { data: duties, - dependentRoot: toHex(dependentRoot), - executionOptimistic: isOptimisticBlock(head), + meta: { + dependentRoot: toHex(dependentRoot), + executionOptimistic: isOptimisticBlock(head), + }, }; }, - async getAttesterDuties(epoch, validatorIndices) { + async getAttesterDuties({epoch, indices}) { notWhileSyncing(); - if (validatorIndices.length === 0) { + if (indices.length === 0) { throw new ApiError(400, "No validator to get attester duties"); } @@ -985,11 +974,11 @@ export function getValidatorApi({ // will equal `currentEpoch + 1` // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() - const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); - const committeeAssignments = state.epochCtx.getCommitteeAssignments(epoch, validatorIndices); + const pubkeys = getPubkeysForIndices(state.validators, indices); + const committeeAssignments = state.epochCtx.getCommitteeAssignments(epoch, indices); const duties: routes.validator.AttesterDuty[] = []; - for (let i = 0, len = validatorIndices.length; i < len; i++) { - const validatorIndex = validatorIndices[i]; + for (let i = 0, len = indices.length; i < len; i++) { + const validatorIndex = indices[i]; const duty = committeeAssignments.get(validatorIndex) as routes.validator.AttesterDuty | undefined; if (duty) { // Mutate existing object instead of re-creating another new object with spread operator @@ -1003,8 +992,10 @@ export function getValidatorApi({ return { data: duties, - dependentRoot: toHex(dependentRoot), - executionOptimistic: isOptimisticBlock(head), + meta: { + dependentRoot: toHex(dependentRoot), + executionOptimistic: isOptimisticBlock(head), + }, }; }, @@ -1021,10 +1012,10 @@ export function getValidatorApi({ * * @param validatorIndices an array of the validator indices for which to obtain the duties. */ - async getSyncCommitteeDuties(epoch, validatorIndices) { + async getSyncCommitteeDuties({epoch, indices}) { notWhileSyncing(); - if (validatorIndices.length === 0) { + if (indices.length === 0) { throw new ApiError(400, "No validator to get attester duties"); } @@ -1038,14 +1029,14 @@ export function getValidatorApi({ const state = chain.getHeadState(); // Check that all validatorIndex belong to the state before calling getCommitteeAssignments() - const pubkeys = getPubkeysForIndices(state.validators, validatorIndices); + const pubkeys = getPubkeysForIndices(state.validators, indices); // Ensures `epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD <= current_epoch // EPOCHS_PER_SYNC_COMMITTEE_PERIOD + 1` const syncCommitteeCache = state.epochCtx.getIndexedSyncCommitteeAtEpoch(epoch); const syncCommitteeValidatorIndexMap = syncCommitteeCache.validatorIndexMap; const duties: routes.validator.SyncDuty[] = []; - for (let i = 0, len = validatorIndices.length; i < len; i++) { - const validatorIndex = validatorIndices[i]; + for (let i = 0, len = indices.length; i < len; i++) { + const validatorIndex = indices[i]; const validatorSyncCommitteeIndices = syncCommitteeValidatorIndexMap.get(validatorIndex); if (validatorSyncCommitteeIndices) { duties.push({ @@ -1058,16 +1049,16 @@ export function getValidatorApi({ return { data: duties, - executionOptimistic: isOptimisticBlock(head), + meta: {executionOptimistic: isOptimisticBlock(head)}, }; }, - async getAggregatedAttestation(attestationDataRoot, slot) { + async getAggregatedAttestation({attestationDataRoot, slot}) { notWhileSyncing(); await waitForSlot(slot); // Must never request for a future slot > currentSlot - const dataRootHex = toHexString(attestationDataRoot); + const dataRootHex = toHex(attestationDataRoot); const aggregate = chain.attestationPool.getAggregate(slot, dataRootHex); if (!aggregate) { @@ -1081,7 +1072,7 @@ export function getValidatorApi({ }; }, - async publishAggregateAndProofs(signedAggregateAndProofs) { + async publishAggregateAndProofs({signedAggregateAndProofs}) { notWhileSyncing(); const seenTimestampSec = Date.now() / 1000; @@ -1148,7 +1139,7 @@ export function getValidatorApi({ * * https://github.com/ethereum/beacon-APIs/pull/137 */ - async publishContributionAndProofs(contributionAndProofs) { + async publishContributionAndProofs({contributionAndProofs}) { notWhileSyncing(); const errors: Error[] = []; @@ -1197,7 +1188,7 @@ export function getValidatorApi({ } }, - async prepareBeaconCommitteeSubnet(subscriptions) { + async prepareBeaconCommitteeSubnet({subscriptions}) { notWhileSyncing(); await network.prepareBeaconCommitteeSubnets( @@ -1230,7 +1221,7 @@ export function getValidatorApi({ * * https://github.com/ethereum/beacon-APIs/pull/136 */ - async prepareSyncCommitteeSubnets(subscriptions) { + async prepareSyncCommitteeSubnets({subscriptions}) { notWhileSyncing(); // A `validatorIndex` can be in multiple subnets, so compute the CommitteeSubscription with double for loop @@ -1257,7 +1248,7 @@ export function getValidatorApi({ } }, - async prepareBeaconProposer(proposers) { + async prepareBeaconProposer({proposers}) { await chain.updateBeaconProposerData(chain.clock.currentEpoch, proposers); }, @@ -1269,8 +1260,8 @@ export function getValidatorApi({ throw new OnlySupportedByDVT(); }, - async getLiveness(epoch, validatorIndices) { - if (validatorIndices.length === 0) { + async getLiveness({epoch, indices}) { + if (indices.length === 0) { return { data: [], }; @@ -1284,14 +1275,14 @@ export function getValidatorApi({ } return { - data: validatorIndices.map((index) => ({ + data: indices.map((index) => ({ index, isLive: chain.validatorSeenAtEpoch(index, epoch), })), }; }, - async registerValidator(registrations) { + async registerValidator({registrations}) { if (!chain.executionBuilder) { throw Error("Execution builder not enabled"); } diff --git a/packages/beacon-node/src/api/rest/base.ts b/packages/beacon-node/src/api/rest/base.ts index 86eab0ba46d4..20439290e939 100644 --- a/packages/beacon-node/src/api/rest/base.ts +++ b/packages/beacon-node/src/api/rest/base.ts @@ -2,6 +2,7 @@ import {parse as parseQueryString} from "qs"; import {FastifyInstance, FastifyRequest, fastify, errorCodes} from "fastify"; import {fastifyCors} from "@fastify/cors"; import bearerAuthPlugin from "@fastify/bearer-auth"; +import {addSszContentTypeParser} from "@lodestar/api/server"; import {ErrorAborted, Gauge, Histogram, Logger} from "@lodestar/utils"; import {isLocalhostIP} from "../../util/ip.js"; import {ApiError, NodeIsSyncing} from "../impl/errors.js"; @@ -64,6 +65,8 @@ export class RestApiServer { http: {maxHeaderSize: opts.headerLimit}, }); + addSszContentTypeParser(server); + this.activeSockets = new HttpActiveSocketsTracker(server.server, metrics); // To parse our ApiError -> statusCode diff --git a/packages/beacon-node/src/api/rest/index.ts b/packages/beacon-node/src/api/rest/index.ts index e46d1ab6d9bd..e27ed6bd9139 100644 --- a/packages/beacon-node/src/api/rest/index.ts +++ b/packages/beacon-node/src/api/rest/index.ts @@ -1,4 +1,5 @@ -import {Api, ServerApi} from "@lodestar/api"; +import {Endpoints} from "@lodestar/api"; +import {BeaconApiMethods} from "@lodestar/api/beacon/server"; import {registerRoutes} from "@lodestar/api/beacon/server"; import {ErrorAborted, Logger} from "@lodestar/utils"; import {ChainForkConfig} from "@lodestar/config"; @@ -10,7 +11,7 @@ export {allNamespaces} from "@lodestar/api"; export type BeaconRestApiServerOpts = Omit & { enabled: boolean; - api: (keyof Api)[]; + api: (keyof Endpoints)[]; }; export const beaconRestApiServerOpts: BeaconRestApiServerOpts = { @@ -26,7 +27,7 @@ export const beaconRestApiServerOpts: BeaconRestApiServerOpts = { export type BeaconRestApiServerModules = RestApiServerModules & { config: ChainForkConfig; logger: Logger; - api: {[K in keyof Api]: ServerApi}; + api: BeaconApiMethods; metrics: RestApiServerMetrics | null; }; diff --git a/packages/beacon-node/src/chain/beaconProposerCache.ts b/packages/beacon-node/src/chain/beaconProposerCache.ts index c6149b0232c9..f8638aa64bf7 100644 --- a/packages/beacon-node/src/chain/beaconProposerCache.ts +++ b/packages/beacon-node/src/chain/beaconProposerCache.ts @@ -8,7 +8,7 @@ const PROPOSER_PRESERVE_EPOCHS = 2; export type ProposerPreparationData = routes.validator.ProposerPreparationData; export class BeaconProposerCache { - private readonly feeRecipientByValidatorIndex: MapDef; + private readonly feeRecipientByValidatorIndex: MapDef; constructor( opts: {suggestedFeeRecipient: string}, private readonly metrics?: Metrics | null @@ -33,11 +33,11 @@ export class BeaconProposerCache { } } - getOrDefault(proposerIndex: number | string): string { - return this.feeRecipientByValidatorIndex.getOrDefault(`${proposerIndex}`).feeRecipient; + getOrDefault(proposerIndex: number): string { + return this.feeRecipientByValidatorIndex.getOrDefault(proposerIndex).feeRecipient; } - get(proposerIndex: number | string): string | undefined { - return this.feeRecipientByValidatorIndex.get(`${proposerIndex}`)?.feeRecipient; + get(proposerIndex: number): string | undefined { + return this.feeRecipientByValidatorIndex.get(proposerIndex)?.feeRecipient; } } diff --git a/packages/beacon-node/src/execution/builder/http.ts b/packages/beacon-node/src/execution/builder/http.ts index 9e26faf90b63..7637023a9c08 100644 --- a/packages/beacon-node/src/execution/builder/http.ts +++ b/packages/beacon-node/src/execution/builder/http.ts @@ -2,10 +2,9 @@ import {allForks, bellatrix, Slot, Root, BLSPubkey, deneb, Wei} from "@lodestar/ import {parseExecutionPayloadAndBlobsBundle, reconstructFullBlockOrContents} from "@lodestar/state-transition"; import {ChainForkConfig} from "@lodestar/config"; import {Logger} from "@lodestar/logger"; -import {getClient, Api as BuilderApi} from "@lodestar/api/builder"; +import {getClient, ApiClient as BuilderApi} from "@lodestar/api/builder"; import {SLOTS_PER_EPOCH, ForkExecution} from "@lodestar/params"; import {toSafePrintableUrl} from "@lodestar/utils"; -import {ApiError} from "@lodestar/api"; import {Metrics} from "../../metrics/metrics.js"; import {IExecutionBuilder} from "./interface.js"; @@ -48,8 +47,10 @@ export class ExecutionBuilderHttp implements IExecutionBuilder { this.api = getClient( { baseUrl, - timeoutMs: opts.timeout, - extraHeaders: opts.userAgent ? {"User-Agent": opts.userAgent} : undefined, + globalInit: { + timeoutMs: opts.timeout, + headers: opts.userAgent ? {"User-Agent": opts.userAgent} : undefined, + }, }, {config, metrics: metrics?.builderHttpClient} ); @@ -82,7 +83,7 @@ export class ExecutionBuilderHttp implements IExecutionBuilder { async checkStatus(): Promise { try { - await this.api.status(); + (await this.api.status()).assertOk(); } catch (e) { // Disable if the status was enabled this.status = false; @@ -91,35 +92,34 @@ export class ExecutionBuilderHttp implements IExecutionBuilder { } async registerValidator(registrations: bellatrix.SignedValidatorRegistrationV1[]): Promise { - ApiError.assert( - await this.api.registerValidator(registrations), - "Failed to forward validator registrations to connected builder" - ); + (await this.api.registerValidator({registrations})).assertOk(); } async getHeader( - fork: ForkExecution, + _fork: ForkExecution, slot: Slot, parentHash: Root, - proposerPubKey: BLSPubkey + proposerPubkey: BLSPubkey ): Promise<{ header: allForks.ExecutionPayloadHeader; executionPayloadValue: Wei; blobKzgCommitments?: deneb.BlobKzgCommitments; }> { - const res = await this.api.getHeader(slot, parentHash, proposerPubKey); - ApiError.assert(res, "execution.builder.getheader"); - const {header, value: executionPayloadValue} = res.response.data.message; - const {blobKzgCommitments} = res.response.data.message as deneb.BuilderBid; + const signedBuilderBid = (await this.api.getHeader({slot, parentHash, proposerPubkey})).value(); + + if (!signedBuilderBid) { + throw Error("No bid received"); + } + + const {header, value: executionPayloadValue} = signedBuilderBid.message; + const {blobKzgCommitments} = signedBuilderBid.message as deneb.BuilderBid; return {header, executionPayloadValue, blobKzgCommitments}; } async submitBlindedBlock( signedBlindedBlock: allForks.SignedBlindedBeaconBlock ): Promise { - const res = await this.api.submitBlindedBlock(signedBlindedBlock, {retries: 2}); - ApiError.assert(res, "execution.builder.submitBlindedBlock"); - const {data} = res.response; + const data = (await this.api.submitBlindedBlock({signedBlindedBlock}, {retries: 2})).value(); const {executionPayload, blobsBundle} = parseExecutionPayloadAndBlobsBundle(data); diff --git a/packages/beacon-node/src/node/nodejs.ts b/packages/beacon-node/src/node/nodejs.ts index 1e9c7794ac68..a1147b60bea2 100644 --- a/packages/beacon-node/src/node/nodejs.ts +++ b/packages/beacon-node/src/node/nodejs.ts @@ -6,7 +6,7 @@ import {BeaconConfig} from "@lodestar/config"; import {phase0} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; import type {LoggerNode} from "@lodestar/logger/node"; -import {Api, ServerApi} from "@lodestar/api"; +import {BeaconApiMethods} from "@lodestar/api/beacon/server"; import {BeaconStateAllForks} from "@lodestar/state-transition"; import {ProcessShutdownCallback} from "@lodestar/validator"; @@ -33,7 +33,7 @@ export type BeaconNodeModules = { metrics: Metrics | null; network: Network; chain: IBeaconChain; - api: {[K in keyof Api]: ServerApi}; + api: BeaconApiMethods; sync: IBeaconSync; backfillSync: BackfillSync | null; metricsServer: HttpMetricsServer | null; @@ -95,7 +95,7 @@ export class BeaconNode { monitoring: MonitoringService | null; network: Network; chain: IBeaconChain; - api: {[K in keyof Api]: ServerApi}; + api: BeaconApiMethods; restApi?: BeaconRestApiServer; sync: IBeaconSync; backfillSync: BackfillSync | null; diff --git a/packages/beacon-node/test/e2e/api/impl/beacon/node/endpoints.test.ts b/packages/beacon-node/test/e2e/api/impl/beacon/node/endpoints.test.ts index 89d98902676b..d85fdb80720f 100644 --- a/packages/beacon-node/test/e2e/api/impl/beacon/node/endpoints.test.ts +++ b/packages/beacon-node/test/e2e/api/impl/beacon/node/endpoints.test.ts @@ -1,8 +1,7 @@ import {describe, beforeAll, afterAll, it, expect, vi} from "vitest"; import {createBeaconConfig} from "@lodestar/config"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; -import {Api, getClient} from "@lodestar/api/beacon"; -import {ApiError} from "@lodestar/api"; +import {ApiClient, getClient} from "@lodestar/api/beacon"; import {sleep} from "@lodestar/utils"; import {LogLevel, testLogger} from "../../../../../utils/logger.js"; import {getDevBeaconNode} from "../../../../../utils/node/beacon.js"; @@ -17,7 +16,7 @@ describe("beacon node api", function () { const validatorCount = 8; let bn: BeaconNode; - let client: Api; + let client: ApiClient; beforeAll(async () => { bn = await getDevBeaconNode({ @@ -46,9 +45,8 @@ describe("beacon node api", function () { describe("getSyncingStatus", () => { it("should return valid syncing status", async () => { const res = await client.node.getSyncingStatus(); - ApiError.assert(res); - expect(res.response.data).toEqual({ + expect(res.value()).toEqual({ headSlot: "0", syncDistance: "0", isSyncing: false, @@ -59,9 +57,8 @@ describe("beacon node api", function () { it("should return 'el_offline' as 'true' for default dev node", async () => { const res = await client.node.getSyncingStatus(); - ApiError.assert(res); - expect(res.response.data.elOffline).toEqual(false); + expect(res.value().elOffline).toEqual(false); }); it("should return 'el_offline' as 'true' when EL not available", async () => { @@ -103,9 +100,8 @@ describe("beacon node api", function () { await sleep(chainConfigDef.SECONDS_PER_SLOT * 2 * 1000); const res = await clientElOffline.node.getSyncingStatus(); - ApiError.assert(res); - expect(res.response.data.elOffline).toEqual(true); + expect(res.value().elOffline).toEqual(true); await Promise.all(validators.map((v) => v.close())); await bnElOffline.close(); @@ -116,7 +112,7 @@ describe("beacon node api", function () { const portSyncing = 9598; let bnSyncing: BeaconNode; - let clientSyncing: Api; + let clientSyncing: ApiClient; beforeAll(async () => { bnSyncing = await getDevBeaconNode({ diff --git a/packages/beacon-node/test/e2e/api/impl/beacon/state/endpoint.test.ts b/packages/beacon-node/test/e2e/api/impl/beacon/state/endpoint.test.ts index 9ab3da7e53f9..01423d72341f 100644 --- a/packages/beacon-node/test/e2e/api/impl/beacon/state/endpoint.test.ts +++ b/packages/beacon-node/test/e2e/api/impl/beacon/state/endpoint.test.ts @@ -2,7 +2,7 @@ import {describe, beforeAll, afterAll, it, expect} from "vitest"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {createBeaconConfig} from "@lodestar/config"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; -import {Api, ApiError, getClient} from "@lodestar/api"; +import {ApiClient, getClient} from "@lodestar/api"; import {computeCommitteeCount} from "@lodestar/state-transition"; import {LogLevel, testLogger} from "../../../../../utils/logger.js"; import {getDevBeaconNode} from "../../../../../utils/node/beacon.js"; @@ -17,7 +17,7 @@ describe("beacon state api", function () { const validatorsPerCommittee = validatorCount / committeeCount; let bn: BeaconNode; - let client: Api["beacon"]; + let client: ApiClient["beacon"]; beforeAll(async () => { bn = await getDevBeaconNode({ @@ -45,9 +45,9 @@ describe("beacon state api", function () { describe("getEpochCommittees", () => { it("should return all committees for the given state", async () => { - const res = await client.getEpochCommittees("head"); - ApiError.assert(res); - const {data: epochCommittees, executionOptimistic, finalized} = res.response; + const res = await client.getEpochCommittees({stateId: "head"}); + const epochCommittees = res.value(); + const {executionOptimistic, finalized} = res.meta(); expect(epochCommittees).toHaveLength(committeeCount); expect(executionOptimistic).toBe(false); @@ -77,9 +77,7 @@ describe("beacon state api", function () { it("should restrict returned committees to those matching the supplied index", async () => { const index = committeesPerSlot / 2; - const res = await client.getEpochCommittees("head", {index}); - ApiError.assert(res); - const epochCommittees = res.response.data; + const epochCommittees = (await client.getEpochCommittees({stateId: "head", index})).value(); expect(epochCommittees).toHaveLength(SLOTS_PER_EPOCH); for (const committee of epochCommittees) { expect(committee.index).toBeWithMessage(index, "Committee index does not match supplied index"); @@ -88,9 +86,7 @@ describe("beacon state api", function () { it("should restrict returned committees to those matching the supplied slot", async () => { const slot = SLOTS_PER_EPOCH / 2; - const res = await client.getEpochCommittees("head", {slot}); - ApiError.assert(res); - const epochCommittees = res.response.data; + const epochCommittees = (await client.getEpochCommittees({stateId: "head", slot})).value(); expect(epochCommittees).toHaveLength(committeesPerSlot); for (const committee of epochCommittees) { expect(committee.slot).toBeWithMessage(slot, "Committee slot does not match supplied slot"); diff --git a/packages/beacon-node/test/e2e/api/impl/lightclient/endpoint.test.ts b/packages/beacon-node/test/e2e/api/impl/lightclient/endpoint.test.ts index 8c83667d5ca5..0f13575a5ec4 100644 --- a/packages/beacon-node/test/e2e/api/impl/lightclient/endpoint.test.ts +++ b/packages/beacon-node/test/e2e/api/impl/lightclient/endpoint.test.ts @@ -2,7 +2,7 @@ import {describe, it, beforeEach, afterEach, expect} from "vitest"; import bls from "@chainsafe/bls"; import {createBeaconConfig, ChainConfig} from "@lodestar/config"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; -import {ApiError, getClient, routes} from "@lodestar/api"; +import {getClient, routes} from "@lodestar/api"; import {sleep} from "@lodestar/utils"; import {ForkName, SYNC_COMMITTEE_SIZE} from "@lodestar/params"; import {Validator} from "@lodestar/validator"; @@ -81,59 +81,55 @@ describe("lightclient api", function () { await sleep(2 * SECONDS_PER_SLOT * 1000); }; - it("getUpdates()", async function () { + it("getLightClientUpdatesByRange()", async function () { const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient; await waitForBestUpdate(); - const res = await client.getUpdates(0, 1); - ApiError.assert(res); - const updates = res.response; + const res = await client.getLightClientUpdatesByRange({startPeriod: 0, count: 1}); + const updates = res.value(); expect(updates.length).toBe(1); // best update could be any slots // version is set - expect(updates[0].version).toBe(ForkName.altair); + expect(res.meta().versions[0]).toBe(ForkName.altair); }); - it("getOptimisticUpdate()", async function () { + it("getLightClientOptimisticUpdate()", async function () { await waitForBestUpdate(); const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient; - const res = await client.getOptimisticUpdate(); - ApiError.assert(res); - const update = res.response; + const res = await client.getLightClientOptimisticUpdate(); + const update = res.value(); const slot = bn.chain.clock.currentSlot; // at slot 2 we got attestedHeader for slot 1 - expect(update.data.attestedHeader.beacon.slot).toBe(slot - 1); + expect(update.attestedHeader.beacon.slot).toBe(slot - 1); // version is set - expect(update.version).toBe(ForkName.altair); + expect(res.meta().version).toBe(ForkName.altair); }); - it.skip("getFinalityUpdate()", async function () { + it.skip("getLightClientFinalityUpdate()", async function () { // TODO: not sure how this causes subsequent tests failed await waitForEvent(bn.chain.emitter, routes.events.EventType.finalizedCheckpoint, 240000); await sleep(SECONDS_PER_SLOT * 1000); const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient; - const res = await client.getFinalityUpdate(); - ApiError.assert(res); - expect(res.response).toBeDefined(); + const finalityUpdate = (await client.getLightClientFinalityUpdate()).value(); + expect(finalityUpdate).toBeDefined(); }); - it("getCommitteeRoot() for the 1st period", async function () { + it("getLightClientCommitteeRoot() for the 1st period", async function () { await waitForBestUpdate(); const lightclient = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).lightclient; - const committeeRes = await lightclient.getCommitteeRoot(0, 1); - ApiError.assert(committeeRes); + const committeeRes = await lightclient.getLightClientCommitteeRoot({startPeriod: 0, count: 1}); + committeeRes.assertOk(); const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).beacon; - const validatorResponse = await client.getStateValidators("head"); - ApiError.assert(validatorResponse); - const pubkeys = validatorResponse.response.data.map((v) => v.validator.pubkey); + const validators = (await client.getStateValidators({stateId: "head"})).value(); + const pubkeys = validators.map((v) => v.validator.pubkey); expect(pubkeys.length).toBe(validatorCount); // only 2 validators spreading to 512 committee slots const committeePubkeys = Array.from({length: SYNC_COMMITTEE_SIZE}, (_, i) => i % 2 === 0 ? pubkeys[0] : pubkeys[1] ); const aggregatePubkey = bls.aggregatePublicKeys(committeePubkeys); - // single committe hash since we requested for the first period - expect(committeeRes.response.data).toEqual([ + // single committee hash since we requested for the first period + expect(committeeRes.value()).toEqual([ ssz.altair.SyncCommittee.hashTreeRoot({ pubkeys: committeePubkeys, aggregatePubkey, diff --git a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts index e9d02beb6835..5cccb2aa0772 100644 --- a/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts +++ b/packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts @@ -3,7 +3,7 @@ import {createBeaconConfig, ChainConfig} from "@lodestar/config"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; import {phase0} from "@lodestar/types"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {getClient, HttpStatusCode} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {LogLevel, testLogger, TestLoggerOpts} from "../../../utils/logger.js"; import {getDevBeaconNode} from "../../../utils/node/beacon.js"; import {waitForEvent} from "../../../utils/events/resolver.js"; @@ -61,19 +61,15 @@ describe("api / impl / validator", function () { const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}); - await expect(client.validator.getLiveness(0, [1, 2, 3, 4, 5])).resolves.toEqual({ - response: { - data: [ - {index: 1, isLive: true}, - {index: 2, isLive: true}, - {index: 3, isLive: true}, - {index: 4, isLive: true}, - {index: 5, isLive: false}, - ], - }, - ok: true, - status: HttpStatusCode.OK, - }); + const res = await client.validator.getLiveness({epoch: 0, indices: [1, 2, 3, 4, 5]}); + + expect(res.value()).toEqual([ + {index: 1, isLive: true}, + {index: 2, isLive: true}, + {index: 3, isLive: true}, + {index: 4, isLive: true}, + {index: 5, isLive: false}, + ]); }); it("Should return only for previous, current and next epoch", async function () { @@ -107,23 +103,23 @@ describe("api / impl / validator", function () { const previousEpoch = currentEpoch - 1; // current epoch is fine - await expect(client.validator.getLiveness(currentEpoch, [1])).resolves.toBeDefined(); + (await client.validator.getLiveness({epoch: currentEpoch, indices: [1]})).assertOk(); // next epoch is fine - await expect(client.validator.getLiveness(nextEpoch, [1])).resolves.toBeDefined(); + (await client.validator.getLiveness({epoch: nextEpoch, indices: [1]})).assertOk(); // previous epoch is fine - await expect(client.validator.getLiveness(previousEpoch, [1])).resolves.toBeDefined(); + (await client.validator.getLiveness({epoch: previousEpoch, indices: [1]})).assertOk(); // more than next epoch is not fine - const res1 = await client.validator.getLiveness(currentEpoch + 2, [1]); + const res1 = await client.validator.getLiveness({epoch: currentEpoch + 2, indices: [1]}); expect(res1.ok).toBe(false); - expect(res1.error?.message).toEqual( + expect(res1.error()?.message).toEqual( expect.stringContaining( `Request epoch ${currentEpoch + 2} is more than one epoch before or after the current epoch ${currentEpoch}` ) ); // more than previous epoch is not fine - const res2 = await client.validator.getLiveness(currentEpoch - 2, [1]); + const res2 = await client.validator.getLiveness({epoch: currentEpoch - 2, indices: [1]}); expect(res2.ok).toBe(false); - expect(res2.error?.message).toEqual( + expect(res2.error()?.message).toEqual( expect.stringContaining( `Request epoch ${currentEpoch - 2} is more than one epoch before or after the current epoch ${currentEpoch}` ) diff --git a/packages/beacon-node/test/e2e/chain/lightclient.test.ts b/packages/beacon-node/test/e2e/chain/lightclient.test.ts index dc440d5982ec..c501a2618049 100644 --- a/packages/beacon-node/test/e2e/chain/lightclient.test.ts +++ b/packages/beacon-node/test/e2e/chain/lightclient.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect, afterEach, vi} from "vitest"; import {JsonPath, toHexString, fromHexString} from "@chainsafe/ssz"; -import {computeDescriptor, TreeOffsetProof} from "@chainsafe/persistent-merkle-tree"; +import {CompactMultiProof, computeDescriptor} from "@chainsafe/persistent-merkle-tree"; import {ChainConfig} from "@lodestar/config"; import {ssz, altair} from "@lodestar/types"; import {TimestampFormatCode} from "@lodestar/logger"; @@ -8,7 +8,7 @@ import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/param import {Lightclient} from "@lodestar/light-client"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {LightClientRestTransport} from "@lodestar/light-client/transport"; -import {Api, ApiError, getClient, routes} from "@lodestar/api"; +import {ApiClient, getClient, routes} from "@lodestar/api"; import {testLogger, LogLevel, TestLoggerOpts} from "../../utils/logger.js"; import {getDevBeaconNode} from "../../utils/node/beacon.js"; import {getAndInitDevValidators} from "../../utils/node/validator.js"; @@ -183,17 +183,13 @@ describe("chain / lightclient", function () { // TODO: Re-incorporate for REST-only light-client async function getHeadStateProof( lightclient: Lightclient, - api: Api, + api: ApiClient, paths: JsonPath[] -): Promise<{proof: TreeOffsetProof; header: altair.LightClientHeader}> { +): Promise<{proof: CompactMultiProof; header: altair.LightClientHeader}> { const header = lightclient.getHead(); const stateId = toHexString(header.beacon.stateRoot); const gindices = paths.map((path) => ssz.bellatrix.BeaconState.getPathInfo(path).gindex); const descriptor = computeDescriptor(gindices); - const res = await api.proof.getStateProof(stateId, descriptor); - ApiError.assert(res); - return { - proof: res.response.data as TreeOffsetProof, - header, - }; + const proof = (await api.proof.getStateProof({stateId, descriptor})).value(); + return {proof, header}; } diff --git a/packages/beacon-node/test/scripts/blsPubkeyBytesFrequency.ts b/packages/beacon-node/test/scripts/blsPubkeyBytesFrequency.ts index c8cd742a6d3f..1889cacf1496 100644 --- a/packages/beacon-node/test/scripts/blsPubkeyBytesFrequency.ts +++ b/packages/beacon-node/test/scripts/blsPubkeyBytesFrequency.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import {digest} from "@chainsafe/as-sha256"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {newZeroedArray} from "@lodestar/state-transition"; @@ -112,12 +112,9 @@ async function writePubkeys(): Promise { const client = getClient({baseUrl}, {config}); - const res = await client.debug.getStateV2("finalized"); - ApiError.assert(res); + const state = (await client.debug.getStateV2({stateId: "finalized"})).value(); - const pubkeys = Array.from(res.response.data.validators).map((validator) => - Buffer.from(validator.pubkey).toString("hex") - ); + const pubkeys = Array.from(state.validators).map((validator) => Buffer.from(validator.pubkey).toString("hex")); fs.writeFileSync("mainnet_pubkeys.csv", pubkeys.join("\n")); } diff --git a/packages/beacon-node/test/sim/mergemock.test.ts b/packages/beacon-node/test/sim/mergemock.test.ts index 4cce95967c55..3705b845d805 100644 --- a/packages/beacon-node/test/sim/mergemock.test.ts +++ b/packages/beacon-node/test/sim/mergemock.test.ts @@ -212,7 +212,7 @@ describe("executionEngine / ExecutionEngineHttp", function () { let builderBlocks = 0; await new Promise((resolve, _reject) => { bn.chain.emitter.on(routes.events.EventType.block, async (blockData) => { - const {data: fullOrBlindedBlock} = (await bn.api.beacon.getBlockV2(blockData.block)) as { + const {data: fullOrBlindedBlock} = (await bn.api.beacon.getBlockV2({blockId: blockData.block})) as { data: allForks.SignedBeaconBlock; }; if (fullOrBlindedBlock !== undefined) { diff --git a/packages/beacon-node/test/unit/api/impl/beacon/beacon.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/beacon.test.ts index 28df366a0cde..a8eaffa42005 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/beacon.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/beacon.test.ts @@ -1,4 +1,5 @@ import {describe, it, expect, beforeAll} from "vitest"; +import {phase0} from "@lodestar/types"; import {ApiTestModules, getApiTestModules} from "../../../../utils/api.js"; import {getBeaconApi} from "../../../../../src/api/impl/beacon/index.js"; import {Mutable} from "../../../../utils/types.js"; @@ -17,7 +18,7 @@ describe("beacon api implementation", function () { (modules.chain as Mutable).genesisTime = 0; (modules.chain as Mutable).genesisValidatorsRoot = Buffer.alloc(32); - const {data: genesis} = await api.getGenesis(); + const {data: genesis} = (await api.getGenesis()) as {data: phase0.Genesis}; if (genesis === null || genesis === undefined) throw Error("Genesis is nullish"); expect(genesis.genesisForkVersion).toBeDefined(); expect(genesis.genesisTime).toBeDefined(); diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/getBlockHeaders.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/getBlockHeaders.test.ts index 38a9e6677639..9b3c960ff2ec 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/getBlockHeaders.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/getBlockHeaders.test.ts @@ -1,6 +1,7 @@ import {toHexString} from "@chainsafe/ssz"; import {describe, it, expect, beforeEach, vi, afterEach} from "vitest"; import {when} from "vitest-when"; +import {routes} from "@lodestar/api"; import {ssz} from "@lodestar/types"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; import {generateProtoBlock, generateSignedBlockAtSlot} from "../../../../../utils/typeGenerator.js"; @@ -44,7 +45,7 @@ describe("api - beacon - getBlockHeaders", function () { modules.db.block.get.mockResolvedValue(blockFromDb3); modules.db.blockArchive.get.mockResolvedValue(null); - const {data: blockHeaders} = await api.getBlockHeaders({}); + const {data: blockHeaders} = (await api.getBlockHeaders({})) as {data: routes.beacon.BlockHeaderResponse[]}; expect(blockHeaders).not.toBeNull(); expect(blockHeaders.length).toBe(2); expect(blockHeaders.filter((header) => header.canonical).length).toBe(1); @@ -66,7 +67,7 @@ describe("api - beacon - getBlockHeaders", function () { .calledWith(0) .thenResolve({block: ssz.phase0.SignedBeaconBlock.defaultValue(), executionOptimistic: false, finalized: false}); when(modules.forkChoice.getBlockSummariesAtSlot).calledWith(0).thenReturn([]); - const {data: blockHeaders} = await api.getBlockHeaders({slot: 0}); + const {data: blockHeaders} = (await api.getBlockHeaders({slot: 0})) as {data: routes.beacon.BlockHeaderResponse[]}; expect(blockHeaders.length).toBe(1); expect(blockHeaders[0].canonical).toBe(true); }); @@ -91,7 +92,9 @@ describe("api - beacon - getBlockHeaders", function () { .thenReturn(generateProtoBlock({blockRoot: toHexString(ssz.phase0.BeaconBlock.hashTreeRoot(canonical.message))})); modules.db.block.get.mockResolvedValue(generateSignedBlockAtSlot(1)); modules.db.block.get.mockResolvedValue(generateSignedBlockAtSlot(2)); - const {data: blockHeaders} = await api.getBlockHeaders({parentRoot}); + const {data: blockHeaders} = (await api.getBlockHeaders({parentRoot})) as { + data: routes.beacon.BlockHeaderResponse[]; + }; expect(blockHeaders.length).toBe(3); expect(blockHeaders.filter((b) => b.canonical).length).toBe(2); }); diff --git a/packages/beacon-node/test/unit/api/impl/config/config.test.ts b/packages/beacon-node/test/unit/api/impl/config/config.test.ts index 5292d67393a3..d6954f632d5e 100644 --- a/packages/beacon-node/test/unit/api/impl/config/config.test.ts +++ b/packages/beacon-node/test/unit/api/impl/config/config.test.ts @@ -1,4 +1,5 @@ import {describe, it, expect, beforeEach} from "vitest"; +import {routes} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {getConfigApi, renderJsonSpec} from "../../../../../src/api/impl/config/index.js"; @@ -18,7 +19,7 @@ describe("config api implementation", function () { describe("getDepositContract", function () { it("should get the deposit contract from config", async function () { - const {data: depositContract} = await api.getDepositContract(); + const {data: depositContract} = (await api.getDepositContract()) as {data: routes.config.DepositContract}; expect(depositContract.address).toBe(config.DEPOSIT_CONTRACT_ADDRESS); expect(depositContract.chainId).toBe(config.DEPOSIT_CHAIN_ID); }); @@ -30,7 +31,7 @@ describe("config api implementation", function () { }); it("should get the spec", async function () { - const {data: specJson} = await api.getSpec(); + const {data: specJson} = (await api.getSpec()) as {data: routes.config.Spec}; expect(specJson.SECONDS_PER_ETH1_BLOCK).toBe("14"); expect(specJson.DEPOSIT_CONTRACT_ADDRESS).toBe("0x1234567890123456789012345678901234567890"); diff --git a/packages/beacon-node/test/unit/api/impl/events/events.test.ts b/packages/beacon-node/test/unit/api/impl/events/events.test.ts index 52ece27c4d5d..e031c3ac9958 100644 --- a/packages/beacon-node/test/unit/api/impl/events/events.test.ts +++ b/packages/beacon-node/test/unit/api/impl/events/events.test.ts @@ -43,8 +43,12 @@ describe("Events api impl", function () { function getEvents(topics: routes.events.EventType[]): routes.events.BeaconEvent[] { const events: routes.events.BeaconEvent[] = []; - void api.eventstream(topics, controller.signal, (event) => { - events.push(event); + void api.eventstream({ + topics, + signal: controller.signal, + onEvent: (event) => { + events.push(event); + }, }); return events; } diff --git a/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts b/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts index bf9544ad683e..d4f7705fb25b 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/duties/proposer.test.ts @@ -1,4 +1,5 @@ import {describe, it, expect, beforeEach, afterEach, vi} from "vitest"; +import {routes} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {MAX_EFFECTIVE_BALANCE, SLOTS_PER_EPOCH} from "@lodestar/params"; import {BeaconStateAllForks} from "@lodestar/state-transition"; @@ -52,18 +53,18 @@ describe("get proposers api impl", function () { vi.advanceTimersByTime((SYNC_TOLERANCE_EPOCHS * SLOTS_PER_EPOCH + 1) * config.SECONDS_PER_SLOT * 1000); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.SyncingHead); - await expect(api.getProposerDuties(1)).rejects.toThrow("Node is syncing - headSlot 0 currentSlot 9"); + await expect(api.getProposerDuties({epoch: 1})).rejects.toThrow("Node is syncing - headSlot 0 currentSlot 9"); }); it("should raise error if node stalled", async () => { vi.advanceTimersByTime((SYNC_TOLERANCE_EPOCHS * SLOTS_PER_EPOCH + 1) * config.SECONDS_PER_SLOT * 1000); vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Stalled); - await expect(api.getProposerDuties(1)).rejects.toThrow("Node is syncing - waiting for peers"); + await expect(api.getProposerDuties({epoch: 1})).rejects.toThrow("Node is syncing - waiting for peers"); }); it("should get proposers for current epoch", async () => { - const {data: result} = await api.getProposerDuties(0); + const {data: result} = (await api.getProposerDuties({epoch: 0})) as {data: routes.validator.ProposerDutyList}; expect(result.length).toBe(SLOTS_PER_EPOCH); expect(cachedState.epochCtx.getBeaconProposers).toHaveBeenCalledOnce(); @@ -72,7 +73,7 @@ describe("get proposers api impl", function () { }); it("should get proposers for next epoch", async () => { - const {data: result} = await api.getProposerDuties(1); + const {data: result} = (await api.getProposerDuties({epoch: 1})) as {data: routes.validator.ProposerDutyList}; expect(result.length).toBe(SLOTS_PER_EPOCH); expect(cachedState.epochCtx.getBeaconProposers).not.toHaveBeenCalled(); @@ -81,27 +82,41 @@ describe("get proposers api impl", function () { }); it("should raise error for more than one epoch in the future", async () => { - await expect(api.getProposerDuties(2)).rejects.toThrow("Requested epoch 2 must equal current 0 or next epoch 1"); + await expect(api.getProposerDuties({epoch: 2})).rejects.toThrow( + "Requested epoch 2 must equal current 0 or next epoch 1" + ); }); it("should have different proposer validator public keys for current and next epoch", async () => { - const {data: currentProposers} = await api.getProposerDuties(0); - const {data: nextProposers} = await api.getProposerDuties(1); + const {data: currentProposers} = (await api.getProposerDuties({epoch: 0})) as { + data: routes.validator.ProposerDutyList; + }; + const {data: nextProposers} = (await api.getProposerDuties({epoch: 1})) as { + data: routes.validator.ProposerDutyList; + }; // Public keys should be different, but for tests we are generating a static list of validators with same public key expect(currentProposers.map((p) => p.pubkey)).toEqual(nextProposers.map((p) => p.pubkey)); }); it("should have different proposer validator indexes for current and next epoch", async () => { - const {data: currentProposers} = await api.getProposerDuties(0); - const {data: nextProposers} = await api.getProposerDuties(1); + const {data: currentProposers} = (await api.getProposerDuties({epoch: 0})) as { + data: routes.validator.ProposerDutyList; + }; + const {data: nextProposers} = (await api.getProposerDuties({epoch: 1})) as { + data: routes.validator.ProposerDutyList; + }; expect(currentProposers.map((p) => p.validatorIndex)).not.toEqual(nextProposers.map((p) => p.validatorIndex)); }); it("should have different proposer slots for current and next epoch", async () => { - const {data: currentProposers} = await api.getProposerDuties(0); - const {data: nextProposers} = await api.getProposerDuties(1); + const {data: currentProposers} = (await api.getProposerDuties({epoch: 0})) as { + data: routes.validator.ProposerDutyList; + }; + const {data: nextProposers} = (await api.getProposerDuties({epoch: 1})) as { + data: routes.validator.ProposerDutyList; + }; expect(currentProposers.map((p) => p.slot)).not.toEqual(nextProposers.map((p) => p.slot)); }); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts index 9c426c677974..256d772d5fc0 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceAttestationData.test.ts @@ -21,7 +21,7 @@ describe("api - validator - produceAttestationData", function () { vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.SyncingFinalized); modules.forkChoice.getHead.mockReturnValue({slot: headSlot} as ProtoBlock); - await expect(api.produceAttestationData(0, 0)).rejects.toThrow("Node is syncing"); + await expect(api.produceAttestationData({committeeIndex: 0, slot: 0})).rejects.toThrow("Node is syncing"); }); it("Should throw error when node is stopped", async function () { @@ -30,6 +30,8 @@ describe("api - validator - produceAttestationData", function () { vi.spyOn(modules.sync, "state", "get").mockReturnValue(SyncState.Stalled); // Should not allow any call to validator API - await expect(api.produceAttestationData(0, 0)).rejects.toThrow("Node is syncing - waiting for peers"); + await expect(api.produceAttestationData({committeeIndex: 0, slot: 0})).rejects.toThrow( + "Node is syncing - waiting for peers" + ); }); }); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts index 370a25d7cc92..e1ca3a084647 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV2.test.ts @@ -58,7 +58,7 @@ describe("api/validator - produceBlockV2", function () { }); // check if expectedFeeRecipient is passed to produceBlock - await api.produceBlockV2(slot, randaoReveal, graffiti, {feeRecipient}); + await api.produceBlockV2({slot, randaoReveal, graffiti, feeRecipient}); expect(modules.chain.produceBlock).toBeCalledWith({ randaoReveal, graffiti: toGraffitiBuffer(graffiti), @@ -69,7 +69,7 @@ describe("api/validator - produceBlockV2", function () { // check that no feeRecipient is passed to produceBlock so that produceBlockBody will // pick it from beaconProposerCache - await api.produceBlockV2(slot, randaoReveal, graffiti); + await api.produceBlockV2({slot, randaoReveal, graffiti}); expect(modules.chain.produceBlock).toBeCalledWith({ randaoReveal, graffiti: toGraffitiBuffer(graffiti), @@ -116,7 +116,7 @@ describe("api/validator - produceBlockV2", function () { parentSlot: slot - 1, parentBlockRoot: fromHexString(ZERO_HASH_HEX), proposerIndex: 0, - proposerPubKey: Uint8Array.from(Buffer.alloc(32, 1)), + proposerPubKey: new Uint8Array(32).fill(1), }); expect(modules.chain["executionEngine"].notifyForkchoiceUpdate).toBeCalledWith( @@ -126,7 +126,7 @@ describe("api/validator - produceBlockV2", function () { ZERO_HASH_HEX, { timestamp: computeTimeAtSlot(modules.config, state.slot, state.genesisTime), - prevRandao: Uint8Array.from(Buffer.alloc(32, 0)), + prevRandao: new Uint8Array(32), suggestedFeeRecipient: feeRecipient, } ); @@ -140,7 +140,7 @@ describe("api/validator - produceBlockV2", function () { parentSlot: slot - 1, parentBlockRoot: fromHexString(ZERO_HASH_HEX), proposerIndex: 0, - proposerPubKey: Uint8Array.from(Buffer.alloc(32, 1)), + proposerPubKey: new Uint8Array(32).fill(1), }); expect(modules.chain["executionEngine"].notifyForkchoiceUpdate).toBeCalledWith( @@ -150,7 +150,7 @@ describe("api/validator - produceBlockV2", function () { ZERO_HASH_HEX, { timestamp: computeTimeAtSlot(modules.config, state.slot, state.genesisTime), - prevRandao: Uint8Array.from(Buffer.alloc(32, 0)), + prevRandao: new Uint8Array(32), suggestedFeeRecipient: "0x fee recipient address", } ); diff --git a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts index 4adb07cd154b..309d68a9c9ec 100644 --- a/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts +++ b/packages/beacon-node/test/unit/api/impl/validator/produceBlockV3.test.ts @@ -131,13 +131,19 @@ describe("api/validator - produceBlockV3", function () { feeRecipient, }; - const block = await api.produceBlockV3(slot, randaoReveal, graffiti, _skipRandaoVerification, produceBlockOpts); + const {data: block, meta} = await api.produceBlockV3({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification: _skipRandaoVerification, + ...produceBlockOpts, + }); const expectedBlock = finalSelection === "builder" ? blindedBlock : fullBlock; const expectedExecution = finalSelection === "builder" ? true : false; - expect(block.data).toEqual(expectedBlock); - expect(block.executionPayloadBlinded).toEqual(expectedExecution); + expect(block).toEqual(expectedBlock); + expect(meta.executionPayloadBlinded).toEqual(expectedExecution); // check call counts if (builderSelection === routes.validator.BuilderSelection.ExecutionOnly) { diff --git a/packages/beacon-node/test/unit/chain/beaconProposerCache.ts b/packages/beacon-node/test/unit/chain/beaconProposerCache.ts index ac54a8c841b0..4545ef0c94b2 100644 --- a/packages/beacon-node/test/unit/chain/beaconProposerCache.ts +++ b/packages/beacon-node/test/unit/chain/beaconProposerCache.ts @@ -8,30 +8,30 @@ describe("BeaconProposerCache", function () { beforeEach(function () { // max 2 items cache = new BeaconProposerCache({suggestedFeeRecipient}); - cache.add(1, {validatorIndex: "23", feeRecipient: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}); - cache.add(3, {validatorIndex: "43", feeRecipient: "0xcccccccccccccccccccccccccccccccccccccccc"}); + cache.add(1, {validatorIndex: 23, feeRecipient: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}); + cache.add(3, {validatorIndex: 43, feeRecipient: "0xcccccccccccccccccccccccccccccccccccccccc"}); }); it("get default", function () { - expect(cache.get("32")).toBe("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(cache.get(32)).toBe("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); }); it("get what has been set", function () { - expect(cache.get("23")).toBe("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + expect(cache.get(23)).toBe("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); }); it("override and get latest", function () { - cache.add(5, {validatorIndex: "23", feeRecipient: "0xdddddddddddddddddddddddddddddddddddddddd"}); - expect(cache.get("23")).toBe("0xdddddddddddddddddddddddddddddddddddddddd"); + cache.add(5, {validatorIndex: 23, feeRecipient: "0xdddddddddddddddddddddddddddddddddddddddd"}); + expect(cache.get(23)).toBe("0xdddddddddddddddddddddddddddddddddddddddd"); }); it("prune", function () { cache.prune(4); // Default for what has been pruned - expect(cache.get("23")).toBe("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(cache.get(23)).toBe("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); // Original for what hasn't been pruned - expect(cache.get("43")).toBe("0xcccccccccccccccccccccccccccccccccccccccc"); + expect(cache.get(43)).toBe("0xcccccccccccccccccccccccccccccccccccccccc"); }); }); diff --git a/packages/beacon-node/test/utils/node/validator.ts b/packages/beacon-node/test/utils/node/validator.ts index ce81fc6d18ca..c686449a29f2 100644 --- a/packages/beacon-node/test/utils/node/validator.ts +++ b/packages/beacon-node/test/utils/node/validator.ts @@ -3,7 +3,8 @@ import type {SecretKey} from "@chainsafe/bls/types"; import {LevelDbController} from "@lodestar/db"; import {interopSecretKey} from "@lodestar/state-transition"; import {SlashingProtection, Validator, Signer, SignerType, ValidatorProposerConfig} from "@lodestar/validator"; -import {ServerApi, Api, HttpStatusCode, APIServerHandler} from "@lodestar/api"; +import {ApiClient, ApiError, HttpStatusCode, ApiResponse} from "@lodestar/api"; +import {BeaconApiMethods} from "@lodestar/api/beacon/server"; import {mapValues} from "@lodestar/utils"; import {BeaconNode} from "../../../src/index.js"; import {testLogger, TestLoggerOpts} from "../logger.js"; @@ -67,7 +68,9 @@ export async function getAndInitDevValidators({ Validator.initializeFromBeaconNode({ db, config: node.config, - api: useRestApi ? getNodeApiUrl(node) : getApiFromServerHandlers(node.api), + api: { + clientOrUrls: useRestApi ? getNodeApiUrl(node) : getApiFromServerHandlers(node.api), + }, slashingProtection, logger, processShutdownCallback: () => {}, @@ -87,38 +90,30 @@ export async function getAndInitDevValidators({ }; } -export function getApiFromServerHandlers(api: {[K in keyof Api]: ServerApi}): Api { +export function getApiFromServerHandlers(api: BeaconApiMethods): ApiClient { return mapValues(api, (apiModule) => - mapValues(apiModule, (api: APIServerHandler) => { - return async (...args: any) => { - let code: HttpStatusCode = HttpStatusCode.OK; + mapValues(apiModule, (api: (args: unknown, context: unknown) => PromiseLike<{data: unknown; meta: unknown}>) => { + return async (args: unknown) => { try { - const response = await api( - ...args, - // request object - {}, - // response object - { - code: (i: number) => { - code = i; - }, - } - ); - return {response, ok: true, status: code}; + const apiResponse = new ApiResponse({} as any, null, new Response(null, {status: HttpStatusCode.OK})); + const result = await api(args, {}); + apiResponse.value = () => result.data; + apiResponse.meta = () => result.meta; + return apiResponse; } catch (err) { - return { - ok: false, - status: code ?? HttpStatusCode.INTERNAL_SERVER_ERROR, - error: { - code: code ?? HttpStatusCode.INTERNAL_SERVER_ERROR, - message: (err as Error).message, - operationId: api.name, - }, + const apiResponse = new ApiResponse( + {} as any, + null, + new Response(null, {status: HttpStatusCode.INTERNAL_SERVER_ERROR}) + ); + apiResponse.error = () => { + return new ApiError((err as Error).message, HttpStatusCode.INTERNAL_SERVER_ERROR, api.name); }; + return apiResponse; } }; }) - ) as Api; + ) as ApiClient; } export function getNodeApiUrl(node: BeaconNode): string { diff --git a/packages/cli/src/cmds/lightclient/handler.ts b/packages/cli/src/cmds/lightclient/handler.ts index 04c833af92d5..a8e3d7333f98 100644 --- a/packages/cli/src/cmds/lightclient/handler.ts +++ b/packages/cli/src/cmds/lightclient/handler.ts @@ -1,6 +1,6 @@ import path from "node:path"; import {fromHexString} from "@chainsafe/ssz"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {Lightclient} from "@lodestar/light-client"; import {LightClientRestTransport} from "@lodestar/light-client/transport"; import {getNodeLogger} from "@lodestar/logger/node"; @@ -19,15 +19,14 @@ export async function lightclientHandler(args: ILightClientArgs & GlobalArgs): P ); const api = getClient({baseUrl: args.beaconApiUrl}, {config}); - const res = await api.beacon.getGenesis(); - ApiError.assert(res, "Can not fetch genesis data"); + const {genesisTime, genesisValidatorsRoot} = (await api.beacon.getGenesis()).value(); const client = await Lightclient.initializeFromCheckpointRoot({ config, logger, genesisData: { - genesisTime: Number(res.response.data.genesisTime), - genesisValidatorsRoot: res.response.data.genesisValidatorsRoot, + genesisTime, + genesisValidatorsRoot, }, checkpointRoot: fromHexString(args.checkpointRoot), transport: new LightClientRestTransport(api), diff --git a/packages/cli/src/cmds/validator/blsToExecutionChange.ts b/packages/cli/src/cmds/validator/blsToExecutionChange.ts index 7452840e1c71..960662b108ea 100644 --- a/packages/cli/src/cmds/validator/blsToExecutionChange.ts +++ b/packages/cli/src/cmds/validator/blsToExecutionChange.ts @@ -5,7 +5,7 @@ import {computeSigningRoot} from "@lodestar/state-transition"; import {DOMAIN_BLS_TO_EXECUTION_CHANGE, ForkName} from "@lodestar/params"; import {createBeaconConfig} from "@lodestar/config"; import {ssz, capella} from "@lodestar/types"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {CliCommand} from "@lodestar/utils"; import {GlobalArgs} from "../../options/index.js"; @@ -59,16 +59,12 @@ like to choose for BLS To Execution Change.", // submitting the signed message const {config: chainForkConfig} = getBeaconConfigFromArgs(args); const client = getClient({urls: args.beaconNodes}, {config: chainForkConfig}); - const genesisRes = await client.beacon.getGenesis(); - ApiError.assert(genesisRes, "Can not fetch genesis data"); - const {genesisValidatorsRoot} = genesisRes.response.data; + const {genesisValidatorsRoot} = (await client.beacon.getGenesis()).value(); const config = createBeaconConfig(chainForkConfig, genesisValidatorsRoot); - const stateValidatorRes = await client.beacon.getStateValidators("head", {id: [publicKey]}); - ApiError.assert(stateValidatorRes, "Can not fetch state validators"); - const stateValidators = stateValidatorRes.response.data; - const stateValidator = stateValidators[0]; - if (stateValidator === undefined) { + const validators = (await client.beacon.getStateValidators({stateId: "head", validatorIds: [publicKey]})).value(); + const validator = validators[0]; + if (validator === undefined) { throw new Error(`Validator pubkey ${publicKey} not found in state`); } @@ -76,7 +72,7 @@ like to choose for BLS To Execution Change.", const fromBlsPubkey = blsPrivkey.toPublicKey().toBytes(PointFormat.compressed); const blsToExecutionChange: capella.BLSToExecutionChange = { - validatorIndex: stateValidator.index, + validatorIndex: validator.index, fromBlsPubkey, toExecutionAddress: fromHexString(args.toExecutionAddress), }; @@ -89,10 +85,12 @@ like to choose for BLS To Execution Change.", signature: blsPrivkey.sign(signingRoot).toBytes(), }; - ApiError.assert( - await client.beacon.submitPoolBlsToExecutionChange([signedBLSToExecutionChange]), - "Can not submit bls to execution change" - ); + ( + await client.beacon.submitPoolBLSToExecutionChange({ + blsToExecutionChanges: [signedBLSToExecutionChange], + }) + ).assertOk(); + console.log(`Submitted bls to execution change for ${publicKey}`); }, }; diff --git a/packages/cli/src/cmds/validator/handler.ts b/packages/cli/src/cmds/validator/handler.ts index 80b1040f226a..fdd93f1ebfc9 100644 --- a/packages/cli/src/cmds/validator/handler.ts +++ b/packages/cli/src/cmds/validator/handler.ts @@ -8,7 +8,7 @@ import { ValidatorProposerConfig, defaultOptions, } from "@lodestar/validator"; -import {routes} from "@lodestar/api"; +import {WireFormat, routes} from "@lodestar/api"; import {getMetrics} from "@lodestar/validator"; import { RegistryMetricCreator, @@ -152,7 +152,13 @@ export async function validatorHandler(args: IValidatorCliArgs & GlobalArgs): Pr db, config, slashingProtection, - api: args.beaconNodes, + api: { + clientOrUrls: args.beaconNodes, + globalInit: { + requestWireFormat: parseWireFormat(args, "http.requestWireFormat"), + responseWireFormat: parseWireFormat(args, "http.responseWireFormat"), + }, + }, logger, processShutdownCallback, signers, @@ -269,3 +275,19 @@ function parseBroadcastValidation(broadcastValidation?: string): routes.beacon.B return broadcastValidation as routes.beacon.BroadcastValidation; } + +function parseWireFormat(args: IValidatorCliArgs, key: keyof IValidatorCliArgs): WireFormat | undefined { + const wireFormat = args[key]; + + if (wireFormat !== undefined) { + switch (wireFormat) { + case WireFormat.json: + case WireFormat.ssz: + break; + default: + throw new YargsError(`Invalid input for ${key}, must be one of "${WireFormat.json}" or "${WireFormat.ssz}"`); + } + } + + return wireFormat; +} diff --git a/packages/cli/src/cmds/validator/keymanager/impl.ts b/packages/cli/src/cmds/validator/keymanager/impl.ts index 3ff7e1af58a2..36ded66976b1 100644 --- a/packages/cli/src/cmds/validator/keymanager/impl.ts +++ b/packages/cli/src/cmds/validator/keymanager/impl.ts @@ -2,7 +2,6 @@ import bls from "@chainsafe/bls"; import {Keystore} from "@chainsafe/bls-keystore"; import {fromHexString} from "@chainsafe/ssz"; import { - Api as KeyManagerClientApi, DeleteRemoteKeyStatus, DeletionStatus, ImportStatus, @@ -11,10 +10,16 @@ import { PubkeyHex, SlashingProtectionData, SignerDefinition, + RemoteSignerDefinition, ImportRemoteKeyStatus, + FeeRecipientData, + GraffitiData, + GasLimitData, + BuilderBoostFactorData, } from "@lodestar/api/keymanager"; +import {KeymanagerApiMethods as Api} from "@lodestar/api/keymanager/server"; import {Interchange, SignerType, Validator} from "@lodestar/validator"; -import {ServerApi} from "@lodestar/api"; +import {ApiError} from "@lodestar/api/server"; import {Epoch} from "@lodestar/types"; import {isValidHttpUrl} from "@lodestar/utils"; import {getPubkeyHexFromKeystore, isValidatePubkeyHex} from "../../../util/format.js"; @@ -22,8 +27,6 @@ import {parseFeeRecipient} from "../../../util/index.js"; import {DecryptKeystoresThreadPool} from "./decryptKeystores/index.js"; import {IPersistedKeysBackend} from "./interface.js"; -type Api = ServerApi; - export class KeymanagerApi implements Api { constructor( private readonly validator: Validator, @@ -38,78 +41,61 @@ export class KeymanagerApi implements Api { } } - async listFeeRecipient(pubkeyHex: string): ReturnType { - return {data: {pubkey: pubkeyHex, ethaddress: this.validator.validatorStore.getFeeRecipient(pubkeyHex)}}; + async listFeeRecipient({pubkey}: {pubkey: PubkeyHex}): ReturnType { + return {data: {pubkey, ethaddress: this.validator.validatorStore.getFeeRecipient(pubkey)}}; } - async setFeeRecipient(pubkeyHex: string, ethaddress: string): Promise { + async setFeeRecipient({pubkey, ethaddress}: FeeRecipientData): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.setFeeRecipient(pubkeyHex, parseFeeRecipient(ethaddress)); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.setFeeRecipient(pubkey, parseFeeRecipient(ethaddress)); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 202}; } - async deleteFeeRecipient(pubkeyHex: string): Promise { + async deleteFeeRecipient({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.deleteFeeRecipient(pubkeyHex); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.deleteFeeRecipient(pubkey); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 204}; } - async listGraffiti(pubkeyHex: string): ReturnType { - return {data: {pubkey: pubkeyHex, graffiti: this.validator.validatorStore.getGraffiti(pubkeyHex)}}; + async getGraffiti({pubkey}: {pubkey: PubkeyHex}): ReturnType { + return {data: {pubkey, graffiti: this.validator.validatorStore.getGraffiti(pubkey)}}; } - async setGraffiti(pubkeyHex: string, graffiti: string): Promise { + async setGraffiti({pubkey, graffiti}: GraffitiData): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.setGraffiti(pubkeyHex, graffiti); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.setGraffiti(pubkey, graffiti); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 202}; } - async deleteGraffiti(pubkeyHex: string): Promise { + async deleteGraffiti({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.deleteGraffiti(pubkeyHex); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.deleteGraffiti(pubkey); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 204}; } - async getGasLimit(pubkeyHex: string): ReturnType { - const gasLimit = this.validator.validatorStore.getGasLimit(pubkeyHex); - return {data: {pubkey: pubkeyHex, gasLimit}}; + async getGasLimit({pubkey}: {pubkey: PubkeyHex}): ReturnType { + const gasLimit = this.validator.validatorStore.getGasLimit(pubkey); + return {data: {pubkey, gasLimit}}; } - async setGasLimit(pubkeyHex: string, gasLimit: number): Promise { + async setGasLimit({pubkey, gasLimit}: GasLimitData): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.setGasLimit(pubkeyHex, gasLimit); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.setGasLimit(pubkey, gasLimit); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 202}; } - async deleteGasLimit(pubkeyHex: string): Promise { + async deleteGasLimit({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.deleteGasLimit(pubkeyHex); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.deleteGasLimit(pubkey); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 204}; } - /** - * List all validating pubkeys known to and decrypted by this keymanager binary - * - * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml - */ async listKeys(): ReturnType { const pubkeys = this.validator.validatorStore.votingPubkeys(); return { @@ -121,43 +107,34 @@ export class KeymanagerApi implements Api { }; } - /** - * Import keystores generated by the Eth2.0 deposit CLI tooling. `passwords[i]` must unlock `keystores[i]`. - * - * Users SHOULD send slashing_protection data associated with the imported pubkeys. MUST follow the format defined in - * EIP-3076: Slashing Protection Interchange Format. - * - * @param keystoresStr JSON-encoded keystore files generated with the Launchpad - * @param passwords Passwords to unlock imported keystore files. `passwords[i]` must unlock `keystores[i]` - * @param slashingProtectionStr Slashing protection data for some of the keys of `keystores` - * @returns Status result of each `request.keystores` with same length and order of `request.keystores` - * - * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml - */ - async importKeystores( - keystoresStr: KeystoreStr[], - passwords: string[], - slashingProtectionStr?: SlashingProtectionData - ): ReturnType { - if (slashingProtectionStr) { + async importKeystores({ + keystores, + passwords, + slashingProtection, + }: { + keystores: KeystoreStr[]; + passwords: string[]; + slashingProtection?: SlashingProtectionData; + }): ReturnType { + if (slashingProtection) { // The arguments to this function is passed in within the body of an HTTP request // hence fastify will parse it into an object before this function is called. - // Even though the slashingProtectionStr is typed as SlashingProtectionData, - // at runtime, when the handler for the request is selected, it would see slashingProtectionStr + // Even though the slashingProtection is typed as SlashingProtectionData, + // at runtime, when the handler for the request is selected, it would see slashingProtection // as an object, hence trying to parse it using JSON.parse won't work. Instead, we cast straight to Interchange - const interchange = ensureJSON(slashingProtectionStr); + const interchange = ensureJSON(slashingProtection); await this.validator.importInterchange(interchange); } const statuses: {status: ImportStatus; message?: string}[] = []; - const decryptKeystores = new DecryptKeystoresThreadPool(keystoresStr.length, this.signal); + const decryptKeystores = new DecryptKeystoresThreadPool(keystores.length, this.signal); - for (let i = 0; i < keystoresStr.length; i++) { + for (let i = 0; i < keystores.length; i++) { try { - const keystoreStr = keystoresStr[i]; + const keystoreStr = keystores[i]; const password = passwords[i]; if (password === undefined) { - throw Error(`No password for keystores[${i}]`); + throw new ApiError(400, `No password for keystores[${i}]`); } const keystore = Keystore.parse(keystoreStr); @@ -205,37 +182,16 @@ export class KeymanagerApi implements Api { return {data: statuses}; } - /** - * DELETE must delete all keys from `request.pubkeys` that are known to the keymanager and exist in its - * persistent storage. Additionally, DELETE must fetch the slashing protection data for the requested keys from - * persistent storage, which must be retained (and not deleted) after the response has been sent. Therefore in the - * case of two identical delete requests being made, both will have access to slashing protection data. - * - * In a single atomic sequential operation the keymanager must: - * 1. Guarantee that key(s) can not produce any more signature; only then - * 2. Delete key(s) and serialize its associated slashing protection data - * - * DELETE should never return a 404 response, even if all pubkeys from request.pubkeys have no extant keystores - * nor slashing protection data. - * - * Slashing protection data must only be returned for keys from `request.pubkeys` for which a - * `deleted` or `not_active` status is returned. - * - * @param pubkeysHex List of public keys to delete. - * @returns Deletion status of all keys in `request.pubkeys` in the same order. - * - * https://github.com/ethereum/keymanager-APIs/blob/0c975dae2ac6053c8245ebdb6a9f27c2f114f407/keymanager-oapi.yaml - */ - async deleteKeys(pubkeysHex: PubkeyHex[]): ReturnType { + async deleteKeys({pubkeys}: {pubkeys: PubkeyHex[]}): ReturnType { const deletedKey: boolean[] = []; - const statuses = new Array<{status: DeletionStatus; message?: string}>(pubkeysHex.length); + const statuses = new Array<{status: DeletionStatus; message?: string}>(pubkeys.length); - for (let i = 0; i < pubkeysHex.length; i++) { + for (let i = 0; i < pubkeys.length; i++) { try { - const pubkeyHex = pubkeysHex[i]; + const pubkeyHex = pubkeys[i]; if (!isValidatePubkeyHex(pubkeyHex)) { - throw Error(`Invalid pubkey ${pubkeyHex}`); + throw new ApiError(400, `Invalid pubkey ${pubkeyHex}`); } // Skip unknown keys or remote signers @@ -256,7 +212,7 @@ export class KeymanagerApi implements Api { const diskDeleteStatus = this.persistedKeysBackend.deleteKeystore(pubkeyHex); if (diskDeleteStatus) { - // TODO: What if the diskDeleteStatus status is incosistent? + // TODO: What if the diskDeleteStatus status is inconsistent? deletedKey[i] = true; } } catch (e) { @@ -264,7 +220,7 @@ export class KeymanagerApi implements Api { } } - const pubkeysBytes = pubkeysHex.map((pubkeyHex) => fromHexString(pubkeyHex)); + const pubkeysBytes = pubkeys.map((pubkeyHex) => fromHexString(pubkeyHex)); const interchangeV5 = await this.validator.exportInterchange(pubkeysBytes, { version: "5", @@ -272,27 +228,26 @@ export class KeymanagerApi implements Api { // After exporting slashing protection data in bulk, render the status const pubkeysWithSlashingProtectionData = new Set(interchangeV5.data.map((data) => data.pubkey)); - for (let i = 0; i < pubkeysHex.length; i++) { + for (let i = 0; i < pubkeys.length; i++) { if (statuses[i]?.status === DeletionStatus.error) { continue; } const status = deletedKey[i] ? DeletionStatus.deleted - : pubkeysWithSlashingProtectionData.has(pubkeysHex[i]) + : pubkeysWithSlashingProtectionData.has(pubkeys[i]) ? DeletionStatus.not_active : DeletionStatus.not_found; statuses[i] = {status}; } return { - data: statuses, - slashingProtection: JSON.stringify(interchangeV5), + data: { + statuses, + slashingProtection: JSON.stringify(interchangeV5), + }, }; } - /** - * List all remote validating pubkeys known to this validator client binary - */ async listRemoteKeys(): ReturnType { const remoteKeys: SignerDefinition[] = []; @@ -308,19 +263,18 @@ export class KeymanagerApi implements Api { }; } - /** - * Import remote keys for the validator client to request duties for - */ - async importRemoteKeys( - remoteSigners: Pick[] - ): ReturnType { + async importRemoteKeys({ + remoteSigners, + }: { + remoteSigners: RemoteSignerDefinition[]; + }): ReturnType { const importPromises = remoteSigners.map(async ({pubkey, url}): Promise> => { try { if (!isValidatePubkeyHex(pubkey)) { - throw Error(`Invalid pubkey ${pubkey}`); + throw new ApiError(400, `Invalid pubkey ${pubkey}`); } if (!isValidHttpUrl(url)) { - throw Error(`Invalid URL ${url}`); + throw new ApiError(400, `Invalid URL ${url}`); } // Check if key exists @@ -351,16 +305,11 @@ export class KeymanagerApi implements Api { }; } - /** - * DELETE must delete all keys from `request.pubkeys` that are known to the validator client and exist in its - * persistent storage. - * DELETE should never return a 404 response, even if all pubkeys from request.pubkeys have no existing keystores. - */ - async deleteRemoteKeys(pubkeys: PubkeyHex[]): ReturnType { + async deleteRemoteKeys({pubkeys}: {pubkeys: PubkeyHex[]}): ReturnType { const results = pubkeys.map((pubkeyHex): ResponseStatus => { try { if (!isValidatePubkeyHex(pubkeyHex)) { - throw Error(`Invalid pubkey ${pubkeyHex}`); + throw new ApiError(400, `Invalid pubkey ${pubkeyHex}`); } const signer = this.validator.validatorStore.getSigner(pubkeyHex); @@ -390,35 +339,31 @@ export class KeymanagerApi implements Api { }; } - async getBuilderBoostFactor(pubkeyHex: string): ReturnType { - const builderBoostFactor = this.validator.validatorStore.getBuilderBoostFactor(pubkeyHex); - return {data: {pubkey: pubkeyHex, builderBoostFactor}}; + async getBuilderBoostFactor({pubkey}: {pubkey: PubkeyHex}): ReturnType { + const builderBoostFactor = this.validator.validatorStore.getBuilderBoostFactor(pubkey); + return {data: {pubkey, builderBoostFactor}}; } - async setBuilderBoostFactor(pubkeyHex: string, builderBoostFactor: bigint): Promise { + async setBuilderBoostFactor({ + pubkey, + builderBoostFactor, + }: BuilderBoostFactorData): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.setBuilderBoostFactor(pubkeyHex, builderBoostFactor); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.setBuilderBoostFactor(pubkey, builderBoostFactor); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 202}; } - async deleteBuilderBoostFactor(pubkeyHex: string): Promise { + async deleteBuilderBoostFactor({pubkey}: {pubkey: PubkeyHex}): ReturnType { this.checkIfProposerWriteEnabled(); - this.validator.validatorStore.deleteBuilderBoostFactor(pubkeyHex); - this.persistedKeysBackend.writeProposerConfig( - pubkeyHex, - this.validator.validatorStore.getProposerConfig(pubkeyHex) - ); + this.validator.validatorStore.deleteBuilderBoostFactor(pubkey); + this.persistedKeysBackend.writeProposerConfig(pubkey, this.validator.validatorStore.getProposerConfig(pubkey)); + return {status: 204}; } - /** - * Create and sign a voluntary exit message for an active validator - */ - async signVoluntaryExit(pubkey: PubkeyHex, epoch?: Epoch): ReturnType { + async signVoluntaryExit({pubkey, epoch}: {pubkey: PubkeyHex; epoch?: Epoch}): ReturnType { if (!isValidatePubkeyHex(pubkey)) { - throw Error(`Invalid pubkey ${pubkey}`); + throw new ApiError(400, `Invalid pubkey ${pubkey}`); } return {data: await this.validator.signVoluntaryExit(pubkey, epoch)}; } diff --git a/packages/cli/src/cmds/validator/keymanager/server.ts b/packages/cli/src/cmds/validator/keymanager/server.ts index 6d2498dfbb2f..03880c8b8842 100644 --- a/packages/cli/src/cmds/validator/keymanager/server.ts +++ b/packages/cli/src/cmds/validator/keymanager/server.ts @@ -3,11 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import {toHexString} from "@chainsafe/ssz"; import {RestApiServer, RestApiServerOpts, RestApiServerModules} from "@lodestar/beacon-node"; -import {Api} from "@lodestar/api/keymanager"; -import {registerRoutes} from "@lodestar/api/keymanager/server"; +import {KeymanagerApiMethods, registerRoutes} from "@lodestar/api/keymanager/server"; import {ChainForkConfig} from "@lodestar/config"; - -import {ServerApi} from "@lodestar/api"; import {writeFile600Perm} from "../../../util/index.js"; export type KeymanagerRestApiServerOpts = RestApiServerOpts & { @@ -28,7 +25,7 @@ export const keymanagerRestApiServerOptsDefault: KeymanagerRestApiServerOpts = { export type KeymanagerRestApiServerModules = RestApiServerModules & { config: ChainForkConfig; - api: ServerApi; + api: KeymanagerApiMethods; }; export const apiTokenFileName = "api-token.txt"; diff --git a/packages/cli/src/cmds/validator/options.ts b/packages/cli/src/cmds/validator/options.ts index d1603461e438..08548edb1072 100644 --- a/packages/cli/src/cmds/validator/options.ts +++ b/packages/cli/src/cmds/validator/options.ts @@ -1,3 +1,4 @@ +import {WireFormat, defaultInit} from "@lodestar/api"; import {defaultOptions} from "@lodestar/validator"; import {CliCommandOptions} from "@lodestar/utils"; import {LogArgs, logOptions} from "../../options/logOptions.js"; @@ -55,6 +56,9 @@ export type IValidatorCliArgs = AccountValidatorArgs & importKeystores?: string[]; importKeystoresPassword?: string; + "http.requestWireFormat"?: string; + "http.responseWireFormat"?: string; + "externalSigner.url"?: string; "externalSigner.pubkeys"?: string[]; "externalSigner.fetch"?: boolean; @@ -304,6 +308,20 @@ export const validatorOptions: CliCommandOptions = { type: "boolean", }, + "http.requestWireFormat": { + type: "string", + description: `Wire format to use in HTTP requests to beacon node. Can be one of \`${WireFormat.json}\` or \`${WireFormat.ssz}\``, + defaultDescription: `${defaultInit.requestWireFormat}`, + group: "http", + }, + + "http.responseWireFormat": { + type: "string", + description: `Preferred wire format for HTTP responses from beacon node. Can be one of \`${WireFormat.json}\` or \`${WireFormat.ssz}\``, + defaultDescription: `${defaultInit.responseWireFormat}`, + group: "http", + }, + // External signer "externalSigner.url": { diff --git a/packages/cli/src/cmds/validator/slashingProtection/utils.ts b/packages/cli/src/cmds/validator/slashingProtection/utils.ts index d81eb0b994e3..fe0af1cb2633 100644 --- a/packages/cli/src/cmds/validator/slashingProtection/utils.ts +++ b/packages/cli/src/cmds/validator/slashingProtection/utils.ts @@ -1,5 +1,5 @@ import {Root} from "@lodestar/types"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {fromHex, Logger} from "@lodestar/utils"; import {genesisData, NetworkName} from "@lodestar/config/networks"; import {SlashingProtection, MetaDataRepository} from "@lodestar/validator"; @@ -44,7 +44,7 @@ export async function getGenesisValidatorsRoot(args: GlobalArgs & ISlashingProte const genesis = await api.beacon.getGenesis(); try { - ApiError.assert(genesis, "Can not fetch genesis data"); + genesis.assertOk(); } catch (e) { if (args.force) { return Buffer.alloc(32, 0); @@ -52,5 +52,5 @@ export async function getGenesisValidatorsRoot(args: GlobalArgs & ISlashingProte throw e; } - return genesis.response.data.genesisValidatorsRoot; + return genesis.value().genesisValidatorsRoot; } diff --git a/packages/cli/src/cmds/validator/voluntaryExit.ts b/packages/cli/src/cmds/validator/voluntaryExit.ts index 89a908516f03..505abee1450c 100644 --- a/packages/cli/src/cmds/validator/voluntaryExit.ts +++ b/packages/cli/src/cmds/validator/voluntaryExit.ts @@ -10,7 +10,7 @@ import {createBeaconConfig, BeaconConfig} from "@lodestar/config"; import {phase0, ssz, ValidatorIndex, Epoch} from "@lodestar/types"; import {CliCommand, toHex} from "@lodestar/utils"; import {externalSignerPostSignature, SignableMessageType, Signer, SignerType} from "@lodestar/validator"; -import {Api, ApiError, getClient} from "@lodestar/api"; +import {ApiClient, getClient} from "@lodestar/api"; import {ensure0xPrefix, YargsError, wrapError} from "../../util/index.js"; import {GlobalArgs} from "../../options/index.js"; import {getBeaconConfigFromArgs} from "../../config/index.js"; @@ -75,9 +75,7 @@ If no `pubkeys` are provided, it will exit all validators that have been importe // Do not use known networks cache, it defaults to mainnet for devnets const {config: chainForkConfig, network} = getBeaconConfigFromArgs(args); const client = getClient({urls: args.beaconNodes}, {config: chainForkConfig}); - const genesisRes = await client.beacon.getGenesis(); - ApiError.assert(genesisRes, "Unable to fetch genesisValidatorsRoot from beacon node"); - const {genesisValidatorsRoot, genesisTime} = genesisRes.response.data; + const {genesisValidatorsRoot, genesisTime} = (await client.beacon.getGenesis()).value(); const config = createBeaconConfig(chainForkConfig, genesisValidatorsRoot); // Set exitEpoch to current epoch if unspecified @@ -143,7 +141,7 @@ ${validatorsToExit.map((v) => `${v.pubkey} ${v.index} ${v.status}`).join("\n")}` }; async function processVoluntaryExit( - {config, client}: {config: BeaconConfig; client: Api}, + {config, client}: {config: BeaconConfig; client: ApiClient}, exitEpoch: Epoch, validatorToExit: {index: ValidatorIndex; signer: Signer; pubkey: string} ): Promise { @@ -170,12 +168,12 @@ async function processVoluntaryExit( throw new YargsError(`Unexpected signer type for ${pubkey}`); } - ApiError.assert( - await client.beacon.submitPoolVoluntaryExit({ - message: voluntaryExit, - signature: signature.toBytes(), - }) - ); + const signedVoluntaryExit: phase0.SignedVoluntaryExit = { + message: voluntaryExit, + signature: signature.toBytes(), + }; + + (await client.beacon.submitPoolVoluntaryExit({signedVoluntaryExit})).assertOk(); } type SignerPubkey = {signer: Signer; pubkey: string}; @@ -206,13 +204,12 @@ function selectSignersToExit(args: VoluntaryExitArgs, signers: Signer[]): Signer } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -async function resolveValidatorIndexes(client: Api, signersToExit: SignerPubkey[]) { +async function resolveValidatorIndexes(client: ApiClient, signersToExit: SignerPubkey[]) { const pubkeys = signersToExit.map(({pubkey}) => pubkey); - const res = await client.beacon.getStateValidators("head", {id: pubkeys}); - ApiError.assert(res, "Can not fetch state validators from beacon node"); + const validators = (await client.beacon.getStateValidators({stateId: "head", validatorIds: pubkeys})).value(); - const dataByPubkey = new Map(res.response.data.map((item) => [toHex(item.validator.pubkey), item])); + const dataByPubkey = new Map(validators.map((item) => [toHex(item.validator.pubkey), item])); return signersToExit.map(({signer, pubkey}) => { const item = dataByPubkey.get(pubkey); diff --git a/packages/cli/src/networks/index.ts b/packages/cli/src/networks/index.ts index c7d4b5301062..c7a2c24186a6 100644 --- a/packages/cli/src/networks/index.ts +++ b/packages/cli/src/networks/index.ts @@ -2,11 +2,10 @@ import fs from "node:fs"; import got from "got"; import {ENR} from "@chainsafe/enr"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {ApiError, getClient} from "@lodestar/api"; -import {getStateTypeFromBytes} from "@lodestar/beacon-node"; +import {WireFormat, getClient} from "@lodestar/api"; import {ChainConfig, ChainForkConfig} from "@lodestar/config"; import {Checkpoint} from "@lodestar/types/phase0"; -import {Slot} from "@lodestar/types"; +import {Slot, ssz} from "@lodestar/types"; import {fromHex, callFnWhenAwait, Logger} from "@lodestar/utils"; import {BeaconStateAllForks, getLatestBlockRoot, computeCheckpointEpochAtStateSlot} from "@lodestar/state-transition"; import {parseBootnodesFile} from "../util/format.js"; @@ -174,19 +173,18 @@ export async function fetchWeakSubjectivityState( } // getStateV2 should be available for all forks including phase0 - const getStatePromise = api.debug.getStateV2(stateId, "ssz"); + const getStatePromise = api.debug.getStateV2({stateId}, {responseWireFormat: WireFormat.ssz}); - const stateBytes = await callFnWhenAwait( + const {stateBytes, fork} = await callFnWhenAwait( getStatePromise, () => logger.info("Download in progress, please wait..."), GET_STATE_LOG_INTERVAL ).then((res) => { - ApiError.assert(res, "Can not fetch state from beacon node"); - return res.response; + return {stateBytes: res.ssz(), fork: res.meta().version}; }); logger.info("Download completed", {stateId}); - const wsState = getStateTypeFromBytes(config, stateBytes).deserializeToViewDU(stateBytes); + const wsState = ssz.allForks[fork].BeaconState.deserializeToViewDU(stateBytes); return { wsState, diff --git a/packages/cli/test/e2e/blsToExecutionchange.test.ts b/packages/cli/test/e2e/blsToExecutionchange.test.ts index 31b4d76d8f00..b273ab90c996 100644 --- a/packages/cli/test/e2e/blsToExecutionchange.test.ts +++ b/packages/cli/test/e2e/blsToExecutionchange.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import {afterAll, describe, it, vi, beforeEach, afterEach} from "vitest"; import {toHexString} from "@chainsafe/ssz"; import {sleep, retry} from "@lodestar/utils"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {interopSecretKey} from "@lodestar/state-transition"; import {execCliCommand, spawnCliCommand, stopChildProcess} from "@lodestar/test-utils"; @@ -39,14 +39,13 @@ describe("bLSToExecutionChange cmd", function () { const baseUrl = `http://127.0.0.1:${restPort}`; // To cleanup the event stream connection const httpClientController = new AbortController(); - const client = getClient({baseUrl, getAbortSignal: () => httpClientController.signal}, {config}); + const client = getClient({baseUrl, globalInit: {signal: httpClientController.signal}}, {config}); // Wait for beacon node API to be available + genesis await retry( async () => { - const head = await client.beacon.getBlockHeader("head"); - ApiError.assert(head); - if (head.response.data.header.message.slot < 1) throw Error("pre-genesis"); + const head = (await client.beacon.getBlockHeader({blockId: "head"})).value(); + if (head.header.message.slot < 1) throw Error("pre-genesis"); }, {retryDelay: 1000, retries: 60} ); @@ -73,9 +72,8 @@ describe("bLSToExecutionChange cmd", function () { "--toExecutionAddress 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ]); - const pooledBlsChanges = await client.beacon.getPoolBlsToExecutionChanges(); - ApiError.assert(pooledBlsChanges); - const message = pooledBlsChanges.response.data[0].message; + const pooledBlsChanges = (await client.beacon.getPoolBLSToExecutionChanges()).value(); + const {message} = pooledBlsChanges[0]; const {validatorIndex, toExecutionAddress, fromBlsPubkey} = message; if ( validatorIndex !== 0 || diff --git a/packages/cli/test/e2e/importKeystoresFromApi.test.ts b/packages/cli/test/e2e/importKeystoresFromApi.test.ts index 8733d6cd5c48..968e8ca980ce 100644 --- a/packages/cli/test/e2e/importKeystoresFromApi.test.ts +++ b/packages/cli/test/e2e/importKeystoresFromApi.test.ts @@ -4,7 +4,7 @@ import {rimraf} from "rimraf"; import {DeletionStatus, getClient, ImportStatus} from "@lodestar/api/keymanager"; import {config} from "@lodestar/config/default"; import {Interchange} from "@lodestar/validator"; -import {ApiError, HttpStatusCode} from "@lodestar/api"; +import {HttpStatusCode} from "@lodestar/api"; import {bufferStderr, spawnCliCommand} from "@lodestar/test-utils"; import {getKeystoresStr} from "@lodestar/test-utils"; import {testFilesDir} from "../utils.js"; @@ -63,10 +63,13 @@ describe("import keystores from api", function () { await expectKeys(keymanagerClient, [], "Wrong listKeys before importing"); // Import test keys - const importRes = await keymanagerClient.importKeystores(keystoresStr, passphrases, slashingProtectionStr); - ApiError.assert(importRes); + const importRes = await keymanagerClient.importKeystores({ + keystores: keystoresStr, + passwords: passphrases, + slashingProtection: slashingProtectionStr, + }); expectDeepEquals( - importRes.response.data, + importRes.value(), pubkeys.map(() => ({status: ImportStatus.imported})), "Wrong importKeystores response" ); @@ -75,10 +78,13 @@ describe("import keystores from api", function () { await expectKeys(keymanagerClient, pubkeys, "Wrong listKeys after importing"); // Attempt to import the same keys again - const importAgainRes = await keymanagerClient.importKeystores(keystoresStr, passphrases, slashingProtectionStr); - ApiError.assert(importAgainRes); + const importAgainRes = await keymanagerClient.importKeystores({ + keystores: keystoresStr, + passwords: passphrases, + slashingProtection: slashingProtectionStr, + }); expectDeepEquals( - importAgainRes.response.data, + importAgainRes.value(), pubkeys.map(() => ({status: ImportStatus.duplicate})), "Wrong importKeystores again response" ); @@ -117,10 +123,9 @@ describe("import keystores from api", function () { await expectKeys(keymanagerClient, pubkeys, "Wrong listKeys before deleting"); // Delete keys - const deleteRes = await keymanagerClient.deleteKeys(pubkeys); - ApiError.assert(deleteRes); + const deleteRes = await keymanagerClient.deleteKeys({pubkeys}); expectDeepEquals( - deleteRes.response.data, + deleteRes.value().statuses, pubkeys.map(() => ({status: DeletionStatus.deleted})), "Wrong deleteKeys response" ); @@ -138,9 +143,12 @@ describe("import keystores from api", function () { it("reject calls without bearerToken", async function () { await startValidatorWithKeyManager([], {dataDir}); - const keymanagerClientNoAuth = getClient({baseUrl: "http://localhost:38011", bearerToken: undefined}, {config}); + const keymanagerClientNoAuth = getClient( + {baseUrl: "http://localhost:38011", globalInit: {bearerToken: undefined}}, + {config} + ); const res = await keymanagerClientNoAuth.listRemoteKeys(); expect(res.ok).toBe(false); - expect(res.error?.code).toEqual(HttpStatusCode.UNAUTHORIZED); + expect(res.status).toEqual(HttpStatusCode.UNAUTHORIZED); }); }); diff --git a/packages/cli/test/e2e/importRemoteKeysFromApi.test.ts b/packages/cli/test/e2e/importRemoteKeysFromApi.test.ts index fd2193060ddd..c5638195d809 100644 --- a/packages/cli/test/e2e/importRemoteKeysFromApi.test.ts +++ b/packages/cli/test/e2e/importRemoteKeysFromApi.test.ts @@ -1,9 +1,9 @@ import path from "node:path"; import {describe, it, expect, beforeAll, vi} from "vitest"; import {rimraf} from "rimraf"; -import {Api, DeleteRemoteKeyStatus, getClient, ImportRemoteKeyStatus} from "@lodestar/api/keymanager"; +import {ApiClient, DeleteRemoteKeyStatus, getClient, ImportRemoteKeyStatus} from "@lodestar/api/keymanager"; import {config} from "@lodestar/config/default"; -import {ApiError, HttpStatusCode} from "@lodestar/api"; +import {HttpStatusCode} from "@lodestar/api"; import {testFilesDir} from "../utils.js"; import {cachedPubkeysHex} from "../utils/cachedKeys.js"; import {expectDeepEquals} from "../utils/runUtils.js"; @@ -11,11 +11,10 @@ import {startValidatorWithKeyManager} from "../utils/validator.js"; const url = "https://remote.signer"; -async function expectKeys(keymanagerClient: Api, expectedPubkeys: string[], message: string): Promise { - const remoteKeys = await keymanagerClient.listRemoteKeys(); - ApiError.assert(remoteKeys); +async function expectKeys(keymanagerClient: ApiClient, expectedPubkeys: string[], message: string): Promise { + const remoteKeys = (await keymanagerClient.listRemoteKeys()).value(); expectDeepEquals( - remoteKeys.response.data, + remoteKeys, expectedPubkeys.map((pubkey) => ({pubkey, url, readonly: false})), message ); @@ -40,10 +39,11 @@ describe("import remoteKeys from api", function () { await expectKeys(keymanagerClient, [], "Wrong listRemoteKeys before importing"); // Import test keys - const importRes = await keymanagerClient.importRemoteKeys(pubkeysToAdd.map((pubkey) => ({pubkey, url}))); - ApiError.assert(importRes); + const importRes = await keymanagerClient.importRemoteKeys({ + remoteSigners: pubkeysToAdd.map((pubkey) => ({pubkey, url})), + }); expectDeepEquals( - importRes.response.data, + importRes.value(), pubkeysToAdd.map(() => ({status: ImportRemoteKeyStatus.imported})), "Wrong importRemoteKeys response" ); @@ -52,10 +52,11 @@ describe("import remoteKeys from api", function () { await expectKeys(keymanagerClient, pubkeysToAdd, "Wrong listRemoteKeys after importing"); // Attempt to import the same keys again - const importAgainRes = await keymanagerClient.importRemoteKeys(pubkeysToAdd.map((pubkey) => ({pubkey, url}))); - ApiError.assert(importAgainRes); + const importAgainRes = await keymanagerClient.importRemoteKeys({ + remoteSigners: pubkeysToAdd.map((pubkey) => ({pubkey, url})), + }); expectDeepEquals( - importAgainRes.response.data, + importAgainRes.value(), pubkeysToAdd.map(() => ({status: ImportRemoteKeyStatus.duplicate})), "Wrong importRemoteKeys again response" ); @@ -67,10 +68,9 @@ describe("import remoteKeys from api", function () { await expectKeys(keymanagerClient, pubkeysToAdd, "Wrong listRemoteKeys before deleting"); // Delete keys - const deleteRes = await keymanagerClient.deleteRemoteKeys(pubkeysToAdd); - ApiError.assert(deleteRes); + const deleteRes = await keymanagerClient.deleteRemoteKeys({pubkeys: pubkeysToAdd}); expectDeepEquals( - deleteRes.response.data, + deleteRes.value(), pubkeysToAdd.map(() => ({status: DeleteRemoteKeyStatus.deleted})), "Wrong deleteRemoteKeys response" ); @@ -82,9 +82,9 @@ describe("import remoteKeys from api", function () { it("reject calls without bearerToken", async function () { await startValidatorWithKeyManager([], {dataDir}); const keymanagerUrl = "http://localhost:38011"; - const keymanagerClientNoAuth = getClient({baseUrl: keymanagerUrl, bearerToken: undefined}, {config}); + const keymanagerClientNoAuth = getClient({baseUrl: keymanagerUrl, globalInit: {bearerToken: undefined}}, {config}); const res = await keymanagerClientNoAuth.listRemoteKeys(); expect(res.ok).toBe(false); - expect(res.error?.code).toEqual(HttpStatusCode.UNAUTHORIZED); + expect(res.status).toEqual(HttpStatusCode.UNAUTHORIZED); }); }); diff --git a/packages/cli/test/e2e/propserConfigfromKeymanager.test.ts b/packages/cli/test/e2e/propserConfigfromKeymanager.test.ts index eff3a488c898..711997a86627 100644 --- a/packages/cli/test/e2e/propserConfigfromKeymanager.test.ts +++ b/packages/cli/test/e2e/propserConfigfromKeymanager.test.ts @@ -1,8 +1,8 @@ import path from "node:path"; import {describe, it, beforeAll, vi} from "vitest"; import {rimraf} from "rimraf"; +import {ImportStatus} from "@lodestar/api/keymanager"; import {Interchange} from "@lodestar/validator"; -import {ApiError} from "@lodestar/api"; import {getKeystoresStr} from "@lodestar/test-utils"; import {testFilesDir} from "../utils.js"; import {cachedPubkeysHex, cachedSeckeysHex} from "../utils/cachedKeys.js"; @@ -55,67 +55,56 @@ describe("import keystores from api, test DefaultProposerConfig", function () { // Produce and encrypt keystores // Import test keys const keystoresStr = await getKeystoresStr(passphrase, secretKeys); - await keymanagerClient.importKeystores(keystoresStr, passphrases, slashingProtectionStr); + const importRes = await keymanagerClient.importKeystores({ + keystores: keystoresStr, + passwords: passphrases, + slashingProtection: slashingProtectionStr, + }); + expectDeepEquals( + importRes.value(), + keystoresStr.map(() => ({status: ImportStatus.imported})), + "Wrong importKeystores response" + ); //////////////// Fee Recipient - let feeRecipient0 = await keymanagerClient.listFeeRecipient(pubkeys[0]); - ApiError.assert(feeRecipient0); + let feeRecipient0 = (await keymanagerClient.listFeeRecipient({pubkey: pubkeys[0]})).value(); expectDeepEquals( - feeRecipient0.response.data, + feeRecipient0, {pubkey: pubkeys[0], ethaddress: defaultOptions.suggestedFeeRecipient}, "FeeRecipient Check default" ); // Set feeClient to updatedOptions - ApiError.assert(await keymanagerClient.setFeeRecipient(pubkeys[0], updatedOptions.suggestedFeeRecipient)); - feeRecipient0 = await keymanagerClient.listFeeRecipient(pubkeys[0]); - ApiError.assert(feeRecipient0); + ( + await keymanagerClient.setFeeRecipient({pubkey: pubkeys[0], ethaddress: updatedOptions.suggestedFeeRecipient}) + ).assertOk(); + feeRecipient0 = (await keymanagerClient.listFeeRecipient({pubkey: pubkeys[0]})).value(); expectDeepEquals( - feeRecipient0.response.data, + feeRecipient0, {pubkey: pubkeys[0], ethaddress: updatedOptions.suggestedFeeRecipient}, "FeeRecipient Check updated" ); //////////////// Graffiti - let graffiti0 = await keymanagerClient.listGraffiti(pubkeys[0]); - ApiError.assert(graffiti0); - expectDeepEquals( - graffiti0.response.data, - {pubkey: pubkeys[0], graffiti: defaultOptions.graffiti}, - "Graffiti Check default" - ); + let graffiti0 = (await keymanagerClient.getGraffiti({pubkey: pubkeys[0]})).value(); + expectDeepEquals(graffiti0, {pubkey: pubkeys[0], graffiti: defaultOptions.graffiti}, "Graffiti Check default"); // Set Graffiti to updatedOptions - ApiError.assert(await keymanagerClient.setGraffiti(pubkeys[0], updatedOptions.graffiti)); - graffiti0 = await keymanagerClient.listGraffiti(pubkeys[0]); - ApiError.assert(graffiti0); - expectDeepEquals( - graffiti0.response.data, - {pubkey: pubkeys[0], graffiti: updatedOptions.graffiti}, - "FeeRecipient Check updated" - ); + (await keymanagerClient.setGraffiti({pubkey: pubkeys[0], graffiti: updatedOptions.graffiti})).assertOk(); + graffiti0 = (await keymanagerClient.getGraffiti({pubkey: pubkeys[0]})).value(); + expectDeepEquals(graffiti0, {pubkey: pubkeys[0], graffiti: updatedOptions.graffiti}, "FeeRecipient Check updated"); /////////// GasLimit - let gasLimit0 = await keymanagerClient.getGasLimit(pubkeys[0]); - ApiError.assert(gasLimit0); - expectDeepEquals( - gasLimit0.response.data, - {pubkey: pubkeys[0], gasLimit: defaultOptions.gasLimit}, - "gasLimit Check default" - ); + let gasLimit0 = (await keymanagerClient.getGasLimit({pubkey: pubkeys[0]})).value(); + expectDeepEquals(gasLimit0, {pubkey: pubkeys[0], gasLimit: defaultOptions.gasLimit}, "gasLimit Check default"); // Set GasLimit to updatedOptions - ApiError.assert(await keymanagerClient.setGasLimit(pubkeys[0], updatedOptions.gasLimit)); - gasLimit0 = await keymanagerClient.getGasLimit(pubkeys[0]); - ApiError.assert(gasLimit0); - expectDeepEquals( - gasLimit0.response.data, - {pubkey: pubkeys[0], gasLimit: updatedOptions.gasLimit}, - "gasLimit Check updated" - ); + (await keymanagerClient.setGasLimit({pubkey: pubkeys[0], gasLimit: updatedOptions.gasLimit})).assertOk(); + gasLimit0 = (await keymanagerClient.getGasLimit({pubkey: pubkeys[0]})).value(); + expectDeepEquals(gasLimit0, {pubkey: pubkeys[0], gasLimit: updatedOptions.gasLimit}, "gasLimit Check updated"); }); it("2 . run 'validator' Check last feeRecipient and gasLimit persists", async () => { @@ -124,57 +113,51 @@ describe("import keystores from api, test DefaultProposerConfig", function () { }); // next time check edited feeRecipient persists - let feeRecipient0 = await keymanagerClient.listFeeRecipient(pubkeys[0]); - ApiError.assert(feeRecipient0); + let feeRecipient0 = (await keymanagerClient.listFeeRecipient({pubkey: pubkeys[0]})).value(); expectDeepEquals( - feeRecipient0.response.data, + feeRecipient0, {pubkey: pubkeys[0], ethaddress: updatedOptions.suggestedFeeRecipient}, "FeeRecipient Check default persists" ); // after deletion feeRecipient restored to default - ApiError.assert(await keymanagerClient.deleteFeeRecipient(pubkeys[0])); - feeRecipient0 = await keymanagerClient.listFeeRecipient(pubkeys[0]); - ApiError.assert(feeRecipient0); + (await keymanagerClient.deleteFeeRecipient({pubkey: pubkeys[0]})).assertOk(); + feeRecipient0 = (await keymanagerClient.listFeeRecipient({pubkey: pubkeys[0]})).value(); expectDeepEquals( - feeRecipient0.response.data, + feeRecipient0, {pubkey: pubkeys[0], ethaddress: defaultOptions.suggestedFeeRecipient}, "FeeRecipient Check default after delete" ); // graffiti persists - let graffiti0 = await keymanagerClient.listGraffiti(pubkeys[0]); - ApiError.assert(graffiti0); + let graffiti0 = (await keymanagerClient.getGraffiti({pubkey: pubkeys[0]})).value(); expectDeepEquals( - graffiti0.response.data, + graffiti0, {pubkey: pubkeys[0], graffiti: updatedOptions.graffiti}, "FeeRecipient Check default persists" ); // after deletion graffiti restored to default - ApiError.assert(await keymanagerClient.deleteGraffiti(pubkeys[0])); - graffiti0 = await keymanagerClient.listGraffiti(pubkeys[0]); - ApiError.assert(graffiti0); + (await keymanagerClient.deleteGraffiti({pubkey: pubkeys[0]})).assertOk(); + graffiti0 = (await keymanagerClient.getGraffiti({pubkey: pubkeys[0]})).value(); expectDeepEquals( - graffiti0.response.data, + graffiti0, {pubkey: pubkeys[0], graffiti: defaultOptions.graffiti}, "FeeRecipient Check default after delete" ); // gasLimit persists - let gasLimit0 = await keymanagerClient.getGasLimit(pubkeys[0]); - ApiError.assert(gasLimit0); + let gasLimit0 = (await keymanagerClient.getGasLimit({pubkey: pubkeys[0]})).value(); expectDeepEquals( - gasLimit0.response.data, + gasLimit0, {pubkey: pubkeys[0], gasLimit: updatedOptions.gasLimit}, "gasLimit Check updated persists" ); - ApiError.assert(await keymanagerClient.deleteGasLimit(pubkeys[0])); - gasLimit0 = await keymanagerClient.getGasLimit(pubkeys[0]); - ApiError.assert(gasLimit0); + (await keymanagerClient.deleteGasLimit({pubkey: pubkeys[0]})).assertOk(); + gasLimit0 = (await keymanagerClient.getGasLimit({pubkey: pubkeys[0]})).value(); expectDeepEquals( - gasLimit0.response.data, + gasLimit0, {pubkey: pubkeys[0], gasLimit: defaultOptions.gasLimit}, "gasLimit Check default after delete" ); @@ -185,28 +168,25 @@ describe("import keystores from api, test DefaultProposerConfig", function () { dataDir, }); - const feeRecipient0 = await keymanagerClient.listFeeRecipient(pubkeys[0]); - ApiError.assert(feeRecipient0); + const feeRecipient0 = (await keymanagerClient.listFeeRecipient({pubkey: pubkeys[0]})).value(); expectDeepEquals( - feeRecipient0.response.data, + feeRecipient0, {pubkey: pubkeys[0], ethaddress: defaultOptions.suggestedFeeRecipient}, "FeeRecipient Check default persists" ); - ApiError.assert(await keymanagerClient.deleteGraffiti(pubkeys[0])); - const graffiti0 = await keymanagerClient.listGraffiti(pubkeys[0]); - ApiError.assert(graffiti0); + (await keymanagerClient.deleteGraffiti({pubkey: pubkeys[0]})).assertOk(); + const graffiti0 = (await keymanagerClient.getGraffiti({pubkey: pubkeys[0]})).value(); expectDeepEquals( - graffiti0.response.data, + graffiti0, {pubkey: pubkeys[0], graffiti: defaultOptions.graffiti}, "FeeRecipient Check default persists" ); - ApiError.assert(await keymanagerClient.deleteGasLimit(pubkeys[0])); - const gasLimit0 = await keymanagerClient.getGasLimit(pubkeys[0]); - ApiError.assert(gasLimit0); + (await keymanagerClient.deleteGasLimit({pubkey: pubkeys[0]})).assertOk(); + const gasLimit0 = (await keymanagerClient.getGasLimit({pubkey: pubkeys[0]})).value(); expectDeepEquals( - gasLimit0.response.data, + gasLimit0, {pubkey: pubkeys[0], gasLimit: defaultOptions.gasLimit}, "gasLimit Check default after delete" ); diff --git a/packages/cli/test/e2e/runDevCmd.test.ts b/packages/cli/test/e2e/runDevCmd.test.ts index c7f51b45045e..8e1b8b04e257 100644 --- a/packages/cli/test/e2e/runDevCmd.test.ts +++ b/packages/cli/test/e2e/runDevCmd.test.ts @@ -1,5 +1,5 @@ import {describe, it, vi, beforeEach, afterEach, afterAll} from "vitest"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {retry} from "@lodestar/utils"; import {spawnCliCommand} from "@lodestar/test-utils"; @@ -26,10 +26,10 @@ describe("Run dev command", function () { const beaconUrl = `http://127.0.0.1:${beaconPort}`; // To cleanup the event stream connection const httpClientController = new AbortController(); - const client = getClient({baseUrl: beaconUrl, getAbortSignal: () => httpClientController.signal}, {config}); + const client = getClient({baseUrl: beaconUrl, globalInit: {signal: httpClientController.signal}}, {config}); // Wrap in retry since the API may not be listening yet - await retry(() => client.node.getHealth().then((res) => ApiError.assert(res)), {retryDelay: 1000, retries: 60}); + await retry(() => client.node.getHealth().then((res) => res.assertOk()), {retryDelay: 1000, retries: 60}); httpClientController.abort(); // The process will exit when the test finishes diff --git a/packages/cli/test/e2e/voluntaryExit.test.ts b/packages/cli/test/e2e/voluntaryExit.test.ts index 89841fb7c3e4..f8c9150790f3 100644 --- a/packages/cli/test/e2e/voluntaryExit.test.ts +++ b/packages/cli/test/e2e/voluntaryExit.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import {afterAll, describe, it, vi, beforeEach, afterEach} from "vitest"; import {retry} from "@lodestar/utils"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {interopSecretKey} from "@lodestar/state-transition"; import {spawnCliCommand, execCliCommand} from "@lodestar/test-utils"; @@ -41,14 +41,13 @@ describe("voluntaryExit cmd", function () { const baseUrl = `http://127.0.0.1:${restPort}`; // To cleanup the event stream connection const httpClientController = new AbortController(); - const client = getClient({baseUrl, getAbortSignal: () => httpClientController.signal}, {config}); + const client = getClient({baseUrl, globalInit: {signal: httpClientController.signal}}, {config}); // Wait for beacon node API to be available + genesis await retry( async () => { - const head = await client.beacon.getBlockHeader("head"); - ApiError.assert(head); - if (head.response.data.header.message.slot < 1) throw Error("pre-genesis"); + const head = (await client.beacon.getBlockHeader({blockId: "head"})).value(); + if (head.header.message.slot < 1) throw Error("pre-genesis"); }, {retryDelay: 1000, retries: 20} ); @@ -79,13 +78,12 @@ describe("voluntaryExit cmd", function () { for (const pubkey of pubkeysToExit) { await retry( async () => { - const res = await client.beacon.getStateValidator("head", pubkey); - ApiError.assert(res); - if (res.response.data.status !== "active_exiting") { + const validator = (await client.beacon.getStateValidator({stateId: "head", validatorId: pubkey})).value(); + if (validator.status !== "active_exiting") { throw Error("Validator not exiting"); } else { // eslint-disable-next-line no-console - console.log(`Confirmed validator ${pubkey} = ${res.response.data.status}`); + console.log(`Confirmed validator ${pubkey} = ${validator.status}`); } }, {retryDelay: 1000, retries: 20} diff --git a/packages/cli/test/e2e/voluntaryExitFromApi.test.ts b/packages/cli/test/e2e/voluntaryExitFromApi.test.ts index 271f3d794ca3..ccd1dfeeba37 100644 --- a/packages/cli/test/e2e/voluntaryExitFromApi.test.ts +++ b/packages/cli/test/e2e/voluntaryExitFromApi.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import {describe, it, vi, expect, afterAll, beforeEach, afterEach} from "vitest"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {getClient as getKeymanagerClient} from "@lodestar/api/keymanager"; import {config} from "@lodestar/config/default"; import {interopSecretKey} from "@lodestar/state-transition"; @@ -53,9 +53,8 @@ describe("voluntary exit from api", function () { // Wait for beacon node API to be available + genesis await retry( async () => { - const head = await beaconClient.getBlockHeader("head"); - ApiError.assert(head); - if (head.response.data.header.message.slot < 1) throw Error("pre-genesis"); + const head = (await beaconClient.getBlockHeader({blockId: "head"})).value(); + if (head.header.message.slot < 1) throw Error("pre-genesis"); }, {retryDelay: 1000, retries: 20} ); @@ -65,9 +64,9 @@ describe("voluntary exit from api", function () { const indexToExit = 0; const pubkeyToExit = interopSecretKey(indexToExit).toPublicKey().toHex(); - const res = await keymanagerClient.signVoluntaryExit(pubkeyToExit, exitEpoch); - ApiError.assert(res); - const signedVoluntaryExit = res.response.data; + const signedVoluntaryExit = ( + await keymanagerClient.signVoluntaryExit({pubkey: pubkeyToExit, epoch: exitEpoch}) + ).value(); expect(signedVoluntaryExit.message.epoch).toBe(exitEpoch); expect(signedVoluntaryExit.message.validatorIndex).toBe(indexToExit); @@ -75,18 +74,17 @@ describe("voluntary exit from api", function () { expect(signedVoluntaryExit.signature).toBeDefined(); // 2. submit signed voluntary exit message to beacon node - ApiError.assert(await beaconClient.submitPoolVoluntaryExit(signedVoluntaryExit)); + (await beaconClient.submitPoolVoluntaryExit({signedVoluntaryExit})).assertOk(); // 3. confirm validator status is 'active_exiting' await retry( async () => { - const res = await beaconClient.getStateValidator("head", pubkeyToExit); - ApiError.assert(res); - if (res.response.data.status !== "active_exiting") { + const validator = (await beaconClient.getStateValidator({stateId: "head", validatorId: pubkeyToExit})).value(); + if (validator.status !== "active_exiting") { throw Error("Validator not exiting"); } else { // eslint-disable-next-line no-console - console.log(`Confirmed validator ${pubkeyToExit} = ${res.response.data.status}`); + console.log(`Confirmed validator ${pubkeyToExit} = ${validator.status}`); } }, {retryDelay: 1000, retries: 20} diff --git a/packages/cli/test/e2e/voluntaryExitRemoteSigner.test.ts b/packages/cli/test/e2e/voluntaryExitRemoteSigner.test.ts index b2f902c0e6dd..ab0c1e1e9ee3 100644 --- a/packages/cli/test/e2e/voluntaryExitRemoteSigner.test.ts +++ b/packages/cli/test/e2e/voluntaryExitRemoteSigner.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import {describe, it, beforeAll, afterAll, beforeEach, afterEach, vi} from "vitest"; import {retry} from "@lodestar/utils"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {interopSecretKey, interopSecretKeys} from "@lodestar/state-transition"; import { @@ -66,9 +66,8 @@ describe("voluntaryExit using remote signer", function () { // Wait for beacon node API to be available + genesis await retry( async () => { - const head = await client.beacon.getBlockHeader("head"); - ApiError.assert(head); - if (head.response.data.header.message.slot < 1) throw Error("pre-genesis"); + const head = (await client.beacon.getBlockHeader({blockId: "head"})).value(); + if (head.header.message.slot < 1) throw Error("pre-genesis"); }, {retryDelay: 1000, retries: 20} ); @@ -94,13 +93,12 @@ describe("voluntaryExit using remote signer", function () { for (const pubkey of pubkeysToExit) { await retry( async () => { - const res = await client.beacon.getStateValidator("head", pubkey); - ApiError.assert(res); - if (res.response.data.status !== "active_exiting") { + const validator = (await client.beacon.getStateValidator({stateId: "head", validatorId: pubkey})).value(); + if (validator.status !== "active_exiting") { throw Error("Validator not exiting"); } else { // eslint-disable-next-line no-console - console.log(`Confirmed validator ${pubkey} = ${res.response.data.status}`); + console.log(`Confirmed validator ${pubkey} = ${validator.status}`); } }, {retryDelay: 1000, retries: 20} diff --git a/packages/cli/test/sim/endpoints.test.ts b/packages/cli/test/sim/endpoints.test.ts index b58bece04d9d..a40a18e379eb 100644 --- a/packages/cli/test/sim/endpoints.test.ts +++ b/packages/cli/test/sim/endpoints.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import assert from "node:assert"; import {toHexString} from "@chainsafe/ssz"; -import {ApiError, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {Simulation} from "../utils/crucible/simulation.js"; import {BeaconClient, ExecutionClient} from "../utils/crucible/interfaces.js"; import {defineSimTestConfig, logFilesDir} from "../utils/crucible/utils/index.js"; @@ -40,20 +40,18 @@ await env.start({runTimeoutMs: estimatedTimeoutMs}); const node = env.nodes[0].beacon; await waitForSlot("Wait for 2 slots before checking endpoints", {env, slot: 2}); -const res = await node.api.beacon.getStateValidators("head"); -ApiError.assert(res); -const stateValidators = res.response.data; +const validators = (await node.api.beacon.getStateValidators({stateId: "head"})).value(); await env.tracker.assert("should have correct validators count called without filters", async () => { - assert.equal(stateValidators.length, validatorCount); + assert.equal(validators.length, validatorCount); }); await env.tracker.assert("should have correct validator index for first validator filters", async () => { - assert.equal(stateValidators[0].index, 0); + assert.equal(validators[0].index, 0); }); await env.tracker.assert("should have correct validator index for second validator filters", async () => { - assert.equal(stateValidators[1].index, 1); + assert.equal(validators[1].index, 1); }); await env.tracker.assert( @@ -62,14 +60,13 @@ await env.tracker.assert( const filterPubKey = "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c"; - const res = await node.api.beacon.getStateValidators("head", { - id: [filterPubKey], - }); - ApiError.assert(res); + const res = await node.api.beacon.getStateValidators({stateId: "head", validatorIds: [filterPubKey]}); - assert.equal(res.response.data.length, 1); - assert.equal(res.response.executionOptimistic, false); - assert.equal(res.response.finalized, false); + assert.equal(res.value().length, 1); + + const {executionOptimistic, finalized} = res.meta(); + assert.equal(executionOptimistic, false); + assert.equal(finalized, false); } ); @@ -79,12 +76,9 @@ await env.tracker.assert( const filterPubKey = "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c"; - const res = await node.api.beacon.getStateValidators("head", { - id: [filterPubKey], - }); - ApiError.assert(res); + const res = await node.api.beacon.getStateValidators({stateId: "head", validatorIds: [filterPubKey]}); - assert.equal(toHexString(res.response.data[0].validator.pubkey), filterPubKey); + assert.equal(toHexString(res.value()[0].validator.pubkey), filterPubKey); } ); @@ -93,10 +87,9 @@ await env.tracker.assert( async () => { const validatorIndex = 0; - const res = await node.api.beacon.getStateValidator("head", validatorIndex); - ApiError.assert(res); + const res = await node.api.beacon.getStateValidator({stateId: "head", validatorId: validatorIndex}); - assert.equal(res.response.data.index, validatorIndex); + assert.equal(res.value().index, validatorIndex); } ); @@ -106,10 +99,9 @@ await env.tracker.assert( const hexPubKey = "0xa99a76ed7796f7be22d5b7e85deeb7c5677e88e511e0b337618f8c4eb61349b4bf2d153f649f7b53359fe8b94a38e44c"; - const res = await node.api.beacon.getStateValidator("head", hexPubKey); - ApiError.assert(res); + const res = await node.api.beacon.getStateValidator({stateId: "head", validatorId: hexPubKey}); - assert.equal(toHexString(res.response.data.validator.pubkey), hexPubKey); + assert.equal(toHexString(res.value().validator.pubkey), hexPubKey); } ); @@ -123,9 +115,8 @@ await env.tracker.assert("BN Not Synced", async () => { }; const res = await node.api.node.getSyncingStatus(); - ApiError.assert(res); - assert.deepEqual(res.response.data, expectedSyncStatus); + assert.deepEqual(res.value(), expectedSyncStatus); }); await env.tracker.assert("Return READY pre genesis", async () => { diff --git a/packages/cli/test/utils/crucible/assertions/blobsAssertion.ts b/packages/cli/test/utils/crucible/assertions/blobsAssertion.ts index 997008272685..dece5bc58ce8 100644 --- a/packages/cli/test/utils/crucible/assertions/blobsAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/blobsAssertion.ts @@ -1,5 +1,4 @@ import {randomBytes} from "node:crypto"; -import {ApiError} from "@lodestar/api"; import {fromHex, toHex} from "@lodestar/utils"; import {Assertion, Match, AssertionResult, NodePair} from "../interfaces.js"; import {EL_GENESIS_ACCOUNT, EL_GENESIS_SECRET_KEY, SIM_ENV_CHAIN_ID} from "../constants.js"; @@ -50,10 +49,9 @@ export function createBlobsAssertion( sentBlobs.push(...blobs.map((b) => fromHex(b))); } - const blobSideCars = await node.beacon.api.beacon.getBlobSidecars(slot); - ApiError.assert(blobSideCars); + const blobSideCars = (await node.beacon.api.beacon.getBlobSidecars({blockId: slot})).value(); - return blobSideCars.response.data.map((b) => b.blob); + return blobSideCars.map((b) => b.blob); }, assert: async ({store}) => { diff --git a/packages/cli/test/utils/crucible/assertions/defaults/attestationParticipationAssertion.ts b/packages/cli/test/utils/crucible/assertions/defaults/attestationParticipationAssertion.ts index e44cb6cf5629..01cd6d29d09a 100644 --- a/packages/cli/test/utils/crucible/assertions/defaults/attestationParticipationAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/defaults/attestationParticipationAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {TIMELY_HEAD_FLAG_INDEX, TIMELY_SOURCE_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX} from "@lodestar/params"; import {isActiveValidator} from "@lodestar/state-transition"; import {altair} from "@lodestar/types"; @@ -26,9 +25,8 @@ export const attestationParticipationAssertion: Assertion< }, async capture({node, epoch}) { - const res = await node.beacon.api.debug.getStateV2("head"); - ApiError.assert(res); - const state = res.response.data as altair.BeaconState; + const res = await node.beacon.api.debug.getStateV2({stateId: "head"}); + const state = res.value() as altair.BeaconState; // Attestation to be computed at the end of epoch. At that time the "currentEpochParticipation" is all set to zero // and we have to use "previousEpochParticipation" instead. diff --git a/packages/cli/test/utils/crucible/assertions/defaults/connectedPeerCountAssertion.ts b/packages/cli/test/utils/crucible/assertions/defaults/connectedPeerCountAssertion.ts index d3f6fe6038ff..83f308e62789 100644 --- a/packages/cli/test/utils/crucible/assertions/defaults/connectedPeerCountAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/defaults/connectedPeerCountAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {AssertionResult, Assertion} from "../../interfaces.js"; import {everySlotMatcher} from "../matchers.js"; @@ -6,9 +5,7 @@ export const connectedPeerCountAssertion: Assertion<"connectedPeerCount", number id: "connectedPeerCount", match: everySlotMatcher, async capture({node}) { - const res = await node.beacon.api.node.getPeerCount(); - ApiError.assert(res); - return res.response.data.connected; + return (await node.beacon.api.node.getPeerCount()).value().connected; }, async assert({nodes, slot, store}) { const errors: AssertionResult[] = []; diff --git a/packages/cli/test/utils/crucible/assertions/defaults/finalizedAssertion.ts b/packages/cli/test/utils/crucible/assertions/defaults/finalizedAssertion.ts index 44bd01dd865e..f03fc41eb8e9 100644 --- a/packages/cli/test/utils/crucible/assertions/defaults/finalizedAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/defaults/finalizedAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {Slot} from "@lodestar/types"; import {AssertionResult, Assertion} from "../../interfaces.js"; import {everySlotMatcher} from "../matchers.js"; @@ -7,9 +6,8 @@ export const finalizedAssertion: Assertion<"finalized", Slot> = { id: "finalized", match: everySlotMatcher, async capture({node}) { - const finalized = await node.beacon.api.beacon.getBlockHeader("finalized"); - ApiError.assert(finalized); - return finalized.response.data.header.message.slot ?? 0; + const finalized = (await node.beacon.api.beacon.getBlockHeader({blockId: "finalized"})).value(); + return finalized.header.message.slot ?? 0; }, async assert({store, slot, clock, epoch}) { const errors: AssertionResult[] = []; diff --git a/packages/cli/test/utils/crucible/assertions/defaults/headAssertion.ts b/packages/cli/test/utils/crucible/assertions/defaults/headAssertion.ts index 9c95b7658144..6464067d5d7d 100644 --- a/packages/cli/test/utils/crucible/assertions/defaults/headAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/defaults/headAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {RootHex, Slot} from "@lodestar/types"; import {toHexString} from "@lodestar/utils"; import {AssertionResult, Assertion} from "../../interfaces.js"; @@ -13,12 +12,11 @@ export const headAssertion: Assertion<"head", HeadSummary> = { id: "head", match: everySlotMatcher, async capture({node}) { - const head = await node.beacon.api.beacon.getBlockHeader("head"); - ApiError.assert(head); + const head = (await node.beacon.api.beacon.getBlockHeader({blockId: "head"})).value(); return { - blockRoot: toHexString(head.response.data.root), - slot: head.response.data.header.message.slot, + blockRoot: toHexString(head.root), + slot: head.header.message.slot, }; }, async assert({nodes, node, store, slot, dependantStores}) { diff --git a/packages/cli/test/utils/crucible/assertions/executionHeadAssertion.ts b/packages/cli/test/utils/crucible/assertions/executionHeadAssertion.ts index 391fb27e6584..8ea0be1b445c 100644 --- a/packages/cli/test/utils/crucible/assertions/executionHeadAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/executionHeadAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {toHex} from "@lodestar/utils"; import {bellatrix} from "@lodestar/types"; import {Match, AssertionResult, Assertion} from "../interfaces.js"; @@ -22,16 +21,13 @@ export function createExecutionHeadAssertion({ if (blockNumber == null) throw new Error("Execution provider not available"); const executionHeadBlock = await node.execution.provider?.eth.getBlock(blockNumber); - const consensusHead = await node.beacon.api.beacon.getBlockV2("head"); - ApiError.assert(consensusHead); + const consensusHead = (await node.beacon.api.beacon.getBlockV2({blockId: "head"})).value(); return { executionHead: {hash: executionHeadBlock?.hash ?? "0x0"}, consensusHead: { executionPayload: { - blockHash: toHex( - (consensusHead.response.data.message as bellatrix.BeaconBlock).body.executionPayload.blockHash - ), + blockHash: toHex((consensusHead.message as bellatrix.BeaconBlock).body.executionPayload.blockHash), }, }, }; diff --git a/packages/cli/test/utils/crucible/assertions/forkAssertion.ts b/packages/cli/test/utils/crucible/assertions/forkAssertion.ts index 5dc804c642f1..8f6372f856f6 100644 --- a/packages/cli/test/utils/crucible/assertions/forkAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/forkAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {ForkName} from "@lodestar/params"; import {Epoch} from "@lodestar/types"; import {toHexString} from "@lodestar/utils"; @@ -15,10 +14,9 @@ export function createForkAssertion(fork: ForkName, epoch: Epoch): Assertion { const errors: AssertionResult[] = []; - const res = await node.beacon.api.debug.getStateV2("head"); - ApiError.assert(res); + const state = (await node.beacon.api.debug.getStateV2({stateId: "head"})).value(); const expectedForkVersion = toHexString(forkConfig.getForkInfo(slot).version); - const currentForkVersion = toHexString(res.response.data.fork.currentVersion); + const currentForkVersion = toHexString(state.fork.currentVersion); if (expectedForkVersion !== currentForkVersion) { errors.push([ diff --git a/packages/cli/test/utils/crucible/assertions/lighthousePeerScoreAssertion.ts b/packages/cli/test/utils/crucible/assertions/lighthousePeerScoreAssertion.ts index 1b6837a881e3..29fa36220b3d 100644 --- a/packages/cli/test/utils/crucible/assertions/lighthousePeerScoreAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/lighthousePeerScoreAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {AssertionResult, BeaconClient, LighthouseAPI, NodePair, Assertion} from "../interfaces.js"; import {neverMatcher} from "./matchers.js"; @@ -47,13 +46,12 @@ export const lighthousePeerScoreAssertion: Assertion<"lighthousePeerScore", {gos }; async function getLodestarPeerIds(nodes: NodePair[]): Promise> { - const lodestartPeers = nodes.filter((n) => n.beacon.client === BeaconClient.Lodestar); + const lodestarPeers = nodes.filter((n) => n.beacon.client === BeaconClient.Lodestar); const peerIdMap: Record = {}; - for (const p of lodestartPeers) { - const res = await p.beacon.api.node.getNetworkIdentity(); - ApiError.assert(res); - peerIdMap[res.response.data.peerId] = p.beacon.id; + for (const p of lodestarPeers) { + const identity = (await p.beacon.api.node.getNetworkIdentity()).value(); + peerIdMap[identity.peerId] = p.beacon.id; } return peerIdMap; diff --git a/packages/cli/test/utils/crucible/assertions/mergeAssertion.ts b/packages/cli/test/utils/crucible/assertions/mergeAssertion.ts index d7a90857ae2c..acd686c87bf7 100644 --- a/packages/cli/test/utils/crucible/assertions/mergeAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/mergeAssertion.ts @@ -1,4 +1,3 @@ -import {ApiError} from "@lodestar/api"; import {BeaconStateAllForks, isExecutionStateType, isMergeTransitionComplete} from "@lodestar/state-transition"; import {AssertionResult, Assertion} from "../interfaces.js"; import {neverMatcher} from "./matchers.js"; @@ -10,9 +9,8 @@ export const mergeAssertion: Assertion<"merge", string> = { async assert({node}) { const errors: AssertionResult[] = []; - const res = await node.beacon.api.debug.getStateV2("head"); - ApiError.assert(res); - const state = res.response.data as unknown as BeaconStateAllForks; + const res = await node.beacon.api.debug.getStateV2({stateId: "head"}); + const state = res.value() as unknown as BeaconStateAllForks; if (!(isExecutionStateType(state) && isMergeTransitionComplete(state))) { errors.push("Node has not yet completed the merged transition"); diff --git a/packages/cli/test/utils/crucible/assertions/nodeAssertion.ts b/packages/cli/test/utils/crucible/assertions/nodeAssertion.ts index 7a449b3f669f..69039accb182 100644 --- a/packages/cli/test/utils/crucible/assertions/nodeAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/nodeAssertion.ts @@ -1,6 +1,5 @@ import type {SecretKey} from "@chainsafe/bls/types"; import {routes} from "@lodestar/api/beacon"; -import {ApiError} from "@lodestar/api"; import {AssertionResult, ValidatorClientKeys, Assertion, ValidatorClient} from "../interfaces.js"; import {arrayEquals} from "../utils/index.js"; import {neverMatcher} from "./matchers.js"; @@ -20,9 +19,8 @@ export const nodeAssertion: Assertion<"node", {health: number; keyManagerKeys: s if (node.validator.client == ValidatorClient.Lighthouse || getAllKeys(node.validator.keys).length === 0) { keyManagerKeys = []; } else { - const res = await node.validator.keyManager.listKeys(); - ApiError.assert(res); - keyManagerKeys = res.response.data.map((k) => k.validatingPubkey); + const keys = (await node.validator.keyManager.listKeys()).value(); + keyManagerKeys = keys.map((k) => k.validatingPubkey); } return {health, keyManagerKeys}; diff --git a/packages/cli/test/utils/crucible/assertions/withdrawalsAssertion.ts b/packages/cli/test/utils/crucible/assertions/withdrawalsAssertion.ts index 7daa38682829..be256a0b696d 100644 --- a/packages/cli/test/utils/crucible/assertions/withdrawalsAssertion.ts +++ b/packages/cli/test/utils/crucible/assertions/withdrawalsAssertion.ts @@ -1,5 +1,4 @@ import {capella} from "@lodestar/types"; -import {ApiError} from "@lodestar/api"; import {MAX_WITHDRAWALS_PER_PAYLOAD} from "@lodestar/params"; import {Match, AssertionResult, Assertion} from "../interfaces.js"; @@ -27,21 +26,23 @@ export function createWithdrawalAssertions( for (const withdrawal of withdrawals) { withdrawalAmount += withdrawal.amount; - const validatorDataLastSlot = await node.beacon.api.beacon.getStateValidator( - slot - 1, - withdrawal.validatorIndex - ); - const validatorDataCurrentSlot = await node.beacon.api.beacon.getStateValidator( - slot, - withdrawal.validatorIndex - ); - ApiError.assert(validatorDataLastSlot); - ApiError.assert(validatorDataCurrentSlot); + const validatorDataLastSlot = ( + await node.beacon.api.beacon.getStateValidator({ + stateId: slot - 1, + validatorId: withdrawal.validatorIndex, + }) + ).value(); + const validatorDataCurrentSlot = ( + await node.beacon.api.beacon.getStateValidator({ + stateId: slot, + validatorId: withdrawal.validatorIndex, + }) + ).value(); validators[withdrawal.validatorIndex] = { withdrawalAmount: withdrawal.amount, - balanceInLastSlot: BigInt(validatorDataLastSlot.response.data.balance), - currentBalance: BigInt(validatorDataCurrentSlot.response.data.balance), + balanceInLastSlot: BigInt(validatorDataLastSlot.balance), + currentBalance: BigInt(validatorDataCurrentSlot.balance), }; } diff --git a/packages/cli/test/utils/crucible/clients/beacon/lighthouse.ts b/packages/cli/test/utils/crucible/clients/beacon/lighthouse.ts index 3e737416ddc1..500b93ee2fb7 100644 --- a/packages/cli/test/utils/crucible/clients/beacon/lighthouse.ts +++ b/packages/cli/test/utils/crucible/clients/beacon/lighthouse.ts @@ -2,7 +2,6 @@ import {writeFile} from "node:fs/promises"; import path from "node:path"; import got, {RequestError} from "got"; import yaml from "js-yaml"; -import {HttpClient} from "@lodestar/api"; import {getClient} from "@lodestar/api/beacon"; import {chainConfigToJson} from "@lodestar/config"; import {BeaconClient, BeaconNodeGenerator, LighthouseAPI, RunnerType} from "../../interfaces.js"; @@ -104,14 +103,15 @@ export const generateLighthouseBeaconNode: BeaconNodeGenerator & { +export type LodestarAPI = ApiClient; +export type LighthouseAPI = Omit & { lighthouse: { getPeers(): Promise<{ status: number; diff --git a/packages/cli/test/utils/crucible/simulation.ts b/packages/cli/test/utils/crucible/simulation.ts index 7a2343b6082d..b55ebf110c89 100644 --- a/packages/cli/test/utils/crucible/simulation.ts +++ b/packages/cli/test/utils/crucible/simulation.ts @@ -173,12 +173,12 @@ export class Simulation { for (const node of this.nodes) { if (node.validator?.keys.type === "remote") { this.externalSigner.addKeys(node.validator?.keys.secretKeys); - await node.validator.keyManager.importRemoteKeys( - node.validator.keys.secretKeys.map((sk) => ({ + await node.validator.keyManager.importRemoteKeys({ + remoteSigners: node.validator.keys.secretKeys.map((sk) => ({ pubkey: sk.toPublicKey().toHex(), url: this.externalSigner.url, - })) - ); + })), + }); this.logger.info(`Imported remote keys for node ${node.id}`); } } diff --git a/packages/cli/test/utils/crucible/simulationTracker.ts b/packages/cli/test/utils/crucible/simulationTracker.ts index 3533015d58d5..e374d3b6c328 100644 --- a/packages/cli/test/utils/crucible/simulationTracker.ts +++ b/packages/cli/test/utils/crucible/simulationTracker.ts @@ -431,19 +431,23 @@ export class SimulationTracker { signal?: AbortSignal ): void { debug("event stream initialized for", node.beacon.id); - void node.beacon.api.events.eventstream(events, signal ?? this.signal, async (event) => { - switch (event.type) { - case routes.events.EventType.block: - debug(`block received node=${node.beacon.id} slot=${event.message.slot}`); - await this.processOnBlock(event.message, node); - return; - case routes.events.EventType.head: - await this.processOnHead(event.message, node); - return; - case routes.events.EventType.finalizedCheckpoint: - this.processOnFinalizedCheckpoint(event.message, node); - return; - } + void node.beacon.api.events.eventstream({ + topics: events, + signal: signal ?? this.signal, + onEvent: async (event) => { + switch (event.type) { + case routes.events.EventType.block: + debug(`block received node=${node.beacon.id} slot=${event.message.slot}`); + await this.processOnBlock(event.message, node); + return; + case routes.events.EventType.head: + await this.processOnHead(event.message, node); + return; + case routes.events.EventType.finalizedCheckpoint: + this.processOnFinalizedCheckpoint(event.message, node); + return; + } + }, }); } } diff --git a/packages/cli/test/utils/crucible/utils/network.ts b/packages/cli/test/utils/crucible/utils/network.ts index b78087d8f931..6042e91bc221 100644 --- a/packages/cli/test/utils/crucible/utils/network.ts +++ b/packages/cli/test/utils/crucible/utils/network.ts @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -import {ApiError} from "@lodestar/api"; import {Slot, allForks} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; import {BeaconClient, BeaconNode, ExecutionClient, ExecutionNode, NodePair} from "../interfaces.js"; @@ -24,22 +23,23 @@ export async function connectNewNode(newNode: NodePair, nodes: NodePair[]): Prom } export async function connectNewCLNode(newNode: BeaconNode, nodes: BeaconNode[]): Promise { - const res = await newNode.api.node.getNetworkIdentity(); - ApiError.assert(res); - const clIdentity = res.response.data; + const clIdentity = (await newNode.api.node.getNetworkIdentity()).value(); if (!clIdentity.peerId) return; for (const node of nodes) { if (node === newNode) continue; if (node.client === BeaconClient.Lodestar) { - const res = await (node as BeaconNode).api.lodestar.connectPeer( - clIdentity.peerId, - // As the lodestar is always running on host - // convert the address to local host to connect the container node - clIdentity.p2pAddresses.map((str) => str.replace(/(\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/)/, "/127.0.0.1/")) - ); - ApiError.assert(res); + ( + await (node as BeaconNode).api.lodestar.connectPeer({ + peerId: clIdentity.peerId, + // As the lodestar is always running on host + // convert the address to local host to connect the container node + multiaddrs: clIdentity.p2pAddresses.map((str) => + str.replace(/(\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/)/, "/127.0.0.1/") + ), + }) + ).assertOk(); } } } @@ -76,9 +76,8 @@ export async function waitForNodeSync( export async function waitForNodeSyncStatus(env: Simulation, node: NodePair): Promise { // eslint-disable-next-line no-constant-condition while (true) { - const result = await node.beacon.api.node.getSyncingStatus(); - ApiError.assert(result); - if (!result.response.data.isSyncing) { + const result = (await node.beacon.api.node.getSyncingStatus()).value(); + if (!result.isSyncing) { break; } else { await sleep(1000, env.options.controller.signal); @@ -158,13 +157,13 @@ export async function fetchBlock( {tries, delay, slot, signal}: {slot: number; tries: number; delay: number; signal?: AbortSignal} ): Promise { for (let i = 0; i < tries; i++) { - const res = await node.beacon.api.beacon.getBlockV2(slot); + const res = await node.beacon.api.beacon.getBlockV2({blockId: slot}); if (!res.ok) { await sleep(delay, signal); continue; } - return res.response.data; + return res.value(); } return; diff --git a/packages/cli/test/utils/crucible/utils/syncing.ts b/packages/cli/test/utils/crucible/utils/syncing.ts index d56f7d7bb388..35b889fb3ac1 100644 --- a/packages/cli/test/utils/crucible/utils/syncing.ts +++ b/packages/cli/test/utils/crucible/utils/syncing.ts @@ -1,4 +1,4 @@ -import {ApiError, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {Slot} from "@lodestar/types"; import {sleep, toHex} from "@lodestar/utils"; import type {Simulation} from "../simulation.js"; @@ -6,8 +6,7 @@ import {BeaconClient, ExecutionClient, NodePair} from "../interfaces.js"; import {connectNewCLNode, connectNewELNode, connectNewNode, waitForHead, waitForSlot} from "./network.js"; export async function assertRangeSync(env: Simulation): Promise { - const currentHead = await env.nodes[0].beacon.api.beacon.getBlockHeader("head"); - ApiError.assert(currentHead); + const currentHead = (await env.nodes[0].beacon.api.beacon.getBlockHeader({blockId: "head"})).value(); const rangeSync = await env.createNodePair({ id: "range-sync-node", @@ -45,8 +44,8 @@ export async function assertRangeSync(env: Simulation): Promise { ); await waitForNodeSync(env, rangeSync, { - head: toHex(currentHead.response.data.root), - slot: currentHead.response.data.header.message.slot, + head: toHex(currentHead.root), + slot: currentHead.header.message.slot, }); await rangeSync.beacon.job.stop(); @@ -63,8 +62,9 @@ export async function assertCheckpointSync(env: Simulation): Promise { }); } - const finalizedCheckpoint = await env.nodes[0].beacon.api.beacon.getStateFinalityCheckpoints("head"); - ApiError.assert(finalizedCheckpoint); + const finalizedCheckpoint = ( + await env.nodes[0].beacon.api.beacon.getStateFinalityCheckpoints({stateId: "head"}) + ).value(); const checkpointSync = await env.createNodePair({ id: "checkpoint-sync-node", @@ -72,7 +72,7 @@ export async function assertCheckpointSync(env: Simulation): Promise { type: BeaconClient.Lodestar, options: { clientOptions: { - wssCheckpoint: `${toHex(finalizedCheckpoint.response.data.finalized.root)}:${finalizedCheckpoint.response.data.finalized.epoch}`, + wssCheckpoint: `${toHex(finalizedCheckpoint.finalized.root)}:${finalizedCheckpoint.finalized.epoch}`, }, }, }, @@ -85,8 +85,8 @@ export async function assertCheckpointSync(env: Simulation): Promise { await connectNewNode(checkpointSync, env.nodes); await waitForNodeSync(env, checkpointSync, { - head: toHex(finalizedCheckpoint.response.data.finalized.root), - slot: env.clock.getLastSlotOfEpoch(finalizedCheckpoint.response.data.finalized.epoch), + head: toHex(finalizedCheckpoint.finalized.root), + slot: env.clock.getLastSlotOfEpoch(finalizedCheckpoint.finalized.epoch), }); await checkpointSync.beacon.job.stop(); @@ -94,10 +94,10 @@ export async function assertCheckpointSync(env: Simulation): Promise { } export async function assertUnknownBlockSync(env: Simulation): Promise { - const currentHead = await env.nodes[0].beacon.api.beacon.getBlockV2("head"); - ApiError.assert(currentHead); - const currentSidecars = await env.nodes[0].beacon.api.beacon.getBlobSidecars(currentHead.response.data.message.slot); - ApiError.assert(currentSidecars); + const currentHead = (await env.nodes[0].beacon.api.beacon.getBlockV2({blockId: "head"})).value(); + const currentSidecars = ( + await env.nodes[0].beacon.api.beacon.getBlobSidecars({blockId: currentHead.message.slot}) + ).value(); const unknownBlockSync = await env.createNodePair({ id: "unknown-block-sync-node", @@ -114,7 +114,7 @@ export async function assertUnknownBlockSync(env: Simulation): Promise { the 'unknown block sync' won't function properly. Moreover, the 'unknownBlockSync' requires some startup time, contributing to the overall gap. For stability in our CI, we've opted to set a higher limit on this constraint. */ - "sync.slotImportTolerance": currentHead.response.data.message.slot, + "sync.slotImportTolerance": currentHead.message.slot, }, }, }, @@ -128,18 +128,16 @@ export async function assertUnknownBlockSync(env: Simulation): Promise { // Wait for EL node to start and sync before publishing an unknown block await sleep(5000); try { - ApiError.assert( - await unknownBlockSync.beacon.api.beacon.publishBlockV2( - { - signedBlock: currentHead.response.data, - blobs: currentSidecars.response.data.map((b) => b.blob), - kzgProofs: currentSidecars.response.data.map((b) => b.kzgProof), + ( + await unknownBlockSync.beacon.api.beacon.publishBlockV2({ + signedBlockOrContents: { + signedBlock: currentHead, + blobs: currentSidecars.map((b) => b.blob), + kzgProofs: currentSidecars.map((b) => b.kzgProof), }, - { - broadcastValidation: routes.beacon.BroadcastValidation.none, - } - ) - ); + broadcastValidation: routes.beacon.BroadcastValidation.none, + }) + ).assertOk(); env.tracker.record({ message: "Publishing unknown block should fail", @@ -157,12 +155,8 @@ export async function assertUnknownBlockSync(env: Simulation): Promise { } await waitForHead(env, unknownBlockSync, { - head: toHex( - env.forkConfig - .getForkTypes(currentHead.response.data.message.slot) - .BeaconBlock.hashTreeRoot(currentHead.response.data.message) - ), - slot: currentHead.response.data.message.slot, + head: toHex(env.forkConfig.getForkTypes(currentHead.message.slot).BeaconBlock.hashTreeRoot(currentHead.message)), + slot: currentHead.message.slot, }); await unknownBlockSync.beacon.job.stop(); @@ -185,9 +179,8 @@ export async function waitForNodeSync( export async function waitForNodeSyncStatus(env: Simulation, node: NodePair): Promise { // eslint-disable-next-line no-constant-condition while (true) { - const result = await node.beacon.api.node.getSyncingStatus(); - ApiError.assert(result); - if (!result.response.data.isSyncing) { + const result = (await node.beacon.api.node.getSyncingStatus()).value(); + if (!result.isSyncing) { break; } else { await sleep(1000, env.options.controller.signal); diff --git a/packages/cli/test/utils/mockBeaconApiServer.ts b/packages/cli/test/utils/mockBeaconApiServer.ts index 9aea2c8d2857..80ce282e102c 100644 --- a/packages/cli/test/utils/mockBeaconApiServer.ts +++ b/packages/cli/test/utils/mockBeaconApiServer.ts @@ -1,6 +1,6 @@ import {RestApiServer, RestApiServerOpts, RestApiServerModules} from "@lodestar/beacon-node"; -import {registerRoutes} from "@lodestar/api/beacon/server"; -import {Api as ClientApi, allNamespaces, ServerApi} from "@lodestar/api"; +import {BeaconApiMethods, registerRoutes} from "@lodestar/api/beacon/server"; +import {allNamespaces} from "@lodestar/api"; import {ChainForkConfig} from "@lodestar/config"; import {config} from "@lodestar/config/default"; import {ssz} from "@lodestar/types"; @@ -10,7 +10,6 @@ import {testLogger} from "../../../beacon-node/test/utils/logger.js"; const ZERO_HASH_HEX = toHex(Buffer.alloc(32, 0)); -type Api = {[K in keyof ClientApi]: ServerApi}; type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; }; @@ -20,7 +19,12 @@ export type MockBeaconApiOpts = { }; class MockBeaconRestApiServer extends RestApiServer { - constructor(optsArg: RestApiServerOpts, modules: RestApiServerModules, config: ChainForkConfig, api: Api) { + constructor( + optsArg: RestApiServerOpts, + modules: RestApiServerModules, + config: ChainForkConfig, + api: BeaconApiMethods + ) { super(optsArg, modules); // Instantiate and register the routes with matching namespace in `opts.api` @@ -70,10 +74,10 @@ export function getMockBeaconApiServer(opts: RestApiServerOpts, apiOpts?: MockBe // Do nothing }, }, - } as DeepPartial; + } as DeepPartial; const logger = testLogger("mock-beacon-api"); - const restApiServer = new MockBeaconRestApiServer(opts, {logger, metrics: null}, config, api as Api); + const restApiServer = new MockBeaconRestApiServer(opts, {logger, metrics: null}, config, api as BeaconApiMethods); return restApiServer; } diff --git a/packages/cli/test/utils/validator.ts b/packages/cli/test/utils/validator.ts index 62aad811bfe0..dc3ef754cc74 100644 --- a/packages/cli/test/utils/validator.ts +++ b/packages/cli/test/utils/validator.ts @@ -1,9 +1,8 @@ import childProcess from "node:child_process"; import {afterEach} from "vitest"; import {retry} from "@lodestar/utils"; -import {Api, getClient} from "@lodestar/api/keymanager"; +import {ApiClient, getClient} from "@lodestar/api/keymanager"; import {config} from "@lodestar/config/default"; -import {ApiError} from "@lodestar/api"; import {spawnCliCommand, gracefullyStopChildProcess} from "@lodestar/test-utils"; import {getMockBeaconApiServer} from "./mockBeaconApiServer.js"; import {expectDeepEqualsUnordered, findApiToken} from "./runUtils.js"; @@ -20,7 +19,7 @@ export async function startValidatorWithKeyManager( ): Promise<{ validator: childProcess.ChildProcessWithoutNullStreams; stopValidator: () => Promise; - keymanagerClient: Api; + keymanagerClient: ApiClient; }> { const keymanagerPort = 38011; const beaconPort = 39011; @@ -55,7 +54,7 @@ export async function startValidatorWithKeyManager( const apiToken = await retry(async () => findApiToken(dataDir), {retryDelay: 500, retries: 10}); const controller = new AbortController(); const keymanagerClient = getClient( - {baseUrl: keymanagerUrl, bearerToken: apiToken, getAbortSignal: () => controller.signal}, + {baseUrl: keymanagerUrl, globalInit: {bearerToken: apiToken, signal: controller.signal}}, {config} ); @@ -86,12 +85,15 @@ export async function startValidatorWithKeyManager( /** * Query `keymanagerClient.listKeys()` API endpoint and assert that expectedPubkeys are in the response */ -export async function expectKeys(keymanagerClient: Api, expectedPubkeys: string[], message: string): Promise { - const keys = await keymanagerClient.listKeys(); - ApiError.assert(keys); +export async function expectKeys( + keymanagerClient: ApiClient, + expectedPubkeys: string[], + message: string +): Promise { + const keys = (await keymanagerClient.listKeys()).value(); // The order of keys isn't always deterministic so we can't use deep equal expectDeepEqualsUnordered( - keys.response.data, + keys, expectedPubkeys.map((pubkey) => ({validatingPubkey: pubkey, derivationPath: "", readonly: false})), message ); diff --git a/packages/flare/src/cmds/selfSlashAttester.ts b/packages/flare/src/cmds/selfSlashAttester.ts index beaa14ed9291..048b62273d77 100644 --- a/packages/flare/src/cmds/selfSlashAttester.ts +++ b/packages/flare/src/cmds/selfSlashAttester.ts @@ -1,6 +1,6 @@ import bls from "@chainsafe/bls"; import type {SecretKey} from "@chainsafe/bls/types"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {phase0, ssz} from "@lodestar/types"; import {config as chainConfig} from "@lodestar/config/default"; import {createBeaconConfig, BeaconConfig} from "@lodestar/config"; @@ -62,10 +62,9 @@ export async function selfSlashAttesterHandler(args: SelfSlashArgs): Promise sk.toPublicKey().toHex()); - const res = await client.beacon.getStateValidators("head", {id: pksHex}); - ApiError.assert(res, "Can not fetch state validators from beacon node"); + const validators = (await client.beacon.getStateValidators({stateId: "head", validatorIds: pksHex})).value(); // All validators in the batch will be part of the same AttesterSlashing - const validators = res.response.data; const attestingIndices = validators.map((v) => v.index); // Submit all ProposerSlashing for range at once @@ -134,7 +131,7 @@ export async function selfSlashAttesterHandler(args: SelfSlashArgs): Promise sk.toPublicKey().toHex()); - const res = await client.beacon.getStateValidators("head", {id: pksHex}); - ApiError.assert(res, "Can not fetch state validators from beacon node"); - const validators = res.response.data; + const validators = (await client.beacon.getStateValidators({stateId: "head", validatorIds: pksHex})).value(); // Submit all ProposerSlashing for range at once await Promise.all( @@ -124,7 +121,7 @@ export async function selfSlashProposerHandler(args: SelfSlashArgs): Promise { - const res = await this.api.lightclient.getUpdates(startPeriod, count); - ApiError.assert(res); - return res.response; + const res = await this.api.lightclient.getLightClientUpdatesByRange({startPeriod, count}); + const updates = res.value(); + const {versions} = res.meta(); + return updates.map((data, i) => ({data, version: versions[i]})); } async getOptimisticUpdate(): Promise<{version: ForkName; data: allForks.LightClientOptimisticUpdate}> { - const res = await this.api.lightclient.getOptimisticUpdate(); - ApiError.assert(res); - return res.response; + const res = await this.api.lightclient.getLightClientOptimisticUpdate(); + return {version: res.meta().version, data: res.value()}; } async getFinalityUpdate(): Promise<{version: ForkName; data: allForks.LightClientFinalityUpdate}> { - const res = await this.api.lightclient.getFinalityUpdate(); - ApiError.assert(res); - return res.response; + const res = await this.api.lightclient.getLightClientFinalityUpdate(); + return {version: res.meta().version, data: res.value()}; } async getBootstrap(blockRoot: string): Promise<{version: ForkName; data: allForks.LightClientBootstrap}> { - const res = await this.api.lightclient.getBootstrap(blockRoot); - ApiError.assert(res); - return res.response; + const res = await this.api.lightclient.getLightClientBootstrap({blockRoot}); + return {version: res.meta().version, data: res.value()}; } async fetchBlock(blockRootAsString: string): Promise<{version: ForkName; data: allForks.SignedBeaconBlock}> { - const res = await this.api.beacon.getBlockV2(blockRootAsString); - ApiError.assert(res); - return res.response; + const res = await this.api.beacon.getBlockV2({blockId: blockRootAsString}); + return {version: res.meta().version, data: res.value()}; } onOptimisticUpdate(handler: (optimisticUpdate: allForks.LightClientOptimisticUpdate) => void): void { @@ -72,10 +69,10 @@ export class LightClientRestTransport implements LightClientTransport { return; } - void this.api.events.eventstream( - [routes.events.EventType.lightClientOptimisticUpdate, routes.events.EventType.lightClientFinalityUpdate], - this.controller.signal, - (event) => { + void this.api.events.eventstream({ + topics: [routes.events.EventType.lightClientOptimisticUpdate, routes.events.EventType.lightClientFinalityUpdate], + signal: this.controller.signal, + onEvent: (event) => { switch (event.type) { case routes.events.EventType.lightClientOptimisticUpdate: this.eventEmitter.emit(routes.events.EventType.lightClientOptimisticUpdate, event.message.data); @@ -85,8 +82,8 @@ export class LightClientRestTransport implements LightClientTransport { this.eventEmitter.emit(routes.events.EventType.lightClientFinalityUpdate, event.message.data); break; } - } - ); + }, + }); this.subscribedEventstream = true; } } diff --git a/packages/light-client/src/utils/api.ts b/packages/light-client/src/utils/api.ts index bcc8a4d4f29c..7947aa96dd3e 100644 --- a/packages/light-client/src/utils/api.ts +++ b/packages/light-client/src/utils/api.ts @@ -1,8 +1,8 @@ -import {getClient, Api} from "@lodestar/api"; +import {getClient, ApiClient} from "@lodestar/api"; import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; import {NetworkName, networksChainConfig} from "@lodestar/config/networks"; -export function getApiFromUrl(url: string, network: NetworkName): Api { +export function getApiFromUrl(url: string, network: NetworkName): ApiClient { if (!(network in networksChainConfig)) { throw Error(`Invalid network name "${network}". Valid options are: ${Object.keys(networksChainConfig).join()}`); } diff --git a/packages/light-client/src/utils/utils.ts b/packages/light-client/src/utils/utils.ts index b7c2c29319e3..5a298d81d37b 100644 --- a/packages/light-client/src/utils/utils.ts +++ b/packages/light-client/src/utils/utils.ts @@ -1,7 +1,7 @@ import bls from "@chainsafe/bls"; import type {PublicKey} from "@chainsafe/bls/types"; import {BitArray} from "@chainsafe/ssz"; -import {Api, ApiError} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; import {altair, Bytes32, Root, ssz} from "@lodestar/types"; import {BeaconBlockHeader} from "@lodestar/types/phase0"; import {GenesisData} from "../index.js"; @@ -81,18 +81,15 @@ export function isEmptyHeader(header: BeaconBlockHeader): boolean { export const isNode = Object.prototype.toString.call(typeof process !== "undefined" ? process : 0) === "[object process]"; -export async function getGenesisData(api: Pick): Promise { - const res = await api.beacon.getGenesis(); - ApiError.assert(res); +export async function getGenesisData(api: Pick): Promise { + const {genesisTime, genesisValidatorsRoot} = (await api.beacon.getGenesis()).value(); return { - genesisTime: res.response.data.genesisTime, - genesisValidatorsRoot: res.response.data.genesisValidatorsRoot, + genesisTime, + genesisValidatorsRoot, }; } -export async function getFinalizedSyncCheckpoint(api: Pick): Promise { - const res = await api.beacon.getStateFinalityCheckpoints("head"); - ApiError.assert(res); - return res.response.data.finalized.root; +export async function getFinalizedSyncCheckpoint(api: Pick): Promise { + return (await api.beacon.getStateFinalityCheckpoints({stateId: "head"})).value().finalized.root; } diff --git a/packages/light-client/test/mocks/EventsServerApiMock.ts b/packages/light-client/test/mocks/EventsServerApiMock.ts index 73c212a9c4ee..905f2f5baa36 100644 --- a/packages/light-client/test/mocks/EventsServerApiMock.ts +++ b/packages/light-client/test/mocks/EventsServerApiMock.ts @@ -1,11 +1,12 @@ -import {routes, ServerApi} from "@lodestar/api"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; type OnEvent = (event: routes.events.BeaconEvent) => void; /** * In-memory simple event emitter for `BeaconEvent` */ -export class EventsServerApiMock implements ServerApi { +export class EventsServerApiMock implements ApplicationMethods { private readonly onEventsByTopic = new Map>(); hasSubscriptions(): boolean { @@ -21,7 +22,15 @@ export class EventsServerApiMock implements ServerApi { } } - async eventstream(topics: routes.events.EventType[], signal: AbortSignal, onEvent: OnEvent): Promise { + async eventstream({ + topics, + signal, + onEvent, + }: { + topics: routes.events.EventType[]; + signal: AbortSignal; + onEvent: OnEvent; + }): Promise { for (const topic of typeof topics === "string" ? [topics] : topics) { let onEvents = this.onEventsByTopic.get(topic); if (!onEvents) { diff --git a/packages/light-client/test/mocks/LightclientServerApiMock.ts b/packages/light-client/test/mocks/LightclientServerApiMock.ts index 0b6676ccdac1..faf33ad260c8 100644 --- a/packages/light-client/test/mocks/LightclientServerApiMock.ts +++ b/packages/light-client/test/mocks/LightclientServerApiMock.ts @@ -1,68 +1,85 @@ import {concat} from "uint8arrays/concat"; import {digest} from "@chainsafe/as-sha256"; -import {createProof, Proof, ProofType} from "@chainsafe/persistent-merkle-tree"; -import {routes, ServerApi} from "@lodestar/api"; +import {CompactMultiProof, createProof, ProofType} from "@chainsafe/persistent-merkle-tree"; +import {routes} from "@lodestar/api"; +import {ApplicationMethods} from "@lodestar/api/server"; import {altair, RootHex, SyncPeriod} from "@lodestar/types"; import {notNullish} from "@lodestar/utils"; import {ForkName} from "@lodestar/params"; import {BeaconStateAltair} from "../utils/types.js"; -export class ProofServerApiMock implements ServerApi { +type ProofApi = ApplicationMethods; + +export class ProofServerApiMock implements ProofApi { readonly states = new Map(); - async getStateProof(stateId: string, descriptor: Uint8Array): Promise<{data: Proof}> { + async getStateProof({ + stateId, + descriptor, + }: { + stateId: string; + descriptor: Uint8Array; + }): ReturnType { const state = this.states.get(stateId); if (!state) throw Error(`stateId ${stateId} not available`); - return {data: createProof(state.node, {type: ProofType.compactMulti, descriptor})}; + const proof = createProof(state.node, {type: ProofType.compactMulti, descriptor}); + return {data: proof as CompactMultiProof, meta: {version: ForkName.bellatrix}}; } - async getBlockProof(blockId: string, _descriptor: Uint8Array): Promise<{data: Proof}> { + async getBlockProof({blockId}: {blockId: string}): ReturnType { throw Error(`blockId ${blockId} not available`); } } -type VersionedLightClientUpdate = { - version: ForkName; - data: altair.LightClientUpdate; -}; +type LightClientApi = ApplicationMethods; -export class LightclientServerApiMock implements ServerApi { +export class LightclientServerApiMock implements LightClientApi { readonly updates = new Map(); readonly snapshots = new Map(); latestHeadUpdate: altair.LightClientOptimisticUpdate | null = null; finalized: altair.LightClientFinalityUpdate | null = null; - async getUpdates(from: SyncPeriod, to: SyncPeriod): Promise { - const updates: VersionedLightClientUpdate[] = []; - for (let period = parseInt(String(from)); period <= parseInt(String(to)); period++) { + async getLightClientUpdatesByRange(args: { + startPeriod: SyncPeriod; + count: number; + }): ReturnType { + const updates: altair.LightClientUpdate[] = []; + for (let period = parseInt(String(args.startPeriod)); period <= parseInt(String(args.count)); period++) { const update = this.updates.get(period); if (update) { - updates.push({ - version: ForkName.bellatrix, - data: update, - }); + updates.push(update); } } - return updates; + return {data: updates, meta: {versions: Array.from({length: updates.length}, () => ForkName.bellatrix)}}; } - async getOptimisticUpdate(): Promise<{version: ForkName; data: altair.LightClientOptimisticUpdate}> { + async getLightClientOptimisticUpdate(): ReturnType { if (!this.latestHeadUpdate) throw Error("No latest head update"); - return {version: ForkName.bellatrix, data: this.latestHeadUpdate}; + return {data: this.latestHeadUpdate, meta: {version: ForkName.bellatrix}}; } - async getFinalityUpdate(): Promise<{version: ForkName; data: altair.LightClientFinalityUpdate}> { + async getLightClientFinalityUpdate(): ReturnType { if (!this.finalized) throw Error("No finalized head update"); - return {version: ForkName.bellatrix, data: this.finalized}; + return {data: this.finalized, meta: {version: ForkName.bellatrix}}; } - async getBootstrap(blockRoot: string): Promise<{version: ForkName; data: altair.LightClientBootstrap}> { + async getLightClientBootstrap({ + blockRoot, + }: { + blockRoot: string; + }): ReturnType { const snapshot = this.snapshots.get(blockRoot); if (!snapshot) throw Error(`snapshot for blockRoot ${blockRoot} not available`); - return {version: ForkName.bellatrix, data: snapshot}; + return {data: snapshot, meta: {version: ForkName.bellatrix}}; } - async getCommitteeRoot(startPeriod: SyncPeriod, count: number): Promise<{data: Uint8Array[]}> { + async getLightClientCommitteeRoot({ + startPeriod, + count, + }: { + startPeriod: SyncPeriod; + count: number; + }): ReturnType { const periods = Array.from({length: count}, (_ignored, i) => i + startPeriod); const committeeHashes = periods .map((period) => this.updates.get(period)?.nextSyncCommittee.pubkeys) diff --git a/packages/light-client/test/unit/sync.node.test.ts b/packages/light-client/test/unit/sync.node.test.ts index 4d4212d626cf..bcd1d0f25d0b 100644 --- a/packages/light-client/test/unit/sync.node.test.ts +++ b/packages/light-client/test/unit/sync.node.test.ts @@ -1,10 +1,10 @@ import {describe, it, expect, afterEach, vi} from "vitest"; import {JsonPath, toHexString} from "@chainsafe/ssz"; -import {computeDescriptor, TreeOffsetProof} from "@chainsafe/persistent-merkle-tree"; +import {CompactMultiProof, computeDescriptor} from "@chainsafe/persistent-merkle-tree"; import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SLOTS_PER_EPOCH} from "@lodestar/params"; import {BeaconStateAllForks, BeaconStateAltair} from "@lodestar/state-transition"; import {altair, ssz} from "@lodestar/types"; -import {routes, Api, getClient, ServerApi, ApiError} from "@lodestar/api"; +import {routes, getClient, ApiClient} from "@lodestar/api"; import {chainConfig as chainConfigDef} from "@lodestar/config/default"; import {createBeaconConfig, ChainConfig} from "@lodestar/config"; import {Lightclient, LightclientEvent} from "../../src/index.js"; @@ -61,7 +61,7 @@ describe("sync", () => { lightclient: lightclientServerApi, events: eventsServerApi, proof: proofServerApi, - } as Partial<{[K in keyof Api]: ServerApi}> as {[K in keyof Api]: ServerApi}); + }); // Populate initial snapshot const {snapshot, checkpointRoot} = computeLightClientSnapshot(initialPeriod); @@ -177,18 +177,14 @@ describe("sync", () => { // TODO: Re-incorporate for REST-only light-client async function getHeadStateProof( lightclient: Lightclient, - api: Api, + api: ApiClient, paths: JsonPath[] -): Promise<{proof: TreeOffsetProof; header: altair.LightClientHeader}> { +): Promise<{proof: CompactMultiProof; header: altair.LightClientHeader}> { const header = lightclient.getHead(); const stateId = toHexString(header.beacon.stateRoot); const gindices = paths.map((path) => ssz.bellatrix.BeaconState.getPathInfo(path).gindex); const descriptor = computeDescriptor(gindices); - const res = await api.proof.getStateProof(stateId, descriptor); - ApiError.assert(res); + const proof = (await api.proof.getStateProof({stateId, descriptor})).value(); - return { - proof: res.response.data as TreeOffsetProof, - header, - }; + return {proof, header}; } diff --git a/packages/light-client/test/utils/getGenesisData.ts b/packages/light-client/test/utils/getGenesisData.ts index 0a5ce6e9f0e0..8e435eb9d218 100644 --- a/packages/light-client/test/utils/getGenesisData.ts +++ b/packages/light-client/test/utils/getGenesisData.ts @@ -1,4 +1,4 @@ -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {NetworkName} from "@lodestar/config/networks.js"; @@ -16,11 +16,10 @@ async function getGenesisData(): Promise { for (const network of networksInInfura) { const baseUrl = getInfuraBeaconUrl(network); const api = getClient({baseUrl}, {config}); - const res = await api.beacon.getGenesis(); - ApiError.assert(res); + const {genesisTime, genesisValidatorsRoot} = (await api.beacon.getGenesis()).value(); console.log(network, { - genesisTime: Number(res.response.data.genesisTime), - genesisValidatorsRoot: "0x" + Buffer.from(res.response.data.genesisValidatorsRoot).toString("hex"), + genesisTime, + genesisValidatorsRoot: "0x" + Buffer.from(genesisValidatorsRoot).toString("hex"), }); } } diff --git a/packages/light-client/test/utils/server.ts b/packages/light-client/test/utils/server.ts index 5fe1dd623ccb..6cc55e03cee4 100644 --- a/packages/light-client/test/utils/server.ts +++ b/packages/light-client/test/utils/server.ts @@ -1,8 +1,9 @@ import {parse as parseQueryString} from "qs"; import {FastifyInstance, fastify} from "fastify"; import {fastifyCors} from "@fastify/cors"; -import {Api, ServerApi} from "@lodestar/api"; -import {registerRoutes} from "@lodestar/api/beacon/server"; +import {Endpoints} from "@lodestar/api"; +import {ApplicationMethods, addSszContentTypeParser} from "@lodestar/api/server"; +import {BeaconApiMethods, registerRoutes} from "@lodestar/api/beacon/server"; import {ChainForkConfig} from "@lodestar/config"; export type ServerOpts = { @@ -10,10 +11,12 @@ export type ServerOpts = { host: string; }; +type LightClientEndpoints = Pick; + export async function startServer( opts: ServerOpts, config: ChainForkConfig, - api: {[K in keyof Api]: ServerApi} + methods: {[K in keyof LightClientEndpoints]: ApplicationMethods} ): Promise { const server = fastify({ logger: false, @@ -21,7 +24,9 @@ export async function startServer( querystringParser: (str) => parseQueryString(str, {comma: true, parseArrays: false}), }); - registerRoutes(server, config, api, ["lightclient", "proof", "events"]); + addSszContentTypeParser(server); + + registerRoutes(server, config, methods as BeaconApiMethods, ["lightclient", "proof", "events"]); void server.register(fastifyCors, {origin: "*"}); diff --git a/packages/prover/README.md b/packages/prover/README.md index b377448a1726..b98340698bbe 100644 --- a/packages/prover/README.md +++ b/packages/prover/README.md @@ -1,7 +1,7 @@ # Lodestar Eth Consensus Lightclient Prover [![Discord](https://img.shields.io/discord/593655374469660673.svg?label=Discord&logo=discord)](https://discord.gg/aMxzVcr) -[![ETH Beacon APIs Spec v2.1.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.1.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.1.0) +[![ETH Beacon APIs Spec v2.5.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.5.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.5.0) ![ES Version](https://img.shields.io/badge/ES-2021-yellow) ![Node Version](https://img.shields.io/badge/node-22.x-green) diff --git a/packages/prover/src/proof_provider/payload_store.ts b/packages/prover/src/proof_provider/payload_store.ts index 183a56274e5c..30d5b2126296 100644 --- a/packages/prover/src/proof_provider/payload_store.ts +++ b/packages/prover/src/proof_provider/payload_store.ts @@ -1,4 +1,4 @@ -import {Api} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; import {allForks, capella} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {MAX_PAYLOAD_HISTORY} from "../constants.js"; @@ -32,7 +32,7 @@ export class PayloadStore { private latestBlockRoot: BlockELRoot | null = null; - constructor(private opts: {api: Api; logger: Logger}) {} + constructor(private opts: {api: ApiClient; logger: Logger}) {} get finalized(): allForks.ExecutionPayload | undefined { const maxBlockNumberForFinalized = this.finalizedRoots.max; diff --git a/packages/prover/src/proof_provider/proof_provider.ts b/packages/prover/src/proof_provider/proof_provider.ts index eaf46c13044b..4cced315da04 100644 --- a/packages/prover/src/proof_provider/proof_provider.ts +++ b/packages/prover/src/proof_provider/proof_provider.ts @@ -1,4 +1,4 @@ -import {Api, getClient} from "@lodestar/api/beacon"; +import {ApiClient, getClient} from "@lodestar/api/beacon"; import {ChainForkConfig, createChainForkConfig} from "@lodestar/config"; import {NetworkName, networksChainConfig} from "@lodestar/config/networks"; import {Lightclient, LightclientEvent, RunStatusCode} from "@lodestar/light-client"; @@ -20,7 +20,7 @@ import {PayloadStore} from "./payload_store.js"; type RootProviderOptions = Omit & { transport: LightClientRestTransport; - api: Api; + api: ApiClient; config: ChainForkConfig; }; @@ -32,7 +32,7 @@ export class ProofProvider { readonly config: ChainForkConfig; readonly network: NetworkName; - readonly api: Api; + readonly api: ApiClient; lightClient?: Lightclient; diff --git a/packages/prover/src/utils/consensus.ts b/packages/prover/src/utils/consensus.ts index a07409458fb6..c34558e96e6d 100644 --- a/packages/prover/src/utils/consensus.ts +++ b/packages/prover/src/utils/consensus.ts @@ -1,28 +1,27 @@ -import {Api} from "@lodestar/api/beacon"; +import {ApiClient} from "@lodestar/api/beacon"; import {allForks, Bytes32, capella} from "@lodestar/types"; import {GenesisData, Lightclient} from "@lodestar/light-client"; -import {ApiError} from "@lodestar/api"; import {Logger} from "@lodestar/utils"; import {MAX_PAYLOAD_HISTORY} from "../constants.js"; import {hexToBuffer} from "./conversion.js"; -export async function fetchBlock(api: Api, slot: number): Promise { - const res = await api.beacon.getBlockV2(slot); +export async function fetchBlock(api: ApiClient, slot: number): Promise { + const res = await api.beacon.getBlockV2({blockId: slot}); - if (res.ok) return res.response.data as capella.SignedBeaconBlock; + if (res.ok) return res.value() as capella.SignedBeaconBlock; return; } export async function fetchNearestBlock( - api: Api, + api: ApiClient, slot: number, direction: "up" | "down" = "down" ): Promise { - const res = await api.beacon.getBlockV2(slot); + const res = await api.beacon.getBlockV2({blockId: slot}); - if (res.ok) return res.response.data as capella.SignedBeaconBlock; + if (res.ok) return res.value() as capella.SignedBeaconBlock; - if (!res.ok && res.error.code === 404) { + if (!res.ok && res.status === 404) { return fetchNearestBlock(api, direction === "down" ? slot - 1 : slot + 1); } @@ -46,7 +45,7 @@ export async function getExecutionPayloads({ endSlot, logger, }: { - api: Api; + api: ApiClient; startSlot: number; endSlot: number; logger: Logger; @@ -80,7 +79,7 @@ export async function getExecutionPayloads({ } export async function getExecutionPayloadForBlockNumber( - api: Api, + api: ApiClient, startSlot: number, blockNumber: number ): Promise> { @@ -98,17 +97,16 @@ export async function getExecutionPayloadForBlockNumber( return payloads; } -export async function getGenesisData(api: Pick): Promise { - const res = await api.beacon.getGenesis(); - ApiError.assert(res); +export async function getGenesisData(api: Pick): Promise { + const {genesisTime, genesisValidatorsRoot} = (await api.beacon.getGenesis()).value(); return { - genesisTime: Number(res.response.data.genesisTime), - genesisValidatorsRoot: res.response.data.genesisValidatorsRoot, + genesisTime, + genesisValidatorsRoot, }; } -export async function getSyncCheckpoint(api: Pick, checkpoint?: string): Promise { +export async function getSyncCheckpoint(api: Pick, checkpoint?: string): Promise { let syncCheckpoint: Bytes32 | undefined = checkpoint ? hexToBuffer(checkpoint) : undefined; if (syncCheckpoint && syncCheckpoint.byteLength !== 32) { @@ -116,9 +114,8 @@ export async function getSyncCheckpoint(api: Pick, checkpoint?: s } if (!syncCheckpoint) { - const res = await api.beacon.getStateFinalityCheckpoints("head"); - ApiError.assert(res); - syncCheckpoint = res.response.data.finalized.root; + const res = await api.beacon.getStateFinalityCheckpoints({stateId: "head"}); + syncCheckpoint = res.value().finalized.root; } return syncCheckpoint; diff --git a/packages/prover/test/unit/proof_provider/payload_store.test.ts b/packages/prover/test/unit/proof_provider/payload_store.test.ts index ad3e0fabad06..81b8ffe3c16c 100644 --- a/packages/prover/test/unit/proof_provider/payload_store.test.ts +++ b/packages/prover/test/unit/proof_provider/payload_store.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect, beforeEach, vi, MockedObject} from "vitest"; import {when} from "vitest-when"; -import {Api, HttpStatusCode, routes} from "@lodestar/api"; +import {ApiClient, ApiResponse, HttpStatusCode, routes} from "@lodestar/api"; import {hash} from "@lodestar/utils"; import {Logger} from "@lodestar/logger"; import {allForks, capella} from "@lodestar/types"; @@ -45,24 +45,21 @@ const buildBlockResponse = ({ }: { slot: number; blockNumber: number; -}): routes.beacon.block.BlockV2Response<"json"> => ({ - ok: true, - status: HttpStatusCode.OK, - response: { - version: ForkName.altair, - executionOptimistic: true, - finalized: false, - data: buildBlock({slot, blockNumber}), - }, -}); +}): ApiResponse => { + const response = new Response(null, {status: HttpStatusCode.OK}); + const apiResponse = new ApiResponse({} as any, null, response); + apiResponse.value = () => buildBlock({slot, blockNumber}); + apiResponse.meta = () => ({version: ForkName.altair, executionOptimistic: true, finalized: false}); + return apiResponse; +}; describe("proof_provider/payload_store", function () { - let api: Api & {beacon: MockedObject}; + let api: ApiClient & {beacon: MockedObject}; let logger: Logger; let store: PayloadStore; beforeEach(() => { - api = {beacon: {getBlockV2: vi.fn()}} as unknown as Api & {beacon: MockedObject}; + api = {beacon: {getBlockV2: vi.fn()}} as unknown as ApiClient & {beacon: MockedObject}; logger = console as unknown as Logger; store = new PayloadStore({api, logger}); }); @@ -194,11 +191,11 @@ describe("proof_provider/payload_store", function () { const unavailablePayload = buildPayload({blockNumber: unavailableBlockNumber}); when(api.beacon.getBlockV2) - .calledWith(blockNumber) + .calledWith({blockId: blockNumber}) .thenResolve(buildBlockResponse({blockNumber, slot: blockNumber})); when(api.beacon.getBlockV2) - .calledWith(unavailableBlockNumber) + .calledWith({blockId: unavailableBlockNumber}) .thenResolve(buildBlockResponse({blockNumber: unavailableBlockNumber, slot: unavailableBlockNumber})); store.set(availablePayload, slotNumber, true); @@ -206,8 +203,8 @@ describe("proof_provider/payload_store", function () { const result = await store.get(unavailablePayload.blockNumber); expect(api.beacon.getBlockV2).toHaveBeenCalledTimes(2); - expect(api.beacon.getBlockV2).toHaveBeenCalledWith(blockNumber); - expect(api.beacon.getBlockV2).toHaveBeenCalledWith(unavailableBlockNumber); + expect(api.beacon.getBlockV2).toHaveBeenCalledWith({blockId: blockNumber}); + expect(api.beacon.getBlockV2).toHaveBeenCalledWith({blockId: unavailableBlockNumber}); expect(result).toEqual(unavailablePayload); }); }); @@ -243,14 +240,13 @@ describe("proof_provider/payload_store", function () { const slot = 20; const header = buildLCHeader({slot, blockNumber}); const blockResponse = buildBlockResponse({blockNumber, slot}); - const executionPayload = (blockResponse.response?.data as capella.SignedBeaconBlock).message.body - .executionPayload; + const executionPayload = (blockResponse.value() as capella.SignedBeaconBlock).message.body.executionPayload; api.beacon.getBlockV2.mockResolvedValue(blockResponse); await store.processLCHeader(header, true); expect(api.beacon.getBlockV2).toHaveBeenCalledOnce(); - expect(api.beacon.getBlockV2).toHaveBeenCalledWith(20); + expect(api.beacon.getBlockV2).toHaveBeenCalledWith({blockId: 20}); expect(store.finalized).toEqual(executionPayload); }); @@ -259,8 +255,7 @@ describe("proof_provider/payload_store", function () { const slot = 20; const header = buildLCHeader({slot, blockNumber}); const blockResponse = buildBlockResponse({blockNumber, slot}); - const executionPayload = (blockResponse.response?.data as capella.SignedBeaconBlock).message.body - .executionPayload; + const executionPayload = (blockResponse.value() as capella.SignedBeaconBlock).message.body.executionPayload; api.beacon.getBlockV2.mockResolvedValue(blockResponse); expect(store.finalized).toBeUndefined(); // First process as unfinalized @@ -284,7 +279,7 @@ describe("proof_provider/payload_store", function () { await store.processLCHeader(header); expect(api.beacon.getBlockV2).toHaveBeenCalledOnce(); - expect(api.beacon.getBlockV2).toHaveBeenCalledWith(20); + expect(api.beacon.getBlockV2).toHaveBeenCalledWith({blockId: 20}); }); it("should not fetch existing payload for lightclient header", async () => { @@ -300,7 +295,7 @@ describe("proof_provider/payload_store", function () { // The network fetch should be done once expect(api.beacon.getBlockV2).toHaveBeenCalledOnce(); - expect(api.beacon.getBlockV2).toHaveBeenCalledWith(20); + expect(api.beacon.getBlockV2).toHaveBeenCalledWith({blockId: 20}); }); it("should prune the existing payloads", async () => { @@ -383,7 +378,7 @@ describe("proof_provider/payload_store", function () { for (let i = 1; i <= numberOfPayloads; i++) { when(api.beacon.getBlockV2) - .calledWith(i) + .calledWith({blockId: i}) .thenResolve(buildBlockResponse({blockNumber: 500 + i, slot: i})); await store.processLCHeader(buildLCHeader({blockNumber: 500 + i, slot: i}), false); diff --git a/packages/reqresp/README.md b/packages/reqresp/README.md index dbfbf304c541..65d0c43b47b0 100644 --- a/packages/reqresp/README.md +++ b/packages/reqresp/README.md @@ -1,7 +1,7 @@ # Lodestar Eth Consensus Req/Resp Protocol [![Discord](https://img.shields.io/discord/593655374469660673.svg?label=Discord&logo=discord)](https://discord.gg/aMxzVcr) -[![ETH Beacon APIs Spec v2.1.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.1.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.1.0) +[![ETH Beacon APIs Spec v2.5.0](https://img.shields.io/badge/ETH%20beacon--APIs-2.5.0-blue)](https://github.com/ethereum/beacon-APIs/releases/tag/v2.5.0) ![ES Version](https://img.shields.io/badge/ES-2021-yellow) ![Node Version](https://img.shields.io/badge/node-22.x-green) diff --git a/packages/state-transition/test/perf/analyzeBlocks.ts b/packages/state-transition/test/perf/analyzeBlocks.ts index f9e26b4f5238..71fd5952799a 100644 --- a/packages/state-transition/test/perf/analyzeBlocks.ts +++ b/packages/state-transition/test/perf/analyzeBlocks.ts @@ -1,6 +1,5 @@ -import {getClient, ApiError} from "@lodestar/api"; +import {getClient} 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 @@ -20,9 +19,7 @@ const network = "mainnet"; const client = getClient({baseUrl: getInfuraBeaconUrl(network)}, {config}); async function run(): Promise { - const res = await client.beacon.getBlockHeader("head"); - ApiError.assert(res); - const headBlock = res.response.data; + const headBlock = (await client.beacon.getBlockHeader({blockId: "head"})).value(); // Count operations let blocks = 0; @@ -40,9 +37,9 @@ async function run(): Promise { const batchSize = 32; for (let slot = startSlot; slot > 0; slot -= batchSize) { - const blockPromises: ReturnType[] = []; + const blockPromises: ReturnType[] = []; for (let s = slot - batchSize; s < slot; s++) { - blockPromises.push(client.beacon.getBlock(s)); + blockPromises.push(client.beacon.getBlockV2({blockId: s})); } const results = await Promise.allSettled(blockPromises); @@ -51,9 +48,8 @@ async function run(): Promise { // Missed block continue; } - ApiError.assert(result.value); - const block = (result.value.response as {data: allForks.SignedBeaconBlock}).data; + const block = result.value.value(); blocks++; attestations += block.message.body.attestations.length; diff --git a/packages/state-transition/test/perf/analyzeEpochs.ts b/packages/state-transition/test/perf/analyzeEpochs.ts index df072a75902b..4b793fe95e6e 100644 --- a/packages/state-transition/test/perf/analyzeEpochs.ts +++ b/packages/state-transition/test/perf/analyzeEpochs.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {config} from "@lodestar/config/default"; import {NetworkName} from "@lodestar/config/networks.js"; import {phase0, ssz} from "@lodestar/types"; @@ -77,22 +77,18 @@ async function analyzeEpochs(network: NetworkName, fromEpoch?: number): Promise< const baseUrl = getInfuraBeaconUrl(network); // Long timeout to download states - const client = getClient({baseUrl, timeoutMs: 5 * 60 * 1000}, {config}); + const client = getClient({baseUrl, globalInit: {timeoutMs: 5 * 60 * 1000}}, {config}); // Start at epoch 1 since 0 will go and fetch state at slot -1 const maxEpoch = fromEpoch ?? Math.max(1, ...currCsv.map((row) => row.epoch)); - const res = await client.beacon.getBlockHeader("head"); - ApiError.assert(res); - const header = res.response.data; - const currentEpoch = computeEpochAtSlot(header.header.message.slot); + const {header} = (await client.beacon.getBlockHeader({blockId: "head"})).value(); + const currentEpoch = computeEpochAtSlot(header.message.slot); for (let epoch = maxEpoch; epoch < currentEpoch; epoch++) { const stateSlot = computeStartSlotAtEpoch(epoch) - 1; - const res = await client.debug.getState(String(stateSlot)); - ApiError.assert(res); - const state = res.response.data; + const state = (await client.debug.getStateV2({stateId: stateSlot})).value(); const preEpoch = computeEpochAtSlot(state.slot); const nextEpochSlot = computeStartSlotAtEpoch(preEpoch + 1); diff --git a/packages/state-transition/test/utils/testFileCache.ts b/packages/state-transition/test/utils/testFileCache.ts index e752f3c36e68..6e554d888d18 100644 --- a/packages/state-transition/test/utils/testFileCache.ts +++ b/packages/state-transition/test/utils/testFileCache.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import got from "got"; -import {ApiError, getClient} from "@lodestar/api"; +import {getClient} from "@lodestar/api"; import {NetworkName, networksChainConfig} from "@lodestar/config/networks"; import {createChainForkConfig, ChainForkConfig} from "@lodestar/config"; import {allForks} from "@lodestar/types"; -import {CachedBeaconStateAllForks, computeEpochAtSlot} from "../../src/index.js"; +import {CachedBeaconStateAllForks} from "../../src/index.js"; import {testCachePath} from "../cache.js"; import {createCachedBeaconStateTest} from "../utils/state.js"; import {getInfuraBeaconUrl} from "./infura.js"; @@ -45,16 +45,13 @@ export async function getNetworkCachedState( const stateSsz = await tryEach([ () => downloadTestFile(fileId), () => { - const client = getClient({baseUrl: getInfuraBeaconUrl(network), timeoutMs: timeout ?? 300_000}, {config}); - return computeEpochAtSlot(slot) < config.ALTAIR_FORK_EPOCH - ? client.debug.getState(String(slot), "ssz").then((r) => { - ApiError.assert(r); - return r.response; - }) - : client.debug.getStateV2(String(slot), "ssz").then((r) => { - ApiError.assert(r); - return r.response; - }); + const client = getClient( + {baseUrl: getInfuraBeaconUrl(network), globalInit: {timeoutMs: timeout ?? 300_000}}, + {config} + ); + return client.debug.getStateV2({stateId: slot}).then((r) => { + return r.ssz(); + }); }, ]); @@ -83,14 +80,12 @@ export async function getNetworkCachedBlock( const blockSsz = await tryEach([ () => downloadTestFile(fileId), async () => { - const client = getClient({baseUrl: getInfuraBeaconUrl(network), timeoutMs: timeout ?? 300_000}, {config}); - - const res = - computeEpochAtSlot(slot) < config.ALTAIR_FORK_EPOCH - ? await client.beacon.getBlock(String(slot)) - : await client.beacon.getBlockV2(String(slot)); - ApiError.assert(res); - return config.getForkTypes(slot).SignedBeaconBlock.serialize(res.response.data); + const client = getClient( + {baseUrl: getInfuraBeaconUrl(network), globalInit: {timeoutMs: timeout ?? 300_000}}, + {config} + ); + + return (await client.beacon.getBlockV2({blockId: slot})).ssz(); }, ]); diff --git a/packages/types/src/utils/stringType.ts b/packages/types/src/utils/stringType.ts index b3ef4f2f1f18..89fa93ec0317 100644 --- a/packages/types/src/utils/stringType.ts +++ b/packages/types/src/utils/stringType.ts @@ -44,6 +44,9 @@ export class StringType extends BasicType { // JSON fromJson(json: unknown): T { + if (typeof json !== "string") { + throw Error(`JSON invalid type ${typeof json} expected string`); + } return json as T; } diff --git a/packages/validator/src/genesis.ts b/packages/validator/src/genesis.ts index 2442d4882ff3..3156acee689f 100644 --- a/packages/validator/src/genesis.ts +++ b/packages/validator/src/genesis.ts @@ -1,17 +1,15 @@ import {Genesis} from "@lodestar/types/phase0"; import {Logger, sleep} from "@lodestar/utils"; -import {Api, ApiError} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; /** The time between polls when waiting for genesis */ const WAITING_FOR_GENESIS_POLL_MS = 12 * 1000; -export async function waitForGenesis(api: Api, logger: Logger, signal?: AbortSignal): Promise { +export async function waitForGenesis(api: ApiClient, logger: Logger, signal?: AbortSignal): Promise { // eslint-disable-next-line no-constant-condition while (true) { try { - const res = await api.beacon.getGenesis(); - ApiError.assert(res, "Can not fetch genesis data from beacon node"); - return res.response.data; + return (await api.beacon.getGenesis()).value(); } catch (e) { // TODO: Search for a 404 error which indicates that genesis has not yet occurred. // Note: Lodestar API does not become online after genesis is found diff --git a/packages/validator/src/services/attestation.ts b/packages/validator/src/services/attestation.ts index 73162b67f1af..57a8a7621a97 100644 --- a/packages/validator/src/services/attestation.ts +++ b/packages/validator/src/services/attestation.ts @@ -2,7 +2,7 @@ import {toHexString} from "@chainsafe/ssz"; import {BLSSignature, phase0, Slot, ssz} from "@lodestar/types"; import {computeEpochAtSlot, isAggregatorFromCommitteeLength} from "@lodestar/state-transition"; import {sleep} from "@lodestar/utils"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {IClock, LoggerVc} from "../util/index.js"; import {PubkeyHex} from "../types.js"; import {Metrics} from "../metrics.js"; @@ -35,7 +35,7 @@ export class AttestationService { constructor( private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, private readonly clock: IClock, private readonly validatorStore: ValidatorStore, private readonly emitter: ValidatorEventEmitter, @@ -167,9 +167,7 @@ export class AttestationService { */ private async produceAttestation(committeeIndex: number, slot: Slot): Promise { // Produce one attestation data per slot and committeeIndex - const res = await this.api.validator.produceAttestationData(committeeIndex, slot); - ApiError.assert(res, "Error producing attestation"); - return res.response.data; + return (await this.api.validator.produceAttestationData({committeeIndex, slot})).value(); } /** @@ -223,7 +221,7 @@ export class AttestationService { ...(this.opts?.disableAttestationGrouping && {index: attestationNoCommittee.index}), }; try { - ApiError.assert(await this.api.beacon.submitPoolAttestations(signedAttestations)); + (await this.api.beacon.submitPoolAttestations({signedAttestations})).assertOk(); this.logger.info("Published attestations", {...logCtx, count: signedAttestations.length}); this.metrics?.publishedAttestations.inc(signedAttestations.length); } catch (e) { @@ -255,13 +253,12 @@ export class AttestationService { } this.logger.verbose("Aggregating attestations", logCtx); - const res = await this.api.validator.getAggregatedAttestation( - ssz.phase0.AttestationData.hashTreeRoot(attestation), - attestation.slot - ); - ApiError.assert(res, "Error producing aggregateAndProofs"); - const aggregate = res.response; - this.metrics?.numParticipantsInAggregate.observe(aggregate.data.aggregationBits.getTrueBitIndexes().length); + const res = await this.api.validator.getAggregatedAttestation({ + attestationDataRoot: ssz.phase0.AttestationData.hashTreeRoot(attestation), + slot: attestation.slot, + }); + const aggregate = res.value(); + this.metrics?.numParticipantsInAggregate.observe(aggregate.aggregationBits.getTrueBitIndexes().length); const signedAggregateAndProofs: phase0.SignedAggregateAndProof[] = []; @@ -272,7 +269,7 @@ export class AttestationService { // Produce signed aggregates only for validators that are subscribed aggregators. if (selectionProof !== null) { signedAggregateAndProofs.push( - await this.validatorStore.signAggregateAndProof(duty, selectionProof, aggregate.data) + await this.validatorStore.signAggregateAndProof(duty, selectionProof, aggregate) ); this.logger.debug("Signed aggregateAndProofs", logCtxValidator); } @@ -286,8 +283,7 @@ export class AttestationService { if (signedAggregateAndProofs.length > 0) { try { - const res = await this.api.validator.publishAggregateAndProofs(signedAggregateAndProofs); - ApiError.assert(res); + (await this.api.validator.publishAggregateAndProofs({signedAggregateAndProofs})).assertOk(); this.logger.info("Published aggregateAndProofs", {...logCtx, count: signedAggregateAndProofs.length}); this.metrics?.publishedAggregates.inc(signedAggregateAndProofs.length); } catch (e) { @@ -322,7 +318,7 @@ export class AttestationService { this.logger.debug("Submitting partial beacon committee selection proofs", {slot, count: partialSelections.length}); const res = await Promise.race([ - this.api.validator.submitBeaconCommitteeSelections(partialSelections), + this.api.validator.submitBeaconCommitteeSelections({selections: partialSelections}), // Exit attestation aggregation flow if there is no response after 1/3 of slot as // beacon node would likely not have enough time to prepare an aggregate attestation. // Note that the aggregations flow is not explicitly exited but rather will be skipped @@ -334,9 +330,8 @@ export class AttestationService { if (!res) { throw new Error("Failed to receive combined selection proofs before 1/3 of slot"); } - ApiError.assert(res, "Error receiving combined selection proofs"); - const combinedSelections = res.response.data; + const combinedSelections = res.value(); this.logger.debug("Received combined beacon committee selection proofs", {slot, count: combinedSelections.length}); const beaconCommitteeSubscriptions: routes.validator.BeaconCommitteeSubscription[] = []; @@ -373,10 +368,7 @@ export class AttestationService { // If there are any subscriptions with aggregators, push them out to the beacon node. if (beaconCommitteeSubscriptions.length > 0) { - ApiError.assert( - await this.api.validator.prepareBeaconCommitteeSubnet(beaconCommitteeSubscriptions), - "Failed to resubscribe to beacon committee subnets" - ); + (await this.api.validator.prepareBeaconCommitteeSubnet({subscriptions: beaconCommitteeSubscriptions})).assertOk(); this.logger.debug("Resubscribed validators as aggregators on beacon committee subnets", { slot, count: beaconCommitteeSubscriptions.length, diff --git a/packages/validator/src/services/attestationDuties.ts b/packages/validator/src/services/attestationDuties.ts index 8fa127c25e8b..1f278aebbd89 100644 --- a/packages/validator/src/services/attestationDuties.ts +++ b/packages/validator/src/services/attestationDuties.ts @@ -3,7 +3,7 @@ import {SLOTS_PER_EPOCH} from "@lodestar/params"; import {sleep} from "@lodestar/utils"; import {computeEpochAtSlot, isAggregatorFromCommitteeLength} from "@lodestar/state-transition"; import {BLSSignature, Epoch, Slot, ValidatorIndex, RootHex} from "@lodestar/types"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {batchItems, IClock, LoggerVc} from "../util/index.js"; import {PubkeyHex} from "../types.js"; import {Metrics} from "../metrics.js"; @@ -48,7 +48,7 @@ export class AttestationDutiesService { constructor( private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, private clock: IClock, private readonly validatorStore: ValidatorStore, chainHeadTracker: ChainHeaderTracker, @@ -213,10 +213,12 @@ export class AttestationDutiesService { // If there are any subscriptions, push them out to the beacon node. if (beaconCommitteeSubscriptions.length > 0) { const subscriptionsBatches = batchItems(beaconCommitteeSubscriptions, {batchSize: SUBSCRIPTIONS_PER_REQUEST}); - const responses = await Promise.all(subscriptionsBatches.map(this.api.validator.prepareBeaconCommitteeSubnet)); + const responses = await Promise.all( + subscriptionsBatches.map((subscriptions) => this.api.validator.prepareBeaconCommitteeSubnet({subscriptions})) + ); for (const res of responses) { - ApiError.assert(res, "Failed to subscribe to beacon committee subnets"); + res.assertOk(); } } } @@ -230,11 +232,10 @@ export class AttestationDutiesService { return; } - const res = await this.api.validator.getAttesterDuties(epoch, indexArr); - ApiError.assert(res, "Failed to obtain attester duty"); - const attesterDuties = res.response; - const {dependentRoot} = attesterDuties; - const relevantDuties = attesterDuties.data.filter((duty) => { + const res = await this.api.validator.getAttesterDuties({epoch, indices: indexArr}); + const attesterDuties = res.value(); + const {dependentRoot} = res.meta(); + const relevantDuties = attesterDuties.filter((duty) => { const pubkeyHex = toHexString(duty.pubkey); return this.validatorStore.hasVotingPubkey(pubkeyHex) && this.validatorStore.isDoppelgangerSafe(pubkeyHex); }); diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index faec8d9e04ee..ab855e264447 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -10,9 +10,9 @@ import { isBlockContents, } from "@lodestar/types"; import {ChainForkConfig} from "@lodestar/config"; -import {ForkPreBlobs, ForkBlobs, ForkSeq, ForkExecution} from "@lodestar/params"; +import {ForkPreBlobs, ForkBlobs, ForkSeq, ForkExecution, ForkName} from "@lodestar/params"; import {extendError, prettyBytes, prettyWeiToEth} from "@lodestar/utils"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {IClock, LoggerVc} from "../util/index.js"; import {PubkeyHex} from "../types.js"; import {Metrics} from "../metrics.js"; @@ -64,7 +64,7 @@ export class BlockProposingService { constructor( private readonly config: ChainForkConfig, private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, private readonly clock: IClock, private readonly validatorStore: ValidatorStore, private readonly metrics: Metrics | null, @@ -138,7 +138,6 @@ export class BlockProposingService { const produceOpts = { feeRecipient, strictFeeRecipientCheck, - builderBoostFactor, blindedLocal, }; const blockContents = await produceBlockFn( @@ -146,6 +145,7 @@ export class BlockProposingService { slot, randaoReveal, graffiti, + builderBoostFactor, produceOpts, builderSelection ).catch((e: Error) => { @@ -184,12 +184,12 @@ export class BlockProposingService { "Ignoring contents while publishing blinded block - publishing beacon should assemble it from its local cache or builder" ); } - ApiError.assert(await this.api.beacon.publishBlindedBlockV2(signedBlock, opts)); + (await this.api.beacon.publishBlindedBlockV2({signedBlindedBlock: signedBlock, ...opts})).assertOk(); } else { if (contents === null) { - ApiError.assert(await this.api.beacon.publishBlockV2(signedBlock, opts)); + (await this.api.beacon.publishBlockV2({signedBlockOrContents: signedBlock, ...opts})).assertOk(); } else { - ApiError.assert(await this.api.beacon.publishBlockV2({...contents, signedBlock}, opts)); + (await this.api.beacon.publishBlockV2({signedBlockOrContents: {...contents, signedBlock}, ...opts})).assertOk(); } } }; @@ -199,32 +199,36 @@ export class BlockProposingService { slot: Slot, randaoReveal: BLSSignature, graffiti: string, - {feeRecipient, strictFeeRecipientCheck, builderBoostFactor, blindedLocal}: routes.validator.ExtraProduceBlockOps, + builderBoostFactor: bigint, + {feeRecipient, strictFeeRecipientCheck, blindedLocal}: routes.validator.ExtraProduceBlockOpts, builderSelection: routes.validator.BuilderSelection ): Promise => { - const res = await this.api.validator.produceBlockV3(slot, randaoReveal, graffiti, false, { + const res = await this.api.validator.produceBlockV3({ + slot, + randaoReveal, + graffiti, + skipRandaoVerification: false, feeRecipient, builderSelection, strictFeeRecipientCheck, blindedLocal, builderBoostFactor, }); - ApiError.assert(res, "Failed to produce block: validator.produceBlockV3"); - const {response} = res; + const meta = res.meta(); const debugLogCtx = { - executionPayloadSource: response.executionPayloadSource, - executionPayloadBlinded: response.executionPayloadBlinded, - executionPayloadValue: prettyWeiToEth(response.executionPayloadValue), - consensusBlockValue: prettyWeiToEth(response.consensusBlockValue), - totalBlockValue: prettyWeiToEth(response.executionPayloadValue + response.consensusBlockValue), + executionPayloadSource: meta.executionPayloadSource, + executionPayloadBlinded: meta.executionPayloadBlinded, + executionPayloadValue: prettyWeiToEth(meta.executionPayloadValue), + consensusBlockValue: prettyWeiToEth(meta.consensusBlockValue), + totalBlockValue: prettyWeiToEth(meta.executionPayloadValue + meta.consensusBlockValue), // TODO PR: should be used in api call instead of adding in log strictFeeRecipientCheck, builderSelection, api: "produceBlockV3", }; - return parseProduceBlockResponse(response, debugLogCtx, builderSelection); + return parseProduceBlockResponse({data: res.value(), ...meta}, debugLogCtx, builderSelection); }; /** a wrapper function used for backward compatibility with the clients who don't have v3 implemented yet */ @@ -233,7 +237,8 @@ export class BlockProposingService { slot: Slot, randaoReveal: BLSSignature, graffiti: string, - _opts: routes.validator.ExtraProduceBlockOps, + _builderBoostFactor: bigint, + _opts: routes.validator.ExtraProduceBlockOpts, builderSelection: routes.validator.BuilderSelection ): Promise => { // other clients have always implemented builder vs execution race in produce blinded block @@ -243,25 +248,23 @@ export class BlockProposingService { if (ForkSeq[fork] < ForkSeq.bellatrix || builderSelection === routes.validator.BuilderSelection.ExecutionOnly) { Object.assign(debugLogCtx, {api: "produceBlockV2"}); - const res = await this.api.validator.produceBlockV2(slot, randaoReveal, graffiti); - ApiError.assert(res, "Failed to produce block: validator.produceBlockV2"); - const {response} = res; + const res = await this.api.validator.produceBlockV2({slot, randaoReveal, graffiti}); + const {version} = res.meta(); const executionPayloadSource = ProducedBlockSource.engine; return parseProduceBlockResponse( - {executionPayloadBlinded: false, executionPayloadSource, ...response}, + {data: res.value(), executionPayloadBlinded: false, executionPayloadSource, version}, debugLogCtx, builderSelection ); } else { Object.assign(debugLogCtx, {api: "produceBlindedBlock"}); - const res = await this.api.validator.produceBlindedBlock(slot, randaoReveal, graffiti); - ApiError.assert(res, "Failed to produce block: validator.produceBlindedBlock"); - const {response} = res; + const res = await this.api.validator.produceBlindedBlock({slot, randaoReveal, graffiti}); + const {version} = res.meta(); const executionPayloadSource = ProducedBlockSource.builder; return parseProduceBlockResponse( - {executionPayloadBlinded: true, executionPayloadSource, ...response}, + {data: res.value(), executionPayloadBlinded: true, executionPayloadSource, version}, debugLogCtx, builderSelection ); @@ -270,7 +273,11 @@ export class BlockProposingService { } function parseProduceBlockResponse( - response: routes.validator.ProduceFullOrBlindedBlockOrContentsRes, + response: {data: allForks.FullOrBlindedBeaconBlockOrContents} & { + executionPayloadSource: ProducedBlockSource; + executionPayloadBlinded: boolean; + version: ForkName; + }, debugLogCtx: Record, builderSelection: routes.validator.BuilderSelection ): FullOrBlindedBlockWithContents & DebugLogCtx { diff --git a/packages/validator/src/services/blockDuties.ts b/packages/validator/src/services/blockDuties.ts index 67b6e5834417..3282987f5d9e 100644 --- a/packages/validator/src/services/blockDuties.ts +++ b/packages/validator/src/services/blockDuties.ts @@ -1,7 +1,7 @@ import {toHexString} from "@chainsafe/ssz"; import {computeEpochAtSlot, computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {BLSPubkey, Epoch, RootHex, Slot} from "@lodestar/types"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {sleep} from "@lodestar/utils"; import {ChainConfig} from "@lodestar/config"; import {IClock, differenceHex, LoggerVc} from "../util/index.js"; @@ -33,7 +33,7 @@ export class BlockDutiesService { constructor( private readonly config: ChainConfig, private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, private readonly clock: IClock, private readonly validatorStore: ValidatorStore, private readonly metrics: Metrics | null, @@ -183,11 +183,10 @@ export class BlockDutiesService { return; } - const res = await this.api.validator.getProposerDuties(epoch); - ApiError.assert(res, "Error on getProposerDuties"); - const proposerDuties = res.response; - const {dependentRoot} = proposerDuties; - const relevantDuties = proposerDuties.data.filter((duty) => { + const res = await this.api.validator.getProposerDuties({epoch}); + const proposerDuties = res.value(); + const {dependentRoot} = res.meta(); + const relevantDuties = proposerDuties.filter((duty) => { const pubkeyHex = toHexString(duty.pubkey); return this.validatorStore.hasVotingPubkey(pubkeyHex) && this.validatorStore.isDoppelgangerSafe(pubkeyHex); }); diff --git a/packages/validator/src/services/chainHeaderTracker.ts b/packages/validator/src/services/chainHeaderTracker.ts index ebb20670bb24..845743264d0b 100644 --- a/packages/validator/src/services/chainHeaderTracker.ts +++ b/packages/validator/src/services/chainHeaderTracker.ts @@ -1,5 +1,5 @@ import {fromHexString} from "@chainsafe/ssz"; -import {Api, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {Logger} from "@lodestar/utils"; import {Slot, Root, RootHex} from "@lodestar/types"; import {GENESIS_SLOT} from "@lodestar/params"; @@ -26,14 +26,24 @@ export class ChainHeaderTracker { constructor( private readonly logger: Logger, - private readonly api: Api, + private readonly api: ApiClient, private readonly emitter: ValidatorEventEmitter ) {} start(signal: AbortSignal): void { this.logger.verbose("Subscribing to head event"); this.api.events - .eventstream([EventType.head], signal, this.onHeadUpdate) + .eventstream({ + topics: [EventType.head], + signal, + onEvent: this.onHeadUpdate, + onError: (e) => { + this.logger.error("Failed to receive head event", {}, e); + }, + onClose: () => { + this.logger.verbose("Closed stream for head event", {}); + }, + }) .catch((e) => this.logger.error("Failed to subscribe to head event", {}, e)); } diff --git a/packages/validator/src/services/doppelgangerService.ts b/packages/validator/src/services/doppelgangerService.ts index 861db27769e7..5435c5aed37f 100644 --- a/packages/validator/src/services/doppelgangerService.ts +++ b/packages/validator/src/services/doppelgangerService.ts @@ -1,6 +1,6 @@ import {fromHexString} from "@chainsafe/ssz"; import {Epoch, ValidatorIndex} from "@lodestar/types"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {Logger, sleep, truncBytes} from "@lodestar/utils"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; import {ISlashingProtection} from "../slashingProtection/index.js"; @@ -43,7 +43,7 @@ export class DoppelgangerService { constructor( private readonly logger: Logger, private readonly clock: IClock, - private readonly api: Api, + private readonly api: ApiClient, private readonly indicesService: IndicesService, private readonly slashingProtection: ISlashingProtection, private readonly processShutdownCallback: ProcessShutdownCallback, @@ -170,16 +170,12 @@ export class DoppelgangerService { return {epoch, responses: []}; } - const res = await this.api.validator.getLiveness(epoch, indicesToCheck); + const res = await this.api.validator.getLiveness({epoch, indices: indicesToCheck}); if (!res.ok) { - this.logger.error( - `Error getting liveness data for epoch ${epoch}`, - {}, - new ApiError(res.error.message ?? "", res.error.code, "validator.getLiveness") - ); + this.logger.error(`Error getting liveness data for epoch ${epoch}`, {}, res.error() as Error); return {epoch, responses: []}; } - return {epoch, responses: res.response.data}; + return {epoch, responses: res.value()}; } private detectDoppelganger( diff --git a/packages/validator/src/services/indices.ts b/packages/validator/src/services/indices.ts index 12de1bbb9392..dcc129cc591e 100644 --- a/packages/validator/src/services/indices.ts +++ b/packages/validator/src/services/indices.ts @@ -1,7 +1,7 @@ import {toHexString} from "@chainsafe/ssz"; import {ValidatorIndex} from "@lodestar/types"; import {Logger, MapDef} from "@lodestar/utils"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {batchItems} from "../util/index.js"; import {Metrics} from "../metrics.js"; @@ -48,7 +48,7 @@ export class IndicesService { constructor( private readonly logger: Logger, - private readonly api: Api, + private readonly api: ApiClient, private readonly metrics: Metrics | null ) { if (metrics) { @@ -126,32 +126,31 @@ export class IndicesService { } private async fetchValidatorIndices(pubkeysHex: string[]): Promise { - const res = await this.api.beacon.getStateValidators("head", {id: pubkeysHex}); - ApiError.assert(res, "Can not fetch state validators from beacon node"); + const validators = (await this.api.beacon.getStateValidators({stateId: "head", validatorIds: pubkeysHex})).value(); const newIndices = []; const allValidatorStatuses = new MapDef(() => 0); - for (const validatorState of res.response.data) { + for (const validator of validators) { // Group all validators by status - const status = statusToSimpleStatusMapping(validatorState.status); + const status = statusToSimpleStatusMapping(validator.status); allValidatorStatuses.set(status, allValidatorStatuses.getOrDefault(status) + 1); - const pubkeyHex = toHexString(validatorState.validator.pubkey); + const pubkeyHex = toHexString(validator.validator.pubkey); if (!this.pubkey2index.has(pubkeyHex)) { this.logger.info("Validator seen on beacon chain", { - validatorIndex: validatorState.index, + validatorIndex: validator.index, pubKey: pubkeyHex, }); - this.pubkey2index.set(pubkeyHex, validatorState.index); - this.index2pubkey.set(validatorState.index, pubkeyHex); - newIndices.push(validatorState.index); + this.pubkey2index.set(pubkeyHex, validator.index); + this.index2pubkey.set(validator.index, pubkeyHex); + newIndices.push(validator.index); } } // The number of validators that are not in the beacon chain - const pendingCount = pubkeysHex.length - res.response.data.length; + const pendingCount = pubkeysHex.length - validators.length; allValidatorStatuses.set("pending", allValidatorStatuses.getOrDefault("pending") + pendingCount); diff --git a/packages/validator/src/services/prepareBeaconProposer.ts b/packages/validator/src/services/prepareBeaconProposer.ts index 7d7907a4592d..6ae4b0a83870 100644 --- a/packages/validator/src/services/prepareBeaconProposer.ts +++ b/packages/validator/src/services/prepareBeaconProposer.ts @@ -1,5 +1,5 @@ import {Epoch, bellatrix} from "@lodestar/types"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {BeaconConfig} from "@lodestar/config"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; @@ -19,7 +19,7 @@ const REGISTRATION_CHUNK_SIZE = 512; export function pollPrepareBeaconProposer( config: BeaconConfig, logger: LoggerVc, - api: Api, + api: ApiClient, clock: IClock, validatorStore: ValidatorStore, _metrics: Metrics | null @@ -40,11 +40,11 @@ export function pollPrepareBeaconProposer( try { const proposers = indices.map( (index): routes.validator.ProposerPreparationData => ({ - validatorIndex: String(index as number), + validatorIndex: index, feeRecipient: validatorStore.getFeeRecipientByIndex(index), }) ); - ApiError.assert(await api.validator.prepareBeaconProposer(proposers)); + (await api.validator.prepareBeaconProposer({proposers})).assertOk(); logger.debug("Registered proposers with beacon node", {epoch, count: proposers.length}); } catch (e) { logger.error("Failed to register proposers with beacon node", {epoch}, e as Error); @@ -65,7 +65,7 @@ export function pollPrepareBeaconProposer( export function pollBuilderValidatorRegistration( config: BeaconConfig, logger: LoggerVc, - api: Api, + api: ApiClient, clock: IClock, validatorStore: ValidatorStore, _metrics: Metrics | null @@ -102,7 +102,7 @@ export function pollBuilderValidatorRegistration( return validatorStore.getValidatorRegistration(pubkeyHex, {feeRecipient, gasLimit}, slot); }) ); - ApiError.assert(await api.validator.registerValidator(registrations)); + (await api.validator.registerValidator({registrations})).assertOk(); logger.info("Published validator registrations to builder", {epoch, count: registrations.length}); } catch (e) { logger.error("Failed to publish validator registrations to builder", {epoch}, e as Error); diff --git a/packages/validator/src/services/syncCommittee.ts b/packages/validator/src/services/syncCommittee.ts index 9f104e0d7d4b..06926724141c 100644 --- a/packages/validator/src/services/syncCommittee.ts +++ b/packages/validator/src/services/syncCommittee.ts @@ -2,7 +2,7 @@ import {ChainForkConfig} from "@lodestar/config"; import {Slot, CommitteeIndex, altair, Root, BLSSignature} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; import {computeEpochAtSlot, isSyncCommitteeAggregator} from "@lodestar/state-transition"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {IClock, LoggerVc} from "../util/index.js"; import {PubkeyHex} from "../types.js"; import {Metrics} from "../metrics.js"; @@ -26,7 +26,7 @@ export class SyncCommitteeService { constructor( private readonly config: ChainForkConfig, private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, private readonly clock: IClock, private readonly validatorStore: ValidatorStore, private readonly emitter: ValidatorEventEmitter, @@ -124,10 +124,7 @@ export class SyncCommitteeService { const blockRoot: Uint8Array = this.chainHeaderTracker.getCurrentChainHead(slot) ?? - (await this.api.beacon.getBlockRoot("head").then((res) => { - ApiError.assert(res, "Error producing SyncCommitteeMessage"); - return res.response.data.root; - })); + (await this.api.beacon.getBlockRoot({blockId: "head"})).value().root; const signatures: altair.SyncCommitteeMessage[] = []; @@ -159,7 +156,7 @@ export class SyncCommitteeService { if (signatures.length > 0) { try { - ApiError.assert(await this.api.beacon.submitPoolSyncCommitteeSignatures(signatures)); + (await this.api.beacon.submitPoolSyncCommitteeSignatures({signatures})).assertOk(); this.logger.info("Published SyncCommitteeMessage", {...logCtx, count: signatures.length}); this.metrics?.publishedSyncCommitteeMessage.inc(signatures.length); } catch (e) { @@ -194,8 +191,7 @@ export class SyncCommitteeService { } this.logger.verbose("Producing SyncCommitteeContribution", logCtx); - const res = await this.api.validator.produceSyncCommitteeContribution(slot, subcommitteeIndex, beaconBlockRoot); - ApiError.assert(res, "Error producing sync committee contribution during produceAndPublishAggregates"); + const res = await this.api.validator.produceSyncCommitteeContribution({slot, subcommitteeIndex, beaconBlockRoot}); const signedContributions: altair.SignedContributionAndProof[] = []; @@ -206,7 +202,7 @@ export class SyncCommitteeService { // Produce signed contributions only for validators that are subscribed aggregators. if (selectionProof !== null) { signedContributions.push( - await this.validatorStore.signContributionAndProof(duty, selectionProof, res.response.data) + await this.validatorStore.signContributionAndProof(duty, selectionProof, res.value()) ); this.logger.debug("Signed SyncCommitteeContribution", logCtxValidator); } @@ -220,8 +216,9 @@ export class SyncCommitteeService { if (signedContributions.length > 0) { try { - const res = await this.api.validator.publishContributionAndProofs(signedContributions); - ApiError.assert(res); + ( + await this.api.validator.publishContributionAndProofs({contributionAndProofs: signedContributions}) + ).assertOk(); this.logger.info("Published SyncCommitteeContribution", {...logCtx, count: signedContributions.length}); this.metrics?.publishedSyncCommitteeContribution.inc(signedContributions.length); } catch (e) { @@ -261,7 +258,7 @@ export class SyncCommitteeService { this.logger.debug("Submitting partial sync committee selection proofs", {slot, count: partialSelections.length}); const res = await Promise.race([ - this.api.validator.submitSyncCommitteeSelections(partialSelections), + this.api.validator.submitSyncCommitteeSelections({selections: partialSelections}), // Exit sync committee contributions flow if there is no response after 2/3 of slot. // This is in contrast to attestations aggregations flow which is already exited at 1/3 of the slot // because for sync committee is not required to resubscribe to subnets as beacon node will assume @@ -275,9 +272,8 @@ export class SyncCommitteeService { if (!res) { throw new Error("Failed to receive combined selection proofs before 2/3 of slot"); } - ApiError.assert(res, "Error receiving combined selection proofs"); - const combinedSelections = res.response.data; + const combinedSelections = res.value(); this.logger.debug("Received combined sync committee selection proofs", {slot, count: combinedSelections.length}); for (const dutyAndProofs of duties) { diff --git a/packages/validator/src/services/syncCommitteeDuties.ts b/packages/validator/src/services/syncCommitteeDuties.ts index e4c3fa2a1bed..edc62dea575c 100644 --- a/packages/validator/src/services/syncCommitteeDuties.ts +++ b/packages/validator/src/services/syncCommitteeDuties.ts @@ -3,7 +3,7 @@ import {EPOCHS_PER_SYNC_COMMITTEE_PERIOD, SYNC_COMMITTEE_SUBNET_SIZE} from "@lod import {computeSyncPeriodAtEpoch, computeSyncPeriodAtSlot, isSyncCommitteeAggregator} from "@lodestar/state-transition"; import {ChainForkConfig} from "@lodestar/config"; import {BLSSignature, Epoch, Slot, SyncPeriod, ValidatorIndex} from "@lodestar/types"; -import {Api, ApiError, routes} from "@lodestar/api"; +import {ApiClient, routes} from "@lodestar/api"; import {IClock, LoggerVc} from "../util/index.js"; import {PubkeyHex} from "../types.js"; import {Metrics} from "../metrics.js"; @@ -77,7 +77,7 @@ export class SyncCommitteeDutiesService { constructor( private readonly config: ChainForkConfig, private readonly logger: LoggerVc, - private readonly api: Api, + private readonly api: ApiClient, clock: IClock, private readonly validatorStore: ValidatorStore, metrics: Metrics | null, @@ -222,8 +222,7 @@ export class SyncCommitteeDutiesService { // If there are any subscriptions, push them out to the beacon node. if (syncCommitteeSubscriptions.length > 0) { // TODO: Should log or throw? - const res = await this.api.validator.prepareSyncCommitteeSubnets(syncCommitteeSubscriptions); - ApiError.assert(res, "Failed to subscribe to sync committee subnets"); + (await this.api.validator.prepareSyncCommitteeSubnets({subscriptions: syncCommitteeSubscriptions})).assertOk(); } } @@ -236,13 +235,12 @@ export class SyncCommitteeDutiesService { return; } - const res = await this.api.validator.getSyncCommitteeDuties(epoch, indexArr); - ApiError.assert(res, "Failed to obtain SyncDuties"); + const duties = (await this.api.validator.getSyncCommitteeDuties({epoch, indices: indexArr})).value(); const dutiesByIndex = new Map(); let count = 0; - for (const duty of res.response.data) { + for (const duty of duties) { const {validatorIndex} = duty; if (!this.validatorStore.hasValidatorIndex(validatorIndex)) { continue; diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 8590fc1d0068..9cb9f2e2d840 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -3,7 +3,7 @@ import {BLSPubkey, phase0, ssz} from "@lodestar/types"; import {createBeaconConfig, BeaconConfig, ChainForkConfig} from "@lodestar/config"; import {Genesis} from "@lodestar/types/phase0"; import {Logger, toSafePrintableUrl} from "@lodestar/utils"; -import {getClient, Api, routes, ApiError} from "@lodestar/api"; +import {getClient, ApiClient, routes, ApiRequestInit, defaultInit} from "@lodestar/api"; import {computeEpochAtSlot, getCurrentSlot} from "@lodestar/state-transition"; import {Clock, IClock} from "./util/clock.js"; import {waitForGenesis} from "./genesis.js"; @@ -32,7 +32,7 @@ export type ValidatorModules = { attestationService: AttestationService; syncCommitteeService: SyncCommitteeService; config: BeaconConfig; - api: Api; + api: ApiClient; clock: IClock; chainHeaderTracker: ChainHeaderTracker; logger: Logger; @@ -45,7 +45,10 @@ export type ValidatorOptions = { slashingProtection: ISlashingProtection; db: LodestarValidatorDatabaseController; config: ChainForkConfig; - api: Api | string | string[]; + api: { + clientOrUrls: ApiClient | string | string[]; + globalInit?: ApiRequestInit; + }; signers: Signer[]; logger: Logger; processShutdownCallback: ProcessShutdownCallback; @@ -83,7 +86,7 @@ export class Validator { private readonly attestationService: AttestationService; private readonly syncCommitteeService: SyncCommitteeService; private readonly config: BeaconConfig; - private readonly api: Api; + private readonly api: ApiClient; private readonly clock: IClock; private readonly chainHeaderTracker: ChainHeaderTracker; private readonly logger: Logger; @@ -157,22 +160,22 @@ export class Validator { const clock = new Clock(config, logger, {genesisTime: Number(genesis.genesisTime)}); const loggerVc = getLoggerVc(logger, clock); - let api: Api; - if (typeof opts.api === "string" || Array.isArray(opts.api)) { + let api: ApiClient; + const {clientOrUrls, globalInit} = opts.api; + if (typeof clientOrUrls === "string" || Array.isArray(clientOrUrls)) { // This new api instance can make do with default timeout as a faster timeout is // not necessary since this instance won't be used for validator duties api = getClient( { - urls: typeof opts.api === "string" ? [opts.api] : opts.api, + urls: typeof clientOrUrls === "string" ? [clientOrUrls] : clientOrUrls, // Validator would need the beacon to respond within the slot // See https://github.com/ChainSafe/lodestar/issues/5315 for rationale - timeoutMs: config.SECONDS_PER_SLOT * 1000, - getAbortSignal: () => controller.signal, + globalInit: {timeoutMs: config.SECONDS_PER_SLOT * 1000, signal: controller.signal, ...globalInit}, }, {config, logger, metrics: metrics?.restApiClient} ); } else { - api = opts.api; + api = clientOrUrls; } const indicesService = new IndicesService(logger, api, metrics); @@ -270,23 +273,27 @@ export class Validator { static async initializeFromBeaconNode(opts: ValidatorOptions, metrics?: Metrics | null): Promise { const {logger, config} = opts; - let api: Api; - if (typeof opts.api === "string" || Array.isArray(opts.api)) { - const urls = typeof opts.api === "string" ? [opts.api] : opts.api; + let api: ApiClient; + const {clientOrUrls, globalInit} = opts.api; + if (typeof clientOrUrls === "string" || Array.isArray(clientOrUrls)) { + const urls = typeof clientOrUrls === "string" ? [clientOrUrls] : clientOrUrls; // This new api instance can make do with default timeout as a faster timeout is // not necessary since this instance won't be used for validator duties - api = getClient({urls, getAbortSignal: () => opts.abortController.signal}, {config, logger}); - logger.info("Beacon node", {urls: urls.map(toSafePrintableUrl).toString()}); + api = getClient({urls, globalInit: {signal: opts.abortController.signal, ...globalInit}}, {config, logger}); + logger.info("Beacon node", { + urls: urls.map(toSafePrintableUrl).toString(), + requestWireFormat: globalInit?.requestWireFormat ?? defaultInit.requestWireFormat, + responseWireFormat: globalInit?.responseWireFormat ?? defaultInit.responseWireFormat, + }); } else { - api = opts.api; + api = clientOrUrls; } const genesis = await waitForGenesis(api, opts.logger, opts.abortController.signal); logger.info("Genesis fetched from the beacon node"); const res = await api.config.getSpec(); - ApiError.assert(res, "Can not fetch spec from beacon node"); - assertEqualParams(config, res.response.data); + assertEqualParams(config, res.value()); logger.info("Verified connected beacon node and validator have same the config"); await assertEqualGenesis(opts, genesis); @@ -340,7 +347,7 @@ export class Validator { async voluntaryExit(publicKey: string, exitEpoch?: number): Promise { const signedVoluntaryExit = await this.signVoluntaryExit(publicKey, exitEpoch); - ApiError.assert(await this.api.beacon.submitPoolVoluntaryExit(signedVoluntaryExit)); + (await this.api.beacon.submitPoolVoluntaryExit({signedVoluntaryExit})).assertOk(); this.logger.info(`Submitted voluntary exit for ${publicKey} to the network`); } @@ -349,12 +356,10 @@ export class Validator { * Create a signed voluntary exit message for the given validator by its key. */ async signVoluntaryExit(publicKey: string, exitEpoch?: number): Promise { - const res = await this.api.beacon.getStateValidators("head", {id: [publicKey]}); - ApiError.assert(res, "Can not fetch state validators from beacon node"); + const validators = (await this.api.beacon.getStateValidators({stateId: "head", validatorIds: [publicKey]})).value(); - const stateValidators = res.response.data; - const stateValidator = stateValidators[0]; - if (stateValidator === undefined) { + const validator = validators[0]; + if (validator === undefined) { throw new Error(`Validator pubkey ${publicKey} not found in state`); } @@ -362,18 +367,16 @@ export class Validator { exitEpoch = computeEpochAtSlot(getCurrentSlot(this.config, this.clock.genesisTime)); } - return this.validatorStore.signVoluntaryExit(publicKey, stateValidator.index, exitEpoch); + return this.validatorStore.signVoluntaryExit(publicKey, validator.index, exitEpoch); } private async fetchBeaconHealth(): Promise { try { const {status: healthCode} = await this.api.node.getHealth(); - // API always returns http status codes - // Need to find a way to return a custom enum type - if ((healthCode as unknown as routes.node.NodeHealth) === routes.node.NodeHealth.READY) return BeaconHealth.READY; - if ((healthCode as unknown as routes.node.NodeHealth) === routes.node.NodeHealth.SYNCING) - return BeaconHealth.SYNCING; - if ((healthCode as unknown as routes.node.NodeHealth) === routes.node.NodeHealth.NOT_INITIALIZED_OR_ISSUES) + + if (healthCode === routes.node.NodeHealth.READY) return BeaconHealth.READY; + if (healthCode === routes.node.NodeHealth.SYNCING) return BeaconHealth.SYNCING; + if (healthCode === routes.node.NodeHealth.NOT_INITIALIZED_OR_ISSUES) return BeaconHealth.NOT_INITIALIZED_OR_ISSUES; else return BeaconHealth.UNKNOWN; } catch (e) { diff --git a/packages/validator/test/unit/services/attestation.test.ts b/packages/validator/test/unit/services/attestation.test.ts index 397fef20b2ba..e1254d1c6a52 100644 --- a/packages/validator/test/unit/services/attestation.test.ts +++ b/packages/validator/test/unit/services/attestation.test.ts @@ -2,11 +2,11 @@ import {describe, it, expect, beforeAll, beforeEach, afterEach, vi} from "vitest import bls from "@chainsafe/bls"; import {toHexString} from "@chainsafe/ssz"; import {ssz} from "@lodestar/types"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {AttestationService, AttestationServiceOpts} from "../../../src/services/attestation.js"; import {AttDutyAndProof} from "../../../src/services/attestationDuties.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {ChainHeaderTracker} from "../../../src/services/chainHeaderTracker.js"; @@ -85,56 +85,31 @@ describe("AttestationService", function () { ]; // Return empty replies to duties service - api.beacon.getStateValidators.mockResolvedValue({ - response: {executionOptimistic: false, finalized: false, data: []}, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.getAttesterDuties.mockResolvedValue({ - response: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false, data: []}, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.getStateValidators.mockResolvedValue( + mockApiResponse({data: [], meta: {executionOptimistic: false, finalized: false}}) + ); + api.validator.getAttesterDuties.mockResolvedValue( + mockApiResponse({data: [], meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); // Mock duties service to return some duties directly vi.spyOn(attestationService["dutiesService"], "getDutiesAtSlot").mockImplementation(() => duties); // Mock beacon's attestation and aggregates endpoints - api.validator.produceAttestationData.mockResolvedValue({ - response: {data: attestation.data}, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.getAggregatedAttestation.mockResolvedValue({ - response: {data: attestation}, - ok: true, - status: HttpStatusCode.OK, - }); - api.beacon.submitPoolAttestations.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.publishAggregateAndProofs.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.produceAttestationData.mockResolvedValue(mockApiResponse({data: attestation.data})); + api.validator.getAggregatedAttestation.mockResolvedValue(mockApiResponse({data: attestation})); + + api.beacon.submitPoolAttestations.mockResolvedValue(mockApiResponse({})); + api.validator.publishAggregateAndProofs.mockResolvedValue(mockApiResponse({})); if (opts.distributedAggregationSelection) { // Mock distributed validator middleware client selections endpoint // and return a selection proof that passes `is_aggregator` test - api.validator.submitBeaconCommitteeSelections.mockResolvedValue({ - response: {data: [{validatorIndex: 0, slot: 0, selectionProof: Buffer.alloc(1, 0x10)}]}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.submitBeaconCommitteeSelections.mockResolvedValue( + mockApiResponse({data: [{validatorIndex: 0, slot: 0, selectionProof: Buffer.alloc(1, 0x10)}]}) + ); // Accept all subscriptions - api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue(mockApiResponse({})); } // Mock signing service @@ -152,7 +127,7 @@ describe("AttestationService", function () { selectionProof: ZERO_HASH, }; expect(api.validator.submitBeaconCommitteeSelections).toHaveBeenCalledOnce(); - expect(api.validator.submitBeaconCommitteeSelections).toHaveBeenCalledWith([selection]); + expect(api.validator.submitBeaconCommitteeSelections).toHaveBeenCalledWith({selections: [selection]}); // Must resubscribe validator as aggregator on beacon committee subnet const subscription: routes.validator.BeaconCommitteeSubscription = { @@ -163,16 +138,16 @@ describe("AttestationService", function () { isAggregator: true, }; expect(api.validator.prepareBeaconCommitteeSubnet).toHaveBeenCalledOnce(); - expect(api.validator.prepareBeaconCommitteeSubnet).toHaveBeenCalledWith([subscription]); + expect(api.validator.prepareBeaconCommitteeSubnet).toHaveBeenCalledWith({subscriptions: [subscription]}); } // Must submit the attestation received through produceAttestationData() expect(api.beacon.submitPoolAttestations).toHaveBeenCalledOnce(); - expect(api.beacon.submitPoolAttestations).toHaveBeenCalledWith([attestation]); + expect(api.beacon.submitPoolAttestations).toHaveBeenCalledWith({signedAttestations: [attestation]}); // Must submit the aggregate received through getAggregatedAttestation() then createAndSignAggregateAndProof() expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledOnce(); - expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledWith([aggregate]); + expect(api.validator.publishAggregateAndProofs).toHaveBeenCalledWith({signedAggregateAndProofs: [aggregate]}); }); }); } diff --git a/packages/validator/test/unit/services/attestationDuties.test.ts b/packages/validator/test/unit/services/attestationDuties.test.ts index 34924a4c3170..f7154a3a174e 100644 --- a/packages/validator/test/unit/services/attestationDuties.test.ts +++ b/packages/validator/test/unit/services/attestationDuties.test.ts @@ -3,12 +3,12 @@ import {toBufferBE} from "bigint-buffer"; import bls from "@chainsafe/bls"; import {toHexString} from "@chainsafe/ssz"; import {chainConfig} from "@lodestar/config/default"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {ssz} from "@lodestar/types"; import {computeEpochAtSlot} from "@lodestar/state-transition"; import {AttestationDutiesService} from "../../../src/services/attestationDuties.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {initValidatorStore} from "../../utils/validatorStore.js"; @@ -55,11 +55,9 @@ describe("AttestationDutiesService", function () { index, validator: {...defaultValidator.validator, pubkey: pubkeys[0]}, }; - api.beacon.getStateValidators.mockResolvedValue({ - response: {data: [validatorResponse], executionOptimistic: false, finalized: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.getStateValidators.mockResolvedValue( + mockApiResponse({data: [validatorResponse], meta: {executionOptimistic: false, finalized: false}}) + ); // Reply with some duties const slot = 1; @@ -73,18 +71,12 @@ describe("AttestationDutiesService", function () { validatorIndex: index, pubkey: pubkeys[0], }; - api.validator.getAttesterDuties.mockResolvedValue({ - response: {dependentRoot: ZERO_HASH_HEX, data: [duty], executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getAttesterDuties.mockResolvedValue( + mockApiResponse({data: [duty], meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); // Accept all subscriptions - api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue(mockApiResponse({})); // Clock will call runAttesterDutiesTasks() immediately const clock = new ClockMock(); @@ -121,11 +113,9 @@ describe("AttestationDutiesService", function () { index, validator: {...defaultValidator.validator, pubkey: pubkeys[0]}, }; - api.beacon.getStateValidators.mockResolvedValue({ - response: {data: [validatorResponse], executionOptimistic: false, finalized: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.getStateValidators.mockResolvedValue( + mockApiResponse({data: [validatorResponse], meta: {executionOptimistic: false, finalized: false}}) + ); // Reply with some duties const slot = 1; @@ -138,18 +128,12 @@ describe("AttestationDutiesService", function () { validatorIndex: index, pubkey: pubkeys[0], }; - api.validator.getAttesterDuties.mockResolvedValue({ - response: {data: [duty], dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getAttesterDuties.mockResolvedValue( + mockApiResponse({data: [duty], meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); // Accept all subscriptions - api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue({ - ok: true, - status: HttpStatusCode.OK, - response: undefined, - }); + api.validator.prepareBeaconCommitteeSubnet.mockResolvedValue(mockApiResponse({})); // Clock will call runAttesterDutiesTasks() immediately const clock = new ClockMock(); diff --git a/packages/validator/test/unit/services/block.test.ts b/packages/validator/test/unit/services/block.test.ts index bcfc57eb8674..6864e62906d1 100644 --- a/packages/validator/test/unit/services/block.test.ts +++ b/packages/validator/test/unit/services/block.test.ts @@ -5,11 +5,11 @@ import {createChainForkConfig} from "@lodestar/config"; import {config as mainnetConfig} from "@lodestar/config/default"; import {sleep} from "@lodestar/utils"; import {ssz, ProducedBlockSource} from "@lodestar/types"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {ForkName} from "@lodestar/params"; import {BlockProposingService} from "../../../src/services/block.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {ZERO_HASH_HEX} from "../../utils/types.js"; @@ -39,15 +39,12 @@ describe("BlockDutiesService", function () { it("Should produce, sign, and publish a block", async function () { // Reply with some duties const slot = 0; // genesisTime is right now, so test with slot = currentSlot - api.validator.getProposerDuties.mockResolvedValue({ - response: { - dependentRoot: ZERO_HASH_HEX, - executionOptimistic: false, - data: [{slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}], - }, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({ + data: [{slot, validatorIndex: 0, pubkey: pubkeys[0]}], + meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}, + }) + ); const clock = new ClockMock(); // use produceBlockV3 @@ -71,19 +68,19 @@ describe("BlockDutiesService", function () { validatorStore.getFeeRecipient.mockReturnValue("0x00"); validatorStore.strictFeeRecipientCheck.mockReturnValue(false); - api.validator.produceBlockV3.mockResolvedValue({ - response: { + api.validator.produceBlockV3.mockResolvedValue( + mockApiResponse({ data: signedBlock.message, - version: ForkName.bellatrix, - executionPayloadValue: BigInt(1), - consensusBlockValue: BigInt(1), - executionPayloadBlinded: false, - executionPayloadSource: ProducedBlockSource.engine, - }, - ok: true, - status: HttpStatusCode.OK, - }); - api.beacon.publishBlockV2.mockResolvedValue({ok: true, status: HttpStatusCode.OK, response: undefined}); + meta: { + version: ForkName.bellatrix, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(1), + executionPayloadBlinded: false, + executionPayloadSource: ProducedBlockSource.engine, + }, + }) + ); + api.beacon.publishBlockV2.mockResolvedValue(mockApiResponse({})); // Trigger block production for slot 1 const notifyBlockProductionFn = blockService["dutiesService"]["notifyBlockProductionFn"]; @@ -95,17 +92,16 @@ describe("BlockDutiesService", function () { // Must have submitted the block received on signBlock() expect(api.beacon.publishBlockV2).toHaveBeenCalledOnce(); expect(api.beacon.publishBlockV2.mock.calls[0]).toEqual([ - signedBlock, - {broadcastValidation: routes.beacon.BroadcastValidation.consensus}, + {signedBlockOrContents: signedBlock, broadcastValidation: routes.beacon.BroadcastValidation.consensus}, ]); // ProduceBlockV3 is called with all correct arguments expect(api.validator.produceBlockV3.mock.calls[0]).toEqual([ - 1, - signedBlock.message.body.randaoReveal, - "aaaa", - false, { + slot: 1, + randaoReveal: signedBlock.message.body.randaoReveal, + graffiti: "aaaa", + skipRandaoVerification: false, feeRecipient: "0x00", builderSelection: routes.validator.BuilderSelection.MaxProfit, strictFeeRecipientCheck: false, @@ -118,15 +114,12 @@ describe("BlockDutiesService", function () { it("Should produce, sign, and publish a blinded block", async function () { // Reply with some duties const slot = 0; // genesisTime is right now, so test with slot = currentSlot - api.validator.getProposerDuties.mockResolvedValue({ - response: { - dependentRoot: ZERO_HASH_HEX, - executionOptimistic: false, - data: [{slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}], - }, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({ + data: [{slot, validatorIndex: 0, pubkey: pubkeys[0]}], + meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}, + }) + ); const clock = new ClockMock(); // use produceBlockV3 @@ -142,19 +135,19 @@ describe("BlockDutiesService", function () { message: block, signature: signedBlock.signature, })); - api.validator.produceBlockV3.mockResolvedValue({ - response: { + api.validator.produceBlockV3.mockResolvedValue( + mockApiResponse({ data: signedBlock.message, - version: ForkName.bellatrix, - executionPayloadValue: BigInt(1), - consensusBlockValue: BigInt(1), - executionPayloadBlinded: true, - executionPayloadSource: ProducedBlockSource.engine, - }, - ok: true, - status: HttpStatusCode.OK, - }); - api.beacon.publishBlindedBlockV2.mockResolvedValue({ok: true, status: HttpStatusCode.OK, response: undefined}); + meta: { + version: ForkName.bellatrix, + executionPayloadValue: BigInt(1), + consensusBlockValue: BigInt(1), + executionPayloadBlinded: true, + executionPayloadSource: ProducedBlockSource.engine, + }, + }) + ); + api.beacon.publishBlindedBlockV2.mockResolvedValue(mockApiResponse({})); // Trigger block production for slot 1 const notifyBlockProductionFn = blockService["dutiesService"]["notifyBlockProductionFn"]; @@ -166,8 +159,7 @@ describe("BlockDutiesService", function () { // Must have submitted the block received on signBlock() expect(api.beacon.publishBlindedBlockV2).toHaveBeenCalledOnce(); expect(api.beacon.publishBlindedBlockV2.mock.calls[0]).toEqual([ - signedBlock, - {broadcastValidation: routes.beacon.BroadcastValidation.consensus}, + {signedBlindedBlock: signedBlock, broadcastValidation: routes.beacon.BroadcastValidation.consensus}, ]); }); }); diff --git a/packages/validator/test/unit/services/blockDuties.test.ts b/packages/validator/test/unit/services/blockDuties.test.ts index 45dd99a80e77..c1edcc955b2d 100644 --- a/packages/validator/test/unit/services/blockDuties.test.ts +++ b/packages/validator/test/unit/services/blockDuties.test.ts @@ -2,20 +2,17 @@ import {describe, it, expect, beforeAll, beforeEach, afterEach, vi} from "vitest import {toBufferBE} from "bigint-buffer"; import bls from "@chainsafe/bls"; import {toHexString} from "@chainsafe/ssz"; -import {RootHex} from "@lodestar/types"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {chainConfig} from "@lodestar/config/default"; import {toHex} from "@lodestar/utils"; import {BlockDutiesService} from "../../../src/services/blockDuties.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {initValidatorStore} from "../../utils/validatorStore.js"; import {ZERO_HASH_HEX} from "../../utils/types.js"; -type ProposerDutiesRes = {dependentRoot: RootHex; data: routes.validator.ProposerDuty[]}; - describe("BlockDutiesService", function () { const api = getApiClientStub(); let validatorStore: ValidatorStore; @@ -36,15 +33,11 @@ describe("BlockDutiesService", function () { it("Should fetch and persist block duties", async function () { // Reply with some duties const slot = 0; // genesisTime is right now, so test with slot = currentSlot - const duties: ProposerDutiesRes = { - dependentRoot: ZERO_HASH_HEX, - data: [{slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}], - }; - api.validator.getProposerDuties.mockResolvedValue({ - response: {...duties, executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + const duties: routes.validator.ProposerDutyList = [{slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}]; + + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({data: duties, meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); const notifyBlockProductionFn = vi.fn(); // Returns void @@ -63,7 +56,7 @@ describe("BlockDutiesService", function () { await clock.tickSlotFns(0, controller.signal); // Duties for this epoch should be persisted - expect(Object.fromEntries(dutiesService["proposers"])).toEqual({0: duties}); + expect(Object.fromEntries(dutiesService["proposers"])).toEqual({0: {dependentRoot: ZERO_HASH_HEX, data: duties}}); expect(dutiesService.getblockProposersAtSlot(slot)).toEqual([pubkeys[0]]); @@ -73,14 +66,8 @@ describe("BlockDutiesService", function () { it("Should call notifyBlockProductionFn again on duties re-org", async () => { // A re-org will happen at slot 1 const dependentRootDiff = toHex(Buffer.alloc(32, 1)); - const dutiesBeforeReorg: ProposerDutiesRes = { - dependentRoot: ZERO_HASH_HEX, - data: [{slot: 1, validatorIndex: 0, pubkey: pubkeys[0]}], - }; - const dutiesAfterReorg: ProposerDutiesRes = { - dependentRoot: dependentRootDiff, - data: [{slot: 1, validatorIndex: 1, pubkey: pubkeys[1]}], - }; + const dutiesBeforeReorg: routes.validator.ProposerDutyList = [{slot: 1, validatorIndex: 0, pubkey: pubkeys[0]}]; + const dutiesAfterReorg: routes.validator.ProposerDutyList = [{slot: 1, validatorIndex: 1, pubkey: pubkeys[1]}]; const notifyBlockProductionFn = vi.fn(); // Returns void @@ -97,23 +84,21 @@ describe("BlockDutiesService", function () { ); // Trigger clock onSlot for slot 0 - api.validator.getProposerDuties.mockResolvedValue({ - response: {...dutiesBeforeReorg, executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({data: dutiesBeforeReorg, meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); await clock.tickSlotFns(0, controller.signal); // Trigger clock onSlot for slot 1 - Return different duties for slot 1 - api.validator.getProposerDuties.mockResolvedValue({ - response: {...dutiesAfterReorg, executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({data: dutiesAfterReorg, meta: {dependentRoot: dependentRootDiff, executionOptimistic: false}}) + ); await clock.tickSlotFns(1, controller.signal); // Should persist the dutiesAfterReorg - expect(Object.fromEntries(dutiesService["proposers"])).toEqual({0: dutiesAfterReorg}); + expect(Object.fromEntries(dutiesService["proposers"])).toEqual({ + 0: {dependentRoot: dependentRootDiff, data: dutiesAfterReorg}, + }); expect(notifyBlockProductionFn).toBeCalledTimes(2); @@ -124,27 +109,19 @@ describe("BlockDutiesService", function () { it("Should remove signer from duty", async function () { // Reply with some duties const slot = 0; // genesisTime is right now, so test with slot = currentSlot - const duties: ProposerDutiesRes = { - dependentRoot: ZERO_HASH_HEX, - data: [ - {slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}, - {slot: slot, validatorIndex: 1, pubkey: pubkeys[1]}, - {slot: 33, validatorIndex: 2, pubkey: pubkeys[2]}, - ], - }; - - const dutiesRemoved: ProposerDutiesRes = { - dependentRoot: ZERO_HASH_HEX, - data: [ - {slot: slot, validatorIndex: 1, pubkey: pubkeys[1]}, - {slot: 33, validatorIndex: 2, pubkey: pubkeys[2]}, - ], - }; - api.validator.getProposerDuties.mockResolvedValue({ - response: {...duties, executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + const duties: routes.validator.ProposerDutyList = [ + {slot: slot, validatorIndex: 0, pubkey: pubkeys[0]}, + {slot: slot, validatorIndex: 1, pubkey: pubkeys[1]}, + {slot: 33, validatorIndex: 2, pubkey: pubkeys[2]}, + ]; + const dutiesRemoved: routes.validator.ProposerDutyList = [ + {slot: slot, validatorIndex: 1, pubkey: pubkeys[1]}, + {slot: 33, validatorIndex: 2, pubkey: pubkeys[2]}, + ]; + + api.validator.getProposerDuties.mockResolvedValue( + mockApiResponse({data: duties, meta: {dependentRoot: ZERO_HASH_HEX, executionOptimistic: false}}) + ); const notifyBlockProductionFn = vi.fn(); // Returns void @@ -164,12 +141,18 @@ describe("BlockDutiesService", function () { await clock.tickSlotFns(32, controller.signal); // first confirm the duties for the epochs was persisted - expect(Object.fromEntries(dutiesService["proposers"])).toEqual({0: duties, 1: duties}); + expect(Object.fromEntries(dutiesService["proposers"])).toEqual({ + 0: {dependentRoot: ZERO_HASH_HEX, data: duties}, + 1: {dependentRoot: ZERO_HASH_HEX, data: duties}, + }); // then remove a signers public key dutiesService.removeDutiesForKey(toHexString(pubkeys[0])); // confirm that the duties no longer contain the signers public key - expect(Object.fromEntries(dutiesService["proposers"])).toEqual({0: dutiesRemoved, 1: dutiesRemoved}); + expect(Object.fromEntries(dutiesService["proposers"])).toEqual({ + 0: {dependentRoot: ZERO_HASH_HEX, data: dutiesRemoved}, + 1: {dependentRoot: ZERO_HASH_HEX, data: dutiesRemoved}, + }); }); }); diff --git a/packages/validator/test/unit/services/doppelganger.test.ts b/packages/validator/test/unit/services/doppelganger.test.ts index b3943f619494..9b669b3c9396 100644 --- a/packages/validator/test/unit/services/doppelganger.test.ts +++ b/packages/validator/test/unit/services/doppelganger.test.ts @@ -2,10 +2,11 @@ import {describe, it, expect} from "vitest"; import {Epoch, Slot, ValidatorIndex} from "@lodestar/types"; import {sleep} from "@lodestar/utils"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Api, HttpStatusCode} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; import {DoppelgangerService, DoppelgangerStatus} from "../../../src/services/doppelgangerService.js"; import {IndicesService} from "../../../src/services/indices.js"; import {SlashingProtectionMock} from "../../utils/slashingProtectionMock.js"; +import {mockApiResponse} from "../../utils/apiStub.js"; import {testLogger} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; @@ -193,26 +194,22 @@ class MapDef extends Map { type LivenessMap = Map>; -function getMockBeaconApi(livenessMap: LivenessMap): Api { +function getMockBeaconApi(livenessMap: LivenessMap): ApiClient { return { validator: { - async getLiveness(epoch, validatorIndices) { - return { - response: { - data: validatorIndices.map((index) => { - const livenessEpoch = livenessMap.get(epoch); - if (!livenessEpoch) throw Error(`Unknown epoch ${epoch}`); - const isLive = livenessEpoch.get(index); - if (isLive === undefined) throw Error(`No liveness for epoch ${epoch} index ${index}`); - return {index, isLive}; - }), - }, - ok: true, - status: HttpStatusCode.OK, - }; + async getLiveness({epoch, indices}) { + return mockApiResponse({ + data: indices.map((index) => { + const livenessEpoch = livenessMap.get(epoch); + if (!livenessEpoch) throw Error(`Unknown epoch ${epoch}`); + const isLive = livenessEpoch.get(index); + if (isLive === undefined) throw Error(`No liveness for epoch ${epoch} index ${index}`); + return {index, isLive}; + }), + }); }, - } as Partial, - } as Partial as Api; + } as Partial, + } as Partial as ApiClient; } class ClockMockMsToSlot extends ClockMock { diff --git a/packages/validator/test/unit/services/syncCommitteDuties.test.ts b/packages/validator/test/unit/services/syncCommitteDuties.test.ts index 75e72cb7a36c..c44360485d09 100644 --- a/packages/validator/test/unit/services/syncCommitteDuties.test.ts +++ b/packages/validator/test/unit/services/syncCommitteDuties.test.ts @@ -5,7 +5,7 @@ import bls from "@chainsafe/bls"; import {toHexString} from "@chainsafe/ssz"; import {createChainForkConfig} from "@lodestar/config"; import {config as mainnetConfig} from "@lodestar/config/default"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {ssz} from "@lodestar/types"; import { SyncCommitteeDutiesService, @@ -13,7 +13,7 @@ import { SyncDutySubnet, } from "../../../src/services/syncCommitteeDuties.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {initValidatorStore} from "../../utils/validatorStore.js"; @@ -59,11 +59,9 @@ describe("SyncCommitteeDutiesService", function () { index: indices[i], validator: {...defaultValidator.validator, pubkey: pubkeys[i]}, })); - api.beacon.getStateValidators.mockResolvedValue({ - response: {data: validatorResponses, executionOptimistic: false, finalized: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.getStateValidators.mockResolvedValue( + mockApiResponse({data: validatorResponses, meta: {executionOptimistic: false, finalized: false}}) + ); }); afterEach(() => controller.abort()); @@ -75,18 +73,12 @@ describe("SyncCommitteeDutiesService", function () { validatorIndex: indices[0], validatorSyncCommitteeIndices: [7], }; - api.validator.getSyncCommitteeDuties.mockResolvedValue({ - response: {data: [duty], executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.validator.getSyncCommitteeDuties.mockResolvedValue( + mockApiResponse({data: [duty], meta: {executionOptimistic: false}}) + ); // Accept all subscriptions - api.validator.prepareSyncCommitteeSubnets.mockResolvedValue({ - ok: true, - status: HttpStatusCode.OK, - response: undefined, - }); + api.validator.prepareSyncCommitteeSubnets.mockResolvedValue(mockApiResponse({})); // Clock will call runAttesterDutiesTasks() immediately const clock = new ClockMock(); @@ -133,23 +125,23 @@ describe("SyncCommitteeDutiesService", function () { validatorSyncCommitteeIndices: [7], }; when(api.validator.getSyncCommitteeDuties) - .calledWith(0, expect.any(Array)) - .thenResolve({response: {data: [duty], executionOptimistic: false}, ok: true, status: HttpStatusCode.OK}); + .calledWith({epoch: 0, indices}) + .thenResolve(mockApiResponse({data: [duty], meta: {executionOptimistic: false}})); // sync period 1 should all return empty when(api.validator.getSyncCommitteeDuties) - .calledWith(256, expect.any(Array)) - .thenResolve({response: {data: [], executionOptimistic: false}, ok: true, status: HttpStatusCode.OK}); + .calledWith({epoch: 256, indices}) + .thenResolve(mockApiResponse({data: [], meta: {executionOptimistic: false}})); when(api.validator.getSyncCommitteeDuties) - .calledWith(257, expect.any(Array)) - .thenResolve({response: {data: [], executionOptimistic: false}, ok: true, status: HttpStatusCode.OK}); + .calledWith({epoch: 257, indices}) + .thenResolve(mockApiResponse({data: [], meta: {executionOptimistic: false}})); const duty2: routes.validator.SyncDuty = { pubkey: pubkeys[1], validatorIndex: indices[1], validatorSyncCommitteeIndices: [5], }; when(api.validator.getSyncCommitteeDuties) - .calledWith(1, expect.any(Array)) - .thenResolve({response: {data: [duty2], executionOptimistic: false}, ok: true, status: HttpStatusCode.OK}); + .calledWith({epoch: 1, indices}) + .thenResolve(mockApiResponse({data: [duty2], meta: {executionOptimistic: false}})); // Clock will call runAttesterDutiesTasks() immediately const clock = new ClockMock(); @@ -197,15 +189,12 @@ describe("SyncCommitteeDutiesService", function () { validatorSyncCommitteeIndices: [7], }; when(api.validator.getSyncCommitteeDuties) - .calledWith(expect.any(Number), expect.any(Array)) - .thenResolve({response: {data: [duty1, duty2], executionOptimistic: false}, ok: true, status: HttpStatusCode.OK}); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + .calledWith({epoch: expect.any(Number), indices}) + .thenResolve(mockApiResponse({data: [duty1, duty2], meta: {executionOptimistic: false}})); // Accept all subscriptions - api.validator.prepareSyncCommitteeSubnets.mockResolvedValue({ - ok: true, - status: HttpStatusCode.OK, - response: undefined, - }); + api.validator.prepareSyncCommitteeSubnets.mockResolvedValue(mockApiResponse({})); // Clock will call runAttesterDutiesTasks() immediately const clock = new ClockMock(); diff --git a/packages/validator/test/unit/services/syncCommittee.test.ts b/packages/validator/test/unit/services/syncCommittee.test.ts index 57870da94dc3..b6cba32fc96b 100644 --- a/packages/validator/test/unit/services/syncCommittee.test.ts +++ b/packages/validator/test/unit/services/syncCommittee.test.ts @@ -4,11 +4,11 @@ import {toHexString} from "@chainsafe/ssz"; import {createChainForkConfig} from "@lodestar/config"; import {config as mainnetConfig} from "@lodestar/config/default"; import {ssz} from "@lodestar/types"; -import {HttpStatusCode, routes} from "@lodestar/api"; +import {routes} from "@lodestar/api"; import {SyncCommitteeService, SyncCommitteeServiceOpts} from "../../../src/services/syncCommittee.js"; import {SyncDutyAndProofs} from "../../../src/services/syncCommitteeDuties.js"; import {ValidatorStore} from "../../../src/services/validatorStore.js"; -import {getApiClientStub} from "../../utils/apiStub.js"; +import {getApiClientStub, mockApiResponse} from "../../utils/apiStub.js"; import {loggerVc} from "../../utils/logger.js"; import {ClockMock} from "../../utils/clock.js"; import {ChainHeaderTracker} from "../../../src/services/chainHeaderTracker.js"; @@ -97,16 +97,12 @@ describe("SyncCommitteeService", function () { ]; // Return empty replies to duties service - api.beacon.getStateValidators.mockResolvedValue({ - response: {data: [], executionOptimistic: false, finalized: false}, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.getSyncCommitteeDuties.mockResolvedValue({ - response: {data: [], executionOptimistic: false}, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.getStateValidators.mockResolvedValue( + mockApiResponse({data: [], meta: {executionOptimistic: false, finalized: false}}) + ); + api.validator.getSyncCommitteeDuties.mockResolvedValue( + mockApiResponse({data: [], meta: {executionOptimistic: false}}) + ); // Mock duties service to return some duties directly vi.spyOn(syncCommitteeService["dutiesService"], "getDutiesAtSlot").mockResolvedValue(duties); @@ -114,32 +110,18 @@ describe("SyncCommitteeService", function () { // Mock beacon's sync committee and contribution routes chainHeaderTracker.getCurrentChainHead.mockReturnValue(beaconBlockRoot); - api.beacon.submitPoolSyncCommitteeSignatures.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.produceSyncCommitteeContribution.mockResolvedValue({ - response: {data: contribution}, - ok: true, - status: HttpStatusCode.OK, - }); - api.validator.publishContributionAndProofs.mockResolvedValue({ - response: undefined, - ok: true, - status: HttpStatusCode.OK, - }); + api.beacon.submitPoolSyncCommitteeSignatures.mockResolvedValue(mockApiResponse({})); + api.validator.produceSyncCommitteeContribution.mockResolvedValue(mockApiResponse({data: contribution})); + api.validator.publishContributionAndProofs.mockResolvedValue(mockApiResponse({})); if (opts.distributedAggregationSelection) { // Mock distributed validator middleware client selections endpoint // and return a selection proof that passes `is_sync_committee_aggregator` test - api.validator.submitSyncCommitteeSelections.mockResolvedValue({ - response: { + api.validator.submitSyncCommitteeSelections.mockResolvedValue( + mockApiResponse({ data: [{validatorIndex: 0, slot: 0, subcommitteeIndex: 0, selectionProof: Buffer.alloc(1, 0x19)}], - }, - ok: true, - status: HttpStatusCode.OK, - }); + }) + ); } // Mock signing service @@ -158,16 +140,20 @@ describe("SyncCommitteeService", function () { selectionProof: ZERO_HASH, }; expect(api.validator.submitSyncCommitteeSelections).toHaveBeenCalledOnce(); - expect(api.validator.submitSyncCommitteeSelections).toHaveBeenCalledWith([selection]); + expect(api.validator.submitSyncCommitteeSelections).toHaveBeenCalledWith({selections: [selection]}); } // Must submit the signature received through signSyncCommitteeSignature() expect(api.beacon.submitPoolSyncCommitteeSignatures).toHaveBeenCalledOnce(); - expect(api.beacon.submitPoolSyncCommitteeSignatures).toHaveBeenCalledWith([syncCommitteeSignature]); + expect(api.beacon.submitPoolSyncCommitteeSignatures).toHaveBeenCalledWith({ + signatures: [syncCommitteeSignature], + }); // Must submit the aggregate received through produceSyncCommitteeContribution() then signContributionAndProof() expect(api.validator.publishContributionAndProofs).toHaveBeenCalledOnce(); - expect(api.validator.publishContributionAndProofs).toHaveBeenCalledWith([contributionAndProof]); + expect(api.validator.publishContributionAndProofs).toHaveBeenCalledWith({ + contributionAndProofs: [contributionAndProof], + }); }); }); } diff --git a/packages/validator/test/utils/apiStub.ts b/packages/validator/test/utils/apiStub.ts index 521abfae171d..0ee39662952f 100644 --- a/packages/validator/test/utils/apiStub.ts +++ b/packages/validator/test/utils/apiStub.ts @@ -1,7 +1,7 @@ import {vi, Mocked} from "vitest"; -import {Api} from "@lodestar/api"; +import {ApiClientMethods, ApiResponse, Endpoint, Endpoints, HttpStatusCode} from "@lodestar/api"; -export function getApiClientStub(): {[K in keyof Api]: Mocked} { +export function getApiClientStub(): {[K in keyof Endpoints]: Mocked>} { return { beacon: { getStateValidators: vi.fn(), @@ -25,5 +25,17 @@ export function getApiClientStub(): {[K in keyof Api]: Mocked} { publishAggregateAndProofs: vi.fn(), submitBeaconCommitteeSelections: vi.fn(), }, - } as unknown as {[K in keyof Api]: Mocked}; + } as unknown as {[K in keyof Endpoints]: Mocked>}; +} + +export function mockApiResponse>({ + data, + meta, +}: (E["return"] extends void ? {data?: never} : {data: E["return"]}) & + (E["meta"] extends void ? {meta?: never} : {meta: E["meta"]})): ApiResponse { + const response = new Response(null, {status: HttpStatusCode.OK}); + const apiResponse = new ApiResponse({} as any, null, response); + apiResponse.value = () => data as T; + apiResponse.meta = () => meta as M; + return apiResponse; } diff --git a/packages/validator/test/utils/validatorStore.ts b/packages/validator/test/utils/validatorStore.ts index 61de1e9371d6..5fe530ea0cfe 100644 --- a/packages/validator/test/utils/validatorStore.ts +++ b/packages/validator/test/utils/validatorStore.ts @@ -1,5 +1,5 @@ import {SecretKey} from "@chainsafe/bls/types"; -import {Api} from "@lodestar/api"; +import {ApiClient} from "@lodestar/api"; import {chainConfig} from "@lodestar/config/default"; import {createBeaconConfig, ChainConfig} from "@lodestar/config"; import {Signer, SignerType, ValidatorStore} from "../../src/index.js"; @@ -13,7 +13,7 @@ import {SlashingProtectionMock} from "./slashingProtectionMock.js"; */ export async function initValidatorStore( secretKeys: SecretKey[], - api: Api, + api: ApiClient, customChainConfig: ChainConfig = chainConfig, valProposerConfig: ValidatorProposerConfig = {defaultConfig: {builder: {}}, proposerConfig: {}} ): Promise {