diff --git a/.changeset/happy-foxes-pay.md b/.changeset/happy-foxes-pay.md new file mode 100644 index 0000000000..83fe6db6f0 --- /dev/null +++ b/.changeset/happy-foxes-pay.md @@ -0,0 +1,5 @@ +--- +'@chainlink/implied-price-test-adapter': minor +--- + +Add decimals params to computedPrice endpoint diff --git a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts index f1ac595189..284373b205 100644 --- a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts @@ -30,6 +30,11 @@ export const inputParameters = new InputParameters( description: 'The minimum number of answers needed to return a value for the operand1', default: 1, }, + operand1DecimalsField: { + required: false, + type: 'string', + description: 'The field path in operand1 response data containing the decimal scaling factor', + }, operand2Sources: { required: true, type: 'string', @@ -50,12 +55,22 @@ export const inputParameters = new InputParameters( description: 'The minimum number of answers needed to return a value for the operand2', default: 1, }, + operand2DecimalsField: { + required: false, + type: 'string', + description: 'The field path in operand2 response data containing the decimal scaling factor', + }, operation: { default: 'divide', type: 'string', description: 'The operation to perform on the operands', options: ['divide', 'multiply'], }, + outputDecimals: { + required: false, + type: 'number', + description: 'Decimal scaling of the result', + }, }, [ { @@ -75,6 +90,11 @@ export type BaseEndpointTypes = { Response: { Result: string Data: { + operand1Result: string + operand2Result: string + operand1Decimals?: number + operand2Decimals?: number + resultDecimals?: number result: string } } @@ -94,16 +114,20 @@ export const endpoint = new AdapterEndpoint({ operand2MinAnswers, operand1Input, operand2Input, + operand1DecimalsField, + operand2DecimalsField, + outputDecimals, } = req.requestContext.data validateSources(operand1Sources, operand1MinAnswers) validateSources(operand2Sources, operand2MinAnswers) validateInputPayload(operand1Input, 'operand1Input') validateInputPayload(operand2Input, 'operand2Input') + validateDecimalsFieldParams(outputDecimals, operand1DecimalsField, operand2DecimalsField) return }, }) -const validateSources = (sources: string[], minAnswers: number) => { +export const validateSources = (sources: string[], minAnswers: number) => { if (sources.length < minAnswers) { throw new AdapterInputError({ statusCode: 400, @@ -127,7 +151,7 @@ const validateSources = (sources: string[], minAnswers: number) => { return } -const validateInputPayload = (input: string, inputName: string) => { +export const validateInputPayload = (input: string, inputName: string) => { try { return JSON.parse(input) } catch (e) { @@ -137,3 +161,19 @@ const validateInputPayload = (input: string, inputName: string) => { }) } } + +export const validateDecimalsFieldParams = ( + outputDecimals: number | undefined, + operand1DecimalsField: string | number | undefined, + operand2DecimalsField: string | number | undefined, + errorMessage = 'Decimals fields should be all set or all unset', +) => { + const fields = [outputDecimals, operand1DecimalsField, operand2DecimalsField] + const definedFields = new Set(fields.map((f) => f !== undefined)) + if (definedFields.size !== 1) { + throw new AdapterInputError({ + statusCode: 400, + message: errorMessage, + }) + } +} diff --git a/packages/composites/implied-price-test/src/transport/computePrice.ts b/packages/composites/implied-price-test/src/transport/computePrice.ts index 324a34819d..e79d6d6ff5 100644 --- a/packages/composites/implied-price-test/src/transport/computePrice.ts +++ b/packages/composites/implied-price-test/src/transport/computePrice.ts @@ -1,12 +1,26 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' 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 { Requester } from '@chainlink/external-adapter-framework/util/requester' import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' -import { BaseEndpointTypes, inputParameters } from '../endpoint/computedPrice' +import Decimal from 'decimal.js' +import { + BaseEndpointTypes, + inputParameters, + validateDecimalsFieldParams, +} from '../endpoint/computedPrice' import { calculateMedian, getOperandSourceUrls } from './utils' +const scaleValue = (value: Decimal, inputDecimals: number, outputDecimals: number): Decimal => + value.div(new Decimal(10).pow(inputDecimals)).mul(new Decimal(10).pow(outputDecimals)) + +const decimalToString = (value: Decimal): string => { + const str = value.toString() + return str.includes('e+') ? value.toFixed() : str +} + const logger = makeLogger('ComputedPriceTransport') type RequestParams = typeof inputParameters.validated @@ -71,7 +85,10 @@ export class ComputedPriceTransport extends SubscriptionTransport { + decimalsField?: string, + ): Promise<{ values: Decimal[]; decimals?: number }> { const promises = sources.map(async (url) => { try { const requestConfig = { @@ -143,7 +211,7 @@ export class ComputedPriceTransport extends SubscriptionTransport( + const result = await this.requester.request>( JSON.stringify(requestConfig), requestConfig, ) @@ -164,7 +232,24 @@ export class ComputedPriceTransport extends SubscriptionTransport r?.response.data.result as number) + const values = successfulResults.map((r) => new Decimal(r?.response.data.result as number)) + + let decimals: number | undefined + if (decimalsField) { + // Extract decimals from all successful responses and verify consistency + const allDecimals = successfulResults + .map((r) => r?.response?.data?.data?.[decimalsField]) + .filter((d): d is number => typeof d === 'number') + + const uniqueDecimals = new Set(allDecimals) + if (uniqueDecimals.size !== 1) { + throw new Error(`Failed to extract consistent decimals from field "${decimalsField}"`) + } + + decimals = allDecimals[0] + } + + return { values, decimals } } } diff --git a/packages/composites/implied-price-test/src/transport/utils.ts b/packages/composites/implied-price-test/src/transport/utils.ts index ed1d85d6a4..54832c0a57 100644 --- a/packages/composites/implied-price-test/src/transport/utils.ts +++ b/packages/composites/implied-price-test/src/transport/utils.ts @@ -1,17 +1,17 @@ import Decimal from 'decimal.js' -export const calculateMedian = (values: number[]): Decimal => { +export const calculateMedian = (values: Decimal[]): Decimal => { if (values.length === 0) { throw new Error('Cannot calculate median of empty array') } - const sortedValues = [...values].sort((a, b) => a - b) + const sortedValues = [...values].sort((a, b) => a.comparedTo(b)) const middleIndex = Math.floor(sortedValues.length / 2) if (sortedValues.length % 2 === 0) { - return new Decimal(sortedValues[middleIndex - 1]).add(sortedValues[middleIndex]).div(2) + return sortedValues[middleIndex - 1].add(sortedValues[middleIndex]).div(2) } else { - return new Decimal(sortedValues[middleIndex]) + return sortedValues[middleIndex] } } diff --git a/packages/composites/implied-price-test/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/implied-price-test/test/integration/__snapshots__/adapter.test.ts.snap index b98d06d805..9257a12bd7 100644 --- a/packages/composites/implied-price-test/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/composites/implied-price-test/test/integration/__snapshots__/adapter.test.ts.snap @@ -44,6 +44,66 @@ exports[`execute computePrice endpoint returns error if operand2 has zero price } `; +exports[`execute computePrice endpoint returns failure when upstream responses are missing decimals field 1`] = ` +{ + "errorMessage": "Failed to extract consistent decimals from field "decimals"", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute computePrice endpoint returns failure with mismatched operand1Decimals 1`] = ` +{ + "errorMessage": "Failed to extract consistent decimals from field "decimals"", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute computePrice endpoint returns success with decimal scaling for divide operation 1`] = ` +{ + "data": { + "operand1Decimals": 2, + "operand1Result": "12.5", + "operand2Decimals": 2, + "operand2Result": "10", + "result": "1250000000000000000", + "resultDecimals": 18, + }, + "result": "1250000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute computePrice endpoint returns success with decimal scaling for multiply operation 1`] = ` +{ + "data": { + "operand1Decimals": 1, + "operand1Result": "12.5", + "operand2Decimals": 1, + "operand2Result": "10", + "result": "125000000", + "resultDecimals": 8, + }, + "result": "125000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + exports[`execute computePrice endpoint should return error for minimum number of operand 1 sources 1`] = ` { "errorMessage": "Insufficient responses: got 1, required 2", @@ -80,6 +140,8 @@ exports[`execute computePrice endpoint should return error for missing source 1` exports[`execute computePrice endpoint should return number > e+21 as fixed point rather than exponential 1`] = ` { "data": { + "operand1Result": "17.15", + "operand2Result": "1000000000000000000000000", "result": "17150000000000000000000000", }, "result": "17150000000000000000000000", @@ -94,6 +156,8 @@ exports[`execute computePrice endpoint should return number > e+21 as fixed poin exports[`execute computePrice endpoint should return success for divide 1`] = ` { "data": { + "operand1Result": "12.5", + "operand2Result": "10", "result": "1.25", }, "result": "1.25", @@ -108,6 +172,8 @@ exports[`execute computePrice endpoint should return success for divide 1`] = ` exports[`execute computePrice endpoint should return success for legacy divisions 1`] = ` { "data": { + "operand1Result": "20", + "operand2Result": "7.5", "result": "2.6666666666666666667", }, "result": "2.6666666666666666667", @@ -122,6 +188,8 @@ exports[`execute computePrice endpoint should return success for legacy division exports[`execute computePrice endpoint should return success for multiply 1`] = ` { "data": { + "operand1Result": "20", + "operand2Result": "10", "result": "200", }, "result": "200", @@ -136,6 +204,8 @@ exports[`execute computePrice endpoint should return success for multiply 1`] = exports[`execute computePrice endpoint should return success for partial source success 1`] = ` { "data": { + "operand1Result": "20", + "operand2Result": "5", "result": "4", }, "result": "4", diff --git a/packages/composites/implied-price-test/test/integration/adapter.test.ts b/packages/composites/implied-price-test/test/integration/adapter.test.ts index 22b07337f9..bcf93d57cd 100644 --- a/packages/composites/implied-price-test/test/integration/adapter.test.ts +++ b/packages/composites/implied-price-test/test/integration/adapter.test.ts @@ -412,5 +412,150 @@ describe('execute', () => { expect(response.body).not.toContain('e+') nock.cleanAll() }) + + it('returns success with decimal scaling for divide operation', async () => { + const data = { + operand1Sources: ['ncfx', 'elwood'], + operand1Input: JSON.stringify({ + from: 'LINK', + to: 'USD8', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + operand2Sources: ['tiingo'], + operand2Input: JSON.stringify({ + from: 'ETH', + to: 'USD8', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'divide', + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', + outputDecimals: 18, + } + mockDPResponseSuccess('tiingo', 10, 2) + mockDPResponseSuccess('ncfx', 20, 2) + mockDPResponseSuccess('elwood', 5, 2) + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + nock.cleanAll() + }) + + it('returns success with decimal scaling for multiply operation', async () => { + const data = { + operand1Sources: ['ncfx', 'elwood'], + operand1Input: JSON.stringify({ + from: 'LINK', + to: 'USD9', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + operand2Sources: ['tiingo'], + operand2Input: JSON.stringify({ + from: 'ETH', + to: 'USD9', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'multiply', + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', + outputDecimals: 8, + } + mockDPResponseSuccess('tiingo', 10, 1) + mockDPResponseSuccess('ncfx', 20, 1) + mockDPResponseSuccess('elwood', 5, 1) + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + nock.cleanAll() + }) + + it('returns failure with mismatched operand1Decimals', async () => { + const data = { + operand1Sources: ['ncfx', 'elwood'], + operand1Input: JSON.stringify({ + from: 'LINK', + to: 'USD10', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + operand2Sources: ['tiingo'], + operand2Input: JSON.stringify({ + from: 'ETH', + to: 'USD10', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'multiply', + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', + outputDecimals: 8, + } + mockDPResponseSuccess('tiingo', 10, 1) + mockDPResponseSuccess('ncfx', 20, 12) + mockDPResponseSuccess('elwood', 5, 5) + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + nock.cleanAll() + }) + + it('returns failure when upstream responses are missing decimals field', async () => { + const data = { + operand1Sources: ['ncfx', 'elwood'], + operand1Input: JSON.stringify({ + from: 'LINK', + to: 'USD11', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + operand2Sources: ['tiingo'], + operand2Input: JSON.stringify({ + from: 'ETH', + to: 'USD11', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'multiply', + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', + outputDecimals: 8, + } + // Respond without the decimals field so the intermediate transport check fires + mockDPResponseSuccess('tiingo', 10) + mockDPResponseSuccess('ncfx', 20) + mockDPResponseSuccess('elwood', 5) + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + nock.cleanAll() + }) }) }) diff --git a/packages/composites/implied-price-test/test/integration/fixtures.ts b/packages/composites/implied-price-test/test/integration/fixtures.ts index 6de74d019d..842c4afdc2 100644 --- a/packages/composites/implied-price-test/test/integration/fixtures.ts +++ b/packages/composites/implied-price-test/test/integration/fixtures.ts @@ -1,6 +1,6 @@ import nock from 'nock' -export const mockDPResponseSuccess = (dp: string, value: number): nock.Scope => +export const mockDPResponseSuccess = (dp: string, value: number, decimals?: number): nock.Scope => nock('http://localhost:8080') .post(`/${dp}`, (body) => { return body && typeof body.data === 'object' && body.data !== null @@ -8,7 +8,10 @@ export const mockDPResponseSuccess = (dp: string, value: number): nock.Scope => .reply(200, () => ({ result: value, statusCode: 200, - data: { result: value }, + data: { + result: value, + ...(decimals !== undefined && { decimals }), + }, timestamps: { providerDataReceivedUnixMs: Date.now(), providerDataStreamEstablishedUnixMs: Date.now(), diff --git a/packages/composites/implied-price-test/test/unit/adapter.test.ts b/packages/composites/implied-price-test/test/unit/adapter.test.ts new file mode 100644 index 0000000000..9688e52e8c --- /dev/null +++ b/packages/composites/implied-price-test/test/unit/adapter.test.ts @@ -0,0 +1,62 @@ +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import Decimal from 'decimal.js' +import { validateDecimalsFieldParams } from '../../src/endpoint/computedPrice' +import { calculateMedian } from '../../src/transport/utils' + +describe('calculateMedian', () => { + it('gets the median of a list of numbers', () => { + expect(calculateMedian([new Decimal(1), new Decimal(2), new Decimal(3)]).toNumber()).toEqual(2) + }) + + it('gets the median with only one number', () => { + expect(calculateMedian([new Decimal(1)]).toNumber()).toEqual(1) + }) + + it('gets the median with an even amount of numbers', () => { + expect(calculateMedian([new Decimal(1), new Decimal(2)]).toNumber()).toEqual(1.5) + }) +}) + +describe('validateDecimalsFieldParams', () => { + it('should not throw when all decimals fields and outputDecimals are defined', () => { + expect(() => validateDecimalsFieldParams(18, 'decimals', 'decimals')).not.toThrow() + }) + + it('should not throw when all are undefined', () => { + expect(() => validateDecimalsFieldParams(undefined, undefined, undefined)).not.toThrow() + }) + + it('should throw when outputDecimals is set but decimals fields are not', () => { + expect(() => validateDecimalsFieldParams(18, undefined, 'decimals')).toThrow(AdapterInputError) + expect(() => validateDecimalsFieldParams(18, 'decimals', undefined)).toThrow(AdapterInputError) + }) + + it('should throw when decimals fields are set but outputDecimals is not', () => { + expect(() => validateDecimalsFieldParams(undefined, 'decimals', 'decimals')).toThrow( + AdapterInputError, + ) + }) + + it('should not throw when operand decimals fields are numeric and all are defined', () => { + expect(() => validateDecimalsFieldParams(18, 8, 18)).not.toThrow() + }) + + it('should throw when outputDecimals is set but numeric operand decimals fields are not', () => { + expect(() => validateDecimalsFieldParams(18, undefined, 8)).toThrow(AdapterInputError) + expect(() => validateDecimalsFieldParams(18, 8, undefined)).toThrow(AdapterInputError) + }) + + it('should use the custom errorMessage when provided', () => { + const customMessage = + 'Intermediate check failed: response decimals fields should be all set or all unset' + expect(() => validateDecimalsFieldParams(18, undefined, undefined, customMessage)).toThrow( + expect.objectContaining({ message: customMessage }), + ) + }) + + it('should use the default errorMessage when not provided', () => { + expect(() => validateDecimalsFieldParams(18, undefined, undefined)).toThrow( + expect.objectContaining({ message: 'Decimals fields should be all set or all unset' }), + ) + }) +})