diff --git a/.changeset/curly-bees-destroy.md b/.changeset/curly-bees-destroy.md new file mode 100644 index 0000000000..99e97aea98 --- /dev/null +++ b/.changeset/curly-bees-destroy.md @@ -0,0 +1,5 @@ +--- +'@chainlink/view-function-multi-chain-adapter': minor +--- + +Add functionality to query decimals from contract diff --git a/packages/sources/view-function-multi-chain/src/endpoint/function.ts b/packages/sources/view-function-multi-chain/src/endpoint/function.ts index a91815f874..efff57e1ad 100644 --- a/packages/sources/view-function-multi-chain/src/endpoint/function.ts +++ b/packages/sources/view-function-multi-chain/src/endpoint/function.ts @@ -27,6 +27,11 @@ export const inputParamDefinition = { description: 'RPC network name', type: 'string', }, + data: { + description: 'Optional map of function calls', + type: 'object' as unknown as Record, + required: false, + }, } as const export const inputParameters = new InputParameters(inputParamDefinition) diff --git a/packages/sources/view-function-multi-chain/src/transport/function-common.ts b/packages/sources/view-function-multi-chain/src/transport/function-common.ts index b3abc36789..0775cb7714 100644 --- a/packages/sources/view-function-multi-chain/src/transport/function-common.ts +++ b/packages/sources/view-function-multi-chain/src/transport/function-common.ts @@ -5,7 +5,10 @@ import { } from '@chainlink/external-adapter-framework/transports' import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' -import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { + AdapterError, + AdapterInputError, +} from '@chainlink/external-adapter-framework/validation/error' import { ethers } from 'ethers' const logger = makeLogger('View Function Multi Chain') @@ -16,6 +19,7 @@ interface RequestParams { inputParams?: Array network: string resultField?: string + data?: Record } export type RawOnchainResponse = { @@ -80,7 +84,36 @@ export class MultiChainFunctionTransport< } async _handleRequest(param: RequestParams): Promise> { - const { address, signature, inputParams, network } = param + const { address, signature, inputParams, network, data } = param + + const mainResult = await this._executeFunction({ + address, + signature, + inputParams, + network, + resultField: param.resultField, + }) + + const nestedResults = await this._processNestedDataRequest(data, address, network) + + const combinedData = { result: mainResult.result, ...nestedResults } + + return { + data: combinedData, + statusCode: 200, + result: mainResult.result, + timestamps: mainResult.timestamps, + } + } + + private async _executeFunction(params: { + address: string + signature: string + inputParams?: Array + network: string + resultField?: string + }) { + const { address, signature, inputParams, network, resultField } = params const networkName = network.toUpperCase() const networkEnvName = `${networkName}_RPC_URL` @@ -102,13 +135,19 @@ export class MultiChainFunctionTransport< const iface = new ethers.Interface([signature]) const fnName = iface.getFunctionName(signature) - const encoded = iface.encodeFunctionData(fnName, [...(inputParams || [])]) + const encoded = iface.encodeFunctionData(fnName, inputParams || []) const providerDataRequestedUnixMs = Date.now() - const encodedResult = await this.providers[networkName].call({ - to: address, - data: encoded, - }) + + let encodedResult + try { + encodedResult = await this.providers[networkName].call({ to: address, data: encoded }) + } catch (err) { + throw new AdapterError({ + statusCode: 500, + message: `RPC call failed for ${fnName} on ${networkName}: ${err}`, + }) + } const timestamps = { providerDataRequestedUnixMs, @@ -116,16 +155,46 @@ export class MultiChainFunctionTransport< providerIndicatedTimeUnixMs: undefined, } - const result = this.hexResultPostProcessor({ iface, fnName, encodedResult }, param.resultField) + const result = this.hexResultPostProcessor({ iface, fnName, encodedResult }, resultField) - return { - data: { - result, - }, - statusCode: 200, - result, - timestamps, + return { result, timestamps } + } + + private async _processNestedDataRequest( + data: Record | undefined, + parentAddress: string, + parentNetwork: string, + ): Promise> { + if (!data || typeof data !== 'object') return {} + + const results: Record = {} + + for (const [key, subReq] of Object.entries(data)) { + try { + const req = subReq as RequestParams + + if (!req.signature) { + logger.warn(`Skipping nested key "${key}" — no signature provided.`) + continue + } + + const nestedParam = { + address: req.address || parentAddress, + network: req.network || parentNetwork, + signature: req.signature, + inputParams: req.inputParams, + resultField: req.resultField, + } + + const subRes = await this._executeFunction(nestedParam) + results[key] = subRes.result + } catch (err) { + logger.warn(`Nested function "${key}" failed: ${err}`) + results[key] = null + } } + + return results } getSubscriptionTtlFromConfig(adapterSettings: T['Settings']): number { diff --git a/packages/sources/view-function-multi-chain/test-payload.json b/packages/sources/view-function-multi-chain/test-payload.json index d522475d08..b75279f05c 100644 --- a/packages/sources/view-function-multi-chain/test-payload.json +++ b/packages/sources/view-function-multi-chain/test-payload.json @@ -2,6 +2,11 @@ "requests": [{ "contract": "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c", "function": "function latestAnswer() view returns (int256)", - "network": "ETHEREUM" + "network": "ETHEREUM", + "data": { + "decimals": { + "signature": "function decimals() view returns (uint8)" + } + } }] } diff --git a/packages/sources/view-function-multi-chain/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/view-function-multi-chain/test/integration/__snapshots__/adapter.test.ts.snap index 03ec5557b5..daa0e1f55a 100644 --- a/packages/sources/view-function-multi-chain/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/sources/view-function-multi-chain/test/integration/__snapshots__/adapter.test.ts.snap @@ -56,6 +56,36 @@ exports[`execute function endpoint should return success for different network 1 } `; +exports[`execute function endpoint should return success with additional data requests 1`] = ` +{ + "data": { + "decimals": "0x0000000000000000000000000000000000000000000000000000000000000008", + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + }, + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute function endpoint should return success with additional data requests 2`] = ` +{ + "data": { + "decimals": "0x0000000000000000000000000000000000000000000000000000000000000008", + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + }, + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + exports[`execute function endpoint should return success with parameters 1`] = ` { "data": { @@ -70,6 +100,20 @@ exports[`execute function endpoint should return success with parameters 1`] = ` } `; +exports[`execute function endpoint should skip additional data requests in case of missing signature 1`] = ` +{ + "data": { + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + }, + "result": "0x000000000000000000000000000000000000000000000000000000005ad789f8", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + exports[`execute function-response-selector endpoint should fail with non-existant resultField 1`] = ` { "errorMessage": "Invalid resultField not found in response", diff --git a/packages/sources/view-function-multi-chain/test/integration/adapter.test.ts b/packages/sources/view-function-multi-chain/test/integration/adapter.test.ts index 5613931fab..c4dbfac37c 100644 --- a/packages/sources/view-function-multi-chain/test/integration/adapter.test.ts +++ b/packages/sources/view-function-multi-chain/test/integration/adapter.test.ts @@ -58,6 +58,57 @@ describe('execute', () => { expect(response.json()).toMatchSnapshot() }) + it('should return success with additional data requests ', async () => { + const data = { + contract: '0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c', + function: 'function latestAnswer() external view returns (int256)', + network: 'ethereum_mainnet', + data: { + decimals: { + signature: 'function decimals() view returns (uint8)', + }, + }, + } + mockETHMainnetContractCallResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success with additional data requests ', async () => { + const data = { + contract: '0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c', + function: 'function latestAnswer() external view returns (int256)', + network: 'ethereum_mainnet', + data: { + decimals: { + signature: 'function decimals() view returns (uint8)', + }, + }, + } + mockETHMainnetContractCallResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should skip additional data requests in case of missing signature', async () => { + const data = { + contract: '0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c', + function: 'function latestAnswer() external view returns (int256)', + network: 'ethereum_mainnet', + data: { + decimals: { + signature: '', + }, + }, + } + mockETHMainnetContractCallResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + it('should return success for different network', async () => { const data = { contract: '0x779877a7b0d9e8603169ddbd7836e478b4624789', diff --git a/packages/sources/view-function-multi-chain/test/integration/fixtures.ts b/packages/sources/view-function-multi-chain/test/integration/fixtures.ts index 9adefeaffb..c29f6bdfd7 100644 --- a/packages/sources/view-function-multi-chain/test/integration/fixtures.ts +++ b/packages/sources/view-function-multi-chain/test/integration/fixtures.ts @@ -37,6 +37,16 @@ export const mockETHMainnetContractCallResponseSuccess = (): nock.Scope => id: request.id, result: '0x000000000000000000000000000000000000000000000000000000005ad789f8', } + } else if ( + request.method === 'eth_call' && + request.params[0].to === '0x2c1d072e956affc0d435cb7ac38ef18d24d9127c' && + request.params[0].data === '0x313ce567' // decimals() + ) { + return { + jsonrpc: '2.0', + id: request.id, + result: '0x0000000000000000000000000000000000000000000000000000000000000008', + } } else if ( request.method === 'eth_call' && request.params[0].to === '0x2c1d072e956affc0d435cb7ac38ef18d24d9127c' && @@ -110,6 +120,16 @@ export const mockETHGoerliContractCallResponseSuccess = (): nock.Scope => id: request.id, result: '0x000000000000000000000000000000000000000000000000eead809f678d30f0', } + } else if ( + request.method === 'eth_call' && + request.params[0].to === '0x779877a7b0d9e8603169ddbd7836e478b4624789' && + request.params[0].data === '0x313ce567' // decimals() + ) { + return { + jsonrpc: '2.0', + id: request.id, + result: '0x0000000000000000000000000000000000000000000000000000000000000018', + } } else { // Default response for unsupported calls return {