diff --git a/yarn-project/circuit-types/package.json b/yarn-project/circuit-types/package.json index b06d89e907c..98fa8212637 100644 --- a/yarn-project/circuit-types/package.json +++ b/yarn-project/circuit-types/package.json @@ -72,7 +72,8 @@ "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", "lodash.times": "^4.3.2", - "tslib": "^2.5.0" + "tslib": "^2.5.0", + "zod": "^3.23.8" }, "devDependencies": { "@jest/globals": "^29.5.0", diff --git a/yarn-project/circuit-types/src/interfaces/index.ts b/yarn-project/circuit-types/src/interfaces/index.ts index 43eec2fc100..06cc98c672e 100644 --- a/yarn-project/circuit-types/src/interfaces/index.ts +++ b/yarn-project/circuit-types/src/interfaces/index.ts @@ -15,3 +15,4 @@ export * from './proving-job.js'; export * from './server_circuit_prover.js'; export * from './sync-status.js'; export * from './world_state.js'; +export * from './prover-node.js'; diff --git a/yarn-project/circuit-types/src/interfaces/prover-node.ts b/yarn-project/circuit-types/src/interfaces/prover-node.ts new file mode 100644 index 00000000000..c2cb376ff29 --- /dev/null +++ b/yarn-project/circuit-types/src/interfaces/prover-node.ts @@ -0,0 +1,45 @@ +import { type Signature } from '@aztec/foundation/eth-signature'; +import { type ApiSchemaFor } from '@aztec/foundation/schemas'; + +import { z } from 'zod'; + +import { EpochProofQuote } from '../prover_coordination/epoch_proof_quote.js'; + +// Required by ts to export the schema of EpochProofQuote +export { type Signature }; + +const EpochProvingJobState = [ + 'initialized', + 'processing', + 'awaiting-prover', + 'publishing-proof', + 'completed', + 'failed', +] as const; + +export type EpochProvingJobState = (typeof EpochProvingJobState)[number]; + +/** JSON RPC public interface to a prover node. */ +export interface ProverNodeApi { + getJobs(): Promise<{ uuid: string; status: EpochProvingJobState }[]>; + + startProof(epochNumber: number): Promise; + + prove(epochNumber: number): Promise; + + sendEpochProofQuote(quote: EpochProofQuote): Promise; +} + +/** Schemas for prover node API functions. */ +export class ProverNodeApiSchema implements ApiSchemaFor { + getJobs = z + .function() + .args() + .returns(z.array(z.object({ uuid: z.string().uuid(), status: z.enum(EpochProvingJobState) }))); + + startProof = z.function().args(z.number()).returns(z.void()); + + prove = z.function().args(z.number()).returns(z.void()); + + sendEpochProofQuote = z.function().args(EpochProofQuote.schema).returns(z.void()); +} diff --git a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.ts b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.ts index be551f48f8d..2e691c4b708 100644 --- a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.ts +++ b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote.ts @@ -1,9 +1,12 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { type Secp256k1Signer, keccak256 } from '@aztec/foundation/crypto'; import { Signature } from '@aztec/foundation/eth-signature'; +import { schemas } from '@aztec/foundation/schemas'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { type FieldsOf } from '@aztec/foundation/types'; +import { z } from 'zod'; + import { Gossipable } from '../p2p/gossipable.js'; import { TopicType, createTopicString } from '../p2p/topic_type.js'; import { EpochProofQuotePayload } from './epoch_proof_quote_payload.js'; @@ -40,8 +43,17 @@ export class EpochProofQuote extends Gossipable { }; } + static get schema() { + return z + .object({ + payload: EpochProofQuotePayload.schema, + signature: schemas.Signature, + }) + .transform(({ payload, signature }) => new EpochProofQuote(payload, signature)); + } + static fromJSON(obj: any) { - return new EpochProofQuote(EpochProofQuotePayload.fromJSON(obj.payload), Signature.from0xString(obj.signature)); + return EpochProofQuote.schema.parse(obj); } // TODO: https://github.com/AztecProtocol/aztec-packages/issues/8911 diff --git a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts index 15086276c06..af38f31e797 100644 --- a/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts +++ b/yarn-project/circuit-types/src/prover_coordination/epoch_proof_quote_payload.ts @@ -1,8 +1,13 @@ -import { EthAddress } from '@aztec/circuits.js'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { schemas } from '@aztec/foundation/schemas'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { type FieldsOf } from '@aztec/foundation/types'; import { inspect } from 'util'; +import { z } from 'zod'; + +// Required so typescript can properly annotate the exported schema +export { type EthAddress }; export class EpochProofQuotePayload { // Cached values @@ -15,7 +20,15 @@ export class EpochProofQuotePayload { public readonly bondAmount: bigint, public readonly prover: EthAddress, public readonly basisPointFee: number, - ) {} + ) { + if (basisPointFee < 0 || basisPointFee > 10000) { + throw new Error(`Invalid basisPointFee ${basisPointFee}`); + } + } + + static empty() { + return new EpochProofQuotePayload(0n, 0n, 0n, EthAddress.ZERO, 0); + } static getFields(fields: FieldsOf) { return [ @@ -68,14 +81,20 @@ export class EpochProofQuotePayload { }; } - static fromJSON(obj: any) { - return new EpochProofQuotePayload( - BigInt(obj.epochToProve), - BigInt(obj.validUntilSlot), - BigInt(obj.bondAmount), - EthAddress.fromString(obj.prover), - obj.basisPointFee, - ); + static get schema() { + return z + .object({ + epochToProve: z.coerce.bigint(), + validUntilSlot: z.coerce.bigint(), + bondAmount: z.coerce.bigint(), + prover: schemas.EthAddress, + basisPointFee: z.number(), + }) + .transform(EpochProofQuotePayload.from); + } + + static fromJSON(obj: any): EpochProofQuotePayload { + return EpochProofQuotePayload.schema.parse(obj); } toViemArgs(): { diff --git a/yarn-project/cli/src/cmds/contracts/parse_parameter_struct.ts b/yarn-project/cli/src/cmds/contracts/parse_parameter_struct.ts index 474028ec0d0..af03828558e 100644 --- a/yarn-project/cli/src/cmds/contracts/parse_parameter_struct.ts +++ b/yarn-project/cli/src/cmds/contracts/parse_parameter_struct.ts @@ -1,5 +1,5 @@ import { type StructType } from '@aztec/foundation/abi'; -import { JsonStringify } from '@aztec/foundation/json-rpc'; +import { jsonStringify } from '@aztec/foundation/json-rpc'; import { type LogFn } from '@aztec/foundation/log'; import { getContractArtifact } from '../../utils/aztec.js'; @@ -23,5 +23,5 @@ export async function parseParameterStruct( } const data = parseStructString(encodedString, parameterAbitype.type as StructType); - log(`\nStruct Data: \n${JsonStringify(data, true)}\n`); + log(`\nStruct Data: \n${jsonStringify(data, true)}\n`); } diff --git a/yarn-project/end-to-end/src/devnet/e2e_smoke.test.ts b/yarn-project/end-to-end/src/devnet/e2e_smoke.test.ts index 522fc09ff09..0614c36ca16 100644 --- a/yarn-project/end-to-end/src/devnet/e2e_smoke.test.ts +++ b/yarn-project/end-to-end/src/devnet/e2e_smoke.test.ts @@ -14,7 +14,7 @@ import { } from '@aztec/aztec.js'; import { DefaultMultiCallEntrypoint } from '@aztec/aztec.js/entrypoint'; import { GasSettings, deriveSigningKey } from '@aztec/circuits.js'; -import { startHttpRpcServer } from '@aztec/foundation/json-rpc/server'; +import { createNamespacedJsonRpcServer, startHttpRpcServer } from '@aztec/foundation/json-rpc/server'; import { type DebugLogger } from '@aztec/foundation/log'; import { promiseWithResolvers } from '@aztec/foundation/promise'; import { FeeJuiceContract, TestContract } from '@aztec/noir-contracts.js'; @@ -109,7 +109,8 @@ describe('End-to-end tests for devnet', () => { const localhost = await getLocalhost(); pxeUrl = `http://${localhost}:${port}`; // start a server for the CLI to talk to - const server = startHttpRpcServer('pxe', pxe, createPXERpcServer, port); + const jsonRpcServer = createNamespacedJsonRpcServer([{ pxe: createPXERpcServer(pxe) }]); + const server = await startHttpRpcServer(jsonRpcServer, { port }); teardown = async () => { const { promise, resolve, reject } = promiseWithResolvers(); diff --git a/yarn-project/foundation/package.json b/yarn-project/foundation/package.json index ce43bbd5e85..5354470a61b 100644 --- a/yarn-project/foundation/package.json +++ b/yarn-project/foundation/package.json @@ -40,6 +40,7 @@ "./worker": "./dest/worker/index.js", "./bigint-buffer": "./dest/bigint-buffer/index.js", "./types": "./dest/types/index.js", + "./schemas": "./dest/schemas/index.js", "./url": "./dest/url/index.js", "./committable": "./dest/committable/index.js", "./noir": "./dest/noir/index.js", @@ -114,7 +115,7 @@ "memdown": "^6.1.1", "pako": "^2.1.0", "sha3": "^2.1.4", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@jest/globals": "^29.5.0", diff --git a/yarn-project/foundation/src/eth-address/index.ts b/yarn-project/foundation/src/eth-address/index.ts index 5f28e59f54a..f30e61a0230 100644 --- a/yarn-project/foundation/src/eth-address/index.ts +++ b/yarn-project/foundation/src/eth-address/index.ts @@ -13,13 +13,9 @@ import { TypeRegistry } from '../serialize/type_registry.js'; * and can be serialized/deserialized from a buffer or BufferReader. */ export class EthAddress { - /** - * The size of an Ethereum address in bytes. - */ + /** The size of an Ethereum address in bytes. */ public static SIZE_IN_BYTES = 20; - /** - * Represents a zero Ethereum address with 20 bytes filled with zeros. - */ + /** Represents a zero Ethereum address with 20 bytes filled with zeros. */ public static ZERO = new EthAddress(Buffer.alloc(EthAddress.SIZE_IN_BYTES)); constructor(private buffer: Buffer) { diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts index aa1c7f93665..2a2fd690184 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts @@ -42,6 +42,13 @@ describe('eth signature', () => { expect(sender).toEqual(signer.address); }); + it('should serialize / deserialize to hex string with v=0', () => { + const signature = new Signature(Buffer32.random(), Buffer32.random(), 0, false); + const serialized = signature.to0xString(); + const deserialized = Signature.from0xString(serialized); + checkEquivalence(signature, deserialized); + }); + it('should serialize / deserialize to hex string with 1-digit v', () => { const signature = new Signature(Buffer32.random(), Buffer32.random(), 1, false); const serialized = signature.to0xString(); diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.ts b/yarn-project/foundation/src/eth-signature/eth_signature.ts index 4226deb801c..521cf680e9e 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.ts @@ -45,6 +45,10 @@ export class Signature { return new Signature(r, s, v, isEmpty); } + static isValid0xString(sig: `0x${string}`): boolean { + return /^0x[0-9a-f]{129,}$/i.test(sig); + } + /** * A seperate method exists for this as when signing locally with viem, as when * parsing from viem, we can expect the v value to be a u8, rather than our @@ -106,4 +110,8 @@ export class Signature { isEmpty: this.isEmpty, }; } + + toJSON() { + return this.to0xString(); + } } diff --git a/yarn-project/foundation/src/json-rpc/client/index.ts b/yarn-project/foundation/src/json-rpc/client/index.ts index 7e348e5323f..3df1583012a 100644 --- a/yarn-project/foundation/src/json-rpc/client/index.ts +++ b/yarn-project/foundation/src/json-rpc/client/index.ts @@ -1 +1,2 @@ export { createJsonRpcClient, defaultFetch, makeFetch } from './json_rpc_client.js'; +export * from './safe_json_rpc_client.js'; diff --git a/yarn-project/foundation/src/json-rpc/client/json_rpc_client.ts b/yarn-project/foundation/src/json-rpc/client/json_rpc_client.ts index 6cf3761ed83..e7b1bd7d077 100644 --- a/yarn-project/foundation/src/json-rpc/client/json_rpc_client.ts +++ b/yarn-project/foundation/src/json-rpc/client/json_rpc_client.ts @@ -8,11 +8,12 @@ import { format } from 'util'; import { type DebugLogger, createDebugLogger } from '../../log/index.js'; import { NoRetryError, makeBackoff, retry } from '../../retry/index.js'; import { ClassConverter, type JsonClassConverterInput, type StringClassConverterInput } from '../class_converter.js'; -import { JsonStringify, convertFromJsonObj, convertToJsonObj } from '../convert.js'; +import { convertFromJsonObj, convertToJsonObj, jsonStringify } from '../convert.js'; -export { JsonStringify } from '../convert.js'; +export { jsonStringify } from '../convert.js'; const log = createDebugLogger('json-rpc:json_rpc_client'); + /** * A normal fetch function that does not retry. * Alternatives are a fetch function with retries, or a mocked fetch. @@ -29,19 +30,20 @@ export async function defaultFetch( body: any, useApiEndpoints: boolean, noRetry = false, + stringify = jsonStringify, ) { log.debug(format(`JsonRpcClient.fetch`, host, rpcMethod, '->', body)); let resp: Response; if (useApiEndpoints) { resp = await fetch(`${host}/${rpcMethod}`, { method: 'POST', - body: JsonStringify(body), + body: stringify(body), headers: { 'content-type': 'application/json' }, }); } else { resp = await fetch(host, { method: 'POST', - body: JsonStringify({ ...body, method: rpcMethod }), + body: stringify({ ...body, method: rpcMethod }), headers: { 'content-type': 'application/json' }, }); } @@ -55,6 +57,7 @@ export async function defaultFetch( } throw new Error(`Failed to parse body as JSON: ${resp.text()}`); } + if (!resp.ok) { const errorMessage = `(JSON-RPC PROPAGATED) (host ${host}) (method ${rpcMethod}) (code ${resp.status}) ${responseJson.error.message}`; if (noRetry || (resp.status >= 400 && resp.status < 500)) { diff --git a/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts b/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts new file mode 100644 index 00000000000..990fdf26917 --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/client/safe_json_rpc_client.ts @@ -0,0 +1,64 @@ +import { format } from 'util'; + +import { createDebugLogger } from '../../log/logger.js'; +import { type ApiSchema, type ApiSchemaFor, schemaHasMethod } from '../../schemas/api.js'; +import { jsonStringify2 } from '../convert.js'; +import { defaultFetch } from './json_rpc_client.js'; + +export { jsonStringify } from '../convert.js'; + +/** + * Creates a Proxy object that delegates over RPC and validates outputs against a given schema. + * The server is expected to be a JsonRpcServer. + * @param host - The host URL. + * @param schema - The api schema to validate returned data against. + * @param useApiEndpoints - Whether to use the API endpoints or the default RPC endpoint. + * @param namespaceMethods - String value (or false/empty) to namespace all methods sent to the server. e.g. 'getInfo' -\> 'pxe_getInfo' + * @param fetch - The fetch implementation to use. + */ +export function createSafeJsonRpcClient( + host: string, + schema: ApiSchemaFor, + useApiEndpoints: boolean = false, + namespaceMethods?: string | false, + fetch = defaultFetch, + log = createDebugLogger('json-rpc:client'), +): T { + let id = 0; + const request = async (methodName: string, params: any[]): Promise => { + if (!schemaHasMethod(schema, methodName)) { + throw new Error(`Unspecified method ${methodName} in client schema`); + } + const method = namespaceMethods ? `${namespaceMethods}_${methodName}` : methodName; + const body = { jsonrpc: '2.0', id: id++, method, params }; + + log.debug(format(`request`, method, params)); + const res = await fetch(host, method, body, useApiEndpoints, undefined, jsonStringify2); + log.debug(format(`result`, method, res)); + + if (res.error) { + throw res.error; + } + // TODO: Why check for string null and undefined? + if ([null, undefined, 'null', 'undefined'].includes(res.result)) { + return; + } + + return (schema as ApiSchema)[methodName].returnType().parse(res.result); + }; + + // Intercept any RPC methods with a proxy + const proxy = new Proxy( + {}, + { + get: (target, method: string) => { + if (['then', 'catch'].includes(method)) { + return Reflect.get(target, method); + } + return (...params: any[]) => request(method, params); + }, + }, + ) as T; + + return proxy; +} diff --git a/yarn-project/foundation/src/json-rpc/convert.ts b/yarn-project/foundation/src/json-rpc/convert.ts index 7e1de2cbd4b..c0c7f200a44 100644 --- a/yarn-project/foundation/src/json-rpc/convert.ts +++ b/yarn-project/foundation/src/json-rpc/convert.ts @@ -46,7 +46,7 @@ export const convertBigintsInObj = (obj: any) => { * @param obj - The object to be stringified. * @returns The resulting string. */ -export function JsonStringify(obj: object, prettify?: boolean): string { +export function jsonStringify(obj: object, prettify?: boolean): string { return JSON.stringify( obj, (key, value) => @@ -60,6 +60,27 @@ export function JsonStringify(obj: object, prettify?: boolean): string { ); } +/** + * JSON.stringify helper that stringifies bigints and buffers. + * @param obj - The object to be stringified. + * @returns The resulting string. + */ +export function jsonStringify2(obj: object, prettify?: boolean): string { + return JSON.stringify( + obj, + (_key, value) => { + if (typeof value === 'bigint') { + return value.toString(); + } else if (typeof value === 'object' && Buffer.isBuffer(value)) { + return value.toString('base64'); + } else { + return value; + } + }, + prettify ? 2 : 0, + ); +} + /** * Convert a JSON-friendly object, which may encode a class object. * @param cc - The class converter. diff --git a/yarn-project/foundation/src/json-rpc/fixtures/test_state.ts b/yarn-project/foundation/src/json-rpc/fixtures/test_state.ts index aad9ce8ee46..6e67d64d785 100644 --- a/yarn-project/foundation/src/json-rpc/fixtures/test_state.ts +++ b/yarn-project/foundation/src/json-rpc/fixtures/test_state.ts @@ -1,3 +1,6 @@ +import { z } from 'zod'; + +import { type ApiSchemaFor, schemas } from '../../schemas/index.js'; import { sleep } from '../../sleep/index.js'; /** @@ -5,6 +8,15 @@ import { sleep } from '../../sleep/index.js'; */ export class TestNote { constructor(private data: string) {} + + static get schema() { + return z.object({ data: z.string() }).transform(({ data }) => new TestNote(data)); + } + + toJSON() { + return { data: this.data }; + } + /** * Create a string representation of this class. * @returns The string representation. @@ -22,13 +34,23 @@ export class TestNote { } } +export interface TestStateApi { + getNote: (index: number) => Promise; + getNotes: () => Promise; + clear: () => Promise; + addNotes: (notes: TestNote[]) => Promise; + fail: () => Promise; + count: () => Promise; + getStatus: () => Promise<{ status: string; count: bigint }>; +} + /** * Represents a simple state management for TestNote instances. * Provides functionality to get a note by index and add notes asynchronously. * Primarily used for testing JSON RPC-related functionalities. */ -export class TestState { - constructor(private notes: TestNote[]) {} +export class TestState implements TestStateApi { + constructor(public notes: TestNote[]) {} /** * Retrieve the TestNote instance at the specified index from the notes array. * This method allows getting a desired TestNote from the collection of notes @@ -37,9 +59,30 @@ export class TestState { * @param index - The index of the TestNote to be retrieved from the notes array. * @returns The TestNote instance corresponding to the given index. */ - getNote(index: number): TestNote { + async getNote(index: number): Promise { + await sleep(0.1); return this.notes[index]; } + + fail(): Promise { + throw new Error('Test state failed'); + } + + async count(): Promise { + await sleep(0.1); + return this.notes.length; + } + + async getNotes(): Promise { + await sleep(0.1); + return this.notes; + } + + async clear(): Promise { + await sleep(0.1); + this.notes = []; + } + /** * Add an array of TestNote instances to the current TestState's notes. * This function simulates asynchronous behavior by waiting for a duration @@ -56,4 +99,24 @@ export class TestState { await sleep(notes.length); return this.notes; } + + async forceClear() { + await sleep(0.1); + this.notes = []; + } + + async getStatus(): Promise<{ status: string; count: bigint }> { + await sleep(0.1); + return { status: 'ok', count: BigInt(this.notes.length) }; + } } + +export const TestStateSchema: ApiSchemaFor = { + getNote: z.function().args(z.number()).returns(TestNote.schema), + getNotes: z.function().returns(z.array(TestNote.schema)), + clear: z.function().returns(z.void()), + addNotes: z.function().args(z.array(TestNote.schema)).returns(z.array(TestNote.schema)), + fail: z.function().returns(z.void()), + count: z.function().returns(z.number()), + getStatus: z.function().returns(z.object({ status: z.string(), count: schemas.BigInt })), +}; diff --git a/yarn-project/foundation/src/json-rpc/index.ts b/yarn-project/foundation/src/json-rpc/index.ts index b02ee1ef753..47cc5f01334 100644 --- a/yarn-project/foundation/src/json-rpc/index.ts +++ b/yarn-project/foundation/src/json-rpc/index.ts @@ -5,4 +5,4 @@ export { ClassConverter, } from './class_converter.js'; -export { JsonStringify } from './convert.js'; +export { jsonStringify } from './convert.js'; diff --git a/yarn-project/foundation/src/json-rpc/js_utils.ts b/yarn-project/foundation/src/json-rpc/js_utils.ts index c24b3d7b897..2766f4cf4b1 100644 --- a/yarn-project/foundation/src/json-rpc/js_utils.ts +++ b/yarn-project/foundation/src/json-rpc/js_utils.ts @@ -1,4 +1,5 @@ // Make sure this property was not inherited +// TODO(palla): Delete this file /** * Does this own the property? diff --git a/yarn-project/foundation/src/json-rpc/server/index.ts b/yarn-project/foundation/src/json-rpc/server/index.ts index a0f5caf72ca..d16e1fca3ff 100644 --- a/yarn-project/foundation/src/json-rpc/server/index.ts +++ b/yarn-project/foundation/src/json-rpc/server/index.ts @@ -1,2 +1,3 @@ export * from './json_rpc_server.js'; +export * from './safe_json_rpc_server.js'; export { JsonProxy } from './json_proxy.js'; diff --git a/yarn-project/foundation/src/json-rpc/server/json_proxy.ts b/yarn-project/foundation/src/json-rpc/server/json_proxy.ts index 348ccc715fa..339d6013b71 100644 --- a/yarn-project/foundation/src/json-rpc/server/json_proxy.ts +++ b/yarn-project/foundation/src/json-rpc/server/json_proxy.ts @@ -25,8 +25,8 @@ export class JsonProxy { classConverter: ClassConverter; constructor( private handler: object, - private stringClassMap: StringClassConverterInput, - private objectClassMap: JsonClassConverterInput, + stringClassMap: StringClassConverterInput, + objectClassMap: JsonClassConverterInput, ) { this.classConverter = new ClassConverter(stringClassMap, objectClassMap); } @@ -57,4 +57,12 @@ export class JsonProxy { log.debug(format('JsonProxy:call', methodName, '->', ret)); return ret; } + + public hasMethod(methodName: string): boolean { + return hasOwnProperty(Object.getPrototypeOf(this.handler), methodName); + } + + public getMethods() { + return Object.getOwnPropertyNames(Object.getPrototypeOf(this.handler)); + } } diff --git a/yarn-project/foundation/src/json-rpc/server/json_rpc_server.ts b/yarn-project/foundation/src/json-rpc/server/json_rpc_server.ts index 6d6833e00bd..5eba75b45f2 100644 --- a/yarn-project/foundation/src/json-rpc/server/json_rpc_server.ts +++ b/yarn-project/foundation/src/json-rpc/server/json_rpc_server.ts @@ -4,6 +4,7 @@ import Koa from 'koa'; import bodyParser from 'koa-bodyparser'; import compress from 'koa-compress'; import Router from 'koa-router'; +import { type AddressInfo } from 'net'; import { createDebugLogger } from '../../log/index.js'; import { promiseWithResolvers } from '../../promise/utils.js'; @@ -110,7 +111,12 @@ export class JsonRpcServer { router.post('/', async (ctx: Koa.Context) => { const { params = [], jsonrpc, id, method } = ctx.request.body as any; // Ignore if not a function - if (method === 'constructor' || typeof proto[method] !== 'function' || this.disallowedMethods.includes(method)) { + if ( + typeof method !== 'string' || + method === 'constructor' || + typeof proto[method] !== 'function' || + this.disallowedMethods.includes(method) + ) { ctx.status = 400; ctx.body = { jsonrpc, @@ -201,7 +207,7 @@ export class JsonRpcServer { /** * Call an RPC method. * @param methodName - The RPC method. - * @param jsonParams - The RPG parameters. + * @param jsonParams - The RPC parameters. * @param skipConversion - Whether to skip conversion of the parameters. * @returns The remote result. */ @@ -234,28 +240,27 @@ export function createStatusRouter(getCurrentStatus: StatusCheckFn, apiPrefix = } /** - * Creates an http server that forwards calls to the underlying instance and starts it on the given port. - * @param instance - Instance to wrap in a JSON-RPC server. - * @param jsonRpcFactoryFunc - Function that wraps the instance in a JSON-RPC server. - * @param port - Port to listen in. + * Wraps a JsonRpcServer in a nodejs http server and starts it. Returns once starts listening. * @returns A running http server. */ -export function startHttpRpcServer( - name: string, - instance: T, - jsonRpcFactoryFunc: (instance: T) => JsonRpcServer, - port: string | number, -): http.Server { - const rpcServer = jsonRpcFactoryFunc(instance); - - const namespacedServer = createNamespacedJsonRpcServer([{ [name]: rpcServer }]); - - const app = namespacedServer.getApp(); +export async function startHttpRpcServer( + rpcServer: Pick, + options: { host?: string; port?: number; apiPrefix?: string; statusCheckFn?: StatusCheckFn } = {}, +): Promise { + const app = rpcServer.getApp(options.apiPrefix); + + if (options.statusCheckFn) { + const statusRouter = createStatusRouter(options.statusCheckFn, options.apiPrefix); + app.use(statusRouter.routes()).use(statusRouter.allowedMethods()); + } const httpServer = http.createServer(app.callback()); - httpServer.listen(port); + const { promise, resolve } = promiseWithResolvers(); + httpServer.listen(options.port ?? 0, options.host ?? '0.0.0.0', () => resolve()); + await promise; - return httpServer; + const port = (httpServer.address() as AddressInfo).port; + return Object.assign(httpServer, { port }); } /** * List of namespace to server instance. @@ -325,7 +330,7 @@ export function createNamespacedJsonRpcServer( Object.create(handler), classMaps.stringClassMap, classMaps.objectClassMap, - [], + disallowedMethods, aggregateHealthCheck, log, ); diff --git a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts new file mode 100644 index 00000000000..28126289cbc --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.test.ts @@ -0,0 +1,135 @@ +import request from 'supertest'; + +import { TestNote, TestState, type TestStateApi, TestStateSchema } from '../fixtures/test_state.js'; +import { + type SafeJsonRpcServer, + createNamespacedSafeJsonRpcServer, + createSafeJsonRpcServer, + makeHandler, +} from './safe_json_rpc_server.js'; + +describe('SafeJsonRpcServer', () => { + let testState: TestState; + let testNotes: TestNote[]; + let server: SafeJsonRpcServer; + + beforeEach(() => { + testNotes = [new TestNote('a'), new TestNote('b')]; + testState = new TestState(testNotes); + }); + + const send = (body: any) => request(server.getApp().callback()).post('/').send(body); + + const expectError = (response: request.Response, httpCode: number, message: string) => { + expect(response.status).toBe(httpCode); + expect(JSON.parse(response.text)).toMatchObject({ error: { message } }); + }; + + describe('single', () => { + beforeEach(() => { + server = createSafeJsonRpcServer(testState, TestStateSchema); + }); + + it('calls an RPC function with a primitive parameter', async () => { + const response = await send({ method: 'getNote', params: [1] }); + expect(response.text).toEqual(JSON.stringify({ result: { data: 'b' } })); + expect(response.status).toBe(200); + }); + + it('calls an RPC function with incorrect parameter type', async () => { + const response = await send({ method: 'getNote', params: [{ index: 1 }] }); + expectError(response, 400, 'Expected number, received object'); + }); + + it('calls an RPC function with a primitive return type', async () => { + const response = await send({ method: 'count', params: [] }); + expect(response.text).toEqual(JSON.stringify({ result: 2 })); + expect(response.status).toBe(200); + }); + + it('calls an RPC function with an array of classes', async () => { + const response = await send({ method: 'addNotes', params: [[{ data: 'c' }, { data: 'd' }]] }); + expect(response.status).toBe(200); + expect(response.text).toBe(JSON.stringify({ result: ['a', 'b', 'c', 'd'].map(data => ({ data })) })); + expect(testState.notes).toEqual([new TestNote('a'), new TestNote('b'), new TestNote('c'), new TestNote('d')]); + expect(testState.notes.every(note => note instanceof TestNote)).toBe(true); + }); + + it('calls an RPC function with no inputs nor outputs', async () => { + const response = await send({ method: 'clear', params: [] }); + expect(response.status).toBe(200); + expect(response.text).toEqual(JSON.stringify({})); + expect(testState.notes).toEqual([]); + }); + + it('calls an RPC function that returns a primitive object and a bigint', async () => { + const response = await send({ method: 'getStatus', params: [] }); + expect(response.status).toBe(200); + expect(response.text).toEqual(JSON.stringify({ result: { status: 'ok', count: '2' } })); + }); + + it('calls an RPC function that throws an error', async () => { + const response = await send({ method: 'fail', params: [] }); + expectError(response, 500, 'Test state failed'); + }); + + it('fails if sends invalid JSON', async () => { + const response = await send('{'); + expectError(response, 400, 'Parse error: Unexpected end of JSON input'); + }); + + it('fails if calls non-existing method in handler', async () => { + const response = await send({ jsonrpc: '2.0', method: 'invalid', params: [], id: 42 }); + expectError(response, 400, 'Method not found: invalid'); + }); + + it('fails if calls method in handler non defined in schema', async () => { + const response = await send({ jsonrpc: '2.0', method: 'forceClear', params: [], id: 42 }); + expectError(response, 400, 'Method not found: forceClear'); + }); + + it('fails if calls base object method', async () => { + const response = await send({ jsonrpc: '2.0', method: 'toString', params: [], id: 42 }); + expectError(response, 400, 'Method not found: toString'); + }); + }); + + describe('namespaced', () => { + let lettersState: TestState; + let numbersState: TestState; + + beforeEach(() => { + lettersState = testState; + numbersState = new TestState([new TestNote('1'), new TestNote('2')]); + server = createNamespacedSafeJsonRpcServer({ + letters: makeHandler(lettersState, TestStateSchema), + numbers: makeHandler(numbersState, TestStateSchema), + }); + }); + + it('routes to the correct namespace', async () => { + const response = await send({ method: 'letters_getNote', params: [1] }); + expect(response.status).toBe(200); + expect(response.text).toEqual(JSON.stringify({ result: { data: 'b' } })); + + const response2 = await send({ method: 'numbers_getNote', params: [1] }); + expect(response2.status).toBe(200); + expect(response2.text).toEqual(JSON.stringify({ result: { data: '2' } })); + }); + + it('fails if namespace is not found', async () => { + const response = await send({ method: 'invalid_getNote', params: [1] }); + expectError(response, 400, 'Method not found: invalid_getNote'); + }); + + it('fails if method is not found in namespace', async () => { + const response = await send({ method: 'letters_invalid', params: [1] }); + expectError(response, 400, 'Method not found: letters_invalid'); + }); + + it('fails if no namespace is provided', async () => { + const response = await send({ method: 'getNote', params: [1] }); + expectError(response, 400, 'Method not found: getNote'); + }); + }); +}); diff --git a/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts new file mode 100644 index 00000000000..7fa2ca14999 --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/server/safe_json_rpc_server.ts @@ -0,0 +1,243 @@ +import cors from '@koa/cors'; +import http from 'http'; +import Koa from 'koa'; +import bodyParser from 'koa-bodyparser'; +import compress from 'koa-compress'; +import Router from 'koa-router'; +import { format } from 'util'; +import { ZodError } from 'zod'; + +import { createDebugLogger } from '../../log/index.js'; +import { promiseWithResolvers } from '../../promise/utils.js'; +import { type ApiSchema, type ApiSchemaFor, schemaHasMethod } from '../../schemas/index.js'; +import { jsonStringify2 } from '../convert.js'; +import { assert } from '../js_utils.js'; + +export class SafeJsonRpcServer { + /** + * The HTTP server accepting remote requests. + * This member field is initialized when the server is started. + */ + private httpServer?: http.Server; + + constructor( + /** The proxy object to delegate requests to. */ + private readonly proxy: Proxy, + /** Logger */ + private log = createDebugLogger('json-rpc:server'), + ) {} + + /** + * Get an express app object. + * @param prefix - Our server prefix. + * @returns The app object. + */ + public getApp(prefix = '') { + const router = this.getRouter(prefix); + + const exceptionHandler = async (ctx: Koa.Context, next: () => Promise) => { + try { + await next(); + } catch (err: any) { + this.log.error(err); + if (err instanceof SyntaxError) { + ctx.status = 400; + ctx.body = { jsonrpc: '2.0', id: null, error: { code: -32700, message: `Parse error: ${err.message}` } }; + } else if (err instanceof ZodError) { + const message = err.issues.map(e => e.message).join(', ') || 'Validation error'; + ctx.status = 400; + ctx.body = { jsonrpc: '2.0', id: null, error: { code: -32701, message } }; + } else { + ctx.status = 500; + ctx.body = { jsonrpc: '2.0', id: null, error: { code: -32600, message: err.message ?? 'Internal error' } }; + } + } + }; + + const jsonResponse = async (ctx: Koa.Context, next: () => Promise) => { + try { + await next(); + if (ctx.body && typeof ctx.body === 'object') { + ctx.body = jsonStringify2(ctx.body); + } + } catch (err: any) { + ctx.status = 500; + ctx.body = { jsonrpc: '2.0', error: { code: -32700, message: `Unable to serialize response: ${err.message}` } }; + } + }; + + const app = new Koa(); + app.on('error', error => { + this.log.error(`Error on API handler: ${error}`); + }); + + app.use(compress({ br: false } as any)); + app.use(jsonResponse); + app.use(exceptionHandler); + app.use(bodyParser({ jsonLimit: '50mb', enableTypes: ['json'], detectJSON: () => true })); + app.use(cors()); + app.use(router.routes()); + app.use(router.allowedMethods()); + + return app; + } + + /** + * Get a router object wrapping our RPC class. + * @param prefix - The server prefix. + * @returns The router object. + */ + private getRouter(prefix: string) { + const router = new Router({ prefix }); + // "JSON RPC mode" where a single endpoint is used and the method is given in the request body + router.post('/', async (ctx: Koa.Context) => { + const { params = [], jsonrpc, id, method } = ctx.request.body as any; + // Fail if not a registered function in the proxy + if (typeof method !== 'string' || method === 'constructor' || !this.proxy.hasMethod(method)) { + ctx.status = 400; + ctx.body = { jsonrpc, id, error: { code: -32601, message: `Method not found: ${method}` } }; + } else { + const result = await this.proxy.call(method, params); + ctx.body = { jsonrpc, id, result }; + ctx.status = 200; + } + }); + + return router; + } + + /** + * Start this server with koa. + * @param port - Port number. + * @param prefix - Prefix string. + */ + public start(port: number, prefix = ''): void { + if (this.httpServer) { + throw new Error('Server is already listening'); + } + + this.httpServer = http.createServer(this.getApp(prefix).callback()); + this.httpServer.listen(port); + } + + /** + * Stops the HTTP server + */ + public stop(): Promise { + if (!this.httpServer) { + return Promise.resolve(); + } + + const { promise, resolve, reject } = promiseWithResolvers(); + this.httpServer.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + return promise; + } + + /** + * Explicitly calls an RPC method. + * @param methodName - The RPC method. + * @param jsonParams - The RPC parameters. + * @returns The remote result. + */ + public async call(methodName: string, jsonParams: any[] = []) { + return await this.proxy.call(methodName, jsonParams); + } +} + +interface Proxy { + hasMethod(methodName: string): boolean; + call(methodName: string, jsonParams?: any[]): Promise; +} + +/** + * Forwards calls to a handler. Relies on a schema definition to validate and convert inputs + * before forwarding calls, and then converts outputs into JSON using default conversions. + */ +export class SafeJsonProxy implements Proxy { + private log = createDebugLogger('json-rpc:proxy'); + private schema: ApiSchema; + + constructor(private handler: T, schema: ApiSchemaFor) { + this.schema = schema; + } + + /** + * Call an RPC method. + * @param methodName - The RPC method. + * @param jsonParams - The RPC parameters. + * @returns The remote result. + */ + public async call(methodName: string, jsonParams: any[] = []) { + this.log.debug(format(`request`, methodName, jsonParams)); + + assert(Array.isArray(jsonParams), `Params to ${methodName} is not an array: ${jsonParams}`); + assert(schemaHasMethod(this.schema, methodName), `Method ${methodName} not found in schema`); + const method = this.handler[methodName as keyof T]; + assert(typeof method === 'function', `Method ${methodName} is not a function`); + + const args = this.schema[methodName].parameters().parse(jsonParams); + const ret = await method.apply(this.handler, args); + this.log.debug(format('response', methodName, ret)); + return ret; + } + + public hasMethod(methodName: string): boolean { + return schemaHasMethod(this.schema, methodName) && typeof this.handler[methodName as keyof T] === 'function'; + } +} + +class NamespacedSafeJsonProxy implements Proxy { + private readonly proxies: Record = {}; + + constructor(handlers: NamespacedApiHandlers) { + for (const [namespace, [handler, schema]] of Object.entries(handlers)) { + this.proxies[namespace] = new SafeJsonProxy(handler, schema); + } + } + + public call(namespacedMethodName: string, jsonParams: any[] = []) { + const [namespace, methodName] = namespacedMethodName.split('_', 2); + assert(namespace && methodName, `Invalid namespaced method name: ${namespacedMethodName}`); + const handler = this.proxies[namespace]; + assert(handler, `Namespace not found: ${namespace}`); + return handler.call(methodName, jsonParams); + } + + public hasMethod(namespacedMethodName: string): boolean { + const [namespace, methodName] = namespacedMethodName.split('_', 2); + const handler = this.proxies[namespace]; + return handler?.hasMethod(methodName); + } +} + +export type NamespacedApiHandlers = Record; + +export type ApiHandler = [T, ApiSchemaFor]; + +export function makeHandler(handler: T, schema: ApiSchemaFor): ApiHandler { + return [handler, schema]; +} + +/** + * Creates a single SafeJsonRpcServer from multiple handlers. + * @param servers - List of handlers to be combined. + * @returns A single JsonRpcServer with namespaced methods. + */ +export function createNamespacedSafeJsonRpcServer( + handlers: NamespacedApiHandlers, + log = createDebugLogger('json-rpc:server'), +): SafeJsonRpcServer { + const proxy = new NamespacedSafeJsonProxy(handlers); + return new SafeJsonRpcServer(proxy, log); +} + +export function createSafeJsonRpcServer(handler: T, schema: ApiSchemaFor) { + const proxy = new SafeJsonProxy(handler, schema); + return new SafeJsonRpcServer(proxy); +} diff --git a/yarn-project/foundation/src/json-rpc/test/integration.test.ts b/yarn-project/foundation/src/json-rpc/test/integration.test.ts new file mode 100644 index 00000000000..d05ceb62a75 --- /dev/null +++ b/yarn-project/foundation/src/json-rpc/test/integration.test.ts @@ -0,0 +1,116 @@ +import { createSafeJsonRpcClient } from '../client/safe_json_rpc_client.js'; +import { TestNote, TestState, type TestStateApi, TestStateSchema } from '../fixtures/test_state.js'; +import { startHttpRpcServer } from '../server/json_rpc_server.js'; +import { + type SafeJsonRpcServer, + createNamespacedSafeJsonRpcServer, + createSafeJsonRpcServer, + makeHandler, +} from '../server/safe_json_rpc_server.js'; + +describe('JsonRpc integration', () => { + let testState: TestState; + let testNotes: TestNote[]; + + let server: SafeJsonRpcServer; + let httpServer: Awaited>; + + beforeEach(() => { + testNotes = [new TestNote('a'), new TestNote('b')]; + testState = new TestState(testNotes); + }); + + afterEach(() => { + httpServer?.close(); + }); + + describe('single', () => { + let client: TestStateApi; + + beforeEach(async () => { + server = createSafeJsonRpcServer(testState, TestStateSchema); + httpServer = await startHttpRpcServer(server, { host: '127.0.0.1' }); + client = createSafeJsonRpcClient(`http://127.0.0.1:${httpServer.port}`, TestStateSchema); + }); + + it('calls an RPC function with a primitive parameter', async () => { + const note = await client.getNote(1); + expect(note).toEqual(testNotes[1]); + expect(note).toBeInstanceOf(TestNote); + }); + + it('calls an RPC function with incorrect parameter type', async () => { + await expect(() => client.getNote('foo' as any)).rejects.toThrow('Expected number, received string'); + }); + + it('calls an RPC function with a primitive return type', async () => { + const count = await client.count(); + expect(count).toBe(2); + }); + + it('calls an RPC function with an array of classes', async () => { + const notes = await client.addNotes(['c', 'd'].map(data => new TestNote(data))); + expect(notes).toEqual(['a', 'b', 'c', 'd'].map(data => new TestNote(data))); + expect(notes.every(note => note instanceof TestNote)).toBe(true); + }); + + it('calls an RPC function with no inputs nor outputs', async () => { + await client.clear(); + expect(testState.notes).toEqual([]); + }); + + it('calls an RPC function that returns a primitive object and a bigint', async () => { + const status = await client.getStatus(); + expect(status).toEqual({ status: 'ok', count: 2n }); + }); + + it('calls an RPC function that throws an error', async () => { + await expect(() => client.fail()).rejects.toThrow('Test state failed'); + }); + + it('fails if calls non-existing method in handler', async () => { + await expect(() => (client as TestState).forceClear()).rejects.toThrow( + 'Unspecified method forceClear in client schema', + ); + }); + }); + + describe('namespaced', () => { + let lettersState: TestState; + let numbersState: TestState; + + let lettersClient: TestStateApi; + let numbersClient: TestStateApi; + + let url: string; + + beforeEach(async () => { + lettersState = testState; + numbersState = new TestState([new TestNote('1'), new TestNote('2')]); + server = createNamespacedSafeJsonRpcServer({ + letters: makeHandler(lettersState, TestStateSchema), + numbers: makeHandler(numbersState, TestStateSchema), + }); + + httpServer = await startHttpRpcServer(server, { host: '127.0.0.1' }); + url = `http://127.0.0.1:${httpServer.port}`; + lettersClient = createSafeJsonRpcClient(url, TestStateSchema, false, 'letters'); + numbersClient = createSafeJsonRpcClient(url, TestStateSchema, false, 'numbers'); + }); + + it('calls correct namespace', async () => { + const note = await lettersClient.getNote(1); + expect(note).toEqual(testNotes[1]); + expect(note).toBeInstanceOf(TestNote); + + const numberNote = await numbersClient.getNote(1); + expect(numberNote).toEqual(new TestNote('2')); + expect(numberNote).toBeInstanceOf(TestNote); + }); + + it('fails if calls without namespace', async () => { + const client = createSafeJsonRpcClient(url, TestStateSchema, false); + await expect(() => client.getNote(1)).rejects.toThrow('Method not found: getNote'); + }); + }); +}); diff --git a/yarn-project/foundation/src/schemas/api.ts b/yarn-project/foundation/src/schemas/api.ts new file mode 100644 index 00000000000..a2e77bf970f --- /dev/null +++ b/yarn-project/foundation/src/schemas/api.ts @@ -0,0 +1,30 @@ +import { type z } from 'zod'; + +type ZodFor = z.ZodType; +type ZodMapTypes = T extends [] + ? [] + : T extends [infer Head, ...infer Rest] + ? [ZodFor, ...ZodMapTypes] + : never; + +/** Maps all functions in an interface to their schema representation. */ +export type ApiSchemaFor = { + [K in keyof T]: T[K] extends (...args: infer Args) => Promise + ? z.ZodFunction, z.ZodUnknown>, ZodFor> + : never; +}; + +/** Generic Api schema not bounded to a specific implementation. */ +export type ApiSchema = { + [key: string]: z.ZodFunction, z.ZodTypeAny>; +}; + +/** Return whether an API schema defines a valid function schema for a given method name. */ +export function schemaHasMethod(schema: ApiSchema, methodName: string) { + return ( + typeof methodName === 'string' && + Object.hasOwn(schema, methodName) && + typeof schema[methodName].parameters === 'function' && + typeof schema[methodName].returnType === 'function' + ); +} diff --git a/yarn-project/foundation/src/schemas/index.ts b/yarn-project/foundation/src/schemas/index.ts new file mode 100644 index 00000000000..a3b3c1a8610 --- /dev/null +++ b/yarn-project/foundation/src/schemas/index.ts @@ -0,0 +1,3 @@ +export * from './api.js'; +export * from './parse.js'; +export * from './schemas.js'; diff --git a/yarn-project/foundation/src/schemas/parse.ts b/yarn-project/foundation/src/schemas/parse.ts new file mode 100644 index 00000000000..d49a690ec13 --- /dev/null +++ b/yarn-project/foundation/src/schemas/parse.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +/** Parses the given arguments using a tuple from the provided schemas. */ +export function parse(args: IArguments, ...schemas: T) { + return z.tuple(schemas).parse(args); +} diff --git a/yarn-project/foundation/src/schemas/schemas.ts b/yarn-project/foundation/src/schemas/schemas.ts new file mode 100644 index 00000000000..2a4a086d9c8 --- /dev/null +++ b/yarn-project/foundation/src/schemas/schemas.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { EthAddress } from '../eth-address/index.js'; +import { Signature } from '../eth-signature/eth_signature.js'; +import { hasHexPrefix } from '../string/index.js'; + +/** + * Validation schemas for common types. Every schema should match its type toJSON. + */ +export const schemas = { + /** Accepts both a 0x string and a structured { type: EthAddress, value: '0x...' } */ + EthAddress: z + .union([ + z.string().refine(EthAddress.isAddress, 'Not a valid Ethereum address'), + z.object({ + type: z.literal('EthAddress'), + value: z.string().refine(EthAddress.isAddress, 'Not a valid Ethereum address'), + }), + ]) + .transform(input => EthAddress.fromString(typeof input === 'string' ? input : input.value)), + + /** Accepts a 0x string */ + Signature: z + .string() + .refine(hasHexPrefix, 'No hex prefix') + .refine(Signature.isValid0xString, 'Not a valid Ethereum signature') + .transform(Signature.from0xString), + + /** Coerces any input to bigint */ + BigInt: z.coerce.bigint(), + + /** Coerces any input to integer number */ + Integer: z.coerce.number().int(), + + /** Accepts a base64 string or a structured { type: 'Buffer', data: [byte, byte...] } */ + Buffer: z.union([ + z + .string() + .base64() + .transform(data => Buffer.from(data, 'base64')), + z + .object({ + type: z.literal('Buffer'), + data: z.array(z.number().int().max(255)), + }) + .transform(({ data }) => Buffer.from(data)), + ]), +}; diff --git a/yarn-project/foundation/src/string/index.ts b/yarn-project/foundation/src/string/index.ts new file mode 100644 index 00000000000..5b8ce4f0fbd --- /dev/null +++ b/yarn-project/foundation/src/string/index.ts @@ -0,0 +1,3 @@ +export function hasHexPrefix(str: string): str is `0x${string}` { + return str.startsWith('0x'); +} diff --git a/yarn-project/foundation/src/validation/index.ts b/yarn-project/foundation/src/validation/index.ts index f8b4be38782..87cede27b63 100644 --- a/yarn-project/foundation/src/validation/index.ts +++ b/yarn-project/foundation/src/validation/index.ts @@ -5,3 +5,14 @@ export function required(value: T | undefined, errMsg?: string): T { } return value; } + +/** + * Helper function to assert a condition is truthy + * @param x - A boolean condition to assert. + * @param err - Error message to throw if x isn't met. + */ +export function assert(x: any, err: string): asserts x { + if (!x) { + throw new Error(err); + } +} diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.ts b/yarn-project/prover-node/src/job/epoch-proving-job.ts index 6257f0be369..1e215223b4e 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.ts @@ -1,6 +1,7 @@ import { EmptyTxValidator, type EpochProver, + type EpochProvingJobState, type L1ToL2MessageSource, type L2Block, type L2BlockSource, @@ -179,10 +180,4 @@ export class EpochProvingJob { } } -export type EpochProvingJobState = - | 'initialized' - | 'processing' - | 'awaiting-prover' - | 'publishing-proof' - | 'completed' - | 'failed'; +export { type EpochProvingJobState }; diff --git a/yarn-project/prover-node/src/prover-node.ts b/yarn-project/prover-node/src/prover-node.ts index 3582d357f28..05aabd683a4 100644 --- a/yarn-project/prover-node/src/prover-node.ts +++ b/yarn-project/prover-node/src/prover-node.ts @@ -8,6 +8,7 @@ import { type L2BlockSource, type MerkleTreeWriteOperations, type ProverCoordination, + type ProverNodeApi, type WorldStateSynchronizer, } from '@aztec/circuit-types'; import { type ContractDataSource } from '@aztec/circuits.js'; @@ -36,7 +37,7 @@ export type ProverNodeOptions = { * from a tx source in the p2p network or an external node, re-executes their public functions, creates a rollup * proof for the epoch, and submits it to L1. */ -export class ProverNode implements ClaimsMonitorHandler, EpochMonitorHandler { +export class ProverNode implements ClaimsMonitorHandler, EpochMonitorHandler, ProverNodeApi { private log = createDebugLogger('aztec:prover-node'); private latestEpochWeAreProving: bigint | undefined; @@ -207,8 +208,8 @@ export class ProverNode implements ClaimsMonitorHandler, EpochMonitorHandler { /** * Returns an array of jobs being processed. */ - public getJobs(): { uuid: string; status: EpochProvingJobState }[] { - return Array.from(this.jobs.entries()).map(([uuid, job]) => ({ uuid, status: job.getState() })); + public getJobs(): Promise<{ uuid: string; status: EpochProvingJobState }[]> { + return Promise.resolve(Array.from(this.jobs.entries()).map(([uuid, job]) => ({ uuid, status: job.getState() }))); } private checkMaximumPendingJobs() { diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index 7d3ed0c0c24..16c668952a9 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -396,6 +396,7 @@ __metadata: tslib: ^2.5.0 typescript: ^5.0.4 viem: ^2.7.15 + zod: ^3.23.8 languageName: unknown linkType: soft @@ -681,7 +682,7 @@ __metadata: ts-node: ^10.9.1 typescript: ^5.0.4 viem: ^2.7.15 - zod: ^3.22.4 + zod: ^3.23.8 languageName: unknown linkType: soft @@ -16758,7 +16759,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.22.4, zod@npm:^3.23.8": +"zod@npm:^3.23.8": version: 3.23.8 resolution: "zod@npm:3.23.8" checksum: 15949ff82118f59c893dacd9d3c766d02b6fa2e71cf474d5aa888570c469dbf5446ac5ad562bb035bf7ac9650da94f290655c194f4a6de3e766f43febd432c5c