diff --git a/packages/airnode-feed/config/airnode-feed.example.json b/packages/airnode-feed/config/airnode-feed.example.json index 492f1333..c569906e 100644 --- a/packages/airnode-feed/config/airnode-feed.example.json +++ b/packages/airnode-feed/config/airnode-feed.example.json @@ -40,7 +40,7 @@ ], "ois": [ { - "oisFormat": "2.3.0", + "oisFormat": "3.0.0", "title": "Nodary", "version": "0.2.0", "apiSpecifications": { diff --git a/packages/airnode-feed/package.json b/packages/airnode-feed/package.json index 74246592..451aa15e 100644 --- a/packages/airnode-feed/package.json +++ b/packages/airnode-feed/package.json @@ -34,14 +34,14 @@ "@api3/airnode-node": "^0.15.0", "@api3/airnode-validator": "^0.15.0", "@api3/commons": "^0.13.4", - "@api3/ois": "^2.3.2", + "@api3/ois": "^3.0.0", "@api3/promise-utils": "^0.4.0", "axios": "^1.11.0", "dotenv": "^17.2.1", "ethers": "^5.8.0", "express": "^5.1.0", "lodash": "^4.17.21", - "zod": "^3.25.76" + "zod": "^4.0.5" }, "devDependencies": { "@types/express": "^5.0.3", diff --git a/packages/airnode-feed/src/api-requests/data-provider.test.ts b/packages/airnode-feed/src/api-requests/data-provider.test.ts index 280fb59a..434fe761 100644 --- a/packages/airnode-feed/src/api-requests/data-provider.test.ts +++ b/packages/airnode-feed/src/api-requests/data-provider.test.ts @@ -24,7 +24,7 @@ describe(makeTemplateRequests.name, () => { jest.useFakeTimers().setSystemTime(new Date('2023-01-20')); // 1674172800 - const response = await makeTemplateRequests(config.triggers.signedApiUpdates[0]); + const response = await makeTemplateRequests(config.triggers.signedApiUpdates[0]!); expect(response).toStrictEqual(nodaryTemplateResponses); expect(adapterModule.buildAndExecuteRequest).toHaveBeenCalledTimes(1); @@ -36,7 +36,7 @@ describe(makeTemplateRequests.name, () => { jest.spyOn(logger, 'warn'); jest.spyOn(adapterModule, 'buildAndExecuteRequest').mockRejectedValue(nodaryTemplateRequestError); - await makeTemplateRequests(config.triggers.signedApiUpdates[0]); + await makeTemplateRequests(config.triggers.signedApiUpdates[0]!); expect(logger.warn).toHaveBeenCalledTimes(1); expect(logger.warn).toHaveBeenCalledWith('Failed to make API call.', { @@ -89,7 +89,7 @@ describe(makeTemplateRequests.name, () => { jest.spyOn(stateModule, 'getState').mockReturnValue(state); jest.mocked(axios).mockRejectedValue(new Error('network error')); - await makeTemplateRequests(config.triggers.signedApiUpdates[0]); + await makeTemplateRequests(config.triggers.signedApiUpdates[0]!); expect(axios).toHaveBeenCalledTimes(1); expect(axios).toHaveBeenCalledWith({ @@ -141,7 +141,7 @@ describe(makeTemplateRequests.name, () => { const buildAndExecuteRequestSpy = jest.spyOn(adapterModule, 'buildAndExecuteRequest'); - const makeTemplateRequestsResult = await makeTemplateRequests(config.triggers.signedApiUpdates[0]); + const makeTemplateRequestsResult = await makeTemplateRequests(config.triggers.signedApiUpdates[0]!); expect(axios).toHaveBeenCalledTimes(0); expect(buildAndExecuteRequestSpy).not.toHaveBeenCalled(); @@ -181,7 +181,7 @@ describe(makeTemplateRequests.name, () => { const buildAndExecuteRequestSpy = jest.spyOn(adapterModule, 'buildAndExecuteRequest'); - const makeTemplateRequestsResult = await makeTemplateRequests(config.triggers.signedApiUpdates[0]); + const makeTemplateRequestsResult = await makeTemplateRequests(config.triggers.signedApiUpdates[0]!); expect(axios).toHaveBeenCalledTimes(0); expect(buildAndExecuteRequestSpy).not.toHaveBeenCalled(); diff --git a/packages/airnode-feed/src/api-requests/signed-api.test.ts b/packages/airnode-feed/src/api-requests/signed-api.test.ts index 10a4611b..d6392cc9 100644 --- a/packages/airnode-feed/src/api-requests/signed-api.test.ts +++ b/packages/airnode-feed/src/api-requests/signed-api.test.ts @@ -35,28 +35,7 @@ describe(pushSignedData.name, () => { expect(response).toStrictEqual([{ success: false }]); expect(logger.warn).toHaveBeenCalledWith('Failed to parse response from the signed API.', { - errors: new ZodError([ - { - code: 'invalid_type', - expected: 'number', - received: 'undefined', - path: ['count'], - message: 'Required', - }, - { - code: 'invalid_type', - expected: 'number', - received: 'undefined', - path: ['skipped'], - message: 'Required', - }, - { - code: 'unrecognized_keys', - keys: ['strange'], - path: [], - message: "Unrecognized key(s) in object: 'strange'", - }, - ]), + errors: expect.any(ZodError), }); }); diff --git a/packages/airnode-feed/src/heartbeat/heartbeat.test.ts b/packages/airnode-feed/src/heartbeat/heartbeat.test.ts index 751f935e..19276e55 100644 --- a/packages/airnode-feed/src/heartbeat/heartbeat.test.ts +++ b/packages/airnode-feed/src/heartbeat/heartbeat.test.ts @@ -41,10 +41,10 @@ describe(logHeartbeat.name, () => { // NOTE: This tests will fail each time the example config changes (except for the nodeVersion). This should be // quite rare and the test verifies that the heartbeat sends correct data. const expectedHeartbeat = { - configHash: '0xce9f58b6927572c9cc6e3bb532e6bd54899c8b3a2c15da18ee556599f1b3249f', + configHash: '0x5c1e8b1f41bf76f010c36a89b73ed4ee5afbb214d7118ac3cdb11e1ad5901982', airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC', signature: - '0x29e4b58fe8ebac9a7c26de469978707b5d291eac06b76b4e8cca501011360de879d2e3dc498fd84e19d4c4a57b809e0e32f6855c2a834a38167dac5f891f159c1c', + '0x21f9249611d0f320f96ee057c7ba36aad4c5acaf8c10e8d50219aba3e2b714d55c1fd6b4207c5ed78cdeb925b31684cf4d00a2445e9c6665d952686b3db9c0e81c', stage: 'test', nodeVersion: '0.7.0', currentTimestamp: '1674172803', diff --git a/packages/airnode-feed/src/validation/env.ts b/packages/airnode-feed/src/validation/env.ts index 8da50121..6bb9c5ca 100644 --- a/packages/airnode-feed/src/validation/env.ts +++ b/packages/airnode-feed/src/validation/env.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import dotenv from 'dotenv'; +import { z } from 'zod'; import { type EnvConfig, envConfigSchema } from './schema'; @@ -13,7 +14,7 @@ export const loadEnv = () => { const parseResult = envConfigSchema.safeParse(process.env); if (!parseResult.success) { - throw new Error(`Invalid environment variables:\n, ${JSON.stringify(parseResult.error.format())}`); + throw new Error(`Invalid environment variables: ${z.prettifyError(parseResult.error)}`); } env = parseResult.data; diff --git a/packages/airnode-feed/src/validation/schema.test.ts b/packages/airnode-feed/src/validation/schema.test.ts index 32971176..f50f3a07 100644 --- a/packages/airnode-feed/src/validation/schema.test.ts +++ b/packages/airnode-feed/src/validation/schema.test.ts @@ -3,25 +3,23 @@ import { join } from 'node:path'; import { interpolateSecretsIntoConfig } from '@api3/commons'; import dotenv from 'dotenv'; -import { ZodError } from 'zod'; import { config } from '../../test/fixtures'; -import { type Config, configSchema, signedApisSchema } from './schema'; +import { type Config, configSchema, signedApiResponseSchema, signedApisSchema } from './schema'; test('validates example config', async () => { const exampleConfig = JSON.parse(readFileSync(join(__dirname, '../../config/airnode-feed.example.json'), 'utf8')); // The mnemonic is not interpolated (and thus invalid). - await expect(configSchema.parseAsync(exampleConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid mnemonic', - path: ['nodeSettings', 'airnodeWalletMnemonic'], - }, - ]) - ); + const result = await configSchema.safeParseAsync(exampleConfig); + expect(result.error?.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid mnemonic', + path: ['nodeSettings', 'airnodeWalletMnemonic'], + }, + ]); const exampleSecrets = dotenv.parse(readFileSync(join(__dirname, '../../config/secrets.example.env'), 'utf8')); await expect( @@ -38,32 +36,29 @@ test('ensures nodeVersion matches Airnode feed version', async () => { }, }; - await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid node version', - path: ['nodeSettings', 'nodeVersion'], - }, - ]) - ); + const result = await configSchema.safeParseAsync(invalidConfig); + expect(result.error?.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid node version', + path: ['nodeSettings', 'nodeVersion'], + }, + ]); }); test('ensures signed API names are unique', () => { - expect(() => - signedApisSchema.parse([ + expect( + signedApisSchema.safeParse([ { name: 'foo', url: 'https://example.com', authToken: null }, { name: 'foo', url: 'https://example.com', authToken: null }, - ]) - ).toThrow( - new ZodError([ - { - code: 'custom', - message: 'Signed API names must be unique', - path: ['signedApis'], - }, - ]) - ); + ]).error?.issues + ).toStrictEqual([ + { + code: 'custom', + message: 'Signed API names must be unique', + path: ['signedApis'], + }, + ]); expect(signedApisSchema.parse([{ name: 'foo', url: 'https://example.com', authToken: null }])).toStrictEqual([ { @@ -90,15 +85,14 @@ describe('validateTriggerReferences', () => { }, }; - await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: `Template "${notFoundTemplateId}" is not defined in the config.templates object`, - path: ['triggers', 'signedApiUpdates', 0, 'templateIds', 0], - }, - ]) - ); + const result = await configSchema.safeParseAsync(invalidConfig); + expect(result.error?.issues).toStrictEqual([ + { + code: 'custom', + message: `Template "${notFoundTemplateId}" is not defined in the config.templates object`, + path: ['triggers', 'signedApiUpdates', 0, 'templateIds', 0], + }, + ]); }); it('validates all templates reference the same endpoint', async () => { @@ -150,15 +144,14 @@ describe('validateTriggerReferences', () => { ], }; - await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'The endpoint utilized by each template must be same', - path: ['triggers', 'signedApiUpdates', 0, 'templateIds'], - }, - ]) - ); + const result = await configSchema.safeParseAsync(invalidConfig); + expect(result.error?.issues).toStrictEqual([ + { + code: 'custom', + message: 'The endpoint utilized by each template must be same', + path: ['triggers', 'signedApiUpdates', 0, 'templateIds'], + }, + ]); }); it('validates operation effects are identical', async () => { @@ -170,15 +163,14 @@ describe('validateTriggerReferences', () => { ], }; - await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'The endpoint utilized by each template must have the same operation effect', - path: ['triggers', 'signedApiUpdates', 0, 'templateIds'], - }, - ]) - ); + const result = await configSchema.safeParseAsync(invalidConfig); + expect(result.error?.issues).toStrictEqual([ + { + code: 'custom', + message: 'The endpoint utilized by each template must have the same operation effect', + path: ['triggers', 'signedApiUpdates', 0, 'templateIds'], + }, + ]); }); it('skips operation effect validation for skip API call endpoints', async () => { @@ -204,3 +196,30 @@ describe('validateTriggerReferences', () => { await expect(configSchema.parseAsync(skipApiCallConfig)).resolves.toBeDefined(); }); }); + +describe('signedApiResponseSchema', () => { + it('validates that the expected keys are present', () => { + expect(signedApiResponseSchema.safeParse({ strange: 'some-invalid-response' }).error?.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + path: ['count'], + message: 'Invalid input: expected number, received undefined', + }, + { + code: 'invalid_type', + expected: 'number', + path: ['skipped'], + message: 'Invalid input: expected number, received undefined', + }, + { + code: 'unrecognized_keys', + keys: ['strange'], + path: [], + message: 'Unrecognized key: "strange"', + }, + ]); + + expect(() => signedApiResponseSchema.parse({ count: 12, skipped: 3 })).not.toThrow(); + }); +}); diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index aae13ec0..462304d9 100644 --- a/packages/airnode-feed/src/validation/schema.ts +++ b/packages/airnode-feed/src/validation/schema.ts @@ -1,5 +1,4 @@ import * as abi from '@api3/airnode-abi'; -import { config } from '@api3/airnode-validator'; import { type LogFormat, type LogLevel, @@ -11,15 +10,18 @@ import { oisSchema, type OIS, type Endpoint as oisEndpoint } from '@api3/ois'; import { go, goSync } from '@api3/promise-utils'; import { ethers } from 'ethers'; import { isNil, uniqWith, isEqual, isEmpty, uniq } from 'lodash'; -import { z, type SuperRefinement } from 'zod'; +import { z } from 'zod'; import packageJson from '../../package.json'; +export const evmIdSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); // eslint-disable-line unicorn/better-regex +export const evmAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/); // eslint-disable-line unicorn/better-regex + export type Config = z.infer; -export type Address = z.infer; -export type BeaconId = z.infer; -export type TemplateId = z.infer; -export type EndpointId = z.infer; +export type Address = z.infer; +export type BeaconId = z.infer; +export type TemplateId = z.infer; +export type EndpointId = z.infer; export const parameterSchema = z.strictObject({ name: z.string(), @@ -30,21 +32,23 @@ export const parameterSchema = z.strictObject({ export type Parameter = z.infer; export const templateSchema = z.strictObject({ - endpointId: config.evmIdSchema, + endpointId: evmIdSchema, parameters: z.array(parameterSchema), }); export type Template = z.infer; -export const templatesSchema = z.record(config.evmIdSchema, templateSchema).superRefine((templates, ctx) => { +export const templatesSchema = z.record(evmIdSchema, templateSchema).check((ctx) => { + const templates = ctx.value; for (const [templateId, template] of Object.entries(templates)) { // Verify that config.templates. is valid by deriving the hash of the endpointId and parameters const goEncodeParameters = goSync(() => abi.encode(template.parameters)); if (!goEncodeParameters.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Unable to encode parameters: ${goEncodeParameters.error.message}`, path: ['templates', templateId, 'parameters'], + input: templates, }); continue; } @@ -54,10 +58,11 @@ export const templatesSchema = z.record(config.evmIdSchema, templateSchema).supe [template.endpointId, goEncodeParameters.data] ); if (derivedTemplateId !== templateId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Template ID "${templateId}" is invalid, expected to be ${derivedTemplateId}`, path: [templateId], + input: templateId, }); } } @@ -72,8 +77,8 @@ export const endpointSchema = z.strictObject({ export type Endpoint = z.infer; -export const endpointsSchema = z.record(endpointSchema).superRefine((endpoints, ctx) => { - for (const [endpointId, endpoint] of Object.entries(endpoints)) { +export const endpointsSchema = z.record(z.string(), endpointSchema).check((ctx) => { + for (const [endpointId, endpoint] of Object.entries(ctx.value)) { // Verify that config.endpoints. is valid // by deriving the hash of the oisTitle and endpointName @@ -82,10 +87,11 @@ export const endpointsSchema = z.record(endpointSchema).superRefine((endpoints, ); if (derivedEndpointId !== endpointId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Endpoint ID "${endpointId}" is invalid`, path: [endpointId], + input: endpointId, }); } } @@ -93,21 +99,16 @@ export const endpointsSchema = z.record(endpointSchema).superRefine((endpoints, export type Endpoints = z.infer; -export const baseBeaconUpdateSchema = z.strictObject({ +export const beaconUpdateSchema = z.strictObject({ + beaconId: evmIdSchema, deviationThreshold: z.number(), heartbeatInterval: z.number().int(), }); -export const beaconUpdateSchema = z - .strictObject({ - beaconId: config.evmIdSchema, - }) - .merge(baseBeaconUpdateSchema); - export type BeaconUpdate = z.infer; export const signedApiUpdateSchema = z.strictObject({ - templateIds: z.array(config.evmIdSchema), + templateIds: z.array(evmIdSchema), fetchInterval: z.number(), }); @@ -119,28 +120,31 @@ export const triggersSchema = z.strictObject({ export type Triggers = z.infer; -const validateTemplatesReferences: SuperRefinement<{ templates: Templates; endpoints: Endpoints }> = (config, ctx) => { - for (const [templateId, template] of Object.entries(config.templates)) { - const endpoint = config.endpoints[template.endpointId]; +const validateTemplatesReferences: z.core.CheckFn<{ templates: Templates; endpoints: Endpoints }> = (ctx) => { + const { templates, endpoints } = ctx.value; + for (const [templateId, template] of Object.entries(templates)) { + const endpoint = endpoints[template.endpointId]; if (isNil(endpoint)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Endpoint "${template.endpointId}" is not defined in the config.endpoints object`, path: ['templates', templateId, 'endpointId'], + input: templateId, }); } } }; -const validateOisReferences: SuperRefinement<{ ois: OIS[]; endpoints: Endpoints }> = (config, ctx) => { - for (const [endpointId, { oisTitle, endpointName }] of Object.entries(config.endpoints)) { +const validateOisReferences: z.core.CheckFn<{ ois: OIS[]; endpoints: Endpoints }> = (ctx) => { + for (const [endpointId, { oisTitle, endpointName }] of Object.entries(ctx.value.endpoints)) { // Check existence of OIS related with oisTitle - const oises = config.ois.filter(({ title }) => title === oisTitle); + const oises = ctx.value.ois.filter(({ title }) => title === oisTitle); if (oises.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `OIS "${oisTitle}" is not defined in the config.ois object`, path: ['endpoints', endpointId, 'oisTitle'], + input: oisTitle, }); continue; } @@ -149,23 +153,24 @@ const validateOisReferences: SuperRefinement<{ ois: OIS[]; endpoints: Endpoints const endpoints = ois.endpoints.filter(({ name }: oisEndpoint) => name === endpointName); if (endpoints.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `OIS titled "${oisTitle}" doesn't have referenced endpoint ${endpointName}`, path: ['endpoints', endpointId, 'endpointName'], + input: endpointName, }); } } }; -const validateTriggerReferences: SuperRefinement<{ +const validateTriggerReferences: z.core.CheckFn<{ ois: OIS[]; endpoints: Endpoints; triggers: Triggers; templates: Templates; apiCredentials: ApisCredentials; -}> = async (config, ctx) => { - const { ois: oises, templates, endpoints, triggers } = config; +}> = async (ctx) => { + const { ois: oises, templates, endpoints, triggers } = ctx.value; for (const [signedApiUpdateIndex, signedApiUpdate] of triggers.signedApiUpdates.entries()) { const { templateIds } = signedApiUpdate; @@ -173,10 +178,11 @@ const validateTriggerReferences: SuperRefinement<{ // Verify all template IDs actually exist in the templates object const referenceErrors = templateIds.map((templateId, templateIdIndex) => { if (!templates[templateId]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Template "${templateId}" is not defined in the config.templates object`, path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds', templateIdIndex], + input: templates, }); return true; } @@ -192,10 +198,11 @@ const validateTriggerReferences: SuperRefinement<{ const endpointIds = templateIds.map((templateId) => templates[templateId]!.endpointId); const uniqueEndpointIds = uniq(endpointIds); if (uniqueEndpointIds.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `The endpoint utilized by each template must be same`, path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds'], + input: templateIds, }); continue; // Continue for the next signedApiUpdate } @@ -223,10 +230,11 @@ const validateTriggerReferences: SuperRefinement<{ const goPreProcess = await go(async () => preProcessEndpointParameters(oisEndpoint, endpointParameters)); if (!goPreProcess.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Unable to pre-process endpoint parameters: ${goPreProcess.error.message}`, path: ['templates', templateId, 'parameters'], + input: endpointParameters, }); return; } @@ -237,10 +245,11 @@ const validateTriggerReferences: SuperRefinement<{ // Verify all processed payloads are identical if (uniqWith(operationsPayloads, isEqual).length !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `The endpoint utilized by each template must have the same operation effect`, path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds'], + input: templateIds, }); continue; // Continue for the next signedApiUpdate } @@ -250,7 +259,7 @@ const validateTriggerReferences: SuperRefinement<{ export const signedApiSchema = z.strictObject({ name: z.string(), - url: z.string().url(), + url: z.url(), authToken: z.string().nullable(), }); @@ -259,22 +268,33 @@ export type SignedApi = z.infer; export const signedApisSchema = z .array(signedApiSchema) .nonempty() - .superRefine((apis, ctx) => { - const names = apis.map((api) => api.name); + .check((ctx) => { + const signedApis = ctx.value; + const names = signedApis.map((api) => api.name); const uniqueNames = [...new Set(names)]; if (names.length !== uniqueNames.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: `Signed API names must be unique`, path: ['signedApis'], + input: signedApis, }); } }); export const oisesSchema = z.array(oisSchema as any); // Casting to "any" because TS falsely complains. -export const apisCredentialsSchema = z.array(config.apiCredentialsSchema); +// TODO: Remove and use config.apiCredentialsSchema when @api3/airnode-validator has upgraded zod to v4 +const apiCredentialsSchema = z + .object({ + securitySchemeName: z.string(), + securitySchemeValue: z.string(), + oisTitle: z.string(), + }) + .strict(); + +export const apisCredentialsSchema = z.array(apiCredentialsSchema); export type ApisCredentials = z.infer; @@ -298,16 +318,16 @@ export const configSchema = z templates: templatesSchema, triggers: triggersSchema, }) - .superRefine(validateTemplatesReferences) - .superRefine(validateOisReferences) - .superRefine(validateTriggerReferences); + .check(validateTemplatesReferences) + .check(validateOisReferences) + .check(validateTriggerReferences); export const encodedValueSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/); export const signatureSchema = z.string().regex(/^0x[\dA-Fa-f]{130}$/); export const signedDataSchema = z.strictObject({ - templateId: config.evmIdSchema, + templateId: evmIdSchema, timestamp: z.string(), encodedValue: encodedValueSchema, signature: signatureSchema, @@ -316,8 +336,8 @@ export const signedDataSchema = z.strictObject({ export type SignedData = z.infer; export const signedApiPayloadV1Schema = signedDataSchema.extend({ - beaconId: config.evmIdSchema, - airnode: config.evmAddressSchema, + beaconId: evmIdSchema, + airnode: evmAddressSchema, }); export type SignedApiPayloadV1 = z.infer; @@ -333,13 +353,13 @@ export const signedApiBatchPayloadV1Schema = z.array(signedApiPayloadV1Schema); export type SignedApiBatchPayloadV1 = z.infer; export const signedApiBatchPayloadV2Schema = z.strictObject({ - airnode: config.evmAddressSchema, + airnode: evmAddressSchema, signedData: z.array(signedApiPayloadV2Schema), }); export type SignedApiBatchPayloadV2 = z.infer; -export const secretsSchema = z.record(z.string()); +export const secretsSchema = z.record(z.string(), z.string()); export const signedApiResponseSchema = z.strictObject({ count: z.number(), @@ -355,16 +375,17 @@ export const envBooleanSchema = z.union([z.literal('true'), z.literal('false')]) export const envConfigSchema = z // Intentionally not using strictObject here because we want to allow other environment variables to be present. .object({ - LOGGER_ENABLED: envBooleanSchema.default('true'), - LOG_COLORIZE: envBooleanSchema.default('false'), + LOGGER_ENABLED: envBooleanSchema.default(true), + LOG_COLORIZE: envBooleanSchema.default(false), LOG_FORMAT: z .string() .transform((value, ctx) => { if (!logFormatOptions.includes(value as any)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: 'Invalid LOG_FORMAT', path: ['LOG_FORMAT'], + input: value, }); return; } @@ -372,15 +393,16 @@ export const envConfigSchema = z return value as LogFormat; }) .default('json'), - LOG_HEARTBEAT: envBooleanSchema.default('true'), + LOG_HEARTBEAT: envBooleanSchema.default(true), LOG_LEVEL: z .string() .transform((value, ctx) => { if (!logLevelOptions.includes(value as any)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: 'Invalid LOG_LEVEL', path: ['LOG_LEVEL'], + input: value, }); return; } diff --git a/packages/airnode-feed/test/fixtures.ts b/packages/airnode-feed/test/fixtures.ts index 6b891060..e1aac35e 100644 --- a/packages/airnode-feed/test/fixtures.ts +++ b/packages/airnode-feed/test/fixtures.ts @@ -50,7 +50,7 @@ export const config: Config = { ], ois: [ { - oisFormat: '2.3.0', + oisFormat: '3.0.0', title: 'Nodary', version: '0.2.0', apiSpecifications: { diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 36da9cfd..9303eda4 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -26,7 +26,7 @@ "ethers": "^5.8.0", "express": "^5.1.0", "lodash": "^4.17.21", - "zod": "^3.25.76" + "zod": "^4.0.5" }, "devDependencies": { "@types/express": "^5.0.3", diff --git a/packages/e2e/src/airnode-feed/airnode-feed.json b/packages/e2e/src/airnode-feed/airnode-feed.json index 149821e1..9362aec8 100644 --- a/packages/e2e/src/airnode-feed/airnode-feed.json +++ b/packages/e2e/src/airnode-feed/airnode-feed.json @@ -45,7 +45,7 @@ ], "ois": [ { - "oisFormat": "2.3.0", + "oisFormat": "3.0.0", "title": "Mock API", "version": "0.2.0", "apiSpecifications": { diff --git a/packages/performance-test/airnode-feed/create-config.ts b/packages/performance-test/airnode-feed/create-config.ts index 430921d8..f8f02caa 100644 --- a/packages/performance-test/airnode-feed/create-config.ts +++ b/packages/performance-test/airnode-feed/create-config.ts @@ -25,7 +25,7 @@ const configTemplate = { ], ois: [ { - oisFormat: '2.3.0', + oisFormat: '3.0.0', title: 'API', version: '0.2.0', apiSpecifications: { diff --git a/packages/signed-api/package.json b/packages/signed-api/package.json index 89c6cc44..6fd23b28 100644 --- a/packages/signed-api/package.json +++ b/packages/signed-api/package.json @@ -44,6 +44,6 @@ "express": "^5.1.0", "lodash": "^4.17.21", "workerpool": "^9.3.3", - "zod": "^3.25.76" + "zod": "^4.0.5" } } diff --git a/packages/signed-api/src/env.ts b/packages/signed-api/src/env.ts index 43f6a18c..fb3ca345 100644 --- a/packages/signed-api/src/env.ts +++ b/packages/signed-api/src/env.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import dotenv from 'dotenv'; +import { z } from 'zod'; import { type EnvConfig, envConfigSchema } from './schema'; @@ -13,7 +14,7 @@ export const loadEnv = () => { const parseResult = envConfigSchema.safeParse(process.env); if (!parseResult.success) { - throw new Error(`Invalid environment variables:\n, ${JSON.stringify(parseResult.error.format())}`); + throw new Error(`Invalid environment variables: ${z.prettifyError(parseResult.error)}`); } env = parseResult.data; diff --git a/packages/signed-api/src/handlers.test.ts b/packages/signed-api/src/handlers.test.ts index edd5e3f7..0ed6b1e6 100644 --- a/packages/signed-api/src/handlers.test.ts +++ b/packages/signed-api/src/handlers.test.ts @@ -59,9 +59,11 @@ describe(batchInsertData.name, () => { context: { v1ParsingIssues: [ { - validation: 'regex', - code: 'invalid_string', - message: 'Invalid', + code: 'invalid_format', + format: 'regex', + pattern: '/^0x[\\dA-Fa-f]{130}$/', + message: 'Invalid string: must match pattern /^0x[\\dA-Fa-f]{130}$/', + origin: 'string', path: [0, 'signature'], }, ], @@ -69,9 +71,8 @@ describe(batchInsertData.name, () => { { code: 'invalid_type', expected: 'object', - received: 'array', path: [], - message: 'Expected object, received array', + message: 'Invalid input: expected object, received array', }, ], }, diff --git a/packages/signed-api/src/schema.test.ts b/packages/signed-api/src/schema.test.ts index 886c4c5d..1d4c8d79 100644 --- a/packages/signed-api/src/schema.test.ts +++ b/packages/signed-api/src/schema.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import dotenv from 'dotenv'; -import { ZodError } from 'zod'; import { allowedAirnodesSchema, @@ -15,26 +14,25 @@ import { describe('endpointSchema', () => { it('validates urlPath', () => { - const expectedError = new ZodError([ + const expectedError = [ { - validation: 'regex', - code: 'invalid_string', + code: 'invalid_format', + format: 'regex', + origin: 'string', message: 'Must start with a slash and contain only alphanumeric characters and dashes', path: ['urlPath'], + pattern: '/^\\/[\\dA-Za-z-]+$/', }, - ]); - expect(() => endpointSchema.parse({ urlPath: '', delaySeconds: 0, authTokens: null, isOev: false })).toThrow( - expectedError - ); - expect(() => endpointSchema.parse({ urlPath: '/', delaySeconds: 0, authTokens: null, isOev: false })).toThrow( - expectedError - ); - expect(() => - endpointSchema.parse({ urlPath: 'url-path', delaySeconds: 0, authTokens: null, isOev: false }) - ).toThrow(expectedError); - expect(() => - endpointSchema.parse({ urlPath: 'url-path', delaySeconds: 0, authTokens: null, isOev: false }) - ).toThrow(expectedError); + ]; + expect( + endpointSchema.safeParse({ urlPath: '', delaySeconds: 0, authTokens: null, isOev: false }).error?.issues + ).toStrictEqual(expectedError); + expect( + endpointSchema.safeParse({ urlPath: '/', delaySeconds: 0, authTokens: null, isOev: false }).error?.issues + ).toStrictEqual(expectedError); + expect( + endpointSchema.safeParse({ urlPath: 'url-path', delaySeconds: 0, authTokens: null, isOev: false }).error?.issues + ).toStrictEqual(expectedError); expect(() => endpointSchema.parse({ urlPath: '/url-path', delaySeconds: 0, authTokens: null, isOev: false }) @@ -44,20 +42,18 @@ describe('endpointSchema', () => { describe('endpointsSchema', () => { it('ensures each urlPath is unique', () => { - expect(() => - endpointsSchema.parse([ + expect( + endpointsSchema.safeParse([ { urlPath: '/url-path', delaySeconds: 0, authTokens: null, isOev: false }, { urlPath: '/url-path', delaySeconds: 0, authTokens: null, isOev: false }, - ]) - ).toThrow( - new ZodError([ - { - code: 'custom', - message: 'Each "urlPath" of an endpoint must be unique', - path: [], - }, - ]) - ); + ]).error?.issues + ).toStrictEqual([ + { + code: 'custom', + message: 'Each "urlPath" of an endpoint must be unique', + path: [], + }, + ]); }); }); @@ -74,38 +70,33 @@ describe('env config schema', () => { expect(envBooleanSchema.parse('true')).toBe(true); expect(envBooleanSchema.parse('false')).toBe(false); - // Using a function to create the expected error because the error message length is too long to be inlined. The - // error messages is trivially stringified if propagated to the user. - const createExpectedError = (received: string) => - new ZodError([ - { - code: 'invalid_union', - unionErrors: [ - new ZodError([ - { - received, - code: 'invalid_literal', - expected: 'true', - path: [], - message: 'Invalid literal value, expected "true"', - }, - ]), - new ZodError([ - { - received, - code: 'invalid_literal', - expected: 'false', - path: [], - message: 'Invalid literal value, expected "false"', - }, - ]), + const expectedIssues = [ + { + code: 'invalid_union', + errors: [ + [ + { + code: 'invalid_value', + path: [], + message: 'Invalid input: expected "true"', + values: ['true'], + }, ], - path: [], - message: 'Invalid input', - }, - ]); - expect(() => envBooleanSchema.parse('')).toThrow(createExpectedError('')); - expect(() => envBooleanSchema.parse('off')).toThrow(createExpectedError('off')); + [ + { + code: 'invalid_value', + path: [], + message: 'Invalid input: expected "false"', + values: ['false'], + }, + ], + ], + path: [], + message: 'Invalid input', + }, + ]; + expect(envBooleanSchema.safeParse('').error?.issues).toStrictEqual(expectedIssues); + expect(envBooleanSchema.safeParse('off').error?.issues).toStrictEqual(expectedIssues); }); it('parses example env correctly', () => { @@ -120,15 +111,13 @@ describe('env config schema', () => { CONFIG_SOURCE: 'aws-s3', }; - expect(() => envConfigSchema.parse(env)).toThrow( - new ZodError([ - { - code: 'custom', - message: 'The AWS_REGION must be set when CONFIG_SOURCE is "aws-s3"', - path: ['AWS_REGION'], - }, - ]) - ); + expect(envConfigSchema.safeParse(env).error?.issues).toStrictEqual([ + { + code: 'custom', + message: 'The AWS_REGION must be set when CONFIG_SOURCE is "aws-s3"', + path: ['AWS_REGION'], + }, + ]); }); }); @@ -146,18 +135,15 @@ describe('allowed Airnodes schema', () => { }); it('disallows empty list', () => { - expect(() => allowedAirnodesSchema.parse([])).toThrow( - new ZodError([ - { - code: 'too_small', - minimum: 1, - type: 'array', - inclusive: true, - exact: false, - message: 'Array must contain at least 1 element(s)', - path: [], - }, - ]) - ); + expect(allowedAirnodesSchema.safeParse([]).error?.issues).toStrictEqual([ + { + code: 'too_small', + origin: 'array', + minimum: 1, + inclusive: true, + message: 'Too small: expected array to have >=1 items', + path: [], + }, + ]); }); }); diff --git a/packages/signed-api/src/schema.ts b/packages/signed-api/src/schema.ts index 71a588f6..7610d2a8 100644 --- a/packages/signed-api/src/schema.ts +++ b/packages/signed-api/src/schema.ts @@ -9,10 +9,11 @@ import packageJson from '../package.json'; export const evmAddressSchema = z.string().transform((val, ctx) => { const goChecksumAddress = goSync(() => ethers.utils.getAddress(val)); if (!goChecksumAddress.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: 'Invalid EVM address', path: [], + input: val, }); return ''; } @@ -78,16 +79,17 @@ export const envBooleanSchema = z.union([z.literal('true'), z.literal('false')]) export const envConfigSchema = z // Intentionally not using strictObject here because we want to allow other environment variables to be present. .object({ - LOG_API_DATA: envBooleanSchema.default('false'), - LOG_COLORIZE: envBooleanSchema.default('false'), + LOG_API_DATA: envBooleanSchema.default(false), + LOG_COLORIZE: envBooleanSchema.default(false), LOG_FORMAT: z .string() .transform((value, ctx) => { if (!logFormatOptions.includes(value as any)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: 'Invalid LOG_FORMAT', path: ['LOG_FORMAT'], + input: value, }); return; } @@ -99,10 +101,11 @@ export const envConfigSchema = z .string() .transform((value, ctx) => { if (!logLevelOptions.includes(value as any)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + ctx.issues.push({ + code: 'custom', message: 'Invalid LOG_LEVEL', path: ['LOG_LEVEL'], + input: value, }); return; } @@ -110,7 +113,7 @@ export const envConfigSchema = z return value as LogLevel; }) .default('info'), - LOGGER_ENABLED: envBooleanSchema.default('true'), + LOGGER_ENABLED: envBooleanSchema.default(true), CONFIG_SOURCE: z.union([z.literal('local'), z.literal('aws-s3')]).default('local'), @@ -121,12 +124,14 @@ export const envConfigSchema = z AWS_SECRET_ACCESS_KEY: z.string().optional(), }) .strip() // We parse from ENV variables of the process which has many variables that we don't care about. - .superRefine((val, ctx) => { - if (val.CONFIG_SOURCE === 'aws-s3' && !val.AWS_REGION) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, + .check((ctx) => { + const env = ctx.value; + if (env.CONFIG_SOURCE === 'aws-s3' && !env.AWS_REGION) { + ctx.issues.push({ + code: 'custom', message: 'The AWS_REGION must be set when CONFIG_SOURCE is "aws-s3"', path: ['AWS_REGION'], + input: env, }); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0efaf219..f9e2ce14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,8 +63,8 @@ importers: specifier: ^0.13.4 version: 0.13.4 '@api3/ois': - specifier: ^2.3.2 - version: 2.3.2 + specifier: ^3.0.0 + version: 3.0.0 '@api3/promise-utils': specifier: ^0.4.0 version: 0.4.0 @@ -84,8 +84,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.0.5 + version: 4.0.10 devDependencies: '@types/express': specifier: ^5.0.3 @@ -124,8 +124,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.0.5 + version: 4.0.10 devDependencies: '@types/express': specifier: ^5.0.3 @@ -173,8 +173,8 @@ importers: specifier: ^9.3.3 version: 9.3.3 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.0.5 + version: 4.0.10 devDependencies: '@types/express': specifier: ^5.0.3 @@ -242,6 +242,9 @@ packages: '@api3/ois@2.3.2': resolution: {integrity: sha512-hYQCbCAtWMGKV+pECRY5tbfCSOcheeo9s6wtrhn1dOWw151VeG0sKG3VbBEsyA6cvSaldewBWY3t/JGaupdThg==} + '@api3/ois@3.0.0': + resolution: {integrity: sha512-scqcvBOBsptgW3rsTLxQSlTA51W1Fr0YpvyKg2JDS2zTDeGNg1gWx9A3tW6fTM4cj57VnowPgj/ZyxQ7w1Ch0Q==} + '@api3/promise-utils@0.4.0': resolution: {integrity: sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A==} @@ -4812,6 +4815,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.0.10: + resolution: {integrity: sha512-3vB+UU3/VmLL2lvwcY/4RV2i9z/YU0DTV/tDuYjrwmx5WeJ7hwy+rGEEx8glHp6Yxw7ibRbKSaIFBgReRPe5KA==} + snapshots: '@adraffy/ens-normalize@1.11.0': {} @@ -5030,6 +5036,11 @@ snapshots: lodash: 4.17.21 zod: 3.25.76 + '@api3/ois@3.0.0': + dependencies: + lodash: 4.17.21 + zod: 4.0.10 + '@api3/promise-utils@0.4.0': {} '@aws-crypto/crc32@5.2.0': @@ -11525,3 +11536,5 @@ snapshots: zod@3.25.28: {} zod@3.25.76: {} + + zod@4.0.10: {}