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/config/index.ts b/packages/sources/view-function-multi-chain/src/config/index.ts index 31b2a3642b..681c9e2bac 100644 --- a/packages/sources/view-function-multi-chain/src/config/index.ts +++ b/packages/sources/view-function-multi-chain/src/config/index.ts @@ -13,6 +13,12 @@ export const config = new AdapterConfig({ required: false, default: '', }, + GROUP_SIZE: { + description: + 'Number of requests to execute asynchronously before the adapter waits to execute the next group of requests.', + type: 'number', + default: 10, + }, BACKGROUND_EXECUTE_MS: { description: 'The amount of time the background execute should sleep before performing the next request', 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..a91d5c6d2a 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,24 @@ export const inputParamDefinition = { description: 'RPC network name', type: 'string', }, + additionalRequests: { + description: 'Optional map of function calls', + array: true, + type: { + name: { + required: true, + type: 'string', + description: 'Unique name or identifier for this additional request', + }, + signature: { + required: true, + type: 'string', + description: + 'Function signature, formatted as human-readable ABI (e.g., balanceOf(address))', + }, + }, + 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..483343e2fa 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 @@ -1,22 +1,27 @@ import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' -import { - TransportDependencies, - TransportGenerics, -} from '@chainlink/external-adapter-framework/transports' +import { TransportDependencies } 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 { GroupRunner } from '@chainlink/external-adapter-framework/util/group-runner' +import { + AdapterError, + AdapterInputError, +} from '@chainlink/external-adapter-framework/validation/error' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' import { ethers } from 'ethers' +import { BaseEndpointTypes as FunctionEndpointTypes } from '../endpoint/function' +import { BaseEndpointTypes as FunctionResponseSelectorEndpointTypes } from '../endpoint/function-response-selector' const logger = makeLogger('View Function Multi Chain') -interface RequestParams { - signature: string - address: string - inputParams?: Array - network: string - resultField?: string -} +type GenericFunctionEndpointTypes = FunctionEndpointTypes | FunctionResponseSelectorEndpointTypes + +// The `extends any ? ... : never` construct forces the compiler to distribute +// over unions. Without it, the compiler doesn't know that T is either +// FunctionEndpointTypes or FunctionResponseSelectorEndpointTypes. +type RequestParams = T extends any + ? TypeFromDefinition + : never export type RawOnchainResponse = { iface: ethers.Interface @@ -30,8 +35,9 @@ export type HexResultPostProcessor = ( ) => string export class MultiChainFunctionTransport< - T extends TransportGenerics, + T extends GenericFunctionEndpointTypes, > extends SubscriptionTransport { + config!: T['Settings'] providers: Record = {} hexResultPostProcessor: HexResultPostProcessor @@ -47,19 +53,15 @@ export class MultiChainFunctionTransport< transportName: string, ): Promise { await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.config = adapterSettings } - async backgroundHandler(context: EndpointContext, entries: Array) { - await Promise.all( - entries.map(async (param) => this.handleRequest(param as unknown as RequestParams)), - ) - await sleep( - (context.adapterSettings as unknown as { BACKGROUND_EXECUTE_MS: number }) - .BACKGROUND_EXECUTE_MS, - ) + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) } - async handleRequest(param: RequestParams) { + async handleRequest(param: RequestParams) { let response: AdapterResponse try { response = await this._handleRequest(param) @@ -76,11 +78,42 @@ export class MultiChainFunctionTransport< }, } } - await this.responseCache.write(this.name, [{ params: param as any, response }]) + + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest(param: RequestParams): Promise> { + const { address, signature, inputParams, network, additionalRequests, resultField } = param + + const [mainResult, nestedResultOutcome] = await Promise.all([ + this._executeFunction({ + address, + signature, + inputParams, + network, + resultField, + }), + this._processNestedDataRequest(additionalRequests, address, network), + ]) + + const combinedData = { result: mainResult.result, ...nestedResultOutcome } + + return { + data: combinedData, + statusCode: 200, + result: mainResult.result, + timestamps: mainResult.timestamps, + } } - async _handleRequest(param: RequestParams): Promise> { - const { address, signature, inputParams, network } = param + 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,25 +155,56 @@ 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( + additionalRequests: + | Array<{ + name: string + signature: string + }> + | undefined, + parentAddress: string, + parentNetwork: string, + ): Promise> { + if (!Array.isArray(additionalRequests) || additionalRequests.length === 0) { + return {} } + + const runner = new GroupRunner(this.config.GROUP_SIZE) + + const processNested = runner.wrapFunction( + async (req: { name: string; signature: string }): Promise<[string, string]> => { + const key = req.name + try { + const nestedParam = { + address: parentAddress, + network: parentNetwork, + signature: req.signature, + } + + const subRes = await this._executeFunction(nestedParam) + return [key, subRes.result] + } catch (err) { + throw new Error(`Nested function "${key}" failed: ${err}`) + } + }, + ) + + const settled: [string, string][] = await Promise.all(additionalRequests.map(processNested)) + return Object.fromEntries(settled) } getSubscriptionTtlFromConfig(adapterSettings: T['Settings']): number { - return (adapterSettings as { WARMUP_SUBSCRIPTION_TTL: number }).WARMUP_SUBSCRIPTION_TTL + return adapterSettings.WARMUP_SUBSCRIPTION_TTL } } // Export a factory function to create transport instances -export function createMultiChainFunctionTransport( +export function createMultiChainFunctionTransport( postProcessor: HexResultPostProcessor, ): MultiChainFunctionTransport { return new MultiChainFunctionTransport(postProcessor) 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..a434c791e1 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 @@ -28,6 +28,17 @@ exports[`execute aptos-df-reader endpoint should return success 1`] = ` } `; +exports[`execute function endpoint should fail additional data requests in case of missing signature 1`] = ` +{ + "errorMessage": "Nested function "decimals" failed: TypeError: no matching function (argument="key", value="", code=INVALID_ARGUMENT, version=6.15.0)", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + exports[`execute function endpoint should return success 1`] = ` { "data": { @@ -56,6 +67,21 @@ 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 parameters 1`] = ` { "data": { 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..cc5491de33 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 @@ -45,6 +45,10 @@ describe('execute', () => { spy.mockRestore() }) + afterEach(() => { + nock.cleanAll() + }) + describe('function endpoint', () => { it('should return success', async () => { const data = { @@ -58,6 +62,42 @@ 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', + additionalRequests: [ + { + name: '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 fail additional data requests in case of missing signature', async () => { + const data = { + contract: '0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c', + function: 'function latestAnswer() external view returns (int256)', + network: 'ethereum_mainnet', + additionalRequests: [ + { + name: 'decimals', + signature: '', + }, + ], + } + mockETHMainnetContractCallResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + 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 {