diff --git a/.changeset/swift-kangaroos-cross.md b/.changeset/swift-kangaroos-cross.md new file mode 100644 index 0000000000..b3f3a0df19 --- /dev/null +++ b/.changeset/swift-kangaroos-cross.md @@ -0,0 +1,8 @@ +--- +'@api3/airnode-adapter': minor +'@api3/airnode-deployer': minor +'@api3/airnode-node': minor +'@api3/airnode-validator': minor +--- + +Add new \_minConfirmations reserved parameter to allow minConfirmations to be specified by a requester diff --git a/packages/airnode-adapter/src/types.ts b/packages/airnode-adapter/src/types.ts index 08a48ecdb1..8b42561f2e 100644 --- a/packages/airnode-adapter/src/types.ts +++ b/packages/airnode-adapter/src/types.ts @@ -62,7 +62,8 @@ export type BaseResponseType = typeof baseResponseTypes[number]; // Use might pass a complex type (e.g. int256[3][]) which we cannot type export type ResponseType = string; -// Reserved parameters specific for response processing i.e. excludes _gasPrice +// Reserved parameters specific for response processing +// i.e. excludes _gasPrice and _minConfirmations export interface ResponseReservedParameters { _path?: string; _times?: string; diff --git a/packages/airnode-adapter/test/fixtures/ois.ts b/packages/airnode-adapter/test/fixtures/ois.ts index 665fe2a99b..fbc9dffe5c 100644 --- a/packages/airnode-adapter/test/fixtures/ois.ts +++ b/packages/airnode-adapter/test/fixtures/ois.ts @@ -78,6 +78,7 @@ export function buildOIS(overrides?: Partial): OIS { default: '100000', }, { name: '_gasPrice' }, + { name: '_minConfirmations' }, ], parameters: [ { diff --git a/packages/airnode-deployer/test/fixtures/config.aws.valid.json b/packages/airnode-deployer/test/fixtures/config.aws.valid.json index de832a3626..95782c0b16 100644 --- a/packages/airnode-deployer/test/fixtures/config.aws.valid.json +++ b/packages/airnode-deployer/test/fixtures/config.aws.valid.json @@ -206,6 +206,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [ diff --git a/packages/airnode-deployer/test/fixtures/config.gcp.valid.json b/packages/airnode-deployer/test/fixtures/config.gcp.valid.json index a98ef86ecc..c03b40dfa8 100644 --- a/packages/airnode-deployer/test/fixtures/config.gcp.valid.json +++ b/packages/airnode-deployer/test/fixtures/config.gcp.valid.json @@ -206,6 +206,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [ diff --git a/packages/airnode-node/src/adapters/http/parameters.test.ts b/packages/airnode-node/src/adapters/http/parameters.test.ts index ba70c25051..511577eaf3 100644 --- a/packages/airnode-node/src/adapters/http/parameters.test.ts +++ b/packages/airnode-node/src/adapters/http/parameters.test.ts @@ -59,6 +59,7 @@ describe('getReservedParameters', () => { { name: '_path', default: 'prices.0.latest' }, { name: '_times', default: '1000000' }, { name: '_gasPrice' }, + { name: '_minConfirmations' }, ], }; }); @@ -68,6 +69,12 @@ describe('getReservedParameters', () => { _type: 'bytes32', _path: 'updated.path', }); - expect(res).toEqual({ _type: 'int256', _path: 'updated.path', _times: '1000000', _gasPrice: undefined }); + expect(res).toEqual({ + _type: 'int256', + _path: 'updated.path', + _times: '1000000', + _gasPrice: undefined, + _minConfirmations: undefined, + }); }); }); diff --git a/packages/airnode-node/src/api/index.test.ts b/packages/airnode-node/src/api/index.test.ts index 9c04413acc..2819f3a46a 100644 --- a/packages/airnode-node/src/api/index.test.ts +++ b/packages/airnode-node/src/api/index.test.ts @@ -14,7 +14,14 @@ describe('callApi', () => { const spy = jest.spyOn(adapter, 'buildAndExecuteRequest') as any; spy.mockResolvedValueOnce({ data: { price: 1000 } }); const requestedGasPrice = '100000000'; - const parameters = { _type: 'int256', _path: 'price', from: 'ETH', _gasPrice: requestedGasPrice }; + const requestedMinConfirmations = '0'; + const parameters = { + _type: 'int256', + _path: 'price', + from: 'ETH', + _gasPrice: requestedGasPrice, + _minConfirmations: requestedMinConfirmations, + }; const [logs, res] = await callApi({ type: 'regular', @@ -30,6 +37,7 @@ describe('callApi', () => { signature: '0xe92f5ee40ddb5aa42cab65fcdc025008b2bc026af80a7c93a9aac4e474f8a88f4f2bd861b9cf9a2b050bf0fd13e9714c4575cebbea658d7501e98c0963a5a38b1c', }, + // _minConfirmations is processed before making API calls reservedParameterOverrides: { gasPrice: requestedGasPrice, }, diff --git a/packages/airnode-node/src/api/index.ts b/packages/airnode-node/src/api/index.ts index 4d0ba64713..8ae423db30 100644 --- a/packages/airnode-node/src/api/index.ts +++ b/packages/airnode-node/src/api/index.ts @@ -226,6 +226,7 @@ export async function processSuccessfulApiCall( const { endpointName, oisTitle, parameters } = aggregatedApiCall; const ois = config.ois.find((o) => o.title === oisTitle)!; const endpoint = ois.endpoints.find((e) => e.name === endpointName)!; + // _minConfirmations is handled prior to the API call const { _type, _path, _times, _gasPrice } = getReservedParameters(endpoint, parameters); const goPostProcessApiSpecifications = await go(() => postProcessApiSpecifications(rawResponse.data, endpoint)); diff --git a/packages/airnode-node/src/coordinator/calls/disaggregation.test.ts b/packages/airnode-node/src/coordinator/calls/disaggregation.test.ts index 2bf98a04ab..6dda8475fa 100644 --- a/packages/airnode-node/src/coordinator/calls/disaggregation.test.ts +++ b/packages/airnode-node/src/coordinator/calls/disaggregation.test.ts @@ -84,9 +84,7 @@ describe('disaggregate - Requests', () => { const stateWithResponses = coordinatorState.addResponses(state, { aggregatedApiCallsById, providerStates }); const [logs, res] = disaggregation.disaggregate(stateWithResponses); - expect(logs).toEqual([ - { level: 'ERROR', message: 'Unable to find matching aggregated API calls for Request:ethCall' }, - ]); + expect(logs).toEqual([]); expect(res[0].requests.apiCalls.length).toEqual(0); expect((res[1].requests.apiCalls[0] as any as RegularApiCallSuccessResponse).data.encodedValue).toEqual('0x123'); expect(res[1].requests.apiCalls[0].errorMessage).toEqual(undefined); diff --git a/packages/airnode-node/src/coordinator/calls/disaggregation.ts b/packages/airnode-node/src/coordinator/calls/disaggregation.ts index 11d886ced5..10c618b8ae 100644 --- a/packages/airnode-node/src/coordinator/calls/disaggregation.ts +++ b/packages/airnode-node/src/coordinator/calls/disaggregation.ts @@ -1,5 +1,4 @@ import flatMap from 'lodash/flatMap'; -import { logger } from '@api3/airnode-utilities'; import { RegularAggregatedApiCallsWithResponseById, ApiCall, @@ -19,10 +18,9 @@ function updateApiCallResponses( const { logs, requests } = apiCalls.reduce( (acc, apiCall): UpdatedRequests => { const aggregatedApiCall = aggregatedApiCallsById[apiCall.id]; - // There should always be a matching AggregatedApiCall. Something has gone wrong if there isn't + // An AggregatedApiCall may be dropped if there hasn't been minConfirmations block confirmations. if (!aggregatedApiCall) { - const log = logger.pend('ERROR', `Unable to find matching aggregated API calls for Request:${apiCall.id}`); - return { ...acc, logs: [...acc.logs, log] }; + return acc; } // Add the error to the ApiCall diff --git a/packages/airnode-node/src/evm/handlers/fetch-pending-requests.ts b/packages/airnode-node/src/evm/handlers/fetch-pending-requests.ts index 5e087412ca..c069871a84 100644 --- a/packages/airnode-node/src/evm/handlers/fetch-pending-requests.ts +++ b/packages/airnode-node/src/evm/handlers/fetch-pending-requests.ts @@ -15,6 +15,7 @@ export async function fetchPendingRequests(state: ProviderState { blockHistoryLimit: 300, currentBlock: 10716084, minConfirmations: 1, + mayOverrideMinConfirmations: false, provider: new ethers.providers.JsonRpcProvider(), chainId: '31137', }; @@ -109,6 +110,7 @@ describe('EVM event logs - fetch', () => { blockHistoryLimit: 30, currentBlock: 10716084, minConfirmations: 0, + mayOverrideMinConfirmations: false, provider: new ethers.providers.JsonRpcProvider(), chainId: '31137', }; @@ -139,6 +141,7 @@ describe('EVM event logs - fetch', () => { blockHistoryLimit: 300, currentBlock: 10716084, minConfirmations: 0, + mayOverrideMinConfirmations: false, provider: new ethers.providers.JsonRpcProvider(), chainId: '31137', }; @@ -154,6 +157,7 @@ describe('EVM event logs - fetch', () => { blockHistoryLimit: 99999999, currentBlock: 10716084, minConfirmations: 10, + mayOverrideMinConfirmations: false, provider: new ethers.providers.JsonRpcProvider(), chainId: '31137', }; @@ -177,6 +181,7 @@ describe('EVM event logs - fetch', () => { blockHistoryLimit: 30, currentBlock: 10716084, minConfirmations: 99999999, + mayOverrideMinConfirmations: false, provider: new ethers.providers.JsonRpcProvider(), chainId: '31137', }; diff --git a/packages/airnode-node/src/evm/requests/event-logs.ts b/packages/airnode-node/src/evm/requests/event-logs.ts index af3e1ca361..229e94d778 100644 --- a/packages/airnode-node/src/evm/requests/event-logs.ts +++ b/packages/airnode-node/src/evm/requests/event-logs.ts @@ -16,6 +16,7 @@ export interface FetchOptions { readonly blockHistoryLimit: number; readonly currentBlock: number; readonly minConfirmations: number; + readonly mayOverrideMinConfirmations: boolean; readonly provider: ethers.providers.JsonRpcProvider; readonly chainId: string; } @@ -37,7 +38,11 @@ export async function fetch(options: FetchOptions): Promise { // Protect against a potential negative fromBlock value const fromBlock = Math.max(0, options.currentBlock - options.blockHistoryLimit); // toBlock should always be >= fromBlock - const toBlock = Math.max(fromBlock, options.currentBlock - options.minConfirmations); + const toBlock = Math.max( + fromBlock, + // Fetch up to currentBlock to handle possibility of _minConfirmations parameter in request + options.currentBlock - (options.mayOverrideMinConfirmations ? 0 : options.minConfirmations) + ); const filter: ethers.providers.Filter = { fromBlock, diff --git a/packages/airnode-node/src/evm/transaction-counts.test.ts b/packages/airnode-node/src/evm/transaction-counts.test.ts index 0bc9856ae2..7028894e2f 100644 --- a/packages/airnode-node/src/evm/transaction-counts.test.ts +++ b/packages/airnode-node/src/evm/transaction-counts.test.ts @@ -25,6 +25,7 @@ describe('fetchBySponsor', () => { masterHDNode: wallet.getMasterHDNode(config), provider: new ethers.providers.JsonRpcProvider(), minConfirmations: 0, + mayOverrideMinConfirmations: false, }; const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181']; const [logs, res] = await transactions.fetchBySponsor(addresses, options); @@ -41,6 +42,7 @@ describe('fetchBySponsor', () => { masterHDNode: wallet.getMasterHDNode(config), provider: new ethers.providers.JsonRpcProvider(), minConfirmations: 2, + mayOverrideMinConfirmations: false, }; const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181']; const [logs, res] = await transactions.fetchBySponsor(addresses, options); @@ -53,6 +55,24 @@ describe('fetchBySponsor', () => { ); }); + it('fetches up to current block when minConfirmations may be overridden', async () => { + getTransactionCountMock.mockResolvedValueOnce(5); + const currentBlock = 10716084; + const options = { + currentBlock: currentBlock, + masterHDNode: wallet.getMasterHDNode(config), + provider: new ethers.providers.JsonRpcProvider(), + minConfirmations: 2, + mayOverrideMinConfirmations: true, + }; + const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181']; + const [logs, res] = await transactions.fetchBySponsor(addresses, options); + expect(logs).toEqual([]); + expect(res).toEqual({ '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181': 5 }); + expect(getTransactionCountMock).toHaveBeenCalledTimes(1); + expect(getTransactionCountMock).toHaveBeenCalledWith('0xdBFe14C250643DEFE92C9AbC52103bf4978C7113', currentBlock); + }); + it('returns transaction counts for multiple wallets', async () => { getTransactionCountMock.mockResolvedValueOnce(45); getTransactionCountMock.mockResolvedValueOnce(123); @@ -61,6 +81,7 @@ describe('fetchBySponsor', () => { masterHDNode: wallet.getMasterHDNode(config), provider: new ethers.providers.JsonRpcProvider(), minConfirmations: 0, + mayOverrideMinConfirmations: false, }; const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x99bd3a5A045066F1CEf37A0A952DFa87Af9D898E']; const [logs, res] = await transactions.fetchBySponsor(addresses, options); @@ -84,6 +105,7 @@ describe('fetchBySponsor', () => { masterHDNode: wallet.getMasterHDNode(config), provider: new ethers.providers.JsonRpcProvider(), minConfirmations: 0, + mayOverrideMinConfirmations: false, }; const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181']; const [logs, res] = await transactions.fetchBySponsor(addresses, options); @@ -104,6 +126,7 @@ describe('fetchBySponsor', () => { masterHDNode: wallet.getMasterHDNode(config), provider: new ethers.providers.JsonRpcProvider(), minConfirmations: 0, + mayOverrideMinConfirmations: false, }; const addresses = ['0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181', '0x69e2B095fbAc6C3f9E528Ef21882b86BF1595181']; const [logs, res] = await transactions.fetchBySponsor(addresses, options); diff --git a/packages/airnode-node/src/evm/transaction-counts.ts b/packages/airnode-node/src/evm/transaction-counts.ts index 51698c854f..cca815b081 100644 --- a/packages/airnode-node/src/evm/transaction-counts.ts +++ b/packages/airnode-node/src/evm/transaction-counts.ts @@ -16,6 +16,7 @@ interface FetchOptions { readonly masterHDNode: ethers.utils.HDNode; readonly provider: ethers.providers.JsonRpcProvider; readonly minConfirmations: number; + readonly mayOverrideMinConfirmations: boolean; } async function getWalletTransactionCount( @@ -24,7 +25,11 @@ async function getWalletTransactionCount( ): Promise> { const address = wallet.deriveSponsorWallet(options.masterHDNode, sponsorAddress).address; const operation = () => - options.provider.getTransactionCount(address, options.currentBlock - options.minConfirmations); + options.provider.getTransactionCount( + address, + // Fetch up to currentBlock to handle possibility of _minConfirmations parameter in request + options.currentBlock - (options.mayOverrideMinConfirmations ? 0 : options.minConfirmations) + ); const goCount = await go(operation, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); if (!goCount.success) { const log = logger.pend('ERROR', `Unable to fetch transaction count for wallet:${address}`, goCount.error); diff --git a/packages/airnode-node/src/handlers/start-coordinator.test.ts b/packages/airnode-node/src/handlers/start-coordinator.test.ts index adfba1c179..5952111418 100644 --- a/packages/airnode-node/src/handlers/start-coordinator.test.ts +++ b/packages/airnode-node/src/handlers/start-coordinator.test.ts @@ -27,9 +27,11 @@ import { ethers } from 'ethers'; import * as adapter from '@api3/airnode-adapter'; import * as validator from '@api3/airnode-validator'; import { randomHexString, caching } from '@api3/airnode-utilities'; -import { startCoordinator } from './start-coordinator'; +import { getMinConfirmationsReservedParameter, startCoordinator } from './start-coordinator'; import * as fixtures from '../../test/fixtures'; import * as calls from '../coordinator/calls'; +import { buildAggregatedRegularApiCall, buildConfig } from '../../test/fixtures'; +import { BLOCK_COUNT_HISTORY_LIMIT } from '../constants'; describe('startCoordinator', () => { jest.setTimeout(30_000); @@ -215,3 +217,30 @@ describe('startCoordinator', () => { expect(contract.fulfill).not.toHaveBeenCalled(); }); }); + +describe('getMinConfirmationsReservedParameter', () => { + const config = buildConfig(); + + it('returns _minConfirmations reserved parameter as a number', () => { + const aggregatedRegularApiCall = buildAggregatedRegularApiCall(); + aggregatedRegularApiCall.parameters = { ...aggregatedRegularApiCall.parameters, _minConfirmations: '0' }; + expect(getMinConfirmationsReservedParameter(aggregatedRegularApiCall, config)).toEqual(0); + }); + + it('returns undefined for a missing or invalid _minConfirmations reserved parameter', () => { + const aggregatedRegularApiCall = buildAggregatedRegularApiCall(); + expect(getMinConfirmationsReservedParameter(aggregatedRegularApiCall, config)).toBe(undefined); + + aggregatedRegularApiCall.parameters = { ...aggregatedRegularApiCall.parameters, _minConfirmations: '-1' }; + expect(getMinConfirmationsReservedParameter(aggregatedRegularApiCall, config)).toBe(undefined); + + aggregatedRegularApiCall.parameters = { ...aggregatedRegularApiCall.parameters, _minConfirmations: '34.2' }; + expect(getMinConfirmationsReservedParameter(aggregatedRegularApiCall, config)).toBe(undefined); + + aggregatedRegularApiCall.parameters = { + ...aggregatedRegularApiCall.parameters, + _minConfirmations: (BLOCK_COUNT_HISTORY_LIMIT + 1).toString(), + }; + expect(getMinConfirmationsReservedParameter(aggregatedRegularApiCall, config)).toBe(undefined); + }); +}); diff --git a/packages/airnode-node/src/handlers/start-coordinator.ts b/packages/airnode-node/src/handlers/start-coordinator.ts index fa77461973..add99f914e 100644 --- a/packages/airnode-node/src/handlers/start-coordinator.ts +++ b/packages/airnode-node/src/handlers/start-coordinator.ts @@ -1,6 +1,7 @@ import flatMap from 'lodash/flatMap'; -import keyBy from 'lodash/keyBy'; +import groupBy from 'lodash/groupBy'; import isEmpty from 'lodash/isEmpty'; +import keyBy from 'lodash/keyBy'; import pickBy from 'lodash/pickBy'; import { logger, formatDateTime, caching } from '@api3/airnode-utilities'; import { go } from '@api3/promise-utils'; @@ -15,8 +16,12 @@ import { WorkerOptions, RegularApiCallSuccessResponse, RegularAggregatedApiCallWithResponse, + RegularAggregatedApiCallsById, + RegularAggregatedApiCall, } from '../types'; import { Config } from '../config'; +import { getReservedParameterValue } from '../adapters/http/parameters'; +import { BLOCK_COUNT_HISTORY_LIMIT } from '../constants'; export async function startCoordinator(config: Config, coordinatorId: string) { const startedAt = new Date(); @@ -81,6 +86,72 @@ function hasCoordinatorNoActionableRequests(state: CoordinatorState) { return providerStates.evm.every((evmProvider) => hasNoActionableRequests(evmProvider!.requests)); } +export function getMinConfirmationsReservedParameter(aggregatedApiCall: RegularAggregatedApiCall, config: Config) { + const { endpointName, oisTitle, parameters } = aggregatedApiCall; + const ois = config.ois.find((o) => o.title === oisTitle)!; + const endpoint = ois.endpoints.find((e) => e.name === endpointName)!; + const _minConfirmations = getReservedParameterValue('_minConfirmations', endpoint, parameters); + const numMinConfirmations = Number(_minConfirmations); + + return !isNaN(numMinConfirmations) && + Number.isInteger(numMinConfirmations) && + numMinConfirmations >= 0 && + numMinConfirmations <= BLOCK_COUNT_HISTORY_LIMIT + ? numMinConfirmations + : undefined; +} + +export function filterByMinConfirmations(state: CoordinatorState) { + const { config, aggregatedApiCallsById } = state; + + const groupedApiCalls = groupBy(aggregatedApiCallsById, 'sponsorAddress'); + + const filteredApiCallsBySponsor = Object.values(groupedApiCalls).map((apiCalls) => { + const reservedMinConfirmations = apiCalls + .map((apiCall) => getMinConfirmationsReservedParameter(apiCall, config)) + // Cannot use lodash compact as 0 (considered falsey) is a valid value + .filter((val) => val !== undefined) as number[]; + + // If any request has _minConfirmations as a parameter, use the maximum value in the queue for all, + // otherwise, if _minConfirmations parameter is not present in any request, use minConfirmations + // from a request's metadata (which originates from chains[n].minConfirmations) for all + const maxValue = !isEmpty(reservedMinConfirmations) + ? Math.max(...reservedMinConfirmations) + : apiCalls[0].metadata.minConfirmations; + + // If a request is skipped, skip all after, which also protects against processing requests out of order + let previousRequestSkipped = false; + + // drop API calls that have insufficient confirmations + return apiCalls.reduce((acc: RegularAggregatedApiCallsById, apiCall) => { + if (previousRequestSkipped) { + logger.debug(`Request ID:${apiCall.id} was skipped because one of the previous requests was skipped`); + return acc; + } + const { blockNumber, currentBlock } = apiCall.metadata; + const numConfirmations = currentBlock - blockNumber; + if (numConfirmations >= maxValue) { + return { ...acc, [apiCall.id]: apiCall }; + } else { + previousRequestSkipped = true; + logger.debug( + `Request ID:${apiCall.id} was skipped as there have been only ${numConfirmations} confirmations of ${maxValue} required` + ); + return acc; + } + }, {}); + }); + + const filteredAggregatedApiCallsById = Object.assign( + {}, + ...filteredApiCallsBySponsor + ) as RegularAggregatedApiCallsById; + + return coordinatorState.update(state, { + aggregatedApiCallsById: filteredAggregatedApiCallsById, + }); +} + function aggregateApiCalls(state: CoordinatorState) { const { providerStates, config } = state; @@ -208,17 +279,22 @@ async function coordinator(config: Config, coordinatorId: string): Promise { logFormat: 'plain', logLevel: 'DEBUG', minConfirmations: 0, + mayOverrideMinConfirmations: true, name: 'Pocket Ethereum Mainnet', cloudProvider: { type: 'local', @@ -204,6 +205,7 @@ describe('initialize', () => { logFormat: 'plain', logLevel: 'DEBUG', minConfirmations: 0, + mayOverrideMinConfirmations: true, name: 'Infura Sepolia', cloudProvider: { type: 'local', diff --git a/packages/airnode-node/src/providers/state.test.ts b/packages/airnode-node/src/providers/state.test.ts index ed0fd8f971..ce26a5ff41 100644 --- a/packages/airnode-node/src/providers/state.test.ts +++ b/packages/airnode-node/src/providers/state.test.ts @@ -1,6 +1,7 @@ import { ethers } from 'ethers'; import * as state from './state'; import * as fixtures from '../../test/fixtures'; +import { BLOCK_MIN_CONFIRMATIONS } from '../constants'; import { EVMProviderState, ProviderState } from '../types'; import { ChainConfig } from '../config'; @@ -79,6 +80,7 @@ describe('create', () => { logFormat: 'plain', logLevel: 'DEBUG', minConfirmations: 0, + mayOverrideMinConfirmations: true, name: 'Ganache test', cloudProvider: { type: 'local', @@ -174,6 +176,7 @@ describe('create', () => { logFormat: 'plain', logLevel: 'DEBUG', minConfirmations: 3, + mayOverrideMinConfirmations: true, name: 'Ganache test', cloudProvider: { type: 'local', @@ -339,3 +342,19 @@ describe('splitStatesBySponsorAddress', () => { ]); }); }); + +it('checks for the presence of a _minConfirmations reserved parameter', () => { + const newState = fixtures.buildEVMProviderState(); + + // buildEVMProviderState() doesn't set minConfirmations in chainConfig + expect(newState.settings.minConfirmations).toEqual(BLOCK_MIN_CONFIRMATIONS); + + // _minConfirmations reserved parameter is set in buildOIS() + expect(newState.settings.mayOverrideMinConfirmations).toBe(true); + + // Remove _minConfirmations reserved parameter + newState.config!.ois[0].endpoints[0].reservedParameters = + newState.config!.ois[0].endpoints[0].reservedParameters.filter((param) => param.name !== '_minConfirmations'); + + expect(state.checkForMinConfirmationsReservedParam(newState.config!)).toBe(false); +}); diff --git a/packages/airnode-node/src/providers/state.ts b/packages/airnode-node/src/providers/state.ts index a164c75b7e..8851cf9009 100644 --- a/packages/airnode-node/src/providers/state.ts +++ b/packages/airnode-node/src/providers/state.ts @@ -33,6 +33,10 @@ export function buildEVMState( logFormat: config.nodeSettings.logFormat, logLevel: config.nodeSettings.logLevel, minConfirmations: chain.minConfirmations || BLOCK_MIN_CONFIRMATIONS, + // If the _minConfirmations reserved parameter is set for one or more endpoints, + // a request may override minConfirmations, but we don't know if it will or the value + // until we fetch the requests and extract the _minConfirmations reserved parameter + mayOverrideMinConfirmations: checkForMinConfirmationsReservedParam(config), name: chainProviderName, cloudProvider: config.nodeSettings.cloudProvider, stage: config.nodeSettings.stage, @@ -58,6 +62,14 @@ export function buildEVMState( }; } +// Checks all OIS endpoints for the presence of a _minConfirmations reserved parameter +// which means that a requester may use a parameter to override the value. +export function checkForMinConfirmationsReservedParam(config: Config): boolean { + return config.ois.some((ois) => + ois.endpoints.some((endpoint) => endpoint.reservedParameters.some((param) => param.name === '_minConfirmations')) + ); +} + export function update(state: ProviderState, newState: Partial>): ProviderState { return { ...state, ...newState }; } diff --git a/packages/airnode-node/src/types.ts b/packages/airnode-node/src/types.ts index c7d652215d..ca41198d39 100644 --- a/packages/airnode-node/src/types.ts +++ b/packages/airnode-node/src/types.ts @@ -140,6 +140,7 @@ export interface ProviderSettings extends CoordinatorSettings { readonly chainType: ChainType; readonly chainOptions: ChainOptions; readonly minConfirmations: number; + readonly mayOverrideMinConfirmations: boolean; readonly name: string; readonly url: string; readonly xpub: string; diff --git a/packages/airnode-node/src/workers/local-handlers.ts b/packages/airnode-node/src/workers/local-handlers.ts index fdae9f85c3..2c0808cfb3 100644 --- a/packages/airnode-node/src/workers/local-handlers.ts +++ b/packages/airnode-node/src/workers/local-handlers.ts @@ -7,7 +7,7 @@ import * as handlers from '../handlers'; import * as state from '../providers/state'; import { WorkerResponse, InitializeProviderPayload, CallApiPayload, ProcessTransactionsPayload } from '../types'; -function loadConfig() { +export function loadConfig() { return loadTrustedConfig(path.resolve(`${__dirname}/../../config/config.json`), process.env); } export function setAirnodePrivateKeyToEnv(airnodeWalletMnemonic: string) { diff --git a/packages/airnode-node/test/e2e/requester-fulfill.feature.ts b/packages/airnode-node/test/e2e/requester-fulfill.feature.ts index 671e544279..1300853b3e 100644 --- a/packages/airnode-node/test/e2e/requester-fulfill.feature.ts +++ b/packages/airnode-node/test/e2e/requester-fulfill.feature.ts @@ -1,5 +1,6 @@ import compact from 'lodash/compact'; -import { startCoordinator } from '../../src/workers/local-handlers'; +import { BLOCK_COUNT_HISTORY_LIMIT } from '../../src'; +import * as local from '../../src/workers/local-handlers'; import { operation } from '../fixtures'; import { fetchAllLogNames, @@ -24,7 +25,7 @@ it('should call fail function on AirnodeRrp contract and emit FailedRequest if r const preInvokeLogs = await fetchAllLogNames(provider, deployment.contracts.AirnodeRrp); expect(preInvokeLogs).toEqual(expect.arrayContaining(preInvokeExpectedLogs)); - await startCoordinator(); + await local.startCoordinator(); const postInvokeExpectedLogs = [...preInvokeExpectedLogs, 'FailedRequest', 'FulfilledRequest']; const postInvokeLogs = await fetchAllLogs(provider, deployment.contracts.AirnodeRrp); @@ -49,7 +50,7 @@ it('should call fail function on AirnodeRrp contract and emit FailedRequest if r const preInvokelogNames = await fetchAllLogNames(provider, deployment.contracts.AirnodeRrp); expect(preInvokelogNames).toEqual(expect.arrayContaining(preInvokeExpectedLogs)); - await startCoordinator(); + await local.startCoordinator(); const postInvokeExpectedLogs = [...preInvokeExpectedLogs, 'FailedRequest', 'FulfilledRequest']; const postInvokeLogs = await fetchAllLogs(provider, deployment.contracts.AirnodeRrp); @@ -77,7 +78,7 @@ it('submits fulfillment with the gas price overridden', async () => { const requests = [operation.buildFullRequest({ parameters: overrideParameters })]; const { provider, deployment } = await deployAirnodeAndMakeRequests(__filename, requests); - await startCoordinator(); + await local.startCoordinator(); const logs = await fetchProviderLogs(provider, deployment.contracts.AirnodeRrp); @@ -96,3 +97,37 @@ it('submits fulfillment with the gas price overridden', async () => { expect(filteredTxs.length).toEqual(1); expect(filteredTxs[0]!.gasPrice!.toString()).toEqual(requestedGasPrice); }); + +it('submits fulfillment only if minConfirmations is overridden by request parameter', async () => { + const overrideParameters = [ + { type: 'string32', name: 'from', value: 'ETH' }, + { type: 'string32', name: 'to', value: 'USD' }, + { type: 'string32', name: '_type', value: 'int256' }, + { type: 'string32', name: '_path', value: 'result' }, + { type: 'string32', name: '_times', value: '100000' }, + { type: 'string32', name: '_minConfirmations', value: '0' }, + ]; + const requests = [operation.buildFullRequest({ parameters: overrideParameters })]; + const { provider, deployment } = await deployAirnodeAndMakeRequests(__filename, requests); + + const preInvokeExpectedLogs = ['SetSponsorshipStatus', 'SetSponsorshipStatus', 'CreatedTemplate', 'MadeFullRequest']; + const preInvokelogNames = await fetchAllLogNames(provider, deployment.contracts.AirnodeRrp); + expect(preInvokelogNames).toEqual(preInvokeExpectedLogs); + + // Set chains[n].minConfirmations such that the request will only be fulfilled if the + // value is overridden by _minConfirmations in the request + const config = local.loadConfig(); + config.chains[0].minConfirmations = BLOCK_COUNT_HISTORY_LIMIT - 1; + + jest.spyOn(local, 'loadConfig').mockReturnValueOnce(config); + + await local.startCoordinator(); + + const postInvokeExpectedLogs = [...preInvokeExpectedLogs, 'FulfilledRequest']; + const postInvokeLogs = await fetchAllLogs(provider, deployment.contracts.AirnodeRrp); + expect(postInvokeLogs.map(({ name }) => name)).toEqual(postInvokeExpectedLogs); + + const fulfilledRequest = filterLogsByName(postInvokeLogs, 'FulfilledRequest')[0]; + const fullRequest = filterLogsByName(postInvokeLogs, 'MadeFullRequest')[0]; + expectSameRequestId(fulfilledRequest, fullRequest); +}); diff --git a/packages/airnode-node/test/fixtures/config/config.valid.json b/packages/airnode-node/test/fixtures/config/config.valid.json index d20a690d53..dea38d94f7 100644 --- a/packages/airnode-node/test/fixtures/config/config.valid.json +++ b/packages/airnode-node/test/fixtures/config/config.valid.json @@ -189,6 +189,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [ diff --git a/packages/airnode-node/test/fixtures/config/ois.ts b/packages/airnode-node/test/fixtures/config/ois.ts index b0be3dff55..3389a66377 100644 --- a/packages/airnode-node/test/fixtures/config/ois.ts +++ b/packages/airnode-node/test/fixtures/config/ois.ts @@ -68,6 +68,7 @@ export function buildOIS(ois?: Partial): OIS { default: '100000', }, { name: '_gasPrice' }, + { name: '_minConfirmations' }, ], parameters: [ { diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index 47e28e00a0..33441bf078 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -194,7 +194,9 @@ export const chainConfigSchema = z blockHistoryLimit: z.number().int().optional(), // Defaults to BLOCK_COUNT_HISTORY_LIMIT defined in airnode-node contracts: chainContractsSchema, id: z.string(), - minConfirmations: z.number().int().optional(), // Defaults to BLOCK_MIN_CONFIRMATIONS defined in airnode-node + // Defaults to BLOCK_MIN_CONFIRMATIONS defined in airnode-node but may be overridden + // by a requester if the _minConfirmations reserved parameter is configured + minConfirmations: z.number().int().optional(), type: chainTypeSchema, options: chainOptionsSchema, providers: providersSchema, diff --git a/packages/airnode-validator/test/fixtures/config.valid.json b/packages/airnode-validator/test/fixtures/config.valid.json index 2da9ccb930..97e6cebe0d 100644 --- a/packages/airnode-validator/test/fixtures/config.valid.json +++ b/packages/airnode-validator/test/fixtures/config.valid.json @@ -195,6 +195,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [ diff --git a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json index 812ca4a868..91800dadd3 100644 --- a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json +++ b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json @@ -199,6 +199,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "preProcessingSpecifications": [], diff --git a/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json b/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json index b9f046c1a8..81871f08fc 100644 --- a/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json +++ b/packages/airnode-validator/test/fixtures/invalid-secret-name/config.json @@ -183,6 +183,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [ diff --git a/packages/airnode-validator/test/fixtures/ois.json b/packages/airnode-validator/test/fixtures/ois.json index bf438e64e0..3e70f44487 100644 --- a/packages/airnode-validator/test/fixtures/ois.json +++ b/packages/airnode-validator/test/fixtures/ois.json @@ -76,6 +76,9 @@ }, { "name": "_gasPrice" + }, + { + "name": "_minConfirmations" } ], "parameters": [