diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index 9cbadde1cca5..1ac7e81156c8 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -373,8 +373,8 @@ function isAbortedError(e: Error): boolean { function getErrorMessage(errBody: string): string { try { - const errJson = JSON.parse(errBody) as {message: string}; - if (errJson.message) { + const errJson = JSON.parse(errBody) as {message: string; failures?: {index: number; message: string}[]}; + if (errJson.message && !errJson.failures) { return errJson.message; } else { return errBody; 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..6fab3447bee2 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -15,6 +15,7 @@ import { SyncCommitteeError, } from "../../../../chain/errors/index.js"; import {validateGossipFnRetryUnknownRoot} from "../../../../network/processor/gossipHandlers.js"; +import {IndexedError} from "../../errors.js"; export function getBeaconPoolApi({ chain, @@ -52,7 +53,7 @@ export function getBeaconPoolApi({ async submitPoolAttestations(attestations) { const seenTimestampSec = Date.now() / 1000; - const errors: Error[] = []; + const errors: {index: number; error: Error}[] = []; await Promise.all( attestations.map(async (attestation, i) => { @@ -91,7 +92,7 @@ export function getBeaconPoolApi({ return; } - errors.push(e as Error); + errors.push({index: i, error: e as Error}); logger.error(`Error on submitPoolAttestations [${i}]`, logCtx, e as Error); if (e instanceof AttestationError && e.action === GossipAction.REJECT) { chain.persistInvalidSszValue(ssz.phase0.Attestation, attestation, "api_reject"); @@ -100,10 +101,8 @@ export function getBeaconPoolApi({ }) ); - if (errors.length > 1) { - throw Error("Multiple errors on submitPoolAttestations\n" + errors.map((e) => e.message).join("\n")); - } else if (errors.length === 1) { - throw errors[0]; + if (errors.length > 0) { + throw new IndexedError("Some errors submitting attestations", errors); } }, @@ -127,7 +126,7 @@ export function getBeaconPoolApi({ }, async submitPoolBlsToExecutionChange(blsToExecutionChanges) { - const errors: Error[] = []; + const errors: {index: number; error: Error}[] = []; await Promise.all( blsToExecutionChanges.map(async (blsToExecutionChange, i) => { @@ -143,7 +142,7 @@ export function getBeaconPoolApi({ await network.publishBlsToExecutionChange(blsToExecutionChange); } } catch (e) { - errors.push(e as Error); + errors.push({index: i, error: e as Error}); logger.error( `Error on submitPoolBlsToExecutionChange [${i}]`, {validatorIndex: blsToExecutionChange.message.validatorIndex}, @@ -153,10 +152,8 @@ export function getBeaconPoolApi({ }) ); - if (errors.length > 1) { - throw Error("Multiple errors on submitPoolBlsToExecutionChange\n" + errors.map((e) => e.message).join("\n")); - } else if (errors.length === 1) { - throw errors[0]; + if (errors.length > 0) { + throw new IndexedError("Some errors submitting BLS to execution change", errors); } }, @@ -180,7 +177,7 @@ export function getBeaconPoolApi({ // TODO: Fetch states at signature slots const state = chain.getHeadState(); - const errors: Error[] = []; + const errors: {index: number; error: Error}[] = []; await Promise.all( signatures.map(async (signature, i) => { @@ -220,7 +217,7 @@ export function getBeaconPoolApi({ return; } - errors.push(e as Error); + errors.push({index: i, error: e as Error}); logger.debug( `Error on submitPoolSyncCommitteeSignatures [${i}]`, {slot: signature.slot, validatorIndex: signature.validatorIndex}, @@ -233,10 +230,8 @@ export function getBeaconPoolApi({ }) ); - if (errors.length > 1) { - throw Error("Multiple errors on submitPoolSyncCommitteeSignatures\n" + errors.map((e) => e.message).join("\n")); - } else if (errors.length === 1) { - throw errors[0]; + if (errors.length > 0) { + throw new IndexedError("Some errors submitting sync committee signatures", errors); } }, }; diff --git a/packages/beacon-node/src/api/impl/errors.ts b/packages/beacon-node/src/api/impl/errors.ts index cc877de90a7a..13991470ccf8 100644 --- a/packages/beacon-node/src/api/impl/errors.ts +++ b/packages/beacon-node/src/api/impl/errors.ts @@ -35,3 +35,17 @@ export class OnlySupportedByDVT extends ApiError { super(501, "Only supported by distributed validator middleware clients"); } } + +export class IndexedError extends ApiError { + failures: {index: number; message: string}[]; + + constructor(message: string, errors: {index: number; error: Error}[]) { + super(400, message); + + const failures = []; + for (const {index, error} of errors) { + failures.push({index: index, message: error.message}); + } + this.failures = failures; + } +} diff --git a/packages/beacon-node/src/api/rest/base.ts b/packages/beacon-node/src/api/rest/base.ts index f14937a49fa4..c30d8ad22d13 100644 --- a/packages/beacon-node/src/api/rest/base.ts +++ b/packages/beacon-node/src/api/rest/base.ts @@ -5,7 +5,7 @@ import bearerAuthPlugin from "@fastify/bearer-auth"; import {RouteConfig} from "@lodestar/api/beacon/server"; import {ErrorAborted, Gauge, Histogram, Logger} from "@lodestar/utils"; import {isLocalhostIP} from "../../util/ip.js"; -import {ApiError, NodeIsSyncing} from "../impl/errors.js"; +import {ApiError, IndexedError, NodeIsSyncing} from "../impl/errors.js"; import {HttpActiveSocketsTracker, SocketMetrics} from "./activeSockets.js"; export type RestApiServerOpts = { @@ -66,6 +66,14 @@ export class RestApiServer { server.setErrorHandler((err, req, res) => { if (err.validation) { void res.status(400).send(err.validation); + } else if (err instanceof IndexedError) { + // api's returning IndexedError need to formatted in a certain way + const body = { + code: err.statusCode, + message: err.message, + failures: err.failures, + }; + void res.status(err.statusCode).send(body); } else { // Convert our custom ApiError into status code const statusCode = err instanceof ApiError ? err.statusCode : 500; diff --git a/packages/beacon-node/test/e2e/api/impl/beacon/pool/endpoints.test.ts b/packages/beacon-node/test/e2e/api/impl/beacon/pool/endpoints.test.ts new file mode 100644 index 000000000000..0663f0d26aa9 --- /dev/null +++ b/packages/beacon-node/test/e2e/api/impl/beacon/pool/endpoints.test.ts @@ -0,0 +1,95 @@ +import {describe, beforeAll, afterAll, it, expect} from "vitest"; +import {createBeaconConfig} from "@lodestar/config"; +import {chainConfig as chainConfigDef} from "@lodestar/config/default"; +import {Api, getClient} from "@lodestar/api"; +import {ssz} from "@lodestar/types"; +import {LogLevel, testLogger} from "../../../../../utils/logger.js"; +import {getDevBeaconNode} from "../../../../../utils/node/beacon.js"; +import {BeaconNode} from "../../../../../../src/node/nodejs.js"; + +describe("beacon pool api", function () { + const restPort = 9596; + const config = createBeaconConfig(chainConfigDef, Buffer.alloc(32, 0xaa)); + const validatorCount = 512; + + let bn: BeaconNode; + let client: Api["beacon"]; + + beforeAll(async () => { + bn = await getDevBeaconNode({ + params: chainConfigDef, + options: { + sync: {isSingleNode: true}, + network: {allowPublishToZeroPeers: true}, + api: { + rest: { + enabled: true, + port: restPort, + }, + }, + chain: {blsVerifyAllMainThread: true}, + }, + validatorCount, + logger: testLogger("Node-A", {level: LogLevel.info}), + }); + client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config}).beacon; + }); + + afterAll(async () => { + await bn.close(); + }); + + describe("submitPoolAttestations", () => { + it("should return correctly formatted errors responses", async () => { + const attestations = [ssz.phase0.Attestation.defaultValue()]; + const res = await client.submitPoolAttestations(attestations); + + expect(res.ok).toBe(false); + expect(res.status).toBe(400); + + const expectedErrorBody = { + code: 400, + message: "Some errors submitting attestations", + failures: [{index: 0, message: "ATTESTATION_ERROR_NOT_EXACTLY_ONE_AGGREGATION_BIT_SET"}], + }; + const expectedErrorMessage = `Bad Request: ${JSON.stringify(expectedErrorBody)}`; + expect(res.error?.message).toEqual(expectedErrorMessage); + }); + }); + + describe("submitPoolBlsToExecutionChange", () => { + it("should return correctly formatted errors responses", async () => { + const blsToExecutionChanges = [ssz.capella.SignedBLSToExecutionChange.defaultValue()]; + const res = await client.submitPoolBlsToExecutionChange(blsToExecutionChanges); + + expect(res.ok).toBe(false); + expect(res.status).toBe(400); + + const expectedErrorBody = { + code: 400, + message: "Some errors submitting BLS to execution change", + failures: [{index: 0, message: "BLS_TO_EXECUTION_CHANGE_ERROR_INVALID"}], + }; + const expectedErrorMessage = `Bad Request: ${JSON.stringify(expectedErrorBody)}`; + expect(res.error?.message).toEqual(expectedErrorMessage); + }); + }); + + describe("submitPoolSyncCommitteeSignatures", () => { + it("should return correctly formatted errors responses", async () => { + const signatures = [ssz.altair.SyncCommitteeMessage.defaultValue()]; + const res = await client.submitPoolSyncCommitteeSignatures(signatures); + + expect(res.ok).toBe(false); + expect(res.status).toBe(400); + + const expectedErrorBody = { + code: 400, + message: "Some errors submitting sync committee signatures", + failures: [{index: 0, message: "Empty SyncCommitteeCache"}], + }; + const expectedErrorMessage = `Bad Request: ${JSON.stringify(expectedErrorBody)}`; + expect(res.error?.message).toEqual(expectedErrorMessage); + }); + }); +});