diff --git a/packages/airnode-feed/src/validation/schema.test.ts b/packages/airnode-feed/src/validation/schema.test.ts index 1f5a3cee..32971176 100644 --- a/packages/airnode-feed/src/validation/schema.test.ts +++ b/packages/airnode-feed/src/validation/schema.test.ts @@ -74,23 +74,133 @@ test('ensures signed API names are unique', () => { ]); }); -test('validates trigger references', async () => { - const invalidConfig: Config = { - ...config, - ois: [ - // By removing the pre-processing the triggers will end up with different operation effects. - { ...config.ois[0]!, endpoints: [{ ...config.ois[0]!.endpoints[0]!, preProcessingSpecifications: undefined }] }, - ], - }; +describe('validateTriggerReferences', () => { + it('validates template references exist', async () => { + const notFoundTemplateId = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; - await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual( - new ZodError([ - { - code: 'custom', - message: - 'If beaconIds contains more than 1 beacon, the endpoint utilized by each beacons must have same operation effect', - path: ['triggers', 'signedApiUpdates', 0], + const invalidConfig: Config = { + ...config, + triggers: { + signedApiUpdates: [ + { + ...config.triggers.signedApiUpdates[0]!, + templateIds: [notFoundTemplateId, ...config.triggers.signedApiUpdates[0]!.templateIds], + }, + ], }, - ]) - ); + }; + + 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], + }, + ]) + ); + }); + + it('validates all templates reference the same endpoint', async () => { + const endpointName = 'testEndpoint'; + const exampleEndpointId = '0x3cd24fa917796c35f96f67fa123c786485ae72133dc2f4da3299cf99fb245317'; + const exampleTemplateId = '0xd16373affaa5ed2ae5a1f740c48954238ba237e0899e1cf5da97025269ad84cc'; + + const invalidConfig: Config = { + ...config, + endpoints: { + ...config.endpoints, + [exampleEndpointId]: { + endpointName, + oisTitle: 'Nodary', + }, + }, + templates: { + ...config.templates, + [exampleTemplateId]: { + endpointId: exampleEndpointId, + parameters: [{ type: 'string32', name: 'name', value: 'DIFFERENT' }], + }, + }, + triggers: { + signedApiUpdates: [ + { + ...config.triggers.signedApiUpdates[0]!, + templateIds: [...config.triggers.signedApiUpdates[0]!.templateIds, exampleTemplateId], + }, + ], + }, + ois: [ + { + ...config.ois[0]!, + endpoints: [ + ...config.ois[0]!.endpoints, + { + fixedOperationParameters: [], + name: endpointName, + operation: { method: 'get', path: '/feed/latest' }, + parameters: [{ name: 'name', operationParameter: { in: 'query', name: 'name' } }], + reservedParameters: [ + { name: '_type', fixed: 'int256' }, + { name: '_times', fixed: '1000000000000000000' }, + ], + }, + ], + }, + ], + }; + + 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'], + }, + ]) + ); + }); + + it('validates operation effects are identical', async () => { + const invalidConfig: Config = { + ...config, + ois: [ + // By removing the pre-processing the triggers will end up with different operation effects. + { ...config.ois[0]!, endpoints: [{ ...config.ois[0]!.endpoints[0]!, preProcessingSpecifications: undefined }] }, + ], + }; + + 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'], + }, + ]) + ); + }); + + it('skips operation effect validation for skip API call endpoints', async () => { + const skipApiCallConfig: Config = { + ...config, + ois: [ + // By removing the pre-processing the triggers will end up with different operation effects. + { + ...config.ois[0]!, + endpoints: [ + { + ...config.ois[0]!.endpoints[0]!, + preProcessingSpecifications: undefined, + operation: undefined, + fixedOperationParameters: [], + }, + ], + }, + ], + }; + + // Should not throw even though the operation effects would be different + await expect(configSchema.parseAsync(skipApiCallConfig)).resolves.toBeDefined(); + }); }); diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index 23371cf1..aae13ec0 100644 --- a/packages/airnode-feed/src/validation/schema.ts +++ b/packages/airnode-feed/src/validation/schema.ts @@ -10,7 +10,7 @@ import { 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 } from 'lodash'; +import { isNil, uniqWith, isEqual, isEmpty, uniq } from 'lodash'; import { z, type SuperRefinement } from 'zod'; import packageJson from '../../package.json'; @@ -139,7 +139,7 @@ const validateOisReferences: SuperRefinement<{ ois: OIS[]; endpoints: Endpoints if (oises.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `OIS titled "${oisTitle}" is not defined in the config.ois object`, + message: `OIS "${oisTitle}" is not defined in the config.ois object`, path: ['endpoints', endpointId, 'oisTitle'], }); continue; @@ -167,33 +167,53 @@ const validateTriggerReferences: SuperRefinement<{ }> = async (config, ctx) => { const { ois: oises, templates, endpoints, triggers } = config; - for (const signedApiUpdate of triggers.signedApiUpdates) { + for (const [signedApiUpdateIndex, signedApiUpdate] of triggers.signedApiUpdates.entries()) { const { templateIds } = signedApiUpdate; + // 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, + message: `Template "${templateId}" is not defined in the config.templates object`, + path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds', templateIdIndex], + }); + return true; + } + return false; + }); + if (referenceErrors.some(Boolean)) { + continue; // Continue for the next signedApiUpdate + } + + // Only perform following checks if multiple templates are specified if (templateIds.length > 1) { - const operationPayloadPromises = templateIds.map(async (templateId) => { - const template = templates[templateId]; - if (!template) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unable to find template with ID: ${templateId}`, - path: ['templates'], - }); - return; - } + // All templates must reference the same endpoint + const endpointIds = templateIds.map((templateId) => templates[templateId]!.endpointId); + const uniqueEndpointIds = uniq(endpointIds); + if (uniqueEndpointIds.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The endpoint utilized by each template must be same`, + path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds'], + }); + continue; // Continue for the next signedApiUpdate + } - const endpoint = endpoints[template.endpointId]; - if (!endpoint) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unable to find endpoint with ID: ${template.endpointId}`, - path: ['endpoints'], - }); - return; - } + // Since all templates use the same endpoint, we can just check the first one + const endpoint = endpoints[endpointIds[0]!]!; + const ois = oises.find((o) => o.title === endpoint.oisTitle)!; + const oisEndpoint = ois.endpoints.find((e) => e.name === endpoint.endpointName)!; + + // Skip operation effect validation if the endpoints utilizes `Skip API call` feature + // https://github.com/api3dao/signed-api/issues/238 + if (!oisEndpoint.operation && isEmpty(oisEndpoint.fixedOperationParameters)) { + continue; // Continue for the next signedApiUpdate + } + + const operationPayloadPromises = templateIds.map(async (templateId) => { + const template = templates[templateId]!; - const ois = oises.find((o) => o.title === endpoint.oisTitle)!; - const oisEndpoint = ois.endpoints.find((e) => e.name === endpoint.endpointName)!; const endpointParameters = template.parameters.reduce((acc, parameter) => { return { ...acc, @@ -215,13 +235,14 @@ const validateTriggerReferences: SuperRefinement<{ const operationsPayloads = await Promise.all(operationPayloadPromises); + // Verify all processed payloads are identical if (uniqWith(operationsPayloads, isEqual).length !== 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `If beaconIds contains more than 1 beacon, the endpoint utilized by each beacons must have same operation effect`, - path: ['triggers', 'signedApiUpdates', triggers.signedApiUpdates.indexOf(signedApiUpdate)], + message: `The endpoint utilized by each template must have the same operation effect`, + path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds'], }); - return; + continue; // Continue for the next signedApiUpdate } } }