diff --git a/packages/api/README.md b/packages/api/README.md index 6acecc43..2d2c62c6 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1,6 +1,9 @@ # api -A service for storing and accessing signed data. It provides endpoints to handle signed data for a specific airnode. +> A service for storing and accessing signed data. It provides endpoints to handle signed data for a specific airnode. + +Signed API is a Node.js API server, dockerized and deployable on any cloud provider or hostable on premise. It stores +the data in memory and provides endpoints to push and retrieve beacon data. ## Local development @@ -9,37 +12,119 @@ A service for storing and accessing signed data. It provides endpoints to handle 2. `cp .env.example .env` - To copy the example environment variables. Optionally change the defaults. 3. `pnpm run dev` - To start the API server. The port number can be configured in the configuration file. -## Deployment +## Configuration -TODO: Write example how to deploy on AWS (and maybe other cloud providers as well). +The API is configured via combination of [environment variables](#environment-variables) and +[configuration file](#configuration-file). -## Configuration +### Environment variables + +Parts of the API needs to be initialized prior the configuration files are loaded. This is done via environment +variables. For example: + +```sh +# Defines a logger suitable for production. +LOGGER_ENABLED=true +LOG_COLORIZE=false +LOG_FORMAT=json +LOG_LEVEL=info + +# Defines the source of the configuration file on AWS S3 (the values specified here are only exemplatory). +CONFIG_SOURCE=aws-s3 +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +AWS_REGION=us-west-1 +AWS_S3_BUCKET_NAME=my-config-bucket +AWS_S3_BUCKET_PATH=configs/my-app/signed-api.json +``` + + + + + +#### `LOGGER_ENABLED` + +Enables or disables logging. Options: + +- `true` - Enables logging. +- `false` - Disables logging. + +#### `LOG_FORMAT` + +The format of the log output. Options: + +- `json` - Specifies JSON log format. This is suitable when running in production and streaming logs to other services. +- `pretty` - Logs are formatted in a human-friendly "pretty" way. Ideal, when running the service locally and in + development. + +#### `LOG_COLORIZE` + +Enables or disables colors in the log output. Options: + +- `true` - Enables colors in the log output. The output has special color setting characters that are parseable by CLI. + Recommended when running locally and in development. +- `false` - Disables colors in the log output. Recommended for production. + +#### `LOG_LEVEL` -The API is configured via `signed-api.json`. You can use this file to specify the port of the server, configure cache -header longevity or maximum batch size the API accepts. +Defines the minimum level of logs. Logs with smaller level (severity) will be silenced. Options: -### Configuring endpoints +- `debug` - Enables all logs. +- `info` - Enables logs with level `info`, `warn` and `error`. +- `warn` - Enables logs with level `warn` and `error`. +- `error` - Enables logs with level `error`. + +### Configuration file + +The API is configured via `signed-api.json` configuration file. + +#### `endpoints` The API needs to be configured with endpoints to be served. This is done via the `endpoints` section. For example: -```json - "endpoints": [ - { - "urlPath": "/real-time", - "delaySeconds": 0 - }, - { - "urlPath": "/delayed", - "delaySeconds": 15 - } - ], +```jsonc +// Defines two endpoints. +"endpoints": [ + // Serves the non-delayed data on URL path "/real-time". + { + "urlPath": "/real-time", + "delaySeconds": 0 + }, + // Serves the data delayed by 15 seconds on URL path "/delayed". + { + "urlPath": "/delayed", + "delaySeconds": 15 + } +] ``` -defines two endpoints. The `/real-time` serves the non-delayed data, the latter (`/delayed`) ignores all signed data -that has bee pushed in the last 15 seconds (configured by `delaySeconds` parameter). You can define multiple endpoints -as long as the `urlPath` is unique. +##### `endpoints[n]` + +Configuration for one of the endpoints. + +###### `urlPath` + +The URL path on which the endpoint is served. Must start with a slash and contain only alphanumeric characters and +dashes. + +###### `delaySeconds` + +The delay in seconds for the endpoint. The endpoint will only serve data that is older than the delay. + +#### `maxBatchSize` -## Usage +The maximum number of signed data entries that can be inserted in one batch. This is a safety measure to prevent +spamming theAPI with large payloads. The batch is rejected if it contains more entries than this value. + +#### `port` + +The port on which the API is served. + +#### `cache.maxAgeSeconds` + +The maximum age of the cache header in seconds. + +## API The API provides the following endpoints: @@ -53,13 +138,11 @@ The API provides the following endpoints: - Returns all Airnode addresses for which there is signed data. It is possible that this data cannot be shown by the delayed endpoints (in case the data is too fresh and there is not an older alternative). -## Local development +## Deployment -Spin up local `express` server: +TODO: Write example how to deploy on AWS. -```bash -pnpm run dev -``` +To deploy on premise you can use the Docker instructions below. ## Docker diff --git a/packages/common/src/logger/README.md b/packages/common/src/logger/README.md index 8f1822c9..2c7750cd 100644 --- a/packages/common/src/logger/README.md +++ b/packages/common/src/logger/README.md @@ -4,35 +4,36 @@ Backend-only logger for Node.js packages based on Winston logger. ## Configuration -Logger configuration is essentially: +Logger configuration allows specifying log format, styling and level. -```ts -interface LogConfiguration { - type: 'hidden' | 'json' | 'pretty'; // Specifies the log format. - styling: 'on' | 'off'; // Toggles output colorization. - minLevel: 'debug' | 'info' | 'warn' | 'error'; // Specifies the minimum log level that is logged. -} -``` + -### type +### `enabled` + +Enables or disables logging. Options: + +- `true` - Enables logging. +- `false` - Disables logging. + +### `format` -- `hidden` - Silences all logs. This is suitable for test environment. - `json` - Specifies JSON log format. This is suitable when running in production and streaming logs to other services. - `pretty` - Logs are formatted in a human-friendly "pretty" way. Ideal, when running the service locally and in development. -### styling +### `colorize` -- `on` - Enables colors in the log output. The output has special color setting characters that are parseable by CLI. - Recommended when running locally and in development. -- `off` - Disables colors in the log output. Recommended for production. +Enables or disables colors in the log output. Options: -### minLevel +- `true` - Enables colors in the log output. The output has special color setting characters that are parseable by CLI. + Recommended when running locally and in development. +- `false` - Disables colors in the log output. Recommended for production. -One of the following options: +### `minLevel` -```ts -'debug' | 'info' | 'warn' | 'error'; -``` +Defines the minimum level of logs. Logs with smaller level (severity) will be silenced. Options: -Logs with smaller level (severity) will be silenced. +- `debug` - Enables all logs. +- `info` - Enables logs with level `info`, `warn` and `error`. +- `warn` - Enables logs with level `warn` and `error`. +- `error` - Enables logs with level `error`. diff --git a/packages/data-pusher/README.md b/packages/data-pusher/README.md index 98815732..67670662 100644 --- a/packages/data-pusher/README.md +++ b/packages/data-pusher/README.md @@ -1,6 +1,17 @@ + + # data-pusher -A service for storing and accessing signed data. +> A service for storing and accessing signed data. + +Pusher is a Node.js service, dockerized and deployable on any cloud provider or hostable on premise. It is continuously +running two core loops: + +1. `Fetch beacon data` - Each `triggers.signedApiUpdates` entry defines a group of templates. Pusher makes a template + request to the API specified in the OIS to get the template data. Pusher's wallet is used to sign the responses and + these are then saved to in-memory storage. +2. `Push signed beacon data to signed API` - For each `triggers.signedApiUpdates`, periodically checks the in-memory + storage and pushes the signed data to the configured API. ## Local development @@ -15,15 +26,313 @@ To start the the pusher in dev mode run the following: file is ignored by git. 2. `cp secrets.example.env secrets.env` - To copy the secrets.env needed for the configuration. This file is also ignored by git. -3. Set the `NODARY_API_KEY` inside the secrets file. +3. Set the `NODARY_API_KEY` inside the secrets file. Ask someone from development team for the key. 4. `cp .env.example .env` - To copy the example environment variables. Optionally change the defaults. 5. `pnpm run dev` - To run the pusher. This step assumes already running signed API as specified in the `pusher.json` configuration. +## Configuration + +Pusher can be configured via a combination of [environment variables](#environment-variables) and +[configuration files](#configuration-files). + +### Environment variables + +Logging needs to be initialized prior the configuration files are loaded. This is done via environment variables. For +example: + +```sh +# Defines a logger suitable for production. +LOGGER_ENABLED=true +LOG_COLORIZE=false +LOG_FORMAT=json +LOG_LEVEL=info +``` + +or + +```sh +# Defines a logger suitable for local development or testing. +LOGGER_ENABLED=true +LOG_COLORIZE=false +LOG_FORMAT=json +LOG_LEVEL=info +``` + + + + + +#### `LOGGER_ENABLED` + +Enables or disables logging. Options: + +- `true` - Enables logging. +- `false` - Disables logging. + +#### `LOG_FORMAT` + +The format of the log output. Options: + +- `json` - Specifies JSON log format. This is suitable when running in production and streaming logs to other services. +- `pretty` - Logs are formatted in a human-friendly "pretty" way. Ideal, when running the service locally and in + development. + +#### `LOG_COLORIZE` + +Enables or disables colors in the log output. Options: + +- `true` - Enables colors in the log output. The output has special color setting characters that are parseable by CLI. + Recommended when running locally and in development. +- `false` - Disables colors in the log output. Recommended for production. + +#### `LOG_LEVEL` + +Defines the minimum level of logs. Logs with smaller level (severity) will be silenced. Options: + +- `debug` - Enables all logs. +- `info` - Enables logs with level `info`, `warn` and `error`. +- `warn` - Enables logs with level `warn` and `error`. +- `error` - Enables logs with level `error`. + +### Configuration files + +Pusher needs two configuration files, `pusher.json` and `secrets.env`. All expressions of a form `${SECRET_NAME}` are +referring to values from secrets and are interpolated inside the `config.json` at runtime. You are advised to put +sensitive information inside secrets. + +You can also refer to the [example configuration](./config). + +#### `airnodeWalletMnemonic` + +Mnemonic for the airnode wallet used to sign the template responses. It is recommended to interpolate this value from +secrets. For example: + +```jsonc +// The mnemonic is interpolated from the "WALLET_MNEMONIC" secret. +"airnodeWalletMnemonic": "${WALLET_MNEMONIC}" +``` + +#### `rateLimiting` + +Configuration for rate limiting OIS requests. Rate limiting can be configured for each OIS separately. For example: + +```jsonc +// Defines no rate limiting. +"rateLimiting": { }, +``` + +or + +```jsonc +// Defines rate limiting for OIS with title "Nodary" +"rateLimiting": { "Nodary": { "maxConcurrency": 25, "minTime": 10 } }, +``` + +##### `rateLimiting[]` + +The configuration for the OIS with title ``. + +###### `maxConcurrency` + +Maximum number of concurrent requests to the OIS. + +###### `minTime` + +Minimum time in milliseconds between two requests to the OIS. + +#### `templates` + +Configuration for the template requests. Each template request is defined by a `templateId` and a `template` object. For +example: + +```jsonc +// Defines a single template. +"templates": { + "0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd": { + "endpointId": "0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc", + "parameters": [{ "type": "string32", "name": "name", "value": "WTI/USD" }] + } +} +``` + +The template ID hash is derived from the template object. You can derive the ID using `ethers` library: + +```js +ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(['string', 'string'], [oisTitle, endpointName])); +``` + +##### `templates[]` + +Configuration for the template object with ID ``. + +###### `endpointId` + +The ID of the endpoint to which the template request is made. + +###### `parameters` + +The parameters of the template request. Refer to +[Airnode ABI](https://dapi-docs.api3.org/reference/airnode/latest/specifications/airnode-abi.html) specification for +details. + +###### `parameters[n]` + +Defines one of the parameters of the template request. + +`type` + +Refer to +[Airnode ABI available types](https://dapi-docs.api3.org/reference/airnode/latest/specifications/airnode-abi.html#details). + +`name` + +The name of the parameter. + +`value` + +The value of the parameter. + +#### `endpoints` + +Configuration for the endpoints. Each endpoint is defined by an `endpointId` and an `endpoint` object. For example: + +```jsonc +"endpoints": { + // Defines a single endpoint pointing to the OIS with title "Nodary" and endpoint named "feed". + "0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc": { + "endpointName": "feed", + "oisTitle": "Nodary" + } +} +``` + +The endpoint ID hash is derived from the endpoint object. You can derive the ID using `ethers` library: + +```js +ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(['string', 'string'], [oisTitle, endpointName])); +``` + +##### `endpoints[]` + +Configuration for the endpoint object with ID ``. + +###### `endpointName` + +The name of the endpoint. + +###### `oisTitle` + +The title of the OIS to which the endpoint belongs. + +#### `triggers.signedApiUpdates` + +Configuration for the signed API update triggers. There can be multiple triggers, each specifying a different update +configuration. + +For example: + +```jsonc +"triggers": { + // Defines a single trigger. + "signedApiUpdates": [ + { + // The data is pushed to the signed API named "localhost". + "signedApiName": "localhost", + // The data is fetched for the templates with the template IDs specified below. + "templateIds": [ + "0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd", + "0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66", + "0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9" + ], + // The template data is fetched every 5 seconds. + "fetchInterval": 5, + // The data remains in in-memory storage for at least 30 seconds before it can be pushed to the signed API. + "updateDelay": 30 + } + ] +} +``` + +##### `triggers.signedApiUpdates[n]` + +Configuration for one of the signed API update triggers. Pusher periodically pushes the data to the signed API. The +period is `2.5` seconds. + +Pusher only makes a single template request independently of the number of template IDs specified. This is to reduce the +number of data provider calls. This implies that all of the templates in the trigger must use the same endpoint and +parameters. You can use [OIS processing](https://dapi-docs.api3.org/reference/ois/latest/processing.html) to remove the +parameters before making the request (using pre-processing) and later get the corresponding template value based on the +endpoint parameters (using-processing). Refer to the [example configuration](./config) for details. + +###### `signedApiName` + +The name of the signed API to which the data is pushed. + +###### `templateIds` + +The IDs of the templates for which the data is fetched, signed and pushed. + +###### `fetchInterval` + +The interval in seconds between two consecutive fetches of the template data. + +###### `updateDelay` + +The minimum delay in seconds before the data can be pushed to signed API. + +#### `signedApis` + +Configuration for the signed APIs. Each signed API is defined by a `signedApiName` and a `signedApi` object. For +example: + +```jsonc +// Defines a single signed API. +"signedApis": [ + { + "name": "localhost", + "url": "http://localhost:8090" + } +] +``` + +##### `signedApis[n]` + +Configuration for one of the signed APIs. + +###### `name` + +The name of the signed API. + +###### `url` + +The URL of the signed API. + +#### `ois` + +Configuration for the OISes. + + + +##### `ois[n]` + +Refer to the [OIS documentation](https://dapi-docs.api3.org/reference/ois/latest/). + +#### `apiCredentials` + +Refer to Airnode's +[API credentials](https://dapi-docs.api3.org/reference/airnode/latest/deployment-files/config-json.html#apicredentials). + +## Deployment + +TODO: Write example how to deploy on AWS + +To deploy on premise you can use the Docker instructions below. + ## Docker -The data pusher is also dockerized. The dockerized pusher needs expects environment variable `CONFIG_PATH` to be -defined, pointing to a directory with `pusher.json` and `secrets.env` files. +Pusher is also dockerized. The dockerized pusher needs expects environment variable `CONFIG_PATH` to be defined, +pointing to a directory with `pusher.json` and `secrets.env` files. In order to run the pusher from a docker, run: diff --git a/packages/data-pusher/config/pusher.example.json b/packages/data-pusher/config/pusher.example.json index 85747a05..5b8699ba 100644 --- a/packages/data-pusher/config/pusher.example.json +++ b/packages/data-pusher/config/pusher.example.json @@ -1,32 +1,18 @@ { - "walletMnemonic": "${WALLET_MNEMONIC}", - "rateLimiting": { "maxDirectGatewayConcurrency": 25, "minDirectGatewayTime": 10 }, - "beacons": { - "0xebba8507d616ed80766292d200a3598fdba656d9938cecc392765d4a284a69a4": { - "airnode": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", - "templateId": "0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd" - }, - "0x6f6acbdadaaf116c89faf0e8de1d0c7c2352b01cce7be0eb9deb126ceaefa6ba": { - "airnode": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", - "templateId": "0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66" - }, - "0x7944f22b40cc691a003e35db4810b41543a83781d94f706b5c0b6980e0a06ed7": { - "airnode": "0xbF3137b0a7574563a23a8fC8badC6537F98197CC", - "templateId": "0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9" - } - }, + "airnodeWalletMnemonic": "${WALLET_MNEMONIC}", + "rateLimiting": { "Nodary": { "maxConcurrency": 25, "minTime": 10 } }, "templates": { "0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd": { "endpointId": "0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc", - "parameters": "0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005754492f55534400000000000000000000000000000000000000000000000000" + "parameters": [{ "type": "string32", "name": "name", "value": "WTI/USD" }] }, "0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66": { "endpointId": "0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc", - "parameters": "0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005841472f55534400000000000000000000000000000000000000000000000000" + "parameters": [{ "type": "string32", "name": "name", "value": "XAG/USD" }] }, "0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9": { "endpointId": "0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc", - "parameters": "0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005841552f55534400000000000000000000000000000000000000000000000000" + "parameters": [{ "type": "string32", "name": "name", "value": "XAU/USD" }] } }, "endpoints": { @@ -39,10 +25,10 @@ "signedApiUpdates": [ { "signedApiName": "localhost", - "beaconIds": [ - "0xebba8507d616ed80766292d200a3598fdba656d9938cecc392765d4a284a69a4", - "0x6f6acbdadaaf116c89faf0e8de1d0c7c2352b01cce7be0eb9deb126ceaefa6ba", - "0x7944f22b40cc691a003e35db4810b41543a83781d94f706b5c0b6980e0a06ed7" + "templateIds": [ + "0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd", + "0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66", + "0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9" ], "fetchInterval": 5, "updateDelay": 30 diff --git a/packages/data-pusher/src/api-requests/data-provider.ts b/packages/data-pusher/src/api-requests/data-provider.ts index 764d120c..7c5e1a62 100644 --- a/packages/data-pusher/src/api-requests/data-provider.ts +++ b/packages/data-pusher/src/api-requests/data-provider.ts @@ -1,4 +1,3 @@ -import * as abi from '@api3/airnode-abi'; import * as node from '@api3/airnode-node'; import { isNil, pick } from 'lodash'; import { getState } from '../state'; @@ -17,20 +16,23 @@ export const callApi = async (payload: node.ApiCallPayload) => { export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Promise => { const { - config: { beacons, endpoints, templates, ois, apiCredentials }, + config: { endpoints, templates, ois, apiCredentials }, apiLimiters, } = getState(); logger.debug('Making template requests', signedApiUpdate); - const { beaconIds } = signedApiUpdate; + const { templateIds } = signedApiUpdate; - // Because each beacon has the same operation, just take first one as operational template. See validation.ts for - // details. - const operationTemplateId = beacons[beaconIds[0]!]!.templateId; + // Because each template has the same operation, just take first one as operational template. See the validation logic + // for details. + const operationTemplateId = templateIds[0]!; const operationTemplate = templates[operationTemplateId]!; - - const parameters = abi.decode(operationTemplate.parameters); const endpoint = endpoints[operationTemplate.endpointId]!; - + const parameters = operationTemplate.parameters.reduce((acc, parameter) => { + return { + ...acc, + [parameter.name]: parameter.value, + }; + }, {}); const aggregatedApiCall: node.BaseAggregatedApiCall = { parameters, ...endpoint, @@ -54,13 +56,16 @@ export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Pr return []; } - const templateIds = beaconIds.map((beaconId) => beacons[beaconId]!.templateId); - const templateResponsePromises = templateIds.map(async (templateId) => { const template = templates[templateId]!; - const parameters = abi.decode(template.parameters); const endpoint = endpoints[template.endpointId]!; + const parameters = template.parameters.reduce((acc, parameter) => { + return { + ...acc, + [parameter.name]: parameter.value, + }; + }, {}); const aggregatedApiCall: node.BaseAggregatedApiCall = { parameters, ...endpoint, diff --git a/packages/data-pusher/src/api-requests/signed-api.ts b/packages/data-pusher/src/api-requests/signed-api.ts index 63ec8e08..feafe361 100644 --- a/packages/data-pusher/src/api-requests/signed-api.ts +++ b/packages/data-pusher/src/api-requests/signed-api.ts @@ -2,6 +2,7 @@ import { go } from '@api3/promise-utils'; import axios, { AxiosError } from 'axios'; import { isEmpty, isNil } from 'lodash'; import { ethers } from 'ethers'; +import { deriveBeaconId } from '@api3/airnode-node'; import { TemplateResponse } from './data-provider'; import { logger } from '../logger'; import { getState } from '../state'; @@ -13,20 +14,22 @@ export type SignedResponse = [TemplateId, SignedData]; export const postSignedApiData = async (group: SignedApiNameUpdateDelayGroup) => { const { - config: { beacons, signedApis }, + config: { signedApis }, templateValues, + walletPrivateKey, } = getState(); - const { signedApiName, beaconIds, updateDelay } = group; + const { signedApiName, templateIds, updateDelay } = group; const logContext = { signedApiName, updateDelay }; logger.debug('Posting signed API data.', { group, ...logContext }); const provider = signedApis.find((a) => a.name === signedApiName)!; - const batchPayloadOrNull = beaconIds.map((beaconId): SignedApiPayload | null => { - const { templateId, airnode } = beacons[beaconId]!; + const airnode = new ethers.Wallet(walletPrivateKey).address; + const batchPayloadOrNull = templateIds.map((templateId): SignedApiPayload | null => { const delayedSignedData = templateValues[templateId]!.get(updateDelay); if (isNil(delayedSignedData)) return null; - return { airnode, templateId, beaconId, ...delayedSignedData }; + + return { airnode, templateId, beaconId: deriveBeaconId(airnode, templateId), ...delayedSignedData }; }); const batchPayload = batchPayloadOrNull.filter((payload): payload is SignedApiPayload => !isNil(payload)); @@ -69,6 +72,7 @@ export const postSignedApiData = async (group: SignedApiNameUpdateDelayGroup) => return { success: true, count }; }; +// TODO: This function could be moved elsewhere export const signTemplateResponses = async (templateResponses: TemplateResponse[]) => { logger.debug('Signing template responses', { templateResponses }); diff --git a/packages/data-pusher/src/constants.ts b/packages/data-pusher/src/constants.ts index 69fa86de..ae2ab3ad 100644 --- a/packages/data-pusher/src/constants.ts +++ b/packages/data-pusher/src/constants.ts @@ -3,9 +3,9 @@ export const SIGNED_DATA_PUSH_POLLING_INTERVAL = 2_500; export const RANDOM_BACKOFF_MIN_MS = 0; export const RANDOM_BACKOFF_MAX_MS = 2_500; // The minimum amount of time between HTTP calls to remote APIs per OIS. -export const DIRECT_GATEWAY_MIN_TIME_DEFAULT_MS = 20; +export const OIS_MIN_TIME_DEFAULT_MS = 20; // The maximum number of simultaneously-running HTTP requests to remote APIs per OIS. -export const DIRECT_GATEWAY_MAX_CONCURRENCY_DEFAULT = 10; +export const OIS_MAX_CONCURRENCY_DEFAULT = 10; export const NO_SIGNED_API_UPDATE_EXIT_CODE = 1; export const NO_FETCH_EXIT_CODE = 2; diff --git a/packages/data-pusher/src/state.ts b/packages/data-pusher/src/state.ts index f82d5c26..a3610344 100644 --- a/packages/data-pusher/src/state.ts +++ b/packages/data-pusher/src/state.ts @@ -1,7 +1,7 @@ import Bottleneck from 'bottleneck'; import { ethers } from 'ethers'; import { Config, SignedData, TemplateId } from './validation/schema'; -import { DIRECT_GATEWAY_MAX_CONCURRENCY_DEFAULT, DIRECT_GATEWAY_MIN_TIME_DEFAULT_MS } from './constants'; +import { OIS_MAX_CONCURRENCY_DEFAULT, OIS_MIN_TIME_DEFAULT_MS } from './constants'; import { deriveEndpointId, getRandomId } from './utils'; export type TemplateValueStorage = Record; @@ -9,6 +9,7 @@ export type TemplateValueStorage = Record; export interface State { config: Config; templateValues: TemplateValueStorage; + // TODO: this can be trivially derived from config - remove. walletPrivateKey: string; apiLimiters: Record; } @@ -27,17 +28,15 @@ export const buildApiLimiters = (config: Config) => { const oisLimiters = Object.fromEntries( config.ois.map((ois) => { - const directGatewayOverrides = config?.rateLimiting?.overrides?.directGateways; - - if (directGatewayOverrides && directGatewayOverrides[ois.title]) { - const { minTime, maxConcurrent } = directGatewayOverrides[ois.title]!; + if (config.rateLimiting[ois.title]) { + const { minTime, maxConcurrency } = config.rateLimiting[ois.title]!; return [ ois.title, new Bottleneck({ id: getRandomId(), - minTime: minTime ?? DIRECT_GATEWAY_MIN_TIME_DEFAULT_MS, - maxConcurrent: maxConcurrent ?? DIRECT_GATEWAY_MAX_CONCURRENCY_DEFAULT, + minTime: minTime ?? OIS_MIN_TIME_DEFAULT_MS, + maxConcurrent: maxConcurrency ?? OIS_MAX_CONCURRENCY_DEFAULT, }), ]; } @@ -46,8 +45,8 @@ export const buildApiLimiters = (config: Config) => { ois.title, new Bottleneck({ id: getRandomId(), - minTime: DIRECT_GATEWAY_MIN_TIME_DEFAULT_MS, - maxConcurrent: DIRECT_GATEWAY_MAX_CONCURRENCY_DEFAULT, + minTime: OIS_MIN_TIME_DEFAULT_MS, + maxConcurrent: OIS_MAX_CONCURRENCY_DEFAULT, }), ]; }) @@ -77,7 +76,7 @@ export const getInitialState = (config: Config) => { config, templateValues: buildTemplateStorages(config), apiLimiters: buildApiLimiters(config), - walletPrivateKey: ethers.Wallet.fromMnemonic(config.walletMnemonic).privateKey, + walletPrivateKey: ethers.Wallet.fromMnemonic(config.airnodeWalletMnemonic).privateKey, sponsorWalletsPrivateKey: {}, }; }; diff --git a/packages/data-pusher/src/unexported-airnode-features/api-specification-processing.ts b/packages/data-pusher/src/unexported-airnode-features/api-specification-processing.ts index dbb8f096..cbcae1be 100644 --- a/packages/data-pusher/src/unexported-airnode-features/api-specification-processing.ts +++ b/packages/data-pusher/src/unexported-airnode-features/api-specification-processing.ts @@ -2,41 +2,11 @@ // implementation. Notably, the reserved paramaters are now inaccessible in processing. // // See: https://github.com/api3dao/airnode/issues/1738 -import { Endpoint, ProcessingSpecification } from '@api3/ois'; +import { ProcessingSpecification } from '@api3/ois'; import { go } from '@api3/promise-utils'; import * as node from '@api3/airnode-node'; import { unsafeEvaluate, unsafeEvaluateAsync } from './unsafe-evaluate'; -export const postProcessApiSpecifications = async (input: unknown, endpoint: Endpoint) => { - const { postProcessingSpecifications } = endpoint; - - if (!postProcessingSpecifications || postProcessingSpecifications?.length === 0) { - return input; - } - - const goResult = await go( - () => - postProcessingSpecifications.reduce(async (input: any, currentValue: ProcessingSpecification) => { - switch (currentValue.environment) { - case 'Node': - return unsafeEvaluate(await input, currentValue.value, currentValue.timeoutMs); - case 'Node async': - return unsafeEvaluateAsync(await input, currentValue.value, currentValue.timeoutMs); - default: - throw new Error(`Environment ${currentValue.environment} is not supported`); - } - }, Promise.resolve(input)), - - { retries: 0, totalTimeoutMs: node.PROCESSING_TIMEOUT } - ); - - if (!goResult.success) { - throw goResult.error; - } - - return goResult.data; -}; - export const preProcessApiSpecifications = async (payload: node.ApiCallPayload): Promise => { const { config, aggregatedApiCall } = payload; const { endpointName, oisTitle } = aggregatedApiCall; diff --git a/packages/data-pusher/src/update-signed-api.ts b/packages/data-pusher/src/update-signed-api.ts index 32764e74..e737ba51 100644 --- a/packages/data-pusher/src/update-signed-api.ts +++ b/packages/data-pusher/src/update-signed-api.ts @@ -2,16 +2,16 @@ import { get, isEmpty } from 'lodash'; import { logger } from './logger'; import { getState } from './state'; import { sleep } from './utils'; -import { BeaconId } from './validation/schema'; +import { TemplateId } from './validation/schema'; import { NO_SIGNED_API_UPDATE_EXIT_CODE, SIGNED_DATA_PUSH_POLLING_INTERVAL } from './constants'; import { postSignedApiData } from './api-requests/signed-api'; -// > -type SignedApiUpdateDelayBeaconIdsMap = Record>; +// > +type SignedApiUpdateDelayTemplateIdsMap = Record>; export type SignedApiNameUpdateDelayGroup = { signedApiName: string; - beaconIds: BeaconId[]; + templateIds: TemplateId[]; updateDelay: number; }; @@ -19,16 +19,16 @@ export const initiateUpdatingSignedApi = async () => { logger.debug('Initiating updating signed API'); const { config } = getState(); - const signedApiUpdateDelayBeaconIdsMap = config.triggers.signedApiUpdates.reduce( - (acc: SignedApiUpdateDelayBeaconIdsMap, signedApiUpdate) => { - if (isEmpty(signedApiUpdate.beaconIds)) return acc; + const signedApiUpdateDelayTemplateIdsMap = config.triggers.signedApiUpdates.reduce( + (acc: SignedApiUpdateDelayTemplateIdsMap, signedApiUpdate) => { + if (isEmpty(signedApiUpdate.templateIds)) return acc; return { ...acc, [signedApiUpdate.signedApiName]: { ...acc[signedApiUpdate.signedApiName], [signedApiUpdate.updateDelay]: [ ...get(acc, [signedApiUpdate.signedApiName, signedApiUpdate.updateDelay], []), - ...signedApiUpdate.beaconIds, + ...signedApiUpdate.templateIds, ], }, }; @@ -37,12 +37,12 @@ export const initiateUpdatingSignedApi = async () => { ); const signedApiUpdateDelayGroups: SignedApiNameUpdateDelayGroup[] = Object.entries( - signedApiUpdateDelayBeaconIdsMap - ).flatMap(([signedApiName, updateDelayBeaconIds]) => - Object.entries(updateDelayBeaconIds).map(([updateDelay, beaconIds]) => ({ + signedApiUpdateDelayTemplateIdsMap + ).flatMap(([signedApiName, updateDelayTemplateIds]) => + Object.entries(updateDelayTemplateIds).map(([updateDelay, templateIds]) => ({ signedApiName, updateDelay: parseInt(updateDelay), - beaconIds, + templateIds, })) ); diff --git a/packages/data-pusher/src/validation/schema.test.ts b/packages/data-pusher/src/validation/schema.test.ts index 1b516b86..f39c269f 100644 --- a/packages/data-pusher/src/validation/schema.test.ts +++ b/packages/data-pusher/src/validation/schema.test.ts @@ -1,9 +1,34 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { configSchema } from './schema'; +import { ZodError } from 'zod'; +import { configSchema, signedApisSchema } from './schema'; it('validates example config', async () => { const config = JSON.parse(readFileSync(join(__dirname, '../../config/pusher.example.json'), 'utf8')); await expect(configSchema.parseAsync(config)).resolves.toEqual(expect.any(Object)); }); + +it('ensures signed API names are unique', () => { + expect(() => + signedApisSchema.parse([ + { name: 'foo', url: 'https://example.com' }, + { name: 'foo', url: 'https://example.com' }, + ]) + ).toThrow( + new ZodError([ + { + code: 'custom', + message: 'Signed API names must be unique', + path: ['signedApis'], + }, + ]) + ); + + expect(signedApisSchema.parse([{ name: 'foo', url: 'https://example.com' }])).toEqual([ + { + name: 'foo', + url: 'https://example.com', + }, + ]); +}); diff --git a/packages/data-pusher/src/validation/schema.ts b/packages/data-pusher/src/validation/schema.ts index db0a0a73..5af5aa9d 100644 --- a/packages/data-pusher/src/validation/schema.ts +++ b/packages/data-pusher/src/validation/schema.ts @@ -6,50 +6,42 @@ import { config } from '@api3/airnode-validator'; import * as abi from '@api3/airnode-abi'; import * as node from '@api3/airnode-node'; import { logFormatSchema, logLevelSchema } from 'signed-api/common'; +import { goSync } from '@api3/promise-utils'; import { preProcessApiSpecifications } from '../unexported-airnode-features/api-specification-processing'; -export const limiterConfig = z.object({ minTime: z.number(), maxConcurrent: z.number() }); +export const limiterConfig = z.object({ minTime: z.number(), maxConcurrency: z.number() }); -export const fetchMethodSchema = z.union([z.literal('gateway'), z.literal('api')]); - -export const beaconSchema = z +export const parameterSchema = z .object({ - airnode: config.evmAddressSchema, - templateId: config.evmIdSchema, - fetchInterval: z.number().int().positive().optional(), - fetchMethod: fetchMethodSchema.optional(), + name: z.string(), + type: z.string(), + value: z.string(), }) .strict(); -export const beaconsSchema = z.record(config.evmIdSchema, beaconSchema).superRefine((beacons, ctx) => { - Object.entries(beacons).forEach(([beaconId, beacon]) => { - // Verify that config.beacons. is valid - // by deriving the hash of the airnode address and templateId - const derivedBeaconId = ethers.utils.solidityKeccak256(['address', 'bytes32'], [beacon.airnode, beacon.templateId]); - if (derivedBeaconId !== beaconId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Beacon ID "${beaconId}" is invalid`, - path: [beaconId], - }); - } - }); -}); - export const templateSchema = z .object({ endpointId: config.evmIdSchema, - parameters: z.string(), + parameters: z.array(parameterSchema), }) .strict(); export const templatesSchema = z.record(config.evmIdSchema, templateSchema).superRefine((templates, ctx) => { Object.entries(templates).forEach(([templateId, template]) => { - // Verify that config.templates. is valid - // by deriving the hash of the endpointId and parameters + // 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, + message: `Unable to encode parameters: ${goEncodeParameters.error}`, + path: ['templates', templateId, 'parameters'], + }); + return; + } + const derivedTemplateId = ethers.utils.solidityKeccak256( ['bytes32', 'bytes'], - [template.endpointId, template.parameters] + [template.endpointId, goEncodeParameters.data] ); if (derivedTemplateId !== templateId) { ctx.addIssue({ @@ -99,7 +91,7 @@ export const beaconUpdateSchema = z export const signedApiUpdateSchema = z.object({ signedApiName: z.string(), - beaconIds: z.array(config.evmIdSchema), + templateIds: z.array(config.evmIdSchema), fetchInterval: z.number(), updateDelay: z.number(), }); @@ -108,28 +100,15 @@ export const triggersSchema = z.object({ signedApiUpdates: z.array(signedApiUpdateSchema), }); -const validateTemplatesReferences: SuperRefinement<{ beacons: Beacons; templates: Templates; endpoints: Endpoints }> = ( - config, - ctx -) => { +const validateTemplatesReferences: SuperRefinement<{ templates: Templates; endpoints: Endpoints }> = (config, ctx) => { Object.entries(config.templates).forEach(([templateId, template]) => { - // Verify that config.templates..endpointId is - // referencing a valid config.endpoints. object - - // Only verify for `api` call endpoints - if ( - Object.values(config.beacons).some( - ({ templateId: tId, fetchMethod }) => fetchMethod === 'api' && tId === templateId - ) - ) { - const endpoint = config.endpoints[template.endpointId]; - if (isNil(endpoint)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Endpoint "${template.endpointId}" is not defined in the config.endpoints object`, - path: ['templates', templateId, 'endpointId'], - }); - } + const endpoint = config.endpoints[template.endpointId]; + if (isNil(endpoint)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Endpoint "${template.endpointId}" is not defined in the config.endpoints object`, + path: ['templates', templateId, 'endpointId'], + }); } }); }; @@ -164,38 +143,26 @@ const validateTriggerReferences: SuperRefinement<{ ois: OIS[]; endpoints: Endpoints; triggers: Triggers; - beacons: Beacons; templates: Templates; apiCredentials: ApisCredentials; }> = async (config, ctx) => { - const { ois, templates, endpoints, beacons, apiCredentials, triggers } = config; + const { ois, templates, endpoints, apiCredentials, triggers } = config; for (const signedApiUpdate of triggers.signedApiUpdates) { - const { beaconIds } = signedApiUpdate; + const { templateIds } = signedApiUpdate; - // Check only if beaconIds contains more than 1 beacon - if (beaconIds.length > 1) { - const operationPayloadPromises = beaconIds.map((beaconId) => { - const beacon = beacons[beaconId]; - if (!beacon) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Unable to find beacon with ID: ${beaconId}`, - path: ['beacons'], - }); - return; - } - const template = templates[beacon.templateId]; + if (templateIds.length > 1) { + const operationPayloadPromises = templateIds.map((templateId) => { + const template = templates[templateId]; if (!template) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Unable to find template with ID: ${beacon.templateId}`, + message: `Unable to find template with ID: ${templateId}`, path: ['templates'], }); return; } - const parameters = abi.decode(template.parameters); const endpoint = endpoints[template.endpointId]; if (!endpoint) { ctx.addIssue({ @@ -206,6 +173,13 @@ const validateTriggerReferences: SuperRefinement<{ return; } + const parameters = template.parameters.reduce((acc, parameter) => { + return { + ...acc, + [parameter.name]: parameter.value, + }; + }, {}); + const aggregatedApiCall = { parameters, ...endpoint, @@ -234,45 +208,14 @@ const validateTriggerReferences: SuperRefinement<{ } }; -const validateBeaconsReferences: SuperRefinement<{ beacons: Beacons; templates: Templates }> = (config, ctx) => { - Object.entries(config.beacons).forEach(([beaconId, beacon]) => { - // Verify that config.beacons..templateId is - // referencing a valid config.templates. object - const template = config.templates[beacon.templateId]; - if (isNil(template)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Template ID "${beacon.templateId}" is not defined in the config.templates object`, - path: ['beacons', beaconId, 'templateId'], - }); - } - }); -}; - -export const rateLimitingSchema = z.object({ - maxGatewayConcurrency: z.number().optional(), - minGatewayTime: z.number().optional(), - maxProviderConcurrency: z.number().optional(), - minProviderTime: z.number().optional(), - minDirectGatewayTime: z.number().optional(), - maxDirectGatewayConcurrency: z.number().optional(), - overrides: z - .object({ - signedDataGateways: z.record(limiterConfig).optional(), // key is Airnode address - directGateways: z.record(limiterConfig).optional(), // key is ois title - }) - .optional(), -}); +export const rateLimitingSchema = z.record(limiterConfig); const validateOisRateLimiterReferences: SuperRefinement<{ ois: OIS[]; - rateLimiting?: RateLimitingConfig | undefined; + rateLimiting: RateLimitingConfig; }> = (config, ctx) => { - const directGateways = config.rateLimiting?.overrides?.directGateways ?? {}; - const oises = config?.ois ?? []; - - Object.keys(directGateways).forEach((oisTitle) => { - if (!oises.find((ois) => ois.title === oisTitle)) { + Object.keys(config.rateLimiting).forEach((oisTitle) => { + if (!config.ois.find((ois) => ois.title === oisTitle)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `OIS Title "${oisTitle}" in rate limiting overrides is not defined in the config.ois array`, @@ -287,7 +230,18 @@ export const signedApiSchema = z.object({ url: z.string().url(), }); -export const signedApisSchema = z.array(signedApiSchema); +export const signedApisSchema = z.array(signedApiSchema).superRefine((apis, ctx) => { + const names = apis.map((api) => api.name); + const uniqueNames = [...new Set(names)]; + + if (names.length !== uniqueNames.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Signed API names must be unique`, + path: ['signedApis'], + }); + } +}); export const oisesSchema = z.array(oisSchema); @@ -295,8 +249,7 @@ export const apisCredentialsSchema = z.array(config.apiCredentialsSchema); export const configSchema = z .object({ - walletMnemonic: z.string(), - beacons: beaconsSchema, + airnodeWalletMnemonic: z.string(), beaconSets: z.any(), chains: z.any(), gateways: z.any(), @@ -306,10 +259,9 @@ export const configSchema = z ois: oisesSchema, apiCredentials: apisCredentialsSchema, endpoints: endpointsSchema, - rateLimiting: rateLimitingSchema.optional(), + rateLimiting: rateLimitingSchema, }) .strict() - .superRefine(validateBeaconsReferences) .superRefine(validateTemplatesReferences) .superRefine(validateOisReferences) .superRefine(validateOisRateLimiterReferences) @@ -317,11 +269,6 @@ export const configSchema = z export const encodedValueSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); export const signatureSchema = z.string().regex(/^0x[a-fA-F0-9]{130}$/); -export const signedDataSchemaLegacy = z.object({ - data: z.object({ timestamp: z.string(), value: encodedValueSchema }), - signature: signatureSchema, -}); - export const signedDataSchema = z.object({ timestamp: z.string(), encodedValue: encodedValueSchema, @@ -339,8 +286,6 @@ export const signedApiBatchPayloadSchema = z.array(signedApiPayloadSchema); export type SignedApiPayload = z.infer; export type SignedApiBatchPayload = z.infer; export type Config = z.infer; -export type Beacon = z.infer; -export type Beacons = z.infer; export type Template = z.infer; export type Templates = z.infer; export type BeaconUpdate = z.infer; @@ -353,10 +298,10 @@ export type EndpointId = z.infer; export type SignedData = z.infer; export type Endpoint = z.infer; export type Endpoints = z.infer; -export type FetchMethod = z.infer; export type LimiterConfig = z.infer; export type RateLimitingConfig = z.infer; export type ApisCredentials = z.infer; +export type Parameter = z.infer; export const secretsSchema = z.record(z.string()); diff --git a/packages/data-pusher/test/fixtures.ts b/packages/data-pusher/test/fixtures.ts index fa800739..d028348e 100644 --- a/packages/data-pusher/test/fixtures.ts +++ b/packages/data-pusher/test/fixtures.ts @@ -6,37 +6,20 @@ import { SignedResponse } from '../src/api-requests/signed-api'; import { TemplateResponse } from '../src/api-requests/data-provider'; export const config: Config = { - walletMnemonic: 'diamond result history offer forest diagram crop armed stumble orchard stage glance', - rateLimiting: { maxDirectGatewayConcurrency: 25, minDirectGatewayTime: 10 }, - beacons: { - '0xebba8507d616ed80766292d200a3598fdba656d9938cecc392765d4a284a69a4': { - airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC', - templateId: '0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd', - }, - '0x6f6acbdadaaf116c89faf0e8de1d0c7c2352b01cce7be0eb9deb126ceaefa6ba': { - airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC', - templateId: '0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66', - }, - '0x7944f22b40cc691a003e35db4810b41543a83781d94f706b5c0b6980e0a06ed7': { - airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC', - templateId: '0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9', - }, - }, + airnodeWalletMnemonic: 'diamond result history offer forest diagram crop armed stumble orchard stage glance', + rateLimiting: { Nodary: { maxConcurrency: 25, minTime: 10 } }, templates: { '0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd': { endpointId: '0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc', - parameters: - '0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005754492f55534400000000000000000000000000000000000000000000000000', + parameters: [{ type: 'string32', name: 'name', value: 'WTI/USD' }], }, '0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66': { endpointId: '0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc', - parameters: - '0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005841472f55534400000000000000000000000000000000000000000000000000', + parameters: [{ type: 'string32', name: 'name', value: 'XAG/USD' }], }, '0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9': { endpointId: '0x3528e42b017a5fbf9d2993a2df04efc3ed474357575065a111b054ddf9de2acc', - parameters: - '0x31730000000000000000000000000000000000000000000000000000000000006e616d65000000000000000000000000000000000000000000000000000000005841552f55534400000000000000000000000000000000000000000000000000', + parameters: [{ type: 'string32', name: 'name', value: 'XAU/USD' }], }, }, endpoints: { @@ -49,10 +32,10 @@ export const config: Config = { signedApiUpdates: [ { signedApiName: 'localhost', - beaconIds: [ - '0xebba8507d616ed80766292d200a3598fdba656d9938cecc392765d4a284a69a4', - '0x6f6acbdadaaf116c89faf0e8de1d0c7c2352b01cce7be0eb9deb126ceaefa6ba', - '0x7944f22b40cc691a003e35db4810b41543a83781d94f706b5c0b6980e0a06ed7', + templateIds: [ + '0xcc35bd1800c06c12856a87311dd95bfcbb3add875844021d59a929d79f3c99bd', + '0x086130c54864b2129f8ac6d8d7ab819fa8181bbe676e35047b1bca4c31d51c66', + '0x1d65c1f1e127a41cebd2339f823d0290322c63f3044380cbac105db8e522ebb9', ], fetchInterval: 5, updateDelay: 5,