diff --git a/.gitignore b/.gitignore index b32dd2bd..1e518405 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ coverage .DS_Store airseeker.json secrets.env -.eslintcache +.eslintcache \ No newline at end of file diff --git a/config/airseeker.example.json b/config/airseeker.example.json index 61e8dc38..ae146b0a 100644 --- a/config/airseeker.example.json +++ b/config/airseeker.example.json @@ -26,5 +26,6 @@ } } }, + "fetchInterval": 10000, "deviationThresholdCoefficient": 1 } diff --git a/package.json b/package.json index 209f47b5..d367f19b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,23 @@ "dist", "*.js" ], + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rm -rf coverage dist", + "dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/index.ts\"", + "eslint:check": "eslint --report-unused-disable-directives --cache --ext js,ts . --max-warnings 0", + "eslint:fix": "pnpm run eslint:check --fix", + "prettier:check": "prettier --check \"./**/*.{js,ts,md,json}\"", + "prettier:fix": "prettier --write \"./**/*.{js,ts,md,json}\"", + "test": "jest --verbose --runInBand --bail --detectOpenHandles --silent", + "test:e2e": "jest --selectProjects e2e --runInBand", + "tsc": "tsc --project .", + "docker:build": "docker build -t api3/airseekerv2:latest -f docker/Dockerfile .", + "docker:run": "docker run -it --rm api3/airseekerv2:latest", + "dev:eth-node": "hardhat node" + }, "devDependencies": { + "@api3/ois": "^2.2.0", "@types/jest": "^29.5.5", "@types/lodash": "^4.14.199", "@types/node": "^20.8.0", @@ -33,22 +49,10 @@ "@api3/airnode-protocol-v1": "^2.10.0", "@api3/commons": "^0.2.0", "@api3/promise-utils": "^0.4.0", + "axios": "^1.5.1", "dotenv": "^16.3.1", "ethers": "^5.7.2", "lodash": "^4.17.21", "zod": "^3.22.2" - }, - "scripts": { - "build": "tsc --project tsconfig.build.json", - "clean": "rm -rf coverage dist", - "dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/index.ts\"", - "eslint:check": "eslint --report-unused-disable-directives --cache --ext js,ts . --max-warnings 0", - "eslint:fix": "pnpm run eslint:check --fix", - "prettier:check": "prettier --check \"./**/*.{js,ts,md,json}\"", - "prettier:fix": "prettier --write \"./**/*.{js,ts,md,json}\"", - "test": "jest", - "tsc": "tsc --project .", - "docker:build": "docker build -t api3/airseekerv2:latest -f docker/Dockerfile .", - "docker:run": "docker run -it --rm api3/airseekerv2:latest" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 541a8e72..5bc942ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: '@api3/promise-utils': specifier: ^0.4.0 version: 0.4.0 + axios: + specifier: ^1.5.1 + version: 1.5.1 dotenv: specifier: ^16.3.1 version: 16.3.1 @@ -28,6 +31,9 @@ dependencies: version: 3.22.2 devDependencies: + '@api3/ois': + specifier: ^2.2.0 + version: 2.2.1 '@types/jest': specifier: ^29.5.5 version: 29.5.5 @@ -139,7 +145,6 @@ packages: dependencies: lodash: 4.17.21 zod: 3.22.4 - dev: false /@api3/promise-utils@0.4.0: resolution: {integrity: sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A==} @@ -1595,6 +1600,10 @@ packages: has-symbols: 1.0.3 dev: false + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: false + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -1604,6 +1613,16 @@ packages: engines: {node: '>=4'} dev: false + /axios@1.5.1: + resolution: {integrity: sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==} + dependencies: + follow-redirects: 1.15.3 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} dependencies: @@ -1876,6 +1895,13 @@ packages: text-hex: 1.0.0 dev: false + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1976,6 +2002,11 @@ packages: has-property-descriptors: 1.0.0 object-keys: 1.1.1 + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: false + /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2714,6 +2745,16 @@ packages: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} dev: false + /follow-redirects@1.15.3: + resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -2727,6 +2768,15 @@ packages: signal-exit: 4.1.0 dev: true + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3761,7 +3811,6 @@ packages: /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: false /logform@2.5.1: resolution: {integrity: sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==} @@ -3825,6 +3874,18 @@ packages: braces: 3.0.2 picomatch: 2.3.1 + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4134,6 +4195,10 @@ packages: react-is: 16.13.1 dev: false + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} dev: true @@ -4986,4 +5051,3 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - dev: false diff --git a/src/config/schema.ts b/src/config/schema.ts index fbb4342e..6840c170 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -6,6 +6,9 @@ export const evmAddressSchema = z.string().regex(/^0x[\dA-Fa-f]{40}$/, 'Must be export const evmIdSchema = z.string().regex(/^0x[\dA-Fa-f]{64}$/, 'Must be a valid EVM hash'); +export type EvmAddress = z.infer; +export type EvmId = z.infer; + export const providerSchema = z .object({ url: z.string().url(), @@ -98,6 +101,7 @@ export const configSchema = z .object({ sponsorWalletMnemonic: z.string().refine((mnemonic) => ethers.utils.isValidMnemonic(mnemonic), 'Invalid mnemonic'), chains: chainsSchema, + fetchInterval: z.number().positive(), deviationThresholdCoefficient: z.number(), }) .strict(); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..48381d40 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT = 10_000; +export const HTTP_SIGNED_DATA_API_HEADROOM = 1000; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..7ab1aff9 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,27 @@ +const debug = (...args: any[]) => + // eslint-disable-next-line no-console + console.debug(...args); +const error = (...args: any[]) => + // eslint-disable-next-line no-console + console.error(...args); +const info = (...args: any[]) => console.info(...args); +const log = (...args: any[]) => + // eslint-disable-next-line no-console + console.log(...args); +const warn = (...args: any[]) => + // eslint-disable-next-line no-console + console.warn(...args); + +export const logErrors = (promiseResults: PromiseSettledResult[], additionalText = '') => { + for (const rejectedPromise of promiseResults.filter((result) => result.status === 'rejected')) { + error(additionalText, rejectedPromise); + } +}; + +export const logger = { + debug, + error, + info, + log, + warn, +}; diff --git a/src/signed-api-fetch/data-fetcher.test.ts b/src/signed-api-fetch/data-fetcher.test.ts new file mode 100644 index 00000000..ef970ef4 --- /dev/null +++ b/src/signed-api-fetch/data-fetcher.test.ts @@ -0,0 +1,64 @@ +import axios from 'axios'; +import { runDataFetcher, stopDataFetcher } from './data-fetcher'; +import * as localDataStore from '../signed-data-store'; +import { init } from '../../test/fixtures/mock-config'; + +const mockedAxios = axios as jest.MockedFunction; +jest.mock('axios'); + +describe('data fetcher', () => { + // eslint-disable-next-line jest/no-hooks + beforeEach(() => { + localDataStore.clear(); + }); + + it('retrieves signed data from urls', async () => { + init(); + + const setStoreDataPointSpy = jest.spyOn(localDataStore, 'setStoreDataPoint'); + + mockedAxios.mockResolvedValue( + Promise.resolve({ + status: 200, + data: { + count: 3, + data: { + '0x91be0acf2d58a15c7cf687edabe4e255fdb27fbb77eba2a52f3bb3b46c99ec04': { + signature: + '0x0fe25ad7debe4d018aa53acfe56d84f35c8bedf58574611f5569a8d4415e342311c093bfe0648d54e0a02f13987ac4b033b24220880638df9103a60d4f74090b1c', + timestamp: '1687850583', + templateId: '0x154c34adf151cf4d91b7abe7eb6dcd193104ef2a29738ddc88020a58d6cf6183', + encodedValue: '0x000000000000000000000000000000000000000000000065954b143faff77440', + airnode: '0xC04575A2773Da9Cd23853A69694e02111b2c4182', + }, + '0xddc6ca9cc6f5768d9bfa8cc59f79bde8cf97a6521d0b95835255951ce06f19e6': { + signature: + '0x1f8993bae330ff73f050aeb8221207f80d22c43174e56079663d520fd2ccaec52b87f56d2fb2184f99d0c37dabd78cf7ff4f2cd27f7fd337d06ebfe590e09a7d1c', + timestamp: '1687850583', + templateId: '0x55d08a477d28519c8bc889b0be4f4d08625cfec5369f047258a1a4d7e1e405f3', + encodedValue: '0x00000000000000000000000000000000000000000000066e419d6bdc61e19680', + airnode: '0xC04575A2773Da9Cd23853A69694e02111b2c4182', + }, + '0x5dd8d9e1429f69ba4bd76df5709155110429857d19670cc157632f66a48ee1f7': { + signature: + '0x48c9c53645b5e69c986ab02fcae88ddd5247ce000bf1fddb2cd83ac6af8553e554164d3f6d5906fa8d24ce9224484a2664a70bb75893e9cf18bcffadee4345bc1c', + timestamp: '1687850583', + templateId: '0x96504241fb9ae9a5941f97c9561dcfcd7cee77ee9486a58c8e78551c1268ddec', + encodedValue: '0x0000000000000000000000000000000000000000000000000e461510ad9d8678', + airnode: '0xC04575A2773Da9Cd23853A69694e02111b2c4182', + }, + }, + }, + }) + ); + + const dataFetcherPromise = runDataFetcher(); + + await expect(dataFetcherPromise).resolves.toBeDefined(); + + stopDataFetcher(); + + expect(mockedAxios).toHaveBeenCalledTimes(2); + expect(setStoreDataPointSpy).toHaveBeenCalledTimes(6); + }); +}); diff --git a/src/signed-api-fetch/data-fetcher.ts b/src/signed-api-fetch/data-fetcher.ts new file mode 100644 index 00000000..6dec0fe0 --- /dev/null +++ b/src/signed-api-fetch/data-fetcher.ts @@ -0,0 +1,94 @@ +import { clearInterval } from 'node:timers'; +import { go, goSync } from '@api3/promise-utils'; +import axios from 'axios'; +import { uniq } from 'lodash'; +import { signedApiResponseSchema, type SignedData } from '../types'; +import * as localDataStore from '../signed-data-store'; +import { getState, setState } from '../state'; +import { logger } from '../logger'; +import { HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT, HTTP_SIGNED_DATA_API_HEADROOM } from '../constants'; + +// Express handler/endpoint path: https://github.com/api3dao/signed-api/blob/b6e0d0700dd9e7547b37eaa65e98b50120220105/packages/api/src/server.ts#L33 +// Actual handler fn: https://github.com/api3dao/signed-api/blob/b6e0d0700dd9e7547b37eaa65e98b50120220105/packages/api/src/handlers.ts#L81 + +/** + * Shuts down intervals + */ +export const stopDataFetcher = () => { + clearInterval(getState().dataFetcherInterval); +}; + +/** + * Calls a remote signed data URL and inserts the result into the datastore + * @param url + */ +const callSignedDataApi = async (url: string): Promise => { + const result = await go( + async () => + axios({ + method: 'get', + timeout: HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT - HTTP_SIGNED_DATA_API_HEADROOM / 2, + url, + headers: { + Accept: 'application/json', + // TODO add API key? + }, + }), + { + attemptTimeoutMs: HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT, + totalTimeoutMs: HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT + HTTP_SIGNED_DATA_API_HEADROOM / 2, + retries: 0, + } + ); + + if (!result.success) { + throw new Error([`HTTP call failed: `, url, result.error].join('\n')); + } + + const { data } = signedApiResponseSchema.parse(result.data.data); + + return Object.values(data); +}; + +export const runDataFetcher = async () => { + const state = getState(); + const { config, dataFetcherInterval } = state; + + const fetchInterval = config.fetchInterval * 1000; + + if (!dataFetcherInterval) { + const dataFetcherInterval = setInterval(runDataFetcher, fetchInterval); + setState({ ...state, dataFetcherInterval }); + } + + const urls = uniq( + Object.values(config.chains).flatMap((chain) => + Object.entries(chain.__Temporary__DapiDataRegistry.airnodeToSignedApiUrl).flatMap( + ([airnodeAddress, baseUrl]) => `${baseUrl}/${airnodeAddress}` + ) + ) + ); + + return Promise.allSettled( + urls.map(async (url) => + go( + async () => { + const payload = await callSignedDataApi(url); + + for (const element of payload) { + const result = goSync(() => localDataStore.setStoreDataPoint(element)); + + if (!result.success) { + logger.warn('Error while storing datapoint in data store.', { ...result.error }); + } + } + }, + { + retries: 0, + totalTimeoutMs: fetchInterval + HTTP_SIGNED_DATA_API_HEADROOM, + attemptTimeoutMs: fetchInterval + HTTP_SIGNED_DATA_API_HEADROOM - 100, + } + ) + ) + ); +}; diff --git a/src/signed-api-fetch/index.ts b/src/signed-api-fetch/index.ts new file mode 100644 index 00000000..38423a6c --- /dev/null +++ b/src/signed-api-fetch/index.ts @@ -0,0 +1 @@ +export * from './data-fetcher'; diff --git a/src/signed-data-store/index.ts b/src/signed-data-store/index.ts new file mode 100644 index 00000000..9d6d060c --- /dev/null +++ b/src/signed-data-store/index.ts @@ -0,0 +1 @@ +export * from './signed-data-store'; diff --git a/src/signed-data-store/signed-data-store.test.ts b/src/signed-data-store/signed-data-store.test.ts new file mode 100644 index 00000000..086a0253 --- /dev/null +++ b/src/signed-data-store/signed-data-store.test.ts @@ -0,0 +1,76 @@ +import { BigNumber, ethers } from 'ethers'; +import * as localDataStore from './signed-data-store'; +import { verifySignedDataIntegrity } from './signed-data-store'; +import { generateRandomBytes32, signData } from '../../test/utils/evm'; +import type { SignedData } from '../types'; + +describe('datastore', () => { + let testDataPoint: SignedData; + const signer = ethers.Wallet.fromMnemonic('test test test test test test test test test test test junk'); + + // eslint-disable-next-line jest/no-hooks + beforeAll(async () => { + const templateId = generateRandomBytes32(); + const timestamp = Math.floor((Date.now() - 25 * 60 * 60 * 1000) / 1000).toString(); + const airnode = signer.address; + const encodedValue = ethers.utils.defaultAbiCoder.encode(['int256'], [BigNumber.from(1)]); + + testDataPoint = { + airnode, + encodedValue, + signature: await signData(signer, templateId, timestamp, encodedValue), + templateId, + timestamp, + }; + }); + + // eslint-disable-next-line jest/no-hooks + beforeEach(localDataStore.clear); + + it('stores and gets a data point', () => { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + const promisedStorage = localDataStore.setStoreDataPoint(testDataPoint); + expect(promisedStorage).toBeFalsy(); + + const datapoint = localDataStore.getStoreDataPoint(testDataPoint.airnode, testDataPoint.templateId); + + const { encodedValue, signature, timestamp } = testDataPoint; + + expect(datapoint).toEqual({ encodedValue, signature, timestamp }); + }); + + it('checks that the timestamp on signed data is not in the future', async () => { + const templateId = generateRandomBytes32(); + const timestamp = Math.floor((Date.now() + 61 * 60 * 1000) / 1000).toString(); + const airnode = signer.address; + const encodedValue = ethers.utils.defaultAbiCoder.encode(['int256'], [BigNumber.from(1)]); + + const futureTestDataPoint = { + airnode, + encodedValue, + signature: await signData(signer, templateId, timestamp, encodedValue), + templateId, + timestamp, + }; + + expect(verifySignedDataIntegrity(testDataPoint)).toBeTruthy(); + expect(verifySignedDataIntegrity(futureTestDataPoint)).toBeFalsy(); + }); + + it('checks the signature on signed data', async () => { + const templateId = generateRandomBytes32(); + const timestamp = Math.floor((Date.now() + 60 * 60 * 1000) / 1000).toString(); + const airnode = ethers.Wallet.createRandom().address; + const encodedValue = ethers.utils.defaultAbiCoder.encode(['int256'], [BigNumber.from(1)]); + + const badTestDataPoint = { + airnode, + encodedValue, + signature: await signData(signer, templateId, timestamp, encodedValue), + templateId, + timestamp, + }; + + expect(verifySignedDataIntegrity(badTestDataPoint)).toBeFalsy(); + }); +}); diff --git a/src/signed-data-store/signed-data-store.ts b/src/signed-data-store/signed-data-store.ts new file mode 100644 index 00000000..4cca52e2 --- /dev/null +++ b/src/signed-data-store/signed-data-store.ts @@ -0,0 +1,97 @@ +import { BigNumber, ethers } from 'ethers'; +import { logger } from '../logger'; +import type { LocalSignedData, SignedData, AirnodeAddress, TemplateId } from '../types'; + +// A simple in-memory data store implementation - the interface allows for swapping in a remote key/value store +let signedApiStore: Record> = {}; + +export const verifySignedData = ({ airnode, templateId, timestamp, signature, encodedValue }: SignedData) => { + // 'encodedValue' is: ethers.utils.defaultAbiCoder.encode(['int256'], [beaconValue]); + + const message = ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['bytes32', 'uint256', 'bytes'], [templateId, timestamp, encodedValue]) + ); + + const signerAddr = ethers.utils.verifyMessage(message, signature); + return signerAddr !== airnode; +}; + +const verifyTimestamp = ({ timestamp, airnode, encodedValue, templateId }: SignedData) => { + if (Number.parseInt(timestamp, 10) * 1000 > Date.now() + 60 * 60 * 1000) { + logger.warn(`Refusing to store sample as timestamp is more than one hour in the future.`, { + airnode, + templateId, + systemDateNow: new Date().toLocaleDateString(), + signedDataDate: new Date(Number.parseInt(timestamp, 10) * 1000).toLocaleDateString(), + }); + return false; + } + + if (Number.parseInt(timestamp, 10) * 1000 > Date.now()) { + logger.warn( + `Sample is in the future, but by less than an hour, therefore storing anyway: (Airnode ${airnode}) (Template ID ${templateId}) (Received timestamp ${new Date( + Number.parseInt(timestamp, 10) * 1000 + ).toLocaleDateString()} vs now ${new Date().toLocaleDateString()}), ${ + BigNumber.from(encodedValue).div(10e10).toNumber() / 10e8 + }` + ); + } + + return true; +}; + +export const verifySignedDataIntegrity = (signedData: SignedData) => { + const { airnode, templateId, timestamp, encodedValue } = signedData; + + if (!verifyTimestamp(signedData)) { + return false; + } + + if (verifySignedData(signedData)) { + logger.warn( + `Refusing to store sample as signature does not match: (Airnode ${airnode}) (Template ID ${templateId}) (Received timestamp ${new Date( + Number.parseInt(timestamp, 10) * 1000 + ).toLocaleDateString()} vs now ${new Date().toLocaleDateString()}), ${ + BigNumber.from(encodedValue).div(10e10).toNumber() / 10e8 + }` + ); + return false; + } + + return true; +}; + +export const setStoreDataPoint = (signedData: SignedData) => { + const { airnode, templateId, signature, timestamp, encodedValue } = signedData; + + if (!verifySignedDataIntegrity(signedData)) { + logger.warn(`Signed data received from signed data API has a signature mismatch.`); + logger.warn(JSON.stringify({ airnode, templateId, signature, timestamp, encodedValue }, null, 2)); + return; + } + + if (!signedApiStore[airnode]) { + signedApiStore[airnode] = {}; + } + + const existingValue = signedApiStore[airnode]![templateId]; + if (existingValue && existingValue.timestamp >= timestamp) { + logger.debug('Skipping store update. The existing store value is fresher.'); + return; + } + + logger.debug( + `Storing sample for (Airnode ${airnode}) (Template ID ${templateId}) (Timestamp ${new Date( + Number.parseInt(timestamp, 10) * 1000 + ).toISOString()}), ${BigNumber.from(encodedValue).div(10e10).toNumber() / 10e8}` + ); + + signedApiStore[airnode]![templateId] = { signature, timestamp, encodedValue }; +}; + +export const getStoreDataPoint = (airnode: AirnodeAddress, templateId: TemplateId) => + signedApiStore[airnode]?.[templateId]; + +export const clear = () => { + signedApiStore = {}; +}; diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 00000000..da885434 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1 @@ +export * from './state'; diff --git a/src/state/state.ts b/src/state/state.ts new file mode 100644 index 00000000..23be35d6 --- /dev/null +++ b/src/state/state.ts @@ -0,0 +1,20 @@ +import type { Config } from '../config/schema'; + +interface State { + config: Config; + dataFetcherInterval?: NodeJS.Timeout; +} + +let state: State | undefined; + +export const getState = (): State => { + if (!state) { + throw new Error('State is undefined.'); + } + + return state; +}; + +export const setState = (newState: State) => { + state = newState; +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..f5b50a6f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { type EvmAddress, evmAddressSchema, type EvmId, evmIdSchema } from './config/schema'; + +export type AirnodeAddress = EvmAddress; +export type TemplateId = EvmId; + +// Taken from https://github.com/api3dao/signed-api/blob/main/packages/api/src/schema.ts +export const signedDataSchema = z.object({ + airnode: evmAddressSchema, + templateId: evmIdSchema, + timestamp: z.string(), + encodedValue: z.string(), + signature: z.string(), +}); + +export type SignedData = z.infer; + +export const signedApiResponseSchema = z.object({ + count: z.number().positive(), + data: z.record(signedDataSchema), +}); + +export type LocalSignedData = Pick; diff --git a/test/fixtures/mock-config.ts b/test/fixtures/mock-config.ts new file mode 100644 index 00000000..514cfbc0 --- /dev/null +++ b/test/fixtures/mock-config.ts @@ -0,0 +1,52 @@ +import { ethers } from 'ethers'; +import { setState } from '../../src/state'; +import type { Config } from '../../src/config/schema'; + +/** + * A stub to retrieve the latest config + */ +const getConfig = () => generateTestConfig(); + +// This is not a secret +// https://pool.nodary.io/0xC04575A2773Da9Cd23853A69694e02111b2c4182 +export const generateTestConfig = (): Config => ({ + sponsorWalletMnemonic: 'test test test test test test test test test test test junk', + chains: { + '31337': { + contracts: { + Api3ServerV1: '', + }, + providers: { hardhat: { url: 'http://127.0.0.1:8545' } }, + __Temporary__DapiDataRegistry: { + airnodeToSignedApiUrl: { + '0xC04575A2773Da9Cd23853A69694e02111b2c4182': 'https://pool.nodary.io', // stale data + '0xc52EeA00154B4fF1EbbF8Ba39FDe37F1AC3B9Fd4': 'https://pool.nodary.io', // fresh data + }, + dataFeedIdToBeacons: { + [ethers.BigNumber.from(ethers.utils.randomBytes(64)).toHexString()]: [ + { + templateId: '0x154c34adf151cf4d91b7abe7eb6dcd193104ef2a29738ddc88020a58d6cf6183', + airnode: '0xC04575A2773Da9Cd23853A69694e02111b2c4182', + }, + ], + [ethers.BigNumber.from(ethers.utils.randomBytes(64)).toHexString()]: [ + { + templateId: '0x96504241fb9ae9a5941f97c9561dcfcd7cee77ee9486a58c8e78551c1268ddec', + airnode: '0xc52EeA00154B4fF1EbbF8Ba39FDe37F1AC3B9Fd4', + }, + ], + }, + activeDapiNames: [], + }, + }, + }, + fetchInterval: 10, + deviationThresholdCoefficient: 1, +}); + +export const init = () => { + const config = getConfig(); + setState({ + config, + }); +}; diff --git a/test/utils/evm.ts b/test/utils/evm.ts new file mode 100644 index 00000000..c7f2d6a2 --- /dev/null +++ b/test/utils/evm.ts @@ -0,0 +1,10 @@ +import { ethers } from 'ethers'; + +export const signData = async (signer: ethers.Signer, templateId: string, timestamp: string, data: string) => + signer.signMessage( + ethers.utils.arrayify( + ethers.utils.solidityKeccak256(['bytes32', 'uint256', 'bytes'], [templateId, timestamp, data]) + ) + ); + +export const generateRandomBytes32 = () => ethers.utils.hexlify(ethers.utils.randomBytes(32));