From 69b44266102c3754ed5f37e0390382f21d102c6e Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Tue, 31 Mar 2026 20:09:10 -0400 Subject: [PATCH 1/8] OPDATA-6509 add decimals inputs to implied-price computedPrice endpoint --- .../src/endpoint/computedPrice.ts | 57 ++++++++++ .../__snapshots__/adapter.test.ts.snap | 37 +++++++ .../test/integration/adapter.test.ts | 102 ++++++++++++++++++ .../implied-price/test/unit/adapter.test.ts | 19 +++- 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/packages/composites/implied-price/src/endpoint/computedPrice.ts b/packages/composites/implied-price/src/endpoint/computedPrice.ts index 6aa8a25b0c..82fc817be9 100644 --- a/packages/composites/implied-price/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price/src/endpoint/computedPrice.ts @@ -22,10 +22,13 @@ export type TInputParameters = { operand1Sources: string | string[] operand1MinAnswers?: number operand1Input: AdapterRequest + operand1Decimals?: number operand2Sources: string | string[] operand2MinAnswers?: number operand2Input: AdapterRequest + operand2Decimals?: number operation: string + outputDecimals?: number } const inputParameters: InputParameters = { @@ -44,6 +47,11 @@ const inputParameters: InputParameters = { required: true, description: 'The payload to send to the operand1 sources', }, + operand1Decimals: { + required: false, + type: 'number', + description: 'The scaling factor (*10^operand1Decimals) of operand1', + }, operand2Sources: { required: true, description: @@ -59,12 +67,22 @@ const inputParameters: InputParameters = { required: true, description: 'The payload to send to the operand2 sources', }, + operand2Decimals: { + required: false, + type: 'number', + description: 'The scaling factor (*10^operand2Decimals) of operand2', + }, operation: { required: true, type: 'string', description: 'The operation to perform on the operands', options: ['divide', 'multiply'], }, + outputDecimals: { + required: false, + type: 'number', + description: 'Decimal scaling of the result', + }, } export const execute: ExecuteWithConfig = (input, _, config) => { @@ -77,6 +95,11 @@ export const execute: ExecuteWithConfig = (input, _, config) => { validator.validated.data.operand2Input, 'operand2Input', ) + validateDecimalsParams( + validator.validated.data.outputDecimals, + validator.validated.data.operand1Decimals, + validator.validated.data.operand2Decimals, + ) return executeComputedPrice(validator.validated.id, validator.validated.data, config) } @@ -123,7 +146,12 @@ export const executeComputedPrice = async ( const operand2MinAnswers = validatedData.operand2MinAnswers as number const operand1Input = validatedData.operand1Input const operand2Input = validatedData.operand2Input + const operand1Decimals = validatedData.operand1Decimals + const operand2Decimals = validatedData.operand2Decimals const operation = validatedData.operation.toLowerCase() + const outputDecimals = validatedData.outputDecimals + const areDecimalsDefined = outputDecimals !== undefined + // TODO: non-nullable default types const operand1Urls = getOperandSourceUrls({ @@ -156,17 +184,30 @@ export const executeComputedPrice = async ( throw new AdapterResponseInvalidError({ message: 'Operand 2 result is zero' }) } + let scalingFactor: Decimal | undefined = undefined let result: Decimal if (operation === 'divide') { result = operand1Result.div(operand2Result) + if (areDecimalsDefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + scalingFactor = new Decimal(10).pow(outputDecimals - (operand1Decimals! - operand2Decimals!)) + } } else if (operation === 'multiply') { result = operand1Result.mul(operand2Result) + if (areDecimalsDefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + scalingFactor = new Decimal(10).pow(outputDecimals - (operand1Decimals! + operand2Decimals!)) + } } else { throw new AdapterError({ message: `Unsupported operation: ${operation}. This should not be possible because of input validation.`, }) } + if (scalingFactor !== undefined) { + result = result.mul(scalingFactor) + } + const data = { operand1Result: operand1Result.toString(), operand2Result: operand2Result.toString(), @@ -244,3 +285,19 @@ export const validateInputPayload = (input: string | object, inputName: string) message: `Invalid input payload type for "${inputName}", expected JSON string or object`, }) } + +export const validateDecimalsParams = ( + outputDecimals: number | undefined, + operand1Decimals: number | undefined, + operand2Decimals: number | undefined, +) => { + // validate decimals are either all set or none set + const decimals = [outputDecimals, operand1Decimals, operand2Decimals] + const definedDecimals = new Set(decimals.map((d) => d !== undefined)) + if (definedDecimals.size !== 1) { + throw new AdapterInputError({ + statusCode: 400, + message: 'Decimals inputs should be all set or all unset', + }) + } +} diff --git a/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap index 2f00e2a620..d12c00558a 100644 --- a/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap @@ -177,6 +177,30 @@ exports[`impliedPrice with endpoint computedPrice successful calls returns succe } `; +exports[`impliedPrice with endpoint computedPrice successful calls returns success with decimal scaling for divide operation 1`] = ` +{ + "data": { + "result": "38975944001909025829000000", + }, + "jobRunID": "1", + "providerStatusCode": 200, + "result": "38975944001909025829000000", + "statusCode": 200, +} +`; + +exports[`impliedPrice with endpoint computedPrice successful calls returns success with decimal scaling for multiply operation 1`] = ` +{ + "data": { + "result": "0.000754625725", + }, + "jobRunID": "1", + "providerStatusCode": 200, + "result": "0.000754625725", + "statusCode": 200, +} +`; + exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request contains unsupported sources 1`] = ` { "error": { @@ -242,6 +266,19 @@ exports[`impliedPrice with endpoint computedPrice validation error returns a val } `; +exports[`impliedPrice with endpoint computedPrice validation error returns error if only some decimals are set (operand decimals only) 1`] = ` +{ + "error": { + "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide","operand1Decimals":8,"operand2Decimals":8}}", + "message": "Decimals inputs should be all set or all unset", + "name": "AdapterError", + }, + "jobRunID": "1", + "status": "errored", + "statusCode": 400, +} +`; + exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if dividend has zero price 1`] = ` { "error": { diff --git a/packages/composites/implied-price/test/integration/adapter.test.ts b/packages/composites/implied-price/test/integration/adapter.test.ts index 9d6c1067e1..0c7a199009 100644 --- a/packages/composites/implied-price/test/integration/adapter.test.ts +++ b/packages/composites/implied-price/test/integration/adapter.test.ts @@ -187,6 +187,74 @@ describe('impliedPrice', () => { expect(response.body).toMatchSnapshot() expect(response.body.result).not.toContain('e+') }) + + it('returns success with decimal scaling for divide operation', async () => { + mockSuccessfulResponseCoingecko() + mockSuccessfulResponseCoinpaprika() + const data: AdapterRequest = { + id: jobID, + data: { + endpoint, + operand1Sources: ['coingecko', 'coinpaprika'], + operand2Sources: ['coingecko', 'coinpaprika'], + operand1Input: { + from: 'LINK', + to: 'USD', + }, + operand2Input: { + from: 'ETH', + to: 'USD', + }, + operation: 'divide', + operand1Decimals: 8, + operand2Decimals: 18, + outputDecimals: 18, + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + expect(response.body).toMatchSnapshot() + }) + + it('returns success with decimal scaling for multiply operation', async () => { + mockSuccessfulResponseCoingecko() + mockSuccessfulResponseCoinpaprika() + const data: AdapterRequest = { + id: jobID, + data: { + endpoint, + operand1Sources: ['coingecko', 'coinpaprika'], + operand2Sources: ['coingecko', 'coinpaprika'], + operand1Input: { + from: 'LINK', + to: 'USD', + }, + operand2Input: { + from: 'ETH', + to: 'USD', + }, + operation: 'multiply', + operand1Decimals: 8, + operand2Decimals: 8, + outputDecimals: 8, + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + expect(response.body).toMatchSnapshot() + }) }) describe('erroring calls', () => { @@ -577,6 +645,40 @@ describe('impliedPrice', () => { .expect(500) expect(response.body).toMatchSnapshot() }) + + it('returns error if only some decimals are set (operand decimals only)', async () => { + const data: AdapterRequest = { + id: jobID, + data: { + endpoint, + operand1Sources: ['coingecko'], + operand2Sources: ['coingecko'], + operand1Input: { + from: 'LINK', + to: 'USD', + }, + operand2Input: { + from: 'ETH', + to: 'USD', + }, + operation: 'divide', + operand1Decimals: 8, + operand2Decimals: 8, + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(400) + expect(response.body).toMatchSnapshot() + expect(response.body.error.message).toContain( + 'Decimals inputs should be all set or all unset', + ) + }) }) }) diff --git a/packages/composites/implied-price/test/unit/adapter.test.ts b/packages/composites/implied-price/test/unit/adapter.test.ts index b6e1e2f5f7..568b996bf4 100644 --- a/packages/composites/implied-price/test/unit/adapter.test.ts +++ b/packages/composites/implied-price/test/unit/adapter.test.ts @@ -1,4 +1,5 @@ -import { median, parseSources } from '../../src/endpoint/computedPrice' +import { AdapterInputError } from '@chainlink/ea-bootstrap' +import { median, parseSources, validateDecimalsParams } from '../../src/endpoint/computedPrice' describe('parseSources', () => { it('parses an array of sources', () => { @@ -23,3 +24,19 @@ describe('median', () => { expect(median([1, 2]).toNumber()).toEqual(1.5) }) }) + +describe('validateDecimalsParams', () => { + it('should not throw when all decimals are defined', () => { + expect(() => validateDecimalsParams(18, 8, 6)).not.toThrow() + }) + + it('should not throw when all decimals are undefined', () => { + expect(() => validateDecimalsParams(undefined, undefined, undefined)).not.toThrow() + }) + + it('should throw when two decimals are defined and one is undefined', () => { + expect(() => validateDecimalsParams(18, 8, undefined)).toThrow(AdapterInputError) + expect(() => validateDecimalsParams(18, undefined, undefined)).toThrow(AdapterInputError) + expect(() => validateDecimalsParams(undefined, 8, 6)).toThrow(AdapterInputError) + }) +}) From aa76c252e9ee9c93c23f4f2d52489837fea2860e Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Tue, 31 Mar 2026 20:09:53 -0400 Subject: [PATCH 2/8] add changeset --- .changeset/happy-foxes-pay.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/happy-foxes-pay.md diff --git a/.changeset/happy-foxes-pay.md b/.changeset/happy-foxes-pay.md new file mode 100644 index 0000000000..1a7d09bbe5 --- /dev/null +++ b/.changeset/happy-foxes-pay.md @@ -0,0 +1,5 @@ +--- +'@chainlink/implied-price-adapter': minor +--- + +Add decimals params to computedPrice endpoint From 418b0f58d09730ec39134a2cbfb7f4fa7ed1e08d Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Thu, 2 Apr 2026 19:45:31 -0400 Subject: [PATCH 3/8] undo changes to implied-price --- .../src/endpoint/computedPrice.ts | 57 ---------- .../__snapshots__/adapter.test.ts.snap | 37 ------- .../test/integration/adapter.test.ts | 102 ------------------ .../implied-price/test/unit/adapter.test.ts | 19 +--- 4 files changed, 1 insertion(+), 214 deletions(-) diff --git a/packages/composites/implied-price/src/endpoint/computedPrice.ts b/packages/composites/implied-price/src/endpoint/computedPrice.ts index 82fc817be9..6aa8a25b0c 100644 --- a/packages/composites/implied-price/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price/src/endpoint/computedPrice.ts @@ -22,13 +22,10 @@ export type TInputParameters = { operand1Sources: string | string[] operand1MinAnswers?: number operand1Input: AdapterRequest - operand1Decimals?: number operand2Sources: string | string[] operand2MinAnswers?: number operand2Input: AdapterRequest - operand2Decimals?: number operation: string - outputDecimals?: number } const inputParameters: InputParameters = { @@ -47,11 +44,6 @@ const inputParameters: InputParameters = { required: true, description: 'The payload to send to the operand1 sources', }, - operand1Decimals: { - required: false, - type: 'number', - description: 'The scaling factor (*10^operand1Decimals) of operand1', - }, operand2Sources: { required: true, description: @@ -67,22 +59,12 @@ const inputParameters: InputParameters = { required: true, description: 'The payload to send to the operand2 sources', }, - operand2Decimals: { - required: false, - type: 'number', - description: 'The scaling factor (*10^operand2Decimals) of operand2', - }, operation: { required: true, type: 'string', description: 'The operation to perform on the operands', options: ['divide', 'multiply'], }, - outputDecimals: { - required: false, - type: 'number', - description: 'Decimal scaling of the result', - }, } export const execute: ExecuteWithConfig = (input, _, config) => { @@ -95,11 +77,6 @@ export const execute: ExecuteWithConfig = (input, _, config) => { validator.validated.data.operand2Input, 'operand2Input', ) - validateDecimalsParams( - validator.validated.data.outputDecimals, - validator.validated.data.operand1Decimals, - validator.validated.data.operand2Decimals, - ) return executeComputedPrice(validator.validated.id, validator.validated.data, config) } @@ -146,12 +123,7 @@ export const executeComputedPrice = async ( const operand2MinAnswers = validatedData.operand2MinAnswers as number const operand1Input = validatedData.operand1Input const operand2Input = validatedData.operand2Input - const operand1Decimals = validatedData.operand1Decimals - const operand2Decimals = validatedData.operand2Decimals const operation = validatedData.operation.toLowerCase() - const outputDecimals = validatedData.outputDecimals - const areDecimalsDefined = outputDecimals !== undefined - // TODO: non-nullable default types const operand1Urls = getOperandSourceUrls({ @@ -184,30 +156,17 @@ export const executeComputedPrice = async ( throw new AdapterResponseInvalidError({ message: 'Operand 2 result is zero' }) } - let scalingFactor: Decimal | undefined = undefined let result: Decimal if (operation === 'divide') { result = operand1Result.div(operand2Result) - if (areDecimalsDefined) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - scalingFactor = new Decimal(10).pow(outputDecimals - (operand1Decimals! - operand2Decimals!)) - } } else if (operation === 'multiply') { result = operand1Result.mul(operand2Result) - if (areDecimalsDefined) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - scalingFactor = new Decimal(10).pow(outputDecimals - (operand1Decimals! + operand2Decimals!)) - } } else { throw new AdapterError({ message: `Unsupported operation: ${operation}. This should not be possible because of input validation.`, }) } - if (scalingFactor !== undefined) { - result = result.mul(scalingFactor) - } - const data = { operand1Result: operand1Result.toString(), operand2Result: operand2Result.toString(), @@ -285,19 +244,3 @@ export const validateInputPayload = (input: string | object, inputName: string) message: `Invalid input payload type for "${inputName}", expected JSON string or object`, }) } - -export const validateDecimalsParams = ( - outputDecimals: number | undefined, - operand1Decimals: number | undefined, - operand2Decimals: number | undefined, -) => { - // validate decimals are either all set or none set - const decimals = [outputDecimals, operand1Decimals, operand2Decimals] - const definedDecimals = new Set(decimals.map((d) => d !== undefined)) - if (definedDecimals.size !== 1) { - throw new AdapterInputError({ - statusCode: 400, - message: 'Decimals inputs should be all set or all unset', - }) - } -} diff --git a/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap index d12c00558a..2f00e2a620 100644 --- a/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap @@ -177,30 +177,6 @@ exports[`impliedPrice with endpoint computedPrice successful calls returns succe } `; -exports[`impliedPrice with endpoint computedPrice successful calls returns success with decimal scaling for divide operation 1`] = ` -{ - "data": { - "result": "38975944001909025829000000", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "38975944001909025829000000", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint computedPrice successful calls returns success with decimal scaling for multiply operation 1`] = ` -{ - "data": { - "result": "0.000754625725", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "0.000754625725", - "statusCode": 200, -} -`; - exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request contains unsupported sources 1`] = ` { "error": { @@ -266,19 +242,6 @@ exports[`impliedPrice with endpoint computedPrice validation error returns a val } `; -exports[`impliedPrice with endpoint computedPrice validation error returns error if only some decimals are set (operand decimals only) 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide","operand1Decimals":8,"operand2Decimals":8}}", - "message": "Decimals inputs should be all set or all unset", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if dividend has zero price 1`] = ` { "error": { diff --git a/packages/composites/implied-price/test/integration/adapter.test.ts b/packages/composites/implied-price/test/integration/adapter.test.ts index 0c7a199009..9d6c1067e1 100644 --- a/packages/composites/implied-price/test/integration/adapter.test.ts +++ b/packages/composites/implied-price/test/integration/adapter.test.ts @@ -187,74 +187,6 @@ describe('impliedPrice', () => { expect(response.body).toMatchSnapshot() expect(response.body.result).not.toContain('e+') }) - - it('returns success with decimal scaling for divide operation', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'coinpaprika'], - operand2Sources: ['coingecko', 'coinpaprika'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - operand1Decimals: 8, - operand2Decimals: 18, - outputDecimals: 18, - }, - } - - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(200) - expect(response.body).toMatchSnapshot() - }) - - it('returns success with decimal scaling for multiply operation', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'coinpaprika'], - operand2Sources: ['coingecko', 'coinpaprika'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'multiply', - operand1Decimals: 8, - operand2Decimals: 8, - outputDecimals: 8, - }, - } - - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(200) - expect(response.body).toMatchSnapshot() - }) }) describe('erroring calls', () => { @@ -645,40 +577,6 @@ describe('impliedPrice', () => { .expect(500) expect(response.body).toMatchSnapshot() }) - - it('returns error if only some decimals are set (operand decimals only)', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko'], - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - operand1Decimals: 8, - operand2Decimals: 8, - }, - } - - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(400) - expect(response.body).toMatchSnapshot() - expect(response.body.error.message).toContain( - 'Decimals inputs should be all set or all unset', - ) - }) }) }) diff --git a/packages/composites/implied-price/test/unit/adapter.test.ts b/packages/composites/implied-price/test/unit/adapter.test.ts index 568b996bf4..b6e1e2f5f7 100644 --- a/packages/composites/implied-price/test/unit/adapter.test.ts +++ b/packages/composites/implied-price/test/unit/adapter.test.ts @@ -1,5 +1,4 @@ -import { AdapterInputError } from '@chainlink/ea-bootstrap' -import { median, parseSources, validateDecimalsParams } from '../../src/endpoint/computedPrice' +import { median, parseSources } from '../../src/endpoint/computedPrice' describe('parseSources', () => { it('parses an array of sources', () => { @@ -24,19 +23,3 @@ describe('median', () => { expect(median([1, 2]).toNumber()).toEqual(1.5) }) }) - -describe('validateDecimalsParams', () => { - it('should not throw when all decimals are defined', () => { - expect(() => validateDecimalsParams(18, 8, 6)).not.toThrow() - }) - - it('should not throw when all decimals are undefined', () => { - expect(() => validateDecimalsParams(undefined, undefined, undefined)).not.toThrow() - }) - - it('should throw when two decimals are defined and one is undefined', () => { - expect(() => validateDecimalsParams(18, 8, undefined)).toThrow(AdapterInputError) - expect(() => validateDecimalsParams(18, undefined, undefined)).toThrow(AdapterInputError) - expect(() => validateDecimalsParams(undefined, 8, 6)).toThrow(AdapterInputError) - }) -}) From c43021e8883cd0338335b47d7b9f91935c3deb74 Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Thu, 2 Apr 2026 21:06:16 -0400 Subject: [PATCH 4/8] port functionality to implied-price-test --- .../src/endpoint/computedPrice.ts | 43 ++++++++++- .../src/transport/computePrice.ts | 69 +++++++++++++++--- .../implied-price-test/src/transport/utils.ts | 8 +-- .../__snapshots__/adapter.test.ts.snap | 48 +++++++++++++ .../test/integration/adapter.test.ts | 72 +++++++++++++++++++ .../test/unit/adapter.test.ts | 34 +++++++++ 6 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 packages/composites/implied-price-test/test/unit/adapter.test.ts diff --git a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts index f1ac595189..8990858637 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, }, + operand1Decimals: { + required: false, + type: 'number', + description: 'The scaling factor (*10^operand1Decimals) of operand1', + }, 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, }, + operand2Decimals: { + required: false, + type: 'number', + description: 'The scaling factor (*10^operand2Decimals) of operand2', + }, 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 + operand1Decimals?: number + operand2Result: string + operand2Decimals?: number + resultDecimals?: number result: string } } @@ -94,16 +114,20 @@ export const endpoint = new AdapterEndpoint({ operand2MinAnswers, operand1Input, operand2Input, + operand1Decimals, + operand2Decimals, + outputDecimals, } = req.requestContext.data validateSources(operand1Sources, operand1MinAnswers) validateSources(operand2Sources, operand2MinAnswers) validateInputPayload(operand1Input, 'operand1Input') validateInputPayload(operand2Input, 'operand2Input') + validateDecimalsParams(outputDecimals, operand1Decimals, operand2Decimals) 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,18 @@ const validateInputPayload = (input: string, inputName: string) => { }) } } + +export const validateDecimalsParams = ( + outputDecimals: number | undefined, + operand1Decimals: number | undefined, + operand2Decimals: number | undefined, +) => { + const decimals = [outputDecimals, operand1Decimals, operand2Decimals] + const definedDecimals = new Set(decimals.map((d) => d !== undefined)) + if (definedDecimals.size !== 1) { + throw new AdapterInputError({ + statusCode: 400, + message: 'Decimals inputs should be all set or all unset', + }) + } +} diff --git a/packages/composites/implied-price-test/src/transport/computePrice.ts b/packages/composites/implied-price-test/src/transport/computePrice.ts index 324a34819d..a36c99b6f2 100644 --- a/packages/composites/implied-price-test/src/transport/computePrice.ts +++ b/packages/composites/implied-price-test/src/transport/computePrice.ts @@ -1,12 +1,22 @@ +/* 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 Decimal from 'decimal.js' import { BaseEndpointTypes, inputParameters } 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 +81,10 @@ export class ComputedPriceTransport extends SubscriptionTransport { + ): Promise { const promises = sources.map(async (url) => { try { const requestConfig = { @@ -164,7 +211,7 @@ export class ComputedPriceTransport extends SubscriptionTransport r?.response.data.result as number) + return successfulResults.map((r) => new Decimal(r?.response.data.result as number)) } } 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..625202d4c2 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,44 @@ exports[`execute computePrice endpoint returns error if operand2 has zero price } `; +exports[`execute computePrice endpoint returns success with decimal scaling for divide operation 1`] = ` +{ + "data": { + "operand1Decimals": 4, + "operand1Result": "12.5", + "operand2Decimals": 4, + "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": "12.5", + "resultDecimals": 1, + }, + "result": "12.5", + "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 +118,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 +134,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 +150,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 +166,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 +182,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..ff3ec0a376 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,77 @@ 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', + operand1Decimals: 4, + operand2Decimals: 4, + outputDecimals: 18, + } + mockDPResponseSuccess('tiingo', 10) + mockDPResponseSuccess('ncfx', 20) + mockDPResponseSuccess('elwood', 5) + 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', + operand1Decimals: 1, + operand2Decimals: 1, + outputDecimals: 1, + } + mockDPResponseSuccess('tiingo', 10) + mockDPResponseSuccess('ncfx', 20) + mockDPResponseSuccess('elwood', 5) + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + nock.cleanAll() + }) }) }) 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..9e31eef6b8 --- /dev/null +++ b/packages/composites/implied-price-test/test/unit/adapter.test.ts @@ -0,0 +1,34 @@ +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import Decimal from 'decimal.js' +import { validateDecimalsParams } 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('validateDecimalsParams', () => { + it('should not throw when all decimals are defined', () => { + expect(() => validateDecimalsParams(18, 8, 6)).not.toThrow() + }) + + it('should not throw when all decimals are undefined', () => { + expect(() => validateDecimalsParams(undefined, undefined, undefined)).not.toThrow() + }) + + it('should throw when two decimals are defined and one is undefined', () => { + expect(() => validateDecimalsParams(18, 8, undefined)).toThrow(AdapterInputError) + expect(() => validateDecimalsParams(18, undefined, undefined)).toThrow(AdapterInputError) + expect(() => validateDecimalsParams(undefined, 8, 6)).toThrow(AdapterInputError) + }) +}) From 484998451fd129da7c919136573755bd523951cd Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Thu, 2 Apr 2026 21:07:11 -0400 Subject: [PATCH 5/8] update changeset --- .changeset/happy-foxes-pay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/happy-foxes-pay.md b/.changeset/happy-foxes-pay.md index 1a7d09bbe5..83fe6db6f0 100644 --- a/.changeset/happy-foxes-pay.md +++ b/.changeset/happy-foxes-pay.md @@ -1,5 +1,5 @@ --- -'@chainlink/implied-price-adapter': minor +'@chainlink/implied-price-test-adapter': minor --- Add decimals params to computedPrice endpoint From 598c052f284b3fbb7dded4e721acd0c5700cc441 Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Thu, 2 Apr 2026 22:16:14 -0400 Subject: [PATCH 6/8] updated to specify operand decimals fields instead of fixed number --- .../src/endpoint/computedPrice.ts | 34 +++++----- .../src/transport/computePrice.ts | 65 ++++++++++++++----- .../__snapshots__/adapter.test.ts.snap | 21 ++++-- .../test/integration/adapter.test.ts | 58 +++++++++++++---- .../test/integration/fixtures.ts | 7 +- .../test/unit/adapter.test.ts | 25 ++++--- 6 files changed, 150 insertions(+), 60 deletions(-) diff --git a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts index 8990858637..f1698f8256 100644 --- a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts @@ -30,10 +30,10 @@ export const inputParameters = new InputParameters( description: 'The minimum number of answers needed to return a value for the operand1', default: 1, }, - operand1Decimals: { + operand1DecimalsField: { required: false, - type: 'number', - description: 'The scaling factor (*10^operand1Decimals) of operand1', + type: 'string', + description: 'The field path in operand1 response data containing the decimal scaling factor', }, operand2Sources: { required: true, @@ -55,10 +55,10 @@ export const inputParameters = new InputParameters( description: 'The minimum number of answers needed to return a value for the operand2', default: 1, }, - operand2Decimals: { + operand2DecimalsField: { required: false, - type: 'number', - description: 'The scaling factor (*10^operand2Decimals) of operand2', + type: 'string', + description: 'The field path in operand2 response data containing the decimal scaling factor', }, operation: { default: 'divide', @@ -91,8 +91,8 @@ export type BaseEndpointTypes = { Result: string Data: { operand1Result: string - operand1Decimals?: number operand2Result: string + operand1Decimals?: number operand2Decimals?: number resultDecimals?: number result: string @@ -114,15 +114,15 @@ export const endpoint = new AdapterEndpoint({ operand2MinAnswers, operand1Input, operand2Input, - operand1Decimals, - operand2Decimals, + operand1DecimalsField, + operand2DecimalsField, outputDecimals, } = req.requestContext.data validateSources(operand1Sources, operand1MinAnswers) validateSources(operand2Sources, operand2MinAnswers) validateInputPayload(operand1Input, 'operand1Input') validateInputPayload(operand2Input, 'operand2Input') - validateDecimalsParams(outputDecimals, operand1Decimals, operand2Decimals) + validateDecimalsFieldParams(outputDecimals, operand1DecimalsField, operand2DecimalsField) return }, }) @@ -162,17 +162,17 @@ export const validateInputPayload = (input: string, inputName: string) => { } } -export const validateDecimalsParams = ( +export const validateDecimalsFieldParams = ( outputDecimals: number | undefined, - operand1Decimals: number | undefined, - operand2Decimals: number | undefined, + operand1DecimalsField: string | undefined, + operand2DecimalsField: string | undefined, ) => { - const decimals = [outputDecimals, operand1Decimals, operand2Decimals] - const definedDecimals = new Set(decimals.map((d) => d !== undefined)) - if (definedDecimals.size !== 1) { + const fields = [outputDecimals, operand1DecimalsField, operand2DecimalsField] + const definedFields = new Set(fields.map((f) => f !== undefined)) + if (definedFields.size !== 1) { throw new AdapterInputError({ statusCode: 400, - message: 'Decimals inputs should be all set or all unset', + message: 'Decimals fields should be all set or all unset', }) } } diff --git a/packages/composites/implied-price-test/src/transport/computePrice.ts b/packages/composites/implied-price-test/src/transport/computePrice.ts index a36c99b6f2..95299b0ebc 100644 --- a/packages/composites/implied-price-test/src/transport/computePrice.ts +++ b/packages/composites/implied-price-test/src/transport/computePrice.ts @@ -81,8 +81,8 @@ export class ComputedPriceTransport extends SubscriptionTransport { + decimalsField?: string, + ): Promise<{ values: Decimal[]; decimals?: number }> { const promises = sources.map(async (url) => { try { const requestConfig = { @@ -190,7 +208,7 @@ export class ComputedPriceTransport extends SubscriptionTransport( + const result = await this.requester.request>( JSON.stringify(requestConfig), requestConfig, ) @@ -211,7 +229,24 @@ export class ComputedPriceTransport extends SubscriptionTransport new Decimal(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/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/implied-price-test/test/integration/__snapshots__/adapter.test.ts.snap index 625202d4c2..db20395693 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,12 +44,23 @@ exports[`execute computePrice endpoint returns error if operand2 has zero price } `; +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": 4, + "operand1Decimals": 2, "operand1Result": "12.5", - "operand2Decimals": 4, + "operand2Decimals": 2, "operand2Result": "10", "result": "1250000000000000000", "resultDecimals": 18, @@ -70,10 +81,10 @@ exports[`execute computePrice endpoint returns success with decimal scaling for "operand1Result": "12.5", "operand2Decimals": 1, "operand2Result": "10", - "result": "12.5", - "resultDecimals": 1, + "result": "125000000", + "resultDecimals": 8, }, - "result": "12.5", + "result": "125000000", "statusCode": 200, "timestamps": { "providerDataReceivedUnixMs": 978347471111, 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 ff3ec0a376..0ab5dbd7fb 100644 --- a/packages/composites/implied-price-test/test/integration/adapter.test.ts +++ b/packages/composites/implied-price-test/test/integration/adapter.test.ts @@ -436,13 +436,13 @@ describe('execute', () => { }, }), operation: 'divide', - operand1Decimals: 4, - operand2Decimals: 4, + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', outputDecimals: 18, } - mockDPResponseSuccess('tiingo', 10) - mockDPResponseSuccess('ncfx', 20) - mockDPResponseSuccess('elwood', 5) + 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() @@ -472,17 +472,53 @@ describe('execute', () => { }, }), operation: 'multiply', - operand1Decimals: 1, - operand2Decimals: 1, - outputDecimals: 1, + operand1DecimalsField: 'decimals', + operand2DecimalsField: 'decimals', + outputDecimals: 8, } - mockDPResponseSuccess('tiingo', 10) - mockDPResponseSuccess('ncfx', 20) - mockDPResponseSuccess('elwood', 5) + 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() + }) }) }) 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 index 9e31eef6b8..bb23aaba83 100644 --- a/packages/composites/implied-price-test/test/unit/adapter.test.ts +++ b/packages/composites/implied-price-test/test/unit/adapter.test.ts @@ -1,6 +1,6 @@ import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' import Decimal from 'decimal.js' -import { validateDecimalsParams } from '../../src/endpoint/computedPrice' +import { validateDecimalsFieldParams } from '../../src/endpoint/computedPrice' import { calculateMedian } from '../../src/transport/utils' describe('calculateMedian', () => { @@ -17,18 +17,23 @@ describe('calculateMedian', () => { }) }) -describe('validateDecimalsParams', () => { - it('should not throw when all decimals are defined', () => { - expect(() => validateDecimalsParams(18, 8, 6)).not.toThrow() +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 decimals are undefined', () => { - expect(() => validateDecimalsParams(undefined, undefined, undefined)).not.toThrow() + it('should not throw when all are undefined', () => { + expect(() => validateDecimalsFieldParams(undefined, undefined, undefined)).not.toThrow() }) - it('should throw when two decimals are defined and one is undefined', () => { - expect(() => validateDecimalsParams(18, 8, undefined)).toThrow(AdapterInputError) - expect(() => validateDecimalsParams(18, undefined, undefined)).toThrow(AdapterInputError) - expect(() => validateDecimalsParams(undefined, 8, 6)).toThrow(AdapterInputError) + 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, + ) }) }) From 2cf8cc4677477c2b03a5212e0870e4342ae19e49 Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Tue, 7 Apr 2026 18:55:55 -0400 Subject: [PATCH 7/8] review fixes --- .../src/endpoint/computedPrice.ts | 7 ++-- .../src/transport/computePrice.ts | 22 +++++++---- .../__snapshots__/adapter.test.ts.snap | 15 +++++++- .../test/integration/adapter.test.ts | 37 +++++++++++++++++++ .../test/unit/adapter.test.ts | 23 ++++++++++++ 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts index f1698f8256..284373b205 100644 --- a/packages/composites/implied-price-test/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price-test/src/endpoint/computedPrice.ts @@ -164,15 +164,16 @@ export const validateInputPayload = (input: string, inputName: string) => { export const validateDecimalsFieldParams = ( outputDecimals: number | undefined, - operand1DecimalsField: string | undefined, - operand2DecimalsField: string | 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: 'Decimals fields should be all set or all unset', + 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 95299b0ebc..5b445a1a25 100644 --- a/packages/composites/implied-price-test/src/transport/computePrice.ts +++ b/packages/composites/implied-price-test/src/transport/computePrice.ts @@ -6,9 +6,17 @@ import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter- import { Requester } from '@chainlink/external-adapter-framework/util/requester' import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' import Decimal from 'decimal.js' -import { BaseEndpointTypes, inputParameters } from '../endpoint/computedPrice' +import { + BaseEndpointTypes, + inputParameters, + validateDecimalsFieldParams, +} from '../endpoint/computedPrice' import { calculateMedian, getOperandSourceUrls } from './utils' +Decimal.set({ precision: 50 }) +Decimal.set({ toExpPos: 50 }) +Decimal.set({ toExpNeg: -50 }) + const scaleValue = (value: Decimal, inputDecimals: number, outputDecimals: number): Decimal => value.div(new Decimal(10).pow(inputDecimals)).mul(new Decimal(10).pow(outputDecimals)) @@ -137,12 +145,12 @@ export class ComputedPriceTransport extends SubscriptionTransport { 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/unit/adapter.test.ts b/packages/composites/implied-price-test/test/unit/adapter.test.ts index bb23aaba83..9688e52e8c 100644 --- a/packages/composites/implied-price-test/test/unit/adapter.test.ts +++ b/packages/composites/implied-price-test/test/unit/adapter.test.ts @@ -36,4 +36,27 @@ describe('validateDecimalsFieldParams', () => { 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' }), + ) + }) }) From 5c261b4f9cda0de865c739ae0a20e111027b59f6 Mon Sep 17 00:00:00 2001 From: Matthew McAllister Date: Wed, 8 Apr 2026 10:06:11 -0400 Subject: [PATCH 8/8] review fix --- .../implied-price-test/src/transport/computePrice.ts | 5 ----- .../test/integration/__snapshots__/adapter.test.ts.snap | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/composites/implied-price-test/src/transport/computePrice.ts b/packages/composites/implied-price-test/src/transport/computePrice.ts index 5b445a1a25..e79d6d6ff5 100644 --- a/packages/composites/implied-price-test/src/transport/computePrice.ts +++ b/packages/composites/implied-price-test/src/transport/computePrice.ts @@ -13,10 +13,6 @@ import { } from '../endpoint/computedPrice' import { calculateMedian, getOperandSourceUrls } from './utils' -Decimal.set({ precision: 50 }) -Decimal.set({ toExpPos: 50 }) -Decimal.set({ toExpNeg: -50 }) - const scaleValue = (value: Decimal, inputDecimals: number, outputDecimals: number): Decimal => value.div(new Decimal(10).pow(inputDecimals)).mul(new Decimal(10).pow(outputDecimals)) @@ -142,7 +138,6 @@ export class ComputedPriceTransport extends SubscriptionTransport