Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-bees-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/view-function-multi-chain-adapter': minor
---

Add functionality to query decimals from contract
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
required: false,
},
Comment on lines +30 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data seems to be very generic name, maybe additionalRequests?

Also it is possible to type this correctly without using any

Copy link
Contributor

@dskloetc dskloetc Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggested data because in the response these fields would also appear in data.
additionalRequests is also fine.

} as const

export const inputParameters = new InputParameters(inputParamDefinition)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -16,6 +19,7 @@ interface RequestParams {
inputParams?: Array<string>
network: string
resultField?: string
data?: Record<string, RequestParams>
}

export type RawOnchainResponse = {
Expand Down Expand Up @@ -80,7 +84,36 @@ export class MultiChainFunctionTransport<
}

async _handleRequest(param: RequestParams): Promise<AdapterResponse<T['Response']>> {
const { address, signature, inputParams, network } = param
const { address, signature, inputParams, network, data } = param

const mainResult = await this._executeFunction({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can run these two in parallel

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<string>
network: string
resultField?: string
}) {
const { address, signature, inputParams, network, resultField } = params

const networkName = network.toUpperCase()
const networkEnvName = `${networkName}_RPC_URL`
Expand All @@ -102,30 +135,66 @@ 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,
providerDataReceivedUnixMs: Date.now(),
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<string, unknown> | undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be typed as Record<string, RequestParams>

parentAddress: string,
parentNetwork: string,
): Promise<Record<string, any>> {
if (!data || typeof data !== 'object') return {}

const results: Record<string, any> = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not use any here?


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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we run these in parallel?

results[key] = subRes.result
} catch (err) {
logger.warn(`Nested function "${key}" failed: ${err}`)
results[key] = null
}
}

return results
}

getSubscriptionTtlFromConfig(adapterSettings: T['Settings']): number {
Expand Down
7 changes: 6 additions & 1 deletion packages/sources/view-function-multi-chain/test-payload.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated test case?

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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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 {
Expand Down
Loading