From 05ff86ea6b415de11a47caa35ac2c147566bcfeb Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Sun, 27 Apr 2025 19:36:59 +0300 Subject: [PATCH 1/6] Refactor trigger validation for better coverage and readability --- .../airnode-feed/src/validation/schema.ts | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index 23371cf1..870cdbf5 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'; @@ -167,33 +167,47 @@ 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] === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Template ID "${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)!; + + 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 +229,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 } } } From 709e938733d875e5a498e5db08caaf85e8e3a89e Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Sun, 27 Apr 2025 19:37:21 +0300 Subject: [PATCH 2/6] Skip operation effect validation for endpoints using 'Skip API call' --- packages/airnode-feed/src/validation/schema.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index 870cdbf5..32192fb6 100644 --- a/packages/airnode-feed/src/validation/schema.ts +++ b/packages/airnode-feed/src/validation/schema.ts @@ -205,6 +205,12 @@ const validateTriggerReferences: SuperRefinement<{ 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]!; From d558fda1ed8a339c3eb826fe2a639a4a257c6273 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Sun, 27 Apr 2025 20:11:40 +0300 Subject: [PATCH 3/6] Enhance test coverage for trigger validations --- .../src/validation/schema.test.ts | 150 ++++++++++++++++-- 1 file changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/airnode-feed/src/validation/schema.test.ts b/packages/airnode-feed/src/validation/schema.test.ts index 1f5a3cee..81ad245c 100644 --- a/packages/airnode-feed/src/validation/schema.test.ts +++ b/packages/airnode-feed/src/validation/schema.test.ts @@ -1,11 +1,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import * as abi from '@api3/airnode-abi'; import { interpolateSecretsIntoConfig } from '@api3/commons'; import dotenv from 'dotenv'; +import { ethers } from 'ethers'; import { ZodError } from 'zod'; import { config } from '../../test/fixtures'; +import { deriveEndpointId } from '../utils'; import { type Config, configSchema, signedApisSchema } from './schema'; @@ -74,23 +77,136 @@ 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 ID "${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 = deriveEndpointId('Nodary', endpointName); + const exampleTemplateId = ethers.utils.solidityKeccak256( + ['bytes32', 'bytes'], + [exampleEndpointId, abi.encode([{ type: 'string32', name: 'name', value: 'DIFFERENT' }])] + ); + + 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(); + }); }); From 7651a94f0cc633620980b6bc81b8557645d7ac4c Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Sun, 27 Apr 2025 20:14:39 +0300 Subject: [PATCH 4/6] Hardcode endpointId and templateId used in trigger validation tests --- packages/airnode-feed/src/validation/schema.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/airnode-feed/src/validation/schema.test.ts b/packages/airnode-feed/src/validation/schema.test.ts index 81ad245c..21831802 100644 --- a/packages/airnode-feed/src/validation/schema.test.ts +++ b/packages/airnode-feed/src/validation/schema.test.ts @@ -1,14 +1,11 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import * as abi from '@api3/airnode-abi'; import { interpolateSecretsIntoConfig } from '@api3/commons'; import dotenv from 'dotenv'; -import { ethers } from 'ethers'; import { ZodError } from 'zod'; import { config } from '../../test/fixtures'; -import { deriveEndpointId } from '../utils'; import { type Config, configSchema, signedApisSchema } from './schema'; @@ -106,11 +103,8 @@ describe('validateTriggerReferences', () => { it('validates all templates reference the same endpoint', async () => { const endpointName = 'testEndpoint'; - const exampleEndpointId = deriveEndpointId('Nodary', endpointName); - const exampleTemplateId = ethers.utils.solidityKeccak256( - ['bytes32', 'bytes'], - [exampleEndpointId, abi.encode([{ type: 'string32', name: 'name', value: 'DIFFERENT' }])] - ); + const exampleEndpointId = '0x3cd24fa917796c35f96f67fa123c786485ae72133dc2f4da3299cf99fb245317'; + const exampleTemplateId = '0xd16373affaa5ed2ae5a1f740c48954238ba237e0899e1cf5da97025269ad84cc'; const invalidConfig: Config = { ...config, From 9632ce4c56fcf216aebbbaaa524580fff303f771 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Tue, 29 Apr 2025 14:47:08 +0300 Subject: [PATCH 5/6] Use consistent issue message --- packages/airnode-feed/src/validation/schema.test.ts | 2 +- packages/airnode-feed/src/validation/schema.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/airnode-feed/src/validation/schema.test.ts b/packages/airnode-feed/src/validation/schema.test.ts index 21831802..32971176 100644 --- a/packages/airnode-feed/src/validation/schema.test.ts +++ b/packages/airnode-feed/src/validation/schema.test.ts @@ -94,7 +94,7 @@ describe('validateTriggerReferences', () => { new ZodError([ { code: 'custom', - message: `Template ID "${notFoundTemplateId}" is not defined in the config.templates object`, + message: `Template "${notFoundTemplateId}" is not defined in the config.templates object`, path: ['triggers', 'signedApiUpdates', 0, 'templateIds', 0], }, ]) diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index 32192fb6..e811ff7c 100644 --- a/packages/airnode-feed/src/validation/schema.ts +++ b/packages/airnode-feed/src/validation/schema.ts @@ -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; @@ -175,7 +175,7 @@ const validateTriggerReferences: SuperRefinement<{ if (templates[templateId] === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Template ID "${templateId}" is not defined in the config.templates object`, + message: `Template "${templateId}" is not defined in the config.templates object`, path: ['triggers', 'signedApiUpdates', signedApiUpdateIndex, 'templateIds', templateIdIndex], }); return true; From 93960712b0f2c25dc3db845c8ff4d779aa817ddd Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Tue, 29 Apr 2025 14:47:18 +0300 Subject: [PATCH 6/6] Simplify template existence check --- packages/airnode-feed/src/validation/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/airnode-feed/src/validation/schema.ts b/packages/airnode-feed/src/validation/schema.ts index e811ff7c..aae13ec0 100644 --- a/packages/airnode-feed/src/validation/schema.ts +++ b/packages/airnode-feed/src/validation/schema.ts @@ -172,7 +172,7 @@ const validateTriggerReferences: SuperRefinement<{ // Verify all template IDs actually exist in the templates object const referenceErrors = templateIds.map((templateId, templateIdIndex) => { - if (templates[templateId] === undefined) { + if (!templates[templateId]) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Template "${templateId}" is not defined in the config.templates object`,