Skip to content
5 changes: 5 additions & 0 deletions .changeset/happy-foxes-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/implied-price-test-adapter': minor
---

Add decimals params to computedPrice endpoint
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
},
},
[
{
Expand All @@ -75,6 +90,11 @@ export type BaseEndpointTypes = {
Response: {
Result: string
Data: {
operand1Result: string
operand2Result: string
operand1Decimals?: number
operand2Decimals?: number
resultDecimals?: number
result: string
}
}
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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,
})
}
}
115 changes: 100 additions & 15 deletions packages/composites/implied-price-test/src/transport/computePrice.ts
Original file line number Diff line number Diff line change
@@ -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))
Comment thread
mxiao-cll marked this conversation as resolved.

const decimalToString = (value: Decimal): string => {
const str = value.toString()
return str.includes('e+') ? value.toFixed() : str
}
Comment thread
mxiao-cll marked this conversation as resolved.

const logger = makeLogger('ComputedPriceTransport')

type RequestParams = typeof inputParameters.validated
Expand Down Expand Up @@ -71,7 +85,10 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
operand2MinAnswers,
operand1Input,
operand2Input,
operand1DecimalsField,
operand2DecimalsField,
operation,
outputDecimals,
} = param

logger.debug(
Expand All @@ -82,7 +99,10 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
operand2MinAnswers,
operand1Input,
operand2Input,
operand1DecimalsField,
operand2DecimalsField,
operation,
outputDecimals,
})}`,
)
const providerDataRequestedUnixMs = Date.now()
Expand All @@ -91,14 +111,24 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
const operand2SourceUrls = getOperandSourceUrls(operand2Sources)

// Fetch data from sources for both operands
const [operand1Result, operand2Result] = await Promise.all([
this.fetchFromSources(operand1SourceUrls, operand1Input, operand1MinAnswers),
this.fetchFromSources(operand2SourceUrls, operand2Input, operand2MinAnswers),
const [operand1Results, operand2Results] = await Promise.all([
this.fetchFromSources(
operand1SourceUrls,
operand1Input,
operand1MinAnswers,
operand1DecimalsField,
),
this.fetchFromSources(
operand2SourceUrls,
operand2Input,
operand2MinAnswers,
operand2DecimalsField,
),
])

// Get the median
const operand1Median = calculateMedian(operand1Result)
const operand2Median = calculateMedian(operand2Result)
const operand1Median = calculateMedian(operand1Results.values)
const operand2Median = calculateMedian(operand2Results.values)

if (operand1Median.isZero()) {
throw new Error('operand1Median result is zero')
Expand All @@ -108,17 +138,54 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
throw new Error('operand2Median result is zero')
}

const result =
operation === 'divide'
? operand1Median.div(operand2Median).toFixed()
: operand1Median.mul(operand2Median).toFixed()
const areDecimalsDefined = outputDecimals !== undefined

validateDecimalsFieldParams(
outputDecimals,
operand1Results.decimals,
operand2Results.decimals,
'Intermediate check failed: response decimals fields should be all set or all unset',
)

// Scale operands down to 0 decimals if decimals are defined
Comment thread
mxiao-cll marked this conversation as resolved.
const scaledOperand1 = areDecimalsDefined
? scaleValue(operand1Median, operand1Results.decimals!, 0)
: operand1Median
const scaledOperand2 = areDecimalsDefined
? scaleValue(operand2Median, operand2Results.decimals!, 0)
: operand2Median

let computedResult: Decimal
if (operation.toLowerCase() === 'divide') {
computedResult = scaledOperand1.div(scaledOperand2)
} else if (operation.toLowerCase() === 'multiply') {
computedResult = scaledOperand1.mul(scaledOperand2)
} else {
throw new AdapterError({
message: `Unsupported operation: ${operation}. This should not be possible because of input validation.`,
})
}

// Scale result up to output decimals if decimals are defined
if (areDecimalsDefined) {
computedResult = scaleValue(computedResult, 0, outputDecimals!)
}

const result = computedResult.toFixed()

return {
data: {
result: result,
result,
operand1Result: decimalToString(operand1Median),
operand2Result: decimalToString(operand2Median),
...(areDecimalsDefined && {
operand1Decimals: operand1Results.decimals!,
operand2Decimals: operand2Results.decimals!,
resultDecimals: outputDecimals!,
}),
},
statusCode: 200,
result: result,
result,
timestamps: {
providerDataRequestedUnixMs,
providerDataReceivedUnixMs: Date.now(),
Expand All @@ -135,15 +202,16 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
sources: string[],
input: string,
minAnswers: number,
): Promise<number[]> {
decimalsField?: string,
): Promise<{ values: Decimal[]; decimals?: number }> {
const promises = sources.map(async (url) => {
try {
const requestConfig = {
url,
method: 'POST',
data: { data: JSON.parse(input) },
}
const result = await this.requester.request<{ result?: number }>(
const result = await this.requester.request<Record<string, any>>(
JSON.stringify(requestConfig),
requestConfig,
)
Expand All @@ -164,7 +232,24 @@ export class ComputedPriceTransport extends SubscriptionTransport<ComputedPriceT
)
}

return successfulResults.map((r) => 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 }
}
}

Expand Down
8 changes: 4 additions & 4 deletions packages/composites/implied-price-test/src/transport/utils.ts
Original file line number Diff line number Diff line change
@@ -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]
}
}

Expand Down
Loading
Loading