From cdcbbae2d796b6b49b75808391c3f0aaaeb64f77 Mon Sep 17 00:00:00 2001 From: Bedirhan Date: Mon, 26 Jun 2023 10:38:58 +0300 Subject: [PATCH 1/7] Revert "Revert "Estimate gas for RRP fulfillments" (#1710)" This reverts commit e8cc9ed179ee0583730e45fc5f383d785aa77dbd. --- .changeset/eighty-goats-provide.md | 6 + .../src/evm/fulfillments/api-calls.test.ts | 1216 ++++++++++++----- .../src/evm/fulfillments/api-calls.ts | 102 +- .../src/evm/fulfillments/index.test.ts | 4 +- packages/airnode-node/src/types.ts | 1 + .../airnode-validator/src/config/config.ts | 2 +- 6 files changed, 971 insertions(+), 360 deletions(-) create mode 100644 .changeset/eighty-goats-provide.md diff --git a/.changeset/eighty-goats-provide.md b/.changeset/eighty-goats-provide.md new file mode 100644 index 0000000000..c528bc5544 --- /dev/null +++ b/.changeset/eighty-goats-provide.md @@ -0,0 +1,6 @@ +--- +'@api3/airnode-validator': minor +'@api3/airnode-node': minor +--- + +Estimate gas for RRP fulfillments diff --git a/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts b/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts index 9d494e7962..2dfc924617 100644 --- a/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts +++ b/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts @@ -3,11 +3,15 @@ import { mockEthers } from '../../../test/mock-utils'; const failMock = jest.fn(); const fulfillMock = jest.fn(); const staticFulfillMock = jest.fn(); +const estimateFulfillMock = jest.fn(); mockEthers({ airnodeRrpMocks: { callStatic: { fulfill: staticFulfillMock, }, + estimateGas: { + fulfill: estimateFulfillMock, + }, fail: failMock, fulfill: fulfillMock, }, @@ -27,379 +31,87 @@ const config = fixtures.buildConfig(); describe('submitApiCall', () => { const masterHDNode = wallet.getMasterHDNode(config); - const gasPriceFallback: GasTarget = { + + const gasTarget: GasTarget = { + type: 2, + maxPriorityFeePerGas: ethers.BigNumber.from(1), + maxFeePerGas: ethers.BigNumber.from(1000), + gasLimit: ethers.BigNumber.from(500_000), + }; + const gasTargetFallback: GasTarget = { type: 0, gasPrice: ethers.BigNumber.from('1000'), gasLimit: ethers.BigNumber.from(500_000), }; - const gasPrice: GasTarget = { + + const gasTargetWithoutGasLimit: GasTarget = { type: 2, maxPriorityFeePerGas: ethers.BigNumber.from(1), maxFeePerGas: ethers.BigNumber.from(1000), - gasLimit: ethers.BigNumber.from(500_000), + }; + const gasTargetFallbackWithoutGasLimit: GasTarget = { + type: 0, + gasPrice: ethers.BigNumber.from('1000'), }; - describe('Pending API calls', () => { - test.each([gasPrice, gasPriceFallback])( - `does nothing for API call requests that do not have a nonce - %#`, - async (gasTarget) => { - const provider = new ethers.providers.JsonRpcProvider(); - const apiCall = fixtures.requests.buildSuccessfulApiCall({ nonce: undefined }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { - level: 'ERROR', - message: `API call for Request:${apiCall.id} cannot be submitted as it does not have a nonce`, - }, - ]); - expect(err).toEqual(null); - expect(data).toEqual(null); - expect(staticFulfillMock).not.toHaveBeenCalled(); - expect(fulfillMock).not.toHaveBeenCalled(); - expect(failMock).not.toHaveBeenCalled(); - } - ); - - test.each([gasPrice, gasPriceFallback])( - `successfully tests and submits a fulfill transaction for pending requests - %#`, - async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; - const provider = new ethers.providers.JsonRpcProvider(); - staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); - fulfillMock.mockResolvedValueOnce({ hash: '0xtransactionId' }); - - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, - { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, - ]); - expect(err).toEqual(null); - expect(data).toEqual({ - ...apiCall, - fulfillment: { hash: '0xtransactionId' }, - }); - expect(staticFulfillMock).toHaveBeenCalledTimes(1); - expect(staticFulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(fulfillMock).toHaveBeenCalledTimes(1); - expect(fulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(failMock).not.toHaveBeenCalled(); - } - ); - - test.each([gasPrice, gasPriceFallback])( - `returns an error if the fulfill transaction for pending requests fails - %#`, - async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; - const provider = new ethers.providers.JsonRpcProvider(); - staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); - (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); - (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); - - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, - { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, - { - error: new Error('Server did not respond'), - level: 'ERROR', - message: - 'Error submitting API call fulfillment transaction for Request:0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - }, - ]); - expect(err).toEqual(new Error('Server did not respond')); - expect(data).toEqual(null); - expect(staticFulfillMock).toHaveBeenCalledTimes(1); - expect(staticFulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(fulfillMock).toHaveBeenCalledTimes(2); - expect(fulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(failMock).not.toHaveBeenCalled(); - } - ); - - test.each([gasPrice, gasPriceFallback])( - `submits a fail transaction if the fulfill call would revert with empty string - %#`, - async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; - const provider = new ethers.providers.JsonRpcProvider(); - staticFulfillMock.mockResolvedValueOnce({ callSuccess: false, callData: '0x' }); - (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, - { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, - ]); - expect(err).toEqual(null); - expect(data).toEqual({ - ...apiCall, - fulfillment: { hash: '0xfailtransaction' }, - errorMessage: 'Fulfill transaction failed', - }); - expect(staticFulfillMock).toHaveBeenCalledTimes(1); - expect(staticFulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(fulfillMock).not.toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - 'No revert string', - txOpts - ); - } - ); - - test.each([gasPrice, gasPriceFallback])( - `submits a fail transaction if the fulfill call would revert with a revert string - %#`, - async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; - const provider = new ethers.providers.JsonRpcProvider(); - staticFulfillMock.mockResolvedValueOnce({ - callSuccess: false, - callData: - '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e416c776179732072657665727473000000000000000000000000000000000000', - }); - (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, - { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, - ]); - expect(err).toEqual(null); - expect(data).toEqual({ - ...apiCall, - fulfillment: { hash: '0xfailtransaction' }, - errorMessage: 'Fulfill transaction failed', - }); - expect(staticFulfillMock).toHaveBeenCalledTimes(1); - expect(staticFulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(fulfillMock).not.toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledTimes(1); - expect(failMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - 'Always reverts', - txOpts - ); - } - ); - - test.each([gasPrice, gasPriceFallback])( - `does nothing if the fulfill test returns nothing - %#`, - async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; - const provider = new ethers.providers.JsonRpcProvider(); - staticFulfillMock.mockResolvedValueOnce(null); - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); - const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { - gasTarget, - masterHDNode, - provider, - }); - expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, - { - level: 'ERROR', - message: `Fulfill attempt for Request:${apiCall.id} responded with unexpected value: 'null'`, - }, - ]); - expect(err).toEqual(null); - expect(data).toEqual(null); - expect(staticFulfillMock).toHaveBeenCalledTimes(1); - expect(staticFulfillMock).toHaveBeenCalledWith( - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); - expect(fulfillMock).not.toHaveBeenCalled(); - expect(failMock).not.toHaveBeenCalled(); - } - ); - - test.each([gasPrice, gasPriceFallback])(`returns an error if everything fails - %#`, async (gasTarget) => { - const txOpts = { ...gasTarget, nonce: 5 }; + test.each([gasTarget, gasTargetFallback, gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `does nothing for API call requests that do not have a nonce - %#`, + async (gasTarget) => { const provider = new ethers.providers.JsonRpcProvider(); - const staticCallError = new Error('Static call error'); - staticFulfillMock.mockRejectedValueOnce(staticCallError); - staticFulfillMock.mockRejectedValueOnce(staticCallError); - const failTxError = new Error('Fail transaction error'); - failMock.mockRejectedValueOnce(failTxError); - failMock.mockRejectedValueOnce(failTxError); - const apiCall = fixtures.requests.buildSuccessfulApiCall({ - id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', - data: { - encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - signature: - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - }, - nonce: 5, - }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ nonce: undefined }); const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, masterHDNode, provider, }); expect(logs).toEqual([ - { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { - error: staticCallError, level: 'ERROR', - message: `Static call fulfillment failed for Request:${apiCall.id} with ${staticCallError}`, - }, - { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, - { - error: failTxError, - level: 'ERROR', - message: `Error submitting API call fail transaction for Request:${apiCall.id}`, + message: `API call for Request:${apiCall.id} cannot be submitted as it does not have a nonce`, }, ]); - expect(err).toEqual(failTxError); + expect(err).toEqual(null); expect(data).toEqual(null); - expect(staticFulfillMock).toHaveBeenCalledTimes(2); - expect(staticFulfillMock).toHaveBeenNthCalledWith( - 2, - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - txOpts - ); + expect(staticFulfillMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); - expect(failMock).toHaveBeenCalledTimes(2); - expect(failMock).toHaveBeenNthCalledWith( - 2, - apiCall.id, - apiCall.airnodeAddress, - apiCall.fulfillAddress, - apiCall.fulfillFunctionId, - 'Static call error', - txOpts - ); - }); - }); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTarget, gasTargetFallback])( + `does not estimate gas if 'gasLimit' is specified - %#`, + async (gasTarget) => { + const provider = new ethers.providers.JsonRpcProvider(); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + nonce: 5, + }); + await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(estimateFulfillMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `estimates gas if 'gasLimit' isn't specified - %#`, + async (gasTarget) => { + const provider = new ethers.providers.JsonRpcProvider(); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + nonce: 5, + }); + await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(estimateFulfillMock).toHaveBeenCalled(); + } + ); describe('Errored API calls', () => { - test.each([gasPrice, gasPriceFallback])( + test.each([gasTarget, gasTargetFallback, gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( `submits a fail transaction with errorMessage for errored requests - %#`, async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; @@ -436,11 +148,12 @@ describe('submitApiCall', () => { txOpts ); expect(staticFulfillMock).not.toHaveBeenCalled(); + expect(estimateFulfillMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); - test.each([gasPrice, gasPriceFallback])( + test.each([gasTarget, gasTargetFallback])( `submits a fail transaction with a trimmed errorMessage for errored requests - %#`, async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; @@ -481,11 +194,12 @@ describe('submitApiCall', () => { txOpts ); expect(staticFulfillMock).not.toHaveBeenCalled(); + expect(estimateFulfillMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); - test.each([gasPrice, gasPriceFallback])( + test.each([gasTarget, gasTargetFallback])( `returns an error if the error transaction fails - %#`, async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; @@ -526,8 +240,808 @@ describe('submitApiCall', () => { `${RequestErrorMessage.ApiCallFailed} with error: Server did not respond`, txOpts ); + expect(staticFulfillMock).not.toHaveBeenCalled(); + expect(estimateFulfillMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); }); + + describe('testAndSubmitFulfill', () => { + describe('Pending API calls', () => { + test.each([gasTarget, gasTargetFallback])( + `successfully tests and submits a fulfill transaction for pending requests - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + fulfillMock.mockResolvedValueOnce({ hash: '0xtransactionId' }); + + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xtransactionId' }, + }); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTarget, gasTargetFallback])( + `returns an error if the fulfill transaction for pending requests fails - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); + (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); + + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, + { + error: new Error('Server did not respond'), + level: 'ERROR', + message: + 'Error submitting API call fulfillment transaction for Request:0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + }, + ]); + expect(err).toEqual(new Error('Server did not respond')); + expect(data).toEqual(null); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).toHaveBeenCalledTimes(2); + expect(fulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTarget, gasTargetFallback])( + `submits a fail transaction if the fulfill call would revert with empty string - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: false, callData: '0x' }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: 'Fulfill transaction failed', + }); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + 'No revert string', + txOpts + ); + } + ); + + test.each([gasTarget, gasTargetFallback])( + `submits a fail transaction if the fulfill call would revert with a revert string - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + staticFulfillMock.mockResolvedValueOnce({ + callSuccess: false, + callData: + '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e416c776179732072657665727473000000000000000000000000000000000000', + }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: 'Fulfill transaction failed', + }); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + 'Always reverts', + txOpts + ); + } + ); + + test.each([gasTarget, gasTargetFallback])( + `does nothing if the fulfill test returns nothing - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + staticFulfillMock.mockResolvedValueOnce(null); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { + level: 'ERROR', + message: `Fulfill attempt for Request:${apiCall.id} responded with unexpected value: 'null'`, + }, + ]); + expect(err).toEqual(null); + expect(data).toEqual(null); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTarget, gasTargetFallback])(`returns an error if everything fails - %#`, async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const staticCallError = new Error('Static call error'); + staticFulfillMock.mockRejectedValueOnce(staticCallError); + staticFulfillMock.mockRejectedValueOnce(staticCallError); + const failTxError = new Error('Fail transaction error'); + failMock.mockRejectedValueOnce(failTxError); + failMock.mockRejectedValueOnce(failTxError); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { + error: staticCallError, + level: 'ERROR', + message: `Static call fulfillment failed for Request:${apiCall.id} with ${staticCallError}`, + }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + { + error: failTxError, + level: 'ERROR', + message: `Error submitting API call fail transaction for Request:${apiCall.id}`, + }, + ]); + expect(err).toEqual(failTxError); + expect(data).toEqual(null); + expect(staticFulfillMock).toHaveBeenCalledTimes(2); + expect(staticFulfillMock).toHaveBeenNthCalledWith( + 2, + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + txOpts + ); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(2); + expect(failMock).toHaveBeenNthCalledWith( + 2, + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + 'Static call error', + txOpts + ); + }); + }); + }); + + describe('estimateGasAndSubmitFulfill', () => { + describe('Pending API calls', () => { + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `successfully estimates gas and submits a fulfill transaction for pending requests - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + estimateFulfillMock.mockResolvedValueOnce(73804); + fulfillMock.mockResolvedValueOnce({ hash: '0xtransactionId' }); + + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { level: 'INFO', message: `Gas limit is set to ${73804} for Request:${apiCall.id}.` }, + { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xtransactionId' }, + }); + expect(estimateFulfillMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(fulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { ...txOpts, gasLimit: 73804 } + ); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `returns an error if the fulfill transaction for pending requests fails - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + estimateFulfillMock.mockResolvedValueOnce(73804); + (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); + (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); + + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { level: 'INFO', message: `Gas limit is set to ${73804} for Request:${apiCall.id}.` }, + { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, + { + error: new Error('Server did not respond'), + level: 'ERROR', + message: + 'Error submitting API call fulfillment transaction for Request:0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + }, + ]); + expect(err).toEqual(new Error('Server did not respond')); + expect(data).toEqual(null); + expect(estimateFulfillMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(fulfillMock).toHaveBeenCalledTimes(2); + expect(fulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { ...txOpts, gasLimit: 73804 } + ); + expect(failMock).not.toHaveBeenCalled(); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the gas estimation is failed but following static call is successful - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const estimateGasError = new Error('Estimate gas error'); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { + error: estimateGasError, + level: 'ERROR', + message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Gas estimation failed with error: ${estimateGasError.message}`, + }); + expect(estimateFulfillMock).toHaveBeenCalledTimes(2); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + estimateGasError.message, + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the gas estimation is failed and following static call is also failed with revert string - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const estimateGasError = new Error('Estimate gas error'); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + staticFulfillMock.mockResolvedValueOnce({ + callSuccess: false, + callData: + '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e416c776179732072657665727473000000000000000000000000000000000000', + }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { + error: estimateGasError, + level: 'ERROR', + message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Fulfill transaction failed`, + }); + expect(estimateFulfillMock).toHaveBeenCalledTimes(2); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + `Always reverts`, + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the gas estimation is resolved with null but following static call is successful - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + estimateFulfillMock.mockResolvedValueOnce(null); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Gas estimation failed`, + }); + expect(estimateFulfillMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + 'Gas estimation failed', + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the gas estimation is resolved with null and following static call is also failed with revert string - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + estimateFulfillMock.mockResolvedValueOnce(null); + staticFulfillMock.mockResolvedValueOnce({ + callSuccess: false, + callData: + '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e416c776179732072657665727473000000000000000000000000000000000000', + }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Fulfill transaction failed`, + }); + expect(estimateFulfillMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + `Always reverts`, + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `returns an error if everything fails - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const estimateGasError = new Error('Estimate gas error'); + const staticCallError = new Error('Static call error'); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + staticFulfillMock.mockRejectedValueOnce(staticCallError); + staticFulfillMock.mockRejectedValueOnce(staticCallError); + + const failTxError = new Error('Fail transaction error'); + failMock.mockRejectedValueOnce(failTxError); + failMock.mockRejectedValueOnce(failTxError); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + }, + { + error: estimateGasError, + level: 'ERROR', + message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { + error: staticCallError, + level: 'ERROR', + message: `Static call fulfillment failed for Request:${apiCall.id} with ${staticCallError}`, + }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + { + error: failTxError, + level: 'ERROR', + message: `Error submitting API call fail transaction for Request:${apiCall.id}`, + }, + ]); + expect(err).toEqual(failTxError); + expect(data).toEqual(null); + expect(estimateFulfillMock).toHaveBeenCalledTimes(2); + expect(estimateFulfillMock).toHaveBeenNthCalledWith( + 2, + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(2); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(2); + expect(failMock).toHaveBeenNthCalledWith( + 2, + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + 'Static call error', + txOpts + ); + } + ); + }); + }); }); diff --git a/packages/airnode-node/src/evm/fulfillments/api-calls.ts b/packages/airnode-node/src/evm/fulfillments/api-calls.ts index 1e0d2554c5..e1fd0544d9 100644 --- a/packages/airnode-node/src/evm/fulfillments/api-calls.ts +++ b/packages/airnode-node/src/evm/fulfillments/api-calls.ts @@ -75,6 +75,38 @@ async function testFulfill( return [[noticeLog], null, goRes.data]; } +async function estimateGasToFulfill( + airnodeRrp: AirnodeRrpV0, + request: Request +): Promise> { + const noticeLog = logger.pend( + 'DEBUG', + `Attempting to estimate gas for API call fulfillment for Request:${request.id}...` + ); + if (!request.success) return [[], new Error('Only successful API can be submitted'), null]; + + const operation = (): Promise => + airnodeRrp.estimateGas.fulfill( + request.id, + request.airnodeAddress, + request.fulfillAddress, + request.fulfillFunctionId, + request.data.encodedValue, + request.data.signature + ); + const goRes = await go(operation, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); + if (!goRes.success) { + const errorLog = logger.pend( + 'ERROR', + `Gas estimation for API call fulfillment failed for Request:${request.id} with ${goRes.error}`, + goRes.error + ); + return [[noticeLog, errorLog], goRes.error, null]; + } + + return [[noticeLog], null, goRes.data]; +} + async function submitFulfill( airnodeRrp: AirnodeRrpV0, request: Request, @@ -108,16 +140,11 @@ async function submitFulfill( return [[noticeLog], null, applyTransactionResult(request, goRes.data)]; } -async function testAndSubmitFulfill( +export async function testAndSubmitFulfill( airnodeRrp: AirnodeRrpV0, request: Request, options: TransactionOptions ): Promise>> { - const errorMessage = requests.getErrorMessage(request); - if (errorMessage) { - return submitFail(airnodeRrp, request, errorMessage, options); - } - // Should not throw const [testLogs, testErr, testData] = await testFulfill(airnodeRrp, request, options); @@ -151,6 +178,60 @@ async function testAndSubmitFulfill( return [[...testLogs, errorLog], testErr, null]; } +export async function estimateGasAndSubmitFulfill( + airnodeRrp: AirnodeRrpV0, + request: Request, + options: TransactionOptions +): Promise>> { + // Should not throw + const [estimateGasLogs, estimateGasErr, estimateGasData] = await estimateGasToFulfill(airnodeRrp, request); + + if (estimateGasErr || !estimateGasData) { + // Make static test call to get revert string + const [testLogs, testErr, testData] = await testFulfill(airnodeRrp, request, options); + + if (testErr || !testData || !testData.callSuccess) { + const updatedRequest: Request = { + ...request, + errorMessage: testErr + ? `${RequestErrorMessage.FulfillTransactionFailed} with error: ${testErr.message}` + : RequestErrorMessage.FulfillTransactionFailed, + }; + const [submitLogs, submitErr, submittedRequest] = await submitFail( + airnodeRrp, + updatedRequest, + testErr?.message ?? decodeRevertString(testData?.callData || '0x'), + options + ); + return [[...estimateGasLogs, ...testLogs, ...submitLogs], submitErr, submittedRequest]; + } + + // If static test call is successful even though gas estimation failure, + // it'll be caused by an RPC issue, so submit specific reason to chain + const updatedRequest: Request = { + ...request, + errorMessage: estimateGasErr + ? `${RequestErrorMessage.GasEstimationFailed} with error: ${estimateGasErr.message}` + : RequestErrorMessage.GasEstimationFailed, + }; + const [submitLogs, submitErr, submittedRequest] = await submitFail( + airnodeRrp, + updatedRequest, + estimateGasErr?.message ?? RequestErrorMessage.GasEstimationFailed, + options + ); + return [[...estimateGasLogs, ...testLogs, ...submitLogs], submitErr, submittedRequest]; + } + + // If gas estimation is success, submit fulfillment without making static test call + const gasLimitNoticeLog = logger.pend('INFO', `Gas limit is set to ${estimateGasData} for Request:${request.id}.`); + const [submitLogs, submitErr, submitData] = await submitFulfill(airnodeRrp, request, { + ...options, + gasTarget: { ...options.gasTarget, gasLimit: estimateGasData }, + }); + return [[...estimateGasLogs, gasLimitNoticeLog, ...submitLogs], submitErr, submitData]; +} + // ================================================================= // Failures // ================================================================= @@ -203,6 +284,13 @@ export const submitApiCall: SubmitRequest = (airnodeRrp, re return Promise.resolve([[log], null, null]); } + const errorMessage = requests.getErrorMessage(request); + if (errorMessage) { + return submitFail(airnodeRrp, request, errorMessage, options); + } + // Should not throw - return testAndSubmitFulfill(airnodeRrp, request, options); + if (options.gasTarget.gasLimit) return testAndSubmitFulfill(airnodeRrp, request, options); + + return estimateGasAndSubmitFulfill(airnodeRrp, request, options); }; diff --git a/packages/airnode-node/src/evm/fulfillments/index.test.ts b/packages/airnode-node/src/evm/fulfillments/index.test.ts index 7aea9d1beb..7617dba240 100644 --- a/packages/airnode-node/src/evm/fulfillments/index.test.ts +++ b/packages/airnode-node/src/evm/fulfillments/index.test.ts @@ -4,12 +4,13 @@ const failMock = jest.fn(); const fulfillMock = jest.fn(); const fulfillWithdrawalMock = jest.fn(); const staticFulfillMock = jest.fn(); +const estimateFulfillMock = jest.fn(); mockEthers({ airnodeRrpMocks: { callStatic: { fulfill: staticFulfillMock, }, - estimateGas: { fulfillWithdrawal: estimateWithdrawalGasMock }, + estimateGas: { fulfill: estimateFulfillMock, fulfillWithdrawal: estimateWithdrawalGasMock }, fail: failMock, fulfill: fulfillMock, fulfillWithdrawal: fulfillWithdrawalMock, @@ -57,6 +58,7 @@ describe('submit', () => { const provider = new ethers.providers.JsonRpcProvider(); const state = providerState.update(mutableInitialState, { gasTarget, provider, requests }); + estimateFulfillMock.mockResolvedValue(73804); staticFulfillMock.mockResolvedValue({ callSuccess: true }); fulfillMock.mockResolvedValueOnce({ hash: '0xapicall_tx1' }); fulfillMock.mockResolvedValueOnce({ hash: '0xapicall_tx2' }); diff --git a/packages/airnode-node/src/types.ts b/packages/airnode-node/src/types.ts index 46c8aaebdb..9d5c5fd09b 100644 --- a/packages/airnode-node/src/types.ts +++ b/packages/airnode-node/src/types.ts @@ -45,6 +45,7 @@ export enum RequestErrorMessage { ApiCallFailed = 'API call failed', ReservedParametersInvalid = 'Reserved parameters are invalid', FulfillTransactionFailed = 'Fulfill transaction failed', + GasEstimationFailed = 'Gas estimation failed', SponsorRequestLimitExceeded = 'Sponsor request limit exceeded', } diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index 43ab8e6bf5..3646e3bfb2 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -153,7 +153,7 @@ export const gasPriceOracleSchema = z export const chainOptionsSchema = z .object({ - fulfillmentGasLimit: z.number().int(), + fulfillmentGasLimit: z.number().int().optional(), withdrawalRemainder: amountSchema.optional(), gasPriceOracle: gasPriceOracleSchema, }) From 3551d099899a0094eabdce856c62f94450877ab5 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Thu, 6 Jul 2023 11:07:12 +0300 Subject: [PATCH 2/7] feat(validator): add AirnodeRrpV0DryRun --- .../src/config/config.test.ts | 85 ++++++++++++++++++- .../airnode-validator/src/config/config.ts | 81 +++++++++++++----- .../fixtures/interpolated-config.valid.json | 3 +- 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/packages/airnode-validator/src/config/config.test.ts b/packages/airnode-validator/src/config/config.test.ts index 656422e535..aceab7b443 100644 --- a/packages/airnode-validator/src/config/config.test.ts +++ b/packages/airnode-validator/src/config/config.test.ts @@ -22,6 +22,7 @@ import { version as packageVersion } from '../../package.json'; import { SchemaType } from '../types'; const AirnodeRrpV0Addresses: { [chainId: string]: string } = references.AirnodeRrpV0; +const AirnodeRrpV0DryRunAddresses: { [chainId: string]: string } = references.AirnodeRrpV0DryRun; const RequesterAuthorizerWithErc721Addresses: { [chainId: string]: string } = references.RequesterAuthorizerWithErc721; it('successfully parses config.json', () => { @@ -666,11 +667,13 @@ describe('ensureValidAirnodeRrp', () => { const { testName, schema, configObject, chainIdField } = obj; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { contracts, ...objectMissingContracts } = configObject; - + //eslint-disable-next-line @typescript-eslint/no-unused-vars + const { AirnodeRrp, ...contractsMissingAirnodeRrp } = contracts; it(`fails if AirnodeRrp contract address within ${testName}.contracts is not specified and there is no deployment for the chain`, () => { const idWithoutDeployment = '99999999999999999999999'; const unknownChain = { ...objectMissingContracts, + contracts: contractsMissingAirnodeRrp, [chainIdField]: idWithoutDeployment, }; @@ -679,7 +682,7 @@ describe('ensureValidAirnodeRrp', () => { { code: 'custom', message: - `AirnodeRrp contract address must be specified for chain ID '${idWithoutDeployment}'` + + `AirnodeRrp contract address must be specified for chain ID '${idWithoutDeployment}' ` + `as there was no deployment for this chain exported from @api3/airnode-protocol`, path: ['contracts'], }, @@ -691,6 +694,7 @@ describe('ensureValidAirnodeRrp', () => { const idWithDeployment = '1'; const chainWithDeployment = { ...objectMissingContracts, + contracts: contractsMissingAirnodeRrp, [chainIdField]: idWithDeployment, }; const parsed = schema.parse(chainWithDeployment); @@ -704,6 +708,7 @@ describe('ensureValidAirnodeRrp', () => { ...objectMissingContracts, contracts: { AirnodeRrp: '0x5FbDB2315678afecb367f032d93F642f64180aa3', + AirnodeRrpDryRun: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', }, }; expect(() => schema.parse(chainWithContracts)).not.toThrow(); @@ -711,6 +716,78 @@ describe('ensureValidAirnodeRrp', () => { }); }); +describe('ensureValidAirnodeRrpDryRun', () => { + const config = JSON.parse( + readFileSync(join(__dirname, '../../test/fixtures/interpolated-config.valid.json')).toString() + ); + + const chainConfig = config.chains[0]; + //eslint-disable-next-line @typescript-eslint/no-unused-vars + const { contracts, ...chainConfigMissingContracts } = chainConfig; + //eslint-disable-next-line @typescript-eslint/no-unused-vars + const { AirnodeRrpDryRun, ...contractsMissingAirnodeRrpDryRun } = contracts; + //eslint-disable-next-line @typescript-eslint/no-unused-vars + const { fulfillmentGasLimit, ...chainOptionsMissingFulfillmentGasLimit } = chainConfigMissingContracts.options; + it(`fails if the fulfillmentGasLimit is not defined and AirnodeRrpDryRun contract address within chains.contracts is not specified and there is no deployment for the chain`, () => { + const idWithoutDeployment = '99999999999999999999999'; + const unknownChain = { + ...chainConfigMissingContracts, + contracts: contractsMissingAirnodeRrpDryRun, + options: chainOptionsMissingFulfillmentGasLimit, + id: idWithoutDeployment, + }; + + expect(() => chainConfigSchema.parse(unknownChain)).toThrow( + new ZodError([ + { + code: 'custom', + message: + `When 'fulfillmentGasLimit' is not specified, AirnodeRrpDryRun contract address must be specified for chain ID '${idWithoutDeployment}' ` + + `as there was no deployment for this chain exported from @api3/airnode-protocol`, + path: ['contracts'], + }, + ]) + ); + }); + + it(`adds chains.contracts object if the fulfillmentGasLimit and the AirnodeRrpDryRun contract address is not specified but there is a deployment for the chain`, () => { + const idWithDeployment = '1'; + const chainWithDeployment = { + ...chainConfigMissingContracts, + contracts: contractsMissingAirnodeRrpDryRun, + options: chainOptionsMissingFulfillmentGasLimit, + id: idWithDeployment, + }; + + const parsed = chainConfigSchema.parse(chainWithDeployment); + expect(parsed.contracts).toEqual({ + ...contractsMissingAirnodeRrpDryRun, + AirnodeRrpDryRun: AirnodeRrpV0DryRunAddresses[chainWithDeployment.id], + }); + }); + + it(`allows an AirnodeRrpDryRun contract address to be specified within chains.contracts`, () => { + const chainWithContracts = { + ...chainConfigMissingContracts, + contracts: { + AirnodeRrp: '0x5FbDB2315678afecb367f032d93F642f64180aa3', + AirnodeRrpDryRun: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + }, + }; + expect(() => chainConfigSchema.parse(chainWithContracts)).not.toThrow(); + }); + + it(`allows an AirnodeRrpDryRun contract address to be not specified within chains.contracts if the fulfillmentGasLimit is defined`, () => { + const chainWithMissingAirnodeRrpDryRunContract = { + ...chainConfigMissingContracts, + contracts: { + AirnodeRrp: '0x5FbDB2315678afecb367f032d93F642f64180aa3', + }, + }; + expect(() => chainConfigSchema.parse(chainWithMissingAirnodeRrpDryRunContract)).not.toThrow(); + }); +}); + describe('authorizers', () => { it('allows simultaneous authorizers', () => { const config = JSON.parse( @@ -808,7 +885,7 @@ describe('ensureCrossChainRequesterAuthorizerWithErc721', () => { { code: 'custom', message: - `RequesterAuthorizerWithErc721 contract address must be specified for chain ID '${idWithoutDeployment}'` + + `RequesterAuthorizerWithErc721 contract address must be specified for chain ID '${idWithoutDeployment}' ` + `as there was no deployment for this chain exported from @api3/airnode-protocol`, path: ['contracts'], }, @@ -849,7 +926,7 @@ describe('ensureRequesterAuthorizerWithErc721', () => { { code: 'custom', message: - `RequesterAuthorizerWithErc721 contract address must be specified for chain ID '${idWithoutDeployment}'` + + `RequesterAuthorizerWithErc721 contract address must be specified for chain ID '${idWithoutDeployment}' ` + `as there was no deployment for this chain exported from @api3/airnode-protocol`, path: ['authorizers', 'requesterAuthorizersWithErc721', 0], }, diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index bd241cfbc5..2aafd2b222 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -4,6 +4,7 @@ import forEach from 'lodash/forEach'; import includes from 'lodash/includes'; import isEmpty from 'lodash/isEmpty'; import size from 'lodash/size'; +import defaults from 'lodash/defaults'; import { goSync } from '@api3/promise-utils'; import { references } from '@api3/airnode-protocol'; import { version as packageVersion } from '../../package.json'; @@ -15,6 +16,7 @@ export const evmIdSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); export const chainIdSchema = z.string().regex(/^\d+$/); const AirnodeRrpV0Addresses: { [chainId: string]: string } = references.AirnodeRrpV0; +const AirnodeRrpV0DryRunAddresses: { [chainId: string]: string } = references.AirnodeRrpV0DryRun; const RequesterAuthorizerWithErc721Addresses: { [chainId: string]: string } = references.RequesterAuthorizerWithErc721; // We use a convention for deriving endpoint ID from OIS title and endpoint name, @@ -62,7 +64,8 @@ export const chainTypeSchema = z.literal('evm'); export const airnodeRrpContractSchema = z .object({ - AirnodeRrp: evmAddressSchema, + AirnodeRrp: evmAddressSchema.optional(), + AirnodeRrpDryRun: evmAddressSchema.optional(), }) .strict(); @@ -159,30 +162,65 @@ export const chainOptionsSchema = z }) .strict(); -export const ensureValidAirnodeRrp = ( - contracts: SchemaType, - chainId: string, - ctx: RefinementCtx -) => { - if (!contracts?.AirnodeRrp) { +export const ensureValidAirnodeRrp = (airnodeRrp: string | undefined, chainId: string, ctx: RefinementCtx) => { + if (!airnodeRrp) { if (!AirnodeRrpV0Addresses[chainId]) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - `AirnodeRrp contract address must be specified for chain ID '${chainId}'` + + `AirnodeRrp contract address must be specified for chain ID '${chainId}' ` + `as there was no deployment for this chain exported from @api3/airnode-protocol`, path: ['contracts'], }); + // This is a special symbol you can use to return early from the transform function. + // It has type `never` so it does not affect the inferred return type. + return z.NEVER; } // Default to the deployed AirnodeRrp contract address if contracts is not specified - return { ...contracts, AirnodeRrp: AirnodeRrpV0Addresses[chainId] }; + return AirnodeRrpV0Addresses[chainId]; } - return contracts; + return airnodeRrp; }; -const ensureConfigValidAirnodeRrp = (value: z.infer, ctx: RefinementCtx) => { - const contracts = ensureValidAirnodeRrp(value.contracts, value.id, ctx); - return { ...value, contracts }; +export const ensureValidAirnodeRrpDryRun = ( + airnodeRrpDryRun: string | undefined, + chainId: string, + ctx: RefinementCtx, + options: SchemaType +) => { + // If 'fulfillmentGasLimit' is defined in the config, + // it indicates that the AirnodeRrpDryRun contract will not be used to estimate gas + if (options?.fulfillmentGasLimit) { + return; + } + + if (!airnodeRrpDryRun) { + if (!AirnodeRrpV0DryRunAddresses[chainId]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + `When 'fulfillmentGasLimit' is not specified, ` + + `AirnodeRrpDryRun contract address must be specified for chain ID '${chainId}' ` + + `as there was no deployment for this chain exported from @api3/airnode-protocol`, + path: ['contracts'], + }); + // This is a special symbol you can use to return early from the transform function. + // It has type `never` so it does not affect the inferred return type. + return z.NEVER; + } + // Default to the deployed AirnodeRrpDryRun contract address if contracts is not specified + return AirnodeRrpV0DryRunAddresses[chainId]; + } + return airnodeRrpDryRun; +}; + +export const ensureConfigValidAirnodeRrp = (value: z.infer, ctx: RefinementCtx) => { + const contracts = defaults(value.contracts, { AirnodeRrp: '' }); + + const AirnodeRrp = ensureValidAirnodeRrp(contracts.AirnodeRrp, value.id, ctx); + const AirnodeRrpDryRun = ensureValidAirnodeRrpDryRun(contracts.AirnodeRrpDryRun, value.id, ctx, value.options); + // This transformation ensures that the output type of `chains.contracts` is { AirnodeRrp: string, AirnodeRrpDryRun: string | undefined } + return { ...value, contracts: { ...value.contracts, AirnodeRrp, AirnodeRrpDryRun } }; }; // Similar to ensureConfigValidAirnodeRrp, but this needs to be a separate function because of the distinct @@ -191,8 +229,11 @@ export const ensureCrossChainRequesterAuthorizerValidAirnodeRrp = ( value: z.infer, ctx: RefinementCtx ) => { - const contracts = ensureValidAirnodeRrp(value.contracts, value.chainId, ctx); - return { ...value, contracts }; + const contracts = defaults(value.contracts, { AirnodeRrp: '' }); + + const AirnodeRrp = ensureValidAirnodeRrp(contracts.AirnodeRrp, value.chainId, ctx); + // This transformation ensures that the output type of `crossChainRequesterAuthorizer.contracts` is { AirnodeRrp: string, AirnodeRrpDryRun: string | undefined } + return { ...value, contracts: { ...value.contracts, AirnodeRrp } }; }; export const chainAuthorizationsSchema = z.object({ @@ -207,7 +248,7 @@ const _crossChainRequesterAuthorizerSchema = z chainType: chainTypeSchema, chainId: chainIdSchema, // The type system requires optional() be transformed to the expected type here despite the later transform - contracts: airnodeRrpContractSchema.optional().transform((val) => (val === undefined ? { AirnodeRrp: '' } : val)), + contracts: airnodeRrpContractSchema.optional().transform((val) => defaults(val, { AirnodeRrp: '' })), chainProvider: providerSchema, }) .strict(); @@ -242,7 +283,7 @@ export const ensureRequesterAuthorizerWithErc721 = (value: z.infer (val === undefined ? { RequesterAuthorizerWithErc721: '' } : val)), + .transform((val) => defaults(val, { RequesterAuthorizerWithErc721: '' })), chainProvider: providerSchema, }); @@ -325,7 +366,7 @@ const _chainConfigSchema = z authorizations: chainAuthorizationsSchema, blockHistoryLimit: z.number().int().optional(), // Defaults to BLOCK_COUNT_HISTORY_LIMIT defined in airnode-node // The type system requires optional() be transformed to the expected type here despite the later transform - contracts: airnodeRrpContractSchema.optional().transform((val) => (val === undefined ? { AirnodeRrp: '' } : val)), + contracts: airnodeRrpContractSchema.optional().transform((val) => defaults(val, { AirnodeRrp: '' })), id: chainIdSchema, // Defaults to BLOCK_MIN_CONFIRMATIONS defined in airnode-node but may be overridden // by a requester if the _minConfirmations reserved parameter is configured diff --git a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json index d692757e7f..974a16e679 100644 --- a/packages/airnode-validator/test/fixtures/interpolated-config.valid.json +++ b/packages/airnode-validator/test/fixtures/interpolated-config.valid.json @@ -41,7 +41,8 @@ "requesterEndpointAuthorizations": {} }, "contracts": { - "AirnodeRrp": "0x5FbDB2315678afecb367f032d93F642f64180aa3" + "AirnodeRrp": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "AirnodeRrpDryRun": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" }, "id": "31337", "providers": { From 546b46115b6e66e628aed1ecc7b4bd7f1811fd76 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Thu, 6 Jul 2023 11:18:06 +0300 Subject: [PATCH 3/7] feat(node): export AirnodeRrpV0DryRun --- .../evm/contracts/airnodeRrpDryRun.test.ts | 32 +++++++++++++++++++ .../src/evm/contracts/airnodeRrpDryRun.ts | 11 +++++++ .../airnode-node/src/evm/contracts/index.ts | 1 + 3 files changed, 44 insertions(+) create mode 100644 packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.test.ts create mode 100644 packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.ts diff --git a/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.test.ts b/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.test.ts new file mode 100644 index 0000000000..a878f3360b --- /dev/null +++ b/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.test.ts @@ -0,0 +1,32 @@ +import { AirnodeRrpV0DryRunFactory, airnodeRrpDryRunTopics } from './airnodeRrpDryRun'; + +describe('AirnodeRrpDryRun', () => { + it('exposes the contract ABI function', () => { + const functions = AirnodeRrpV0DryRunFactory.abi + .filter((fn: any) => fn.type === 'function') + .map((fn: any) => fn.name) + .sort(); + + expect(functions).toEqual(['fulfill']); + }); + + it('exposes the contract ABI events', () => { + const events = AirnodeRrpV0DryRunFactory.abi + .filter((fn: any) => fn.type === 'event') + .map((fn: any) => fn.name) + .sort(); + expect(events).toEqual(['FulfilledRequest']); + }); + + it('exposes the contract topics', () => { + // Make sure all topics are covered + expect.assertions(2); + + expect(Object.keys(airnodeRrpDryRunTopics).sort()).toEqual(['FulfilledRequest']); + + // API calls + expect(airnodeRrpDryRunTopics.FulfilledRequest).toEqual( + '0xc0977dab79883641ece94bb6a932ca83049f561ffff8d8daaeafdbc1acce9e0a' + ); + }); +}); diff --git a/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.ts b/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.ts new file mode 100644 index 0000000000..e986f78575 --- /dev/null +++ b/packages/airnode-node/src/evm/contracts/airnodeRrpDryRun.ts @@ -0,0 +1,11 @@ +import { ethers } from 'ethers'; +import { AirnodeRrpV0DryRun, AirnodeRrpV0DryRunFactory } from '@api3/airnode-protocol'; + +const FulfilledRequest = ethers.utils.id('FulfilledRequest(address,bytes32,bytes)'); + +const airnodeRrpDryRunTopics = { + // API calls + FulfilledRequest, +}; + +export { AirnodeRrpV0DryRunFactory, AirnodeRrpV0DryRun, airnodeRrpDryRunTopics }; diff --git a/packages/airnode-node/src/evm/contracts/index.ts b/packages/airnode-node/src/evm/contracts/index.ts index 7b78a91cb6..45762b9d2b 100644 --- a/packages/airnode-node/src/evm/contracts/index.ts +++ b/packages/airnode-node/src/evm/contracts/index.ts @@ -1 +1,2 @@ export * from './airnodeRrp'; +export * from './airnodeRrpDryRun'; From 1b4d17a05c1e6a0d08c0e7b96cda16ae26847572 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Thu, 6 Jul 2023 11:21:08 +0300 Subject: [PATCH 4/7] feat(node): leverage AirnodeRrpV0DryRun while estimating gas --- packages/airnode-node/src/config/index.ts | 2 +- .../src/evm/fulfillments/api-calls.test.ts | 376 +++++++++++++++--- .../src/evm/fulfillments/api-calls.ts | 119 +++++- .../src/evm/fulfillments/index.test.ts | 6 +- .../src/evm/fulfillments/index.ts | 1 + .../src/evm/fulfillments/withdrawals.test.ts | 8 + packages/airnode-node/src/types.ts | 2 + 7 files changed, 443 insertions(+), 71 deletions(-) diff --git a/packages/airnode-node/src/config/index.ts b/packages/airnode-node/src/config/index.ts index db3f649b4d..626278b5ab 100644 --- a/packages/airnode-node/src/config/index.ts +++ b/packages/airnode-node/src/config/index.ts @@ -28,7 +28,7 @@ const readConfig = (configPath: string): unknown => { export function addDefaultContractAddresses(config: configTypes.Config): configTypes.Config { const ctx = { addIssue: () => {}, path: [] }; // Unused, but required by validator functions const chains = config.chains.map((chain) => { - const contracts = configTypes.ensureValidAirnodeRrp(chain.contracts, chain.id, ctx); + const { contracts } = configTypes.ensureConfigValidAirnodeRrp(chain, ctx); const crossChainRequesterAuthorizers = chain.authorizers.crossChainRequesterAuthorizers.map((craObj) => { return configTypes.ensureCrossChainRequesterAuthorizerValidAirnodeRrp(craObj, ctx); diff --git a/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts b/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts index 2dfc924617..896eabc13d 100644 --- a/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts +++ b/packages/airnode-node/src/evm/fulfillments/api-calls.test.ts @@ -3,18 +3,22 @@ import { mockEthers } from '../../../test/mock-utils'; const failMock = jest.fn(); const fulfillMock = jest.fn(); const staticFulfillMock = jest.fn(); -const estimateFulfillMock = jest.fn(); +const estimateFulfillmentCallOverheadMock = jest.fn(); +const estimateAirnodeRrpOverheadMock = jest.fn(); mockEthers({ airnodeRrpMocks: { callStatic: { fulfill: staticFulfillMock, }, - estimateGas: { - fulfill: estimateFulfillMock, - }, fail: failMock, fulfill: fulfillMock, }, + // Mock to be used in the function `estimateFulfillmentCallOverhead` + ethersMocks: { + VoidSigner: jest.fn().mockImplementation((_signerAddress, _provider) => ({ + estimateGas: estimateFulfillmentCallOverheadMock, + })), + }, }); import { ethers } from 'ethers'; @@ -23,15 +27,23 @@ import * as apiCalls from './api-calls'; import * as fixtures from '../../../test/fixtures'; import * as wallet from '../wallet'; import { RequestErrorMessage } from '../../types'; -import { AirnodeRrpV0 } from '../contracts'; +import { AirnodeRrpV0, AirnodeRrpV0DryRunFactory } from '../contracts'; import { MAXIMUM_ONCHAIN_ERROR_LENGTH } from '../../constants'; +// Mock to be used in the function `estimateAirnodeRrpOverhead` +jest.spyOn(AirnodeRrpV0DryRunFactory, 'connect').mockImplementation( + () => + ({ + estimateGas: { fulfill: estimateAirnodeRrpOverheadMock }, + } as any) +); + const createAirnodeRrpFake = () => new ethers.Contract('address', ['ABI']) as unknown as AirnodeRrpV0; const config = fixtures.buildConfig(); describe('submitApiCall', () => { const masterHDNode = wallet.getMasterHDNode(config); - + const contracts = { AirnodeRrp: '', AirnodeRrpDryRun: '' }; const gasTarget: GasTarget = { type: 2, maxPriorityFeePerGas: ethers.BigNumber.from(1), @@ -61,6 +73,7 @@ describe('submitApiCall', () => { const apiCall = fixtures.requests.buildSuccessfulApiCall({ nonce: undefined }); const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -87,10 +100,11 @@ describe('submitApiCall', () => { }); await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); - expect(estimateFulfillMock).not.toHaveBeenCalled(); + expect(estimateAirnodeRrpOverheadMock).not.toHaveBeenCalled(); } ); @@ -103,10 +117,11 @@ describe('submitApiCall', () => { }); await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); - expect(estimateFulfillMock).toHaveBeenCalled(); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalled(); } ); @@ -123,6 +138,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -148,7 +164,8 @@ describe('submitApiCall', () => { txOpts ); expect(staticFulfillMock).not.toHaveBeenCalled(); - expect(estimateFulfillMock).not.toHaveBeenCalled(); + expect(estimateAirnodeRrpOverheadMock).not.toHaveBeenCalled(); + expect(estimateFulfillmentCallOverheadMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); @@ -168,6 +185,7 @@ describe('submitApiCall', () => { const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -194,7 +212,8 @@ describe('submitApiCall', () => { txOpts ); expect(staticFulfillMock).not.toHaveBeenCalled(); - expect(estimateFulfillMock).not.toHaveBeenCalled(); + expect(estimateAirnodeRrpOverheadMock).not.toHaveBeenCalled(); + expect(estimateFulfillmentCallOverheadMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); @@ -214,6 +233,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.submitApiCall(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -241,7 +261,8 @@ describe('submitApiCall', () => { txOpts ); expect(staticFulfillMock).not.toHaveBeenCalled(); - expect(estimateFulfillMock).not.toHaveBeenCalled(); + expect(estimateAirnodeRrpOverheadMock).not.toHaveBeenCalled(); + expect(estimateFulfillmentCallOverheadMock).not.toHaveBeenCalled(); expect(fulfillMock).not.toHaveBeenCalled(); } ); @@ -268,6 +289,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -324,6 +346,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -381,6 +404,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -439,6 +463,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -492,6 +517,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -539,6 +565,7 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.testAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); @@ -591,8 +618,11 @@ describe('submitApiCall', () => { async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); - estimateFulfillMock.mockResolvedValueOnce(73804); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(73804)); + estimateFulfillmentCallOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(290001)); fulfillMock.mockResolvedValueOnce({ hash: '0xtransactionId' }); + const txToCallFulfillFunction = { to: '0xfulfillAddress', data: '0xdata' }; + jest.spyOn(apiCalls, 'createTxToCallFulfillFunction').mockReturnValueOnce(txToCallFulfillFunction); const apiCall = fixtures.requests.buildSuccessfulApiCall({ id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', @@ -603,17 +633,32 @@ describe('submitApiCall', () => { }, nonce: 5, }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate fulfillment call overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'INFO', + message: `Gas limit is set to ${ + 73804 + 290001 + } (AirnodeRrp: ${73804} + Fulfillment Call: ${290001}) for Request:${apiCall.id}.`, }, - { level: 'INFO', message: `Gas limit is set to ${73804} for Request:${apiCall.id}.` }, { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, ]); expect(err).toEqual(null); @@ -621,15 +666,18 @@ describe('submitApiCall', () => { ...apiCall, fulfillment: { hash: '0xtransactionId' }, }); - expect(estimateFulfillMock).toHaveBeenCalledTimes(1); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); + expect(estimateFulfillmentCallOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillmentCallOverheadMock).toHaveBeenCalledWith(txToCallFulfillFunction); expect(fulfillMock).toHaveBeenCalledTimes(1); expect(fulfillMock).toHaveBeenCalledWith( apiCall.id, @@ -638,7 +686,7 @@ describe('submitApiCall', () => { apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - { ...txOpts, gasLimit: 73804 } + { ...txOpts, gasLimit: ethers.BigNumber.from(73804 + 290001) } ); expect(failMock).not.toHaveBeenCalled(); } @@ -649,9 +697,12 @@ describe('submitApiCall', () => { async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); - estimateFulfillMock.mockResolvedValueOnce(73804); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(73804)); + estimateFulfillmentCallOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(290001)); (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); (fulfillMock as any).mockRejectedValueOnce(new Error('Server did not respond')); + const txToCallFulfillFunction = { to: '0xfulfillAddress', data: '0xdata' }; + jest.spyOn(apiCalls, 'createTxToCallFulfillFunction').mockReturnValueOnce(txToCallFulfillFunction); const apiCall = fixtures.requests.buildSuccessfulApiCall({ id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', @@ -664,15 +715,29 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate fulfillment call overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'INFO', + message: `Gas limit is set to ${ + 73804 + 290001 + } (AirnodeRrp: ${73804} + Fulfillment Call: ${290001}) for Request:${apiCall.id}.`, }, - { level: 'INFO', message: `Gas limit is set to ${73804} for Request:${apiCall.id}.` }, { level: 'INFO', message: `Submitting API call fulfillment for Request:${apiCall.id}...` }, { error: new Error('Server did not respond'), @@ -683,15 +748,18 @@ describe('submitApiCall', () => { ]); expect(err).toEqual(new Error('Server did not respond')); expect(data).toEqual(null); - expect(estimateFulfillMock).toHaveBeenCalledTimes(1); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); + expect(estimateFulfillmentCallOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateFulfillmentCallOverheadMock).toHaveBeenCalledWith(txToCallFulfillFunction); expect(fulfillMock).toHaveBeenCalledTimes(2); expect(fulfillMock).toHaveBeenCalledWith( apiCall.id, @@ -700,22 +768,179 @@ describe('submitApiCall', () => { apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', - { ...txOpts, gasLimit: 73804 } + { ...txOpts, gasLimit: ethers.BigNumber.from(73804 + 290001) } ); expect(failMock).not.toHaveBeenCalled(); } ); test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( - `submits a fail transaction if the gas estimation is failed but following static call is successful - %#`, + `submits a fail transaction if the gas estimation for AirnodeRrp overhead is failed but following static call is successful - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const estimateGasError = new Error('Estimate gas error'); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + contracts, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + error: estimateGasError, + level: 'ERROR', + message: `AirnodeRrp overhead estimation failed for Request:${apiCall.id} with ${estimateGasError}`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Gas estimation failed with error: ${estimateGasError.message}`, + }); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(2); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } + ); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + estimateGasError.message, + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the transaction creation during gas estimation for fulfillment call overhead is failed but following static call is successful - %#`, + async (gasTarget) => { + const txOpts = { ...gasTarget, nonce: 5 }; + const provider = new ethers.providers.JsonRpcProvider(); + const createTxError = new Error('Error while hexlify'); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(73804)); + staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); + (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + jest.spyOn(apiCalls, 'createTxToCallFulfillFunction').mockImplementationOnce(() => { + throw createTxError; + }); + + const apiCall = fixtures.requests.buildSuccessfulApiCall({ + id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', + data: { + encodedValue: '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + signature: + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + }, + nonce: 5, + }); + const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { + gasTarget, + contracts, + masterHDNode, + provider, + }); + expect(logs).toEqual([ + { + level: 'DEBUG', + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate fulfillment call overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + error: createTxError, + level: 'ERROR', + message: `Transaction creation while fulfillment call overhead estimation failed for Request:${apiCall.id} with ${createTxError}`, + }, + { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, + { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, + ]); + expect(err).toEqual(null); + expect(data).toEqual({ + ...apiCall, + fulfillment: { hash: '0xfailtransaction' }, + errorMessage: `Gas estimation failed with error: ${createTxError.message}`, + }); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } + ); + expect(estimateFulfillmentCallOverheadMock).not.toHaveBeenCalled(); + expect(apiCalls.createTxToCallFulfillFunction).toHaveBeenCalledTimes(1); + expect(staticFulfillMock).toHaveBeenCalledTimes(1); + expect(fulfillMock).not.toHaveBeenCalled(); + expect(failMock).toHaveBeenCalledTimes(1); + expect(failMock).toHaveBeenCalledWith( + apiCall.id, + apiCall.airnodeAddress, + apiCall.fulfillAddress, + apiCall.fulfillFunctionId, + createTxError.message, + txOpts + ); + } + ); + + test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( + `submits a fail transaction if the gas estimation for fulfillment call overhead is failed but following static call is successful - %#`, async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); const estimateGasError = new Error('Estimate gas error'); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(ethers.BigNumber.from(73804)); + estimateFulfillmentCallOverheadMock.mockRejectedValueOnce(estimateGasError); + estimateFulfillmentCallOverheadMock.mockRejectedValueOnce(estimateGasError); staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); + const txToCallFulfillFunction = { to: '0xfulfillAddress', data: '0xdata' }; + jest.spyOn(apiCalls, 'createTxToCallFulfillFunction').mockReturnValueOnce(txToCallFulfillFunction); + const apiCall = fixtures.requests.buildSuccessfulApiCall({ id: '0xb56b66dc089eab3dc98672ea5e852488730a8f76621fd9ea719504ea205980f8', data: { @@ -727,18 +952,27 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate fulfillment call overhead to fulfill API call for Request:${apiCall.id}...`, }, { error: estimateGasError, level: 'ERROR', - message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + message: `Fulfillment call overhead estimation failed for Request:${apiCall.id} with ${estimateGasError}`, }, { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, @@ -749,15 +983,17 @@ describe('submitApiCall', () => { fulfillment: { hash: '0xfailtransaction' }, errorMessage: `Gas estimation failed with error: ${estimateGasError.message}`, }); - expect(estimateFulfillMock).toHaveBeenCalledTimes(2); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); + expect(estimateFulfillmentCallOverheadMock).toHaveBeenCalledTimes(2); expect(staticFulfillMock).toHaveBeenCalledTimes(1); expect(fulfillMock).not.toHaveBeenCalled(); expect(failMock).toHaveBeenCalledTimes(1); @@ -773,13 +1009,13 @@ describe('submitApiCall', () => { ); test.each([gasTargetWithoutGasLimit, gasTargetFallbackWithoutGasLimit])( - `submits a fail transaction if the gas estimation is failed and following static call is also failed with revert string - %#`, + `submits a fail transaction if the gas estimation for AirnodeRrp overhead is failed and following static call is also failed with revert string - %#`, async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); const estimateGasError = new Error('Estimate gas error'); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); staticFulfillMock.mockResolvedValueOnce({ callSuccess: false, callData: @@ -797,18 +1033,23 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, }, { error: estimateGasError, level: 'ERROR', - message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + message: `AirnodeRrp overhead estimation failed for Request:${apiCall.id} with ${estimateGasError}`, }, { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, @@ -819,14 +1060,15 @@ describe('submitApiCall', () => { fulfillment: { hash: '0xfailtransaction' }, errorMessage: `Fulfill transaction failed`, }); - expect(estimateFulfillMock).toHaveBeenCalledTimes(2); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(2); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); expect(staticFulfillMock).toHaveBeenCalledTimes(1); expect(fulfillMock).not.toHaveBeenCalled(); @@ -847,7 +1089,7 @@ describe('submitApiCall', () => { async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); - estimateFulfillMock.mockResolvedValueOnce(null); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(null); staticFulfillMock.mockResolvedValueOnce({ callSuccess: true, callData: '0x' }); (failMock as jest.Mock).mockResolvedValueOnce({ hash: '0xfailtransaction' }); const apiCall = fixtures.requests.buildSuccessfulApiCall({ @@ -861,13 +1103,18 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, }, { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, @@ -878,14 +1125,15 @@ describe('submitApiCall', () => { fulfillment: { hash: '0xfailtransaction' }, errorMessage: `Gas estimation failed`, }); - expect(estimateFulfillMock).toHaveBeenCalledTimes(1); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); expect(staticFulfillMock).toHaveBeenCalledTimes(1); expect(fulfillMock).not.toHaveBeenCalled(); @@ -906,7 +1154,7 @@ describe('submitApiCall', () => { async (gasTarget) => { const txOpts = { ...gasTarget, nonce: 5 }; const provider = new ethers.providers.JsonRpcProvider(); - estimateFulfillMock.mockResolvedValueOnce(null); + estimateAirnodeRrpOverheadMock.mockResolvedValueOnce(null); staticFulfillMock.mockResolvedValueOnce({ callSuccess: false, callData: @@ -924,13 +1172,18 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, }, { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { level: 'INFO', message: `Submitting API call fail for Request:${apiCall.id}...` }, @@ -941,14 +1194,15 @@ describe('submitApiCall', () => { fulfillment: { hash: '0xfailtransaction' }, errorMessage: `Fulfill transaction failed`, }); - expect(estimateFulfillMock).toHaveBeenCalledTimes(1); - expect(estimateFulfillMock).toHaveBeenCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(1); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledWith( apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); expect(staticFulfillMock).toHaveBeenCalledTimes(1); expect(fulfillMock).not.toHaveBeenCalled(); @@ -971,8 +1225,8 @@ describe('submitApiCall', () => { const provider = new ethers.providers.JsonRpcProvider(); const estimateGasError = new Error('Estimate gas error'); const staticCallError = new Error('Static call error'); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); - estimateFulfillMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); + estimateAirnodeRrpOverheadMock.mockRejectedValueOnce(estimateGasError); staticFulfillMock.mockRejectedValueOnce(staticCallError); staticFulfillMock.mockRejectedValueOnce(staticCallError); @@ -990,18 +1244,23 @@ describe('submitApiCall', () => { }); const [logs, err, data] = await apiCalls.estimateGasAndSubmitFulfill(createAirnodeRrpFake(), apiCall, { gasTarget, + contracts, masterHDNode, provider, }); expect(logs).toEqual([ { level: 'DEBUG', - message: `Attempting to estimate gas for API call fulfillment for Request:${apiCall.id}...`, + message: `Attempting to estimate required gas to fulfill API call for Request:${apiCall.id}...`, + }, + { + level: 'DEBUG', + message: `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${apiCall.id}...`, }, { error: estimateGasError, level: 'ERROR', - message: `Gas estimation for API call fulfillment failed for Request:${apiCall.id} with ${estimateGasError}`, + message: `AirnodeRrp overhead estimation failed for Request:${apiCall.id} with ${estimateGasError}`, }, { level: 'DEBUG', message: `Attempting to fulfill API call for Request:${apiCall.id}...` }, { @@ -1018,15 +1277,16 @@ describe('submitApiCall', () => { ]); expect(err).toEqual(failTxError); expect(data).toEqual(null); - expect(estimateFulfillMock).toHaveBeenCalledTimes(2); - expect(estimateFulfillMock).toHaveBeenNthCalledWith( + expect(estimateAirnodeRrpOverheadMock).toHaveBeenCalledTimes(2); + expect(estimateAirnodeRrpOverheadMock).toHaveBeenNthCalledWith( 2, apiCall.id, apiCall.airnodeAddress, apiCall.fulfillAddress, apiCall.fulfillFunctionId, '0x448b8ad3a330cf8f269f487881b59efff721b3dfa8e61f7c8fd2480389459ed3', - '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b' + '0xda6d5aa27f48aa951ba401c8a779645f7d1fa4a46a5e99eb7da04b4e059449a834ca1058c85dfe8117305265228f8cf7ae64c3ef3c4d1cc191f77807227dac461b', + { nonce: apiCall.nonce } ); expect(staticFulfillMock).toHaveBeenCalledTimes(2); expect(fulfillMock).not.toHaveBeenCalled(); diff --git a/packages/airnode-node/src/evm/fulfillments/api-calls.ts b/packages/airnode-node/src/evm/fulfillments/api-calls.ts index e1fd0544d9..83a3260c94 100644 --- a/packages/airnode-node/src/evm/fulfillments/api-calls.ts +++ b/packages/airnode-node/src/evm/fulfillments/api-calls.ts @@ -1,7 +1,7 @@ import isNil from 'lodash/isNil'; import { ethers } from 'ethers'; import { logger } from '@api3/airnode-utilities'; -import { go } from '@api3/promise-utils'; +import { go, goSync } from '@api3/promise-utils'; import { applyTransactionResult } from './requests'; import * as requests from '../../requests'; import { BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT, MAXIMUM_ONCHAIN_ERROR_LENGTH } from '../../constants'; @@ -12,8 +12,10 @@ import { TransactionOptions, SubmitRequest, ApiCallWithResponse, + RegularApiCallSuccessResponse, + ApiCall, } from '../../types'; -import { AirnodeRrpV0 } from '../contracts'; +import { AirnodeRrpV0, AirnodeRrpV0DryRunFactory } from '../contracts'; import { decodeRevertString } from '../utils'; type StaticResponse = { readonly callSuccess: boolean; readonly callData: string } | null; @@ -77,28 +79,118 @@ async function testFulfill( async function estimateGasToFulfill( airnodeRrp: AirnodeRrpV0, - request: Request + request: Request, + options: TransactionOptions +): Promise> { + const noticeLog = logger.pend( + 'DEBUG', + `Attempting to estimate required gas to fulfill API call for Request:${request.id}...` + ); + + const [estimateGasAirnodeRrpLogs, estimateGasAirnodeRrpErr, estimateGasAirnodeRrpData] = + await estimateAirnodeRrpOverhead(airnodeRrp, request, options); + + if (estimateGasAirnodeRrpErr || !estimateGasAirnodeRrpData) + return [[noticeLog, ...estimateGasAirnodeRrpLogs], estimateGasAirnodeRrpErr, null]; + + const [estimateGasFulfillmentCallLogs, estimateGasFulfillmentCallErr, estimateGasFulfillmentCallData] = + await estimateFulfillmentCallOverhead(airnodeRrp, request, options); + + if (estimateGasFulfillmentCallErr || !estimateGasFulfillmentCallData) + return [ + [noticeLog, ...estimateGasAirnodeRrpLogs, ...estimateGasFulfillmentCallLogs], + estimateGasFulfillmentCallErr, + null, + ]; + + return [ + [noticeLog, ...estimateGasAirnodeRrpLogs, ...estimateGasFulfillmentCallLogs], + null, + [estimateGasAirnodeRrpData, estimateGasFulfillmentCallData], + ]; +} + +async function estimateAirnodeRrpOverhead( + airnodeRrp: AirnodeRrpV0, + request: Request, + options: TransactionOptions ): Promise> { const noticeLog = logger.pend( 'DEBUG', - `Attempting to estimate gas for API call fulfillment for Request:${request.id}...` + `Attempting to estimate AirnodeRrp overhead to fulfill API call for Request:${request.id}...` ); if (!request.success) return [[], new Error('Only successful API can be submitted'), null]; + const airnodeRrpDryRunAddress = options.contracts?.AirnodeRrpDryRun; + + const airnodeRrpDryRun = AirnodeRrpV0DryRunFactory.connect(airnodeRrpDryRunAddress!, airnodeRrp.signer); + const operation = (): Promise => - airnodeRrp.estimateGas.fulfill( + airnodeRrpDryRun.estimateGas.fulfill( request.id, request.airnodeAddress, request.fulfillAddress, request.fulfillFunctionId, request.data.encodedValue, - request.data.signature + request.data.signature, + { + nonce: request.nonce!, + } ); const goRes = await go(operation, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); if (!goRes.success) { const errorLog = logger.pend( 'ERROR', - `Gas estimation for API call fulfillment failed for Request:${request.id} with ${goRes.error}`, + `AirnodeRrp overhead estimation failed for Request:${request.id} with ${goRes.error}`, + goRes.error + ); + return [[noticeLog, errorLog], goRes.error, null]; + } + + return [[noticeLog], null, goRes.data]; +} + +export const createTxToCallFulfillFunction = (request: Request) => ({ + to: request.fulfillAddress, + data: ethers.utils.hexConcat([ + request.fulfillFunctionId, + ethers.utils.defaultAbiCoder.encode(['bytes32', 'bytes'], [request.id, request.data.encodedValue]), + ]), +}); + +async function estimateFulfillmentCallOverhead( + airnodeRrp: AirnodeRrpV0, + request: Request, + options: TransactionOptions +): Promise> { + const noticeLog = logger.pend( + 'DEBUG', + `Attempting to estimate fulfillment call overhead to fulfill API call for Request:${request.id}...` + ); + + if (!request.success) return [[], new Error('Only successful API can be submitted'), null]; + + // Create transaction to call fulfill function + const goTx = goSync(() => createTxToCallFulfillFunction(request)); + + if (!goTx.success) { + const errorLog = logger.pend( + 'ERROR', + `Transaction creation while fulfillment call overhead estimation failed for Request:${request.id} with ${goTx.error}`, + goTx.error + ); + return [[noticeLog, errorLog], goTx.error, null]; + } + + // Create voidSigner with address of airnodeRrp contract to avoid being blocked by `onlyAirnodeRrp` modifier + const voidSignerAirnodeRrp = new ethers.VoidSigner(airnodeRrp.address, options.provider); + + const operation = (): Promise => voidSignerAirnodeRrp.estimateGas(goTx.data); + const goRes = await go(operation, { retries: 1, attemptTimeoutMs: BLOCKCHAIN_CALL_ATTEMPT_TIMEOUT }); + if (!goRes.success) { + const errorLog = logger.pend( + 'ERROR', + `Fulfillment call overhead estimation failed for Request:${request.id} with ${goRes.error}`, goRes.error ); return [[noticeLog, errorLog], goRes.error, null]; @@ -184,8 +276,8 @@ export async function estimateGasAndSubmitFulfill( options: TransactionOptions ): Promise>> { // Should not throw - const [estimateGasLogs, estimateGasErr, estimateGasData] = await estimateGasToFulfill(airnodeRrp, request); - + const [estimateGasLogs, estimateGasErr, estimateGasData] = await estimateGasToFulfill(airnodeRrp, request, options); + // const [estimateGasLogs, estimateGasErr, estimateGasData] = [[], new Error('cartcut'), [ethers.BigNumber.from(55),ethers.BigNumber.from(65)]]; if (estimateGasErr || !estimateGasData) { // Make static test call to get revert string const [testLogs, testErr, testData] = await testFulfill(airnodeRrp, request, options); @@ -223,11 +315,16 @@ export async function estimateGasAndSubmitFulfill( return [[...estimateGasLogs, ...testLogs, ...submitLogs], submitErr, submittedRequest]; } + const [airnodeRrpGasCost, fulfillmentCallGasCost] = estimateGasData; + const totalCost = airnodeRrpGasCost.add(fulfillmentCallGasCost); // If gas estimation is success, submit fulfillment without making static test call - const gasLimitNoticeLog = logger.pend('INFO', `Gas limit is set to ${estimateGasData} for Request:${request.id}.`); + const gasLimitNoticeLog = logger.pend( + 'INFO', + `Gas limit is set to ${totalCost} (AirnodeRrp: ${airnodeRrpGasCost} + Fulfillment Call: ${fulfillmentCallGasCost}) for Request:${request.id}.` + ); const [submitLogs, submitErr, submitData] = await submitFulfill(airnodeRrp, request, { ...options, - gasTarget: { ...options.gasTarget, gasLimit: estimateGasData }, + gasTarget: { ...options.gasTarget, gasLimit: totalCost }, }); return [[...estimateGasLogs, gasLimitNoticeLog, ...submitLogs], submitErr, submitData]; } diff --git a/packages/airnode-node/src/evm/fulfillments/index.test.ts b/packages/airnode-node/src/evm/fulfillments/index.test.ts index 7617dba240..74db192351 100644 --- a/packages/airnode-node/src/evm/fulfillments/index.test.ts +++ b/packages/airnode-node/src/evm/fulfillments/index.test.ts @@ -54,7 +54,11 @@ describe('submit', () => { }), ], }; - const gasTarget: GasTarget = { type: 0, gasPrice: ethers.BigNumber.from(1000) }; + const gasTarget: GasTarget = { + type: 0, + gasPrice: ethers.BigNumber.from(1000), + gasLimit: ethers.BigNumber.from(500_000), + }; const provider = new ethers.providers.JsonRpcProvider(); const state = providerState.update(mutableInitialState, { gasTarget, provider, requests }); diff --git a/packages/airnode-node/src/evm/fulfillments/index.ts b/packages/airnode-node/src/evm/fulfillments/index.ts index af1a34b512..5c34342082 100644 --- a/packages/airnode-node/src/evm/fulfillments/index.ts +++ b/packages/airnode-node/src/evm/fulfillments/index.ts @@ -49,6 +49,7 @@ function getTransactionOptions( return { gasTarget: gasTarget, + contracts: state.contracts, masterHDNode: state.masterHDNode, provider: state.provider, withdrawalRemainder: state.settings.chainOptions.withdrawalRemainder, diff --git a/packages/airnode-node/src/evm/fulfillments/withdrawals.test.ts b/packages/airnode-node/src/evm/fulfillments/withdrawals.test.ts index 53cc8bd094..fd488dd9b6 100644 --- a/packages/airnode-node/src/evm/fulfillments/withdrawals.test.ts +++ b/packages/airnode-node/src/evm/fulfillments/withdrawals.test.ts @@ -42,6 +42,7 @@ const gasTargetFallback: GasTarget = { describe('submitWithdrawal', () => { const masterHDNode = wallet.getMasterHDNode(config); + const contracts = { AirnodeRrp: '', AirnodeRrpDryRun: '' }; test.each([gasTarget, gasTargetFallback])( `subtracts transaction costs and submits the remaining balance for pending requests - %#`, @@ -54,6 +55,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: 5 }); const options = { gasTarget, + contracts, masterHDNode, provider, }; @@ -100,6 +102,7 @@ describe('submitWithdrawal', () => { }; const options = { gasTarget, + contracts, masterHDNode, provider, withdrawalRemainder: remainder, @@ -141,6 +144,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: 5 }); const options = { gasTarget, + contracts, masterHDNode, provider, }; @@ -165,6 +169,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: undefined }); const options = { gasTarget, + contracts, masterHDNode, provider, }; @@ -190,6 +195,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: 5 }); const options = { gasTarget, + contracts, masterHDNode, provider, }; @@ -217,6 +223,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: 5 }); const options = { gasTarget, + contracts, masterHDNode, provider, }; @@ -245,6 +252,7 @@ describe('submitWithdrawal', () => { const withdrawal = fixtures.requests.buildWithdrawal({ nonce: 5 }); const options = { gasTarget, + contracts, masterHDNode, provider, }; diff --git a/packages/airnode-node/src/types.ts b/packages/airnode-node/src/types.ts index 9d5c5fd09b..03c37432d5 100644 --- a/packages/airnode-node/src/types.ts +++ b/packages/airnode-node/src/types.ts @@ -195,6 +195,7 @@ export interface CoordinatorStateWithApiResponses extends CoordinatorState { export interface EVMContracts { // TODO: Rename to airnodeRrp for consistency readonly AirnodeRrp: string; + readonly AirnodeRrpDryRun?: string | undefined; } export interface EVMProviderState { @@ -211,6 +212,7 @@ export interface EVMProviderSponsorState extends EVMProviderState { export interface TransactionOptions { readonly gasTarget: GasTarget; + readonly contracts: EVMContracts; readonly masterHDNode: ethers.utils.HDNode; readonly provider: ethers.providers.JsonRpcProvider; readonly withdrawalRemainder?: Amount; From 525eb5892782023bf0d2ccc7e1bac176caf7076c Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Thu, 6 Jul 2023 11:22:58 +0300 Subject: [PATCH 5/7] test(node): fix chainId supposed to have deployments --- packages/airnode-node/src/config/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/airnode-node/src/config/index.test.ts b/packages/airnode-node/src/config/index.test.ts index d689cea763..5f07ac047a 100644 --- a/packages/airnode-node/src/config/index.test.ts +++ b/packages/airnode-node/src/config/index.test.ts @@ -13,7 +13,7 @@ describe('config validation', () => { const exampleSecrets = dotenv.parse(readFileSync(join(__dirname, '../../config/secrets.example.env'))); it('loads the config and adds default contract addresses', () => { - const chainId = '4'; + const chainId = '5'; const invalidConfig = JSON.parse(readFileSync(exampleConfigPath, 'utf-8')); invalidConfig.chains[0].id = chainId; // Need to use an actual chain not hardhat delete invalidConfig.chains[0].contracts; From 93bc917d360e9c3993c6b6e44b806b1dfc67cb61 Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Thu, 6 Jul 2023 11:24:11 +0300 Subject: [PATCH 6/7] chore(node): add changeset & fix typo --- .changeset/{eighty-goats-provide.md => rich-snails-design.md} | 0 packages/airnode-node/src/evm/utils.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .changeset/{eighty-goats-provide.md => rich-snails-design.md} (100%) diff --git a/.changeset/eighty-goats-provide.md b/.changeset/rich-snails-design.md similarity index 100% rename from .changeset/eighty-goats-provide.md rename to .changeset/rich-snails-design.md diff --git a/packages/airnode-node/src/evm/utils.ts b/packages/airnode-node/src/evm/utils.ts index b1a8916e0e..b95145722e 100644 --- a/packages/airnode-node/src/evm/utils.ts +++ b/packages/airnode-node/src/evm/utils.ts @@ -21,7 +21,7 @@ export function sortBigNumbers(bigNumbers: ethers.BigNumber[]) { export function decodeRevertString(callData: string) { // Refer to https://ethereum.stackexchange.com/a/83577 - // Skip the funciton selector from the returned encoded data + // Skip the function selector from the returned encoded data // and only decode the revert reason string. // Function selector is 4 bytes long and that is why we skip // the first 2 bytes (0x) and the rest 8 bytes is the function selector From 64afdf08addbb32b12ed9e5b94047c301a09cb0a Mon Sep 17 00:00:00 2001 From: bdrhn9 Date: Mon, 10 Jul 2023 02:56:34 +0300 Subject: [PATCH 7/7] refactor: visit pr suggestions --- packages/airnode-node/src/evm/fulfillments/api-calls.ts | 6 +++--- packages/airnode-node/src/types.ts | 2 +- packages/airnode-validator/src/config/config.test.ts | 1 + packages/airnode-validator/src/config/config.ts | 4 ++-- packages/airnode-validator/test/fixtures/config.valid.json | 3 ++- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/airnode-node/src/evm/fulfillments/api-calls.ts b/packages/airnode-node/src/evm/fulfillments/api-calls.ts index 83a3260c94..1fbe3dc617 100644 --- a/packages/airnode-node/src/evm/fulfillments/api-calls.ts +++ b/packages/airnode-node/src/evm/fulfillments/api-calls.ts @@ -121,9 +121,9 @@ async function estimateAirnodeRrpOverhead( ); if (!request.success) return [[], new Error('Only successful API can be submitted'), null]; - const airnodeRrpDryRunAddress = options.contracts?.AirnodeRrpDryRun; + const airnodeRrpDryRunAddress = options.contracts.AirnodeRrpDryRun!; - const airnodeRrpDryRun = AirnodeRrpV0DryRunFactory.connect(airnodeRrpDryRunAddress!, airnodeRrp.signer); + const airnodeRrpDryRun = AirnodeRrpV0DryRunFactory.connect(airnodeRrpDryRunAddress, airnodeRrp.signer); const operation = (): Promise => airnodeRrpDryRun.estimateGas.fulfill( @@ -277,7 +277,7 @@ export async function estimateGasAndSubmitFulfill( ): Promise>> { // Should not throw const [estimateGasLogs, estimateGasErr, estimateGasData] = await estimateGasToFulfill(airnodeRrp, request, options); - // const [estimateGasLogs, estimateGasErr, estimateGasData] = [[], new Error('cartcut'), [ethers.BigNumber.from(55),ethers.BigNumber.from(65)]]; + if (estimateGasErr || !estimateGasData) { // Make static test call to get revert string const [testLogs, testErr, testData] = await testFulfill(airnodeRrp, request, options); diff --git a/packages/airnode-node/src/types.ts b/packages/airnode-node/src/types.ts index 03c37432d5..18b2f73505 100644 --- a/packages/airnode-node/src/types.ts +++ b/packages/airnode-node/src/types.ts @@ -195,7 +195,7 @@ export interface CoordinatorStateWithApiResponses extends CoordinatorState { export interface EVMContracts { // TODO: Rename to airnodeRrp for consistency readonly AirnodeRrp: string; - readonly AirnodeRrpDryRun?: string | undefined; + readonly AirnodeRrpDryRun?: string; } export interface EVMProviderState { diff --git a/packages/airnode-validator/src/config/config.test.ts b/packages/airnode-validator/src/config/config.test.ts index aceab7b443..deb721d02f 100644 --- a/packages/airnode-validator/src/config/config.test.ts +++ b/packages/airnode-validator/src/config/config.test.ts @@ -699,6 +699,7 @@ describe('ensureValidAirnodeRrp', () => { }; const parsed = schema.parse(chainWithDeployment); expect(parsed.contracts).toEqual({ + ...contractsMissingAirnodeRrp, AirnodeRrp: AirnodeRrpV0Addresses[chainWithDeployment[chainIdField]], }); }); diff --git a/packages/airnode-validator/src/config/config.ts b/packages/airnode-validator/src/config/config.ts index 2aafd2b222..09bc00631d 100644 --- a/packages/airnode-validator/src/config/config.ts +++ b/packages/airnode-validator/src/config/config.ts @@ -162,7 +162,7 @@ export const chainOptionsSchema = z }) .strict(); -export const ensureValidAirnodeRrp = (airnodeRrp: string | undefined, chainId: string, ctx: RefinementCtx) => { +export const ensureValidAirnodeRrp = (airnodeRrp: string, chainId: string, ctx: RefinementCtx) => { if (!airnodeRrp) { if (!AirnodeRrpV0Addresses[chainId]) { ctx.addIssue({ @@ -191,7 +191,7 @@ export const ensureValidAirnodeRrpDryRun = ( // If 'fulfillmentGasLimit' is defined in the config, // it indicates that the AirnodeRrpDryRun contract will not be used to estimate gas if (options?.fulfillmentGasLimit) { - return; + return airnodeRrpDryRun; } if (!airnodeRrpDryRun) { diff --git a/packages/airnode-validator/test/fixtures/config.valid.json b/packages/airnode-validator/test/fixtures/config.valid.json index b1d3275776..d96e6c1b7c 100644 --- a/packages/airnode-validator/test/fixtures/config.valid.json +++ b/packages/airnode-validator/test/fixtures/config.valid.json @@ -41,7 +41,8 @@ "requesterEndpointAuthorizations": {} }, "contracts": { - "AirnodeRrp": "0x5FbDB2315678afecb367f032d93F642f64180aa3" + "AirnodeRrp": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "AirnodeRrpDryRun": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" }, "id": "31337", "providers": {