Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 127 additions & 17 deletions packages/airnode-feed/src/validation/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 = '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();
});
});
73 changes: 47 additions & 26 deletions packages/airnode-feed/src/validation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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] === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Template ID "${templateId}" is not defined in the config.templates object`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
message: `Template ID "${templateId}" is not defined in the config.templates object`,
message: `Template ID "${templateId}" is not defined`,

if we ever refactor the config it can go stale.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we ever refactor the config it can go stale.

You're right but current version clearly points where the issue arises. I'd recommend to keep it.

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,
Expand All @@ -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
}
}
}
Expand Down