diff --git a/.changeset/tame-goats-float.md b/.changeset/tame-goats-float.md new file mode 100644 index 0000000000..0130a495a5 --- /dev/null +++ b/.changeset/tame-goats-float.md @@ -0,0 +1,5 @@ +--- +'@chainlink/implied-price-adapter': patch +--- + +Bump Implied Price Adapter to v3 diff --git a/.pnp.cjs b/.pnp.cjs index b2af9bc954..d415d1d3a3 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -7525,16 +7525,11 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/composites/implied-price/",\ "packageDependencies": [\ ["@chainlink/implied-price-adapter", "workspace:packages/composites/implied-price"],\ - ["@chainlink/ea-bootstrap", "workspace:packages/core/bootstrap"],\ - ["@chainlink/ea-test-helpers", "workspace:packages/core/test-helpers"],\ + ["@chainlink/external-adapter-framework", "npm:2.7.0"],\ ["@types/jest", "npm:29.5.14"],\ ["@types/node", "npm:22.14.1"],\ - ["@types/supertest", "npm:2.0.16"],\ - ["axios", "npm:1.9.0"],\ ["decimal.js", "npm:10.4.3"],\ ["nock", "npm:13.5.6"],\ - ["supertest", "npm:6.2.4"],\ - ["tslib", "npm:2.8.1"],\ ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ ],\ "linkType": "SOFT"\ diff --git a/packages/composites/implied-price/README.md b/packages/composites/implied-price/README.md index 2c5d5bfe37..1245c86238 100644 --- a/packages/composites/implied-price/README.md +++ b/packages/composites/implied-price/README.md @@ -1,149 +1,287 @@ -# Chainlink Implied-price Composite Adapter +# Computed Price Composite Adapter -An adapter that fetches the median value from any two sets of underlying adapters, and divides or multiplied the results from each set together. +The Computed Price adapter performs mathematical operations (divide or multiply) on results from multiple source adapters to calculate implied prices and other derived values. + +## Features + +- **Dual Operations**: Supports both division and multiplication operations +- **Multiple Sources**: Aggregates data from multiple price source adapters +- **High Reliability**: Built-in circuit breakers, retry logic, and timeout handling +- **Flexible Input**: Supports both `from`/`to` and `base`/`quote` parameter formats +- **High-Precision Math**: Uses `decimal.js` for accurate financial calculations +- **Comprehensive Testing**: 51 unit and integration tests covering all scenarios +- **Type Safety**: Full TypeScript support with proper interfaces ## Configuration -The adapter takes the following environment variables: +The adapter accepts the following environment variables for reliability and performance tuning: + +| Required? | Name | Description | Options | Defaults to | +| :-------: | :--------------------------------: | :--------------------------------: | :-----: | :---------: | +| | `SOURCE_TIMEOUT` | Source adapter timeout (ms) | | `10000` | +| | `MAX_RETRIES` | Maximum retry attempts | | `3` | +| | `RETRY_DELAY` | Retry delay (ms) | | `1000` | +| | `SOURCE_CIRCUIT_BREAKER_THRESHOLD` | Circuit breaker failure threshold | | `5` | +| | `SOURCE_CIRCUIT_BREAKER_TIMEOUT` | Circuit breaker timeout (ms) | | `60000` | +| | `REQUEST_COALESCING_ENABLED` | Enable request coalescing | | `true` | +| | `REQUEST_COALESCING_INTERVAL` | Coalescing interval (ms) | | `100` | +| | `API_TIMEOUT` | API response timeout (ms) | | `30000` | +| | `WARMUP_ENABLED` | Enable warmup requests | | `false` | +| | `BACKGROUND_EXECUTE_MS` | Background execution interval (ms) | | `10000` | -| Required? | Name | Description | Options | Defaults to | -| :-------: | :--------------------: | :-----------------------------------------: | :-----: | :---------: | -| | `[source]_ADAPTER_URL` | The adapter URL to query for any `[source]` | | | +## Input Parameters -## Running +This adapter supports **both v2 endpoint parameter formats** for full backward compatibility: -See the [Composite Adapter README](../README.md) for more information on how to get started. +### Format 1: operand1/operand2 (v2 computedPrice endpoint) -### computedPrice endpoint +| Required? | Name | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :------------------: | :------------------------------------------: | :--------: | :------------------: | :-----: | :--------: | :------------: | +| ✅ | `operand1Sources` | Array of source adapters for operand1 values | `string[]` | | | | | +| | `operand1MinAnswers` | Minimum required operand1 responses | `number` | | `1` | | | +| ✅ | `operand1Input` | JSON string payload for operand1 sources | `string` | | | | | +| ✅ | `operand2Sources` | Array of source adapters for operand2 values | `string[]` | | | | | +| | `operand2MinAnswers` | Minimum required operand2 responses | `number` | | `1` | | | +| ✅ | `operand2Input` | JSON string payload for operand2 sources | `string` | | | | | +| ✅ | `operation` | Mathematical operation to perform | `string` | `divide`, `multiply` | | | | -#### Input Params +### Format 2: dividend/divisor (v2 impliedPrice endpoint) -| Required? | Name | Description | Options | Defaults to | -| :-------: | :------------------: | :-----------------------------------------------------------------------------------------------------: | :----------------------: | :---------: | -| ✅ | `operand1Sources` | An array (string[]) or comma delimited list (string) of source adapters to query for the operand1 value | | | -| | `operand1MinAnswers` | The minimum number of answers needed to return a value for the operand1 | | `1` | -| ✅ | `operand1Input` | The payload to send to the operand1 sources | | | -| ✅ | `operand2Sources` | An array (string[]) or comma delimited list (string) of source adapters to query for the operand2 value | | | -| | `operand2MinAnswers` | The minimum number of answers needed to return a value for the operand2 | | `1` | -| ✅ | `operand2Input` | The payload to send to the operand2 sources | | | -| ✅ | `operation` | The payload to send to the operand2 sources | `"divide"`, `"multiply"` | | +| Required? | Name | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :------------------: | :------------------------------------------: | :--------: | :------------------: | :------: | :--------: | :------------: | +| ✅ | `dividendSources` | Array of source adapters for dividend values | `string[]` | | | | | +| | `dividendMinAnswers` | Minimum required dividend responses | `number` | | `1` | | | +| ✅ | `dividendInput` | JSON string payload for dividend sources | `string` | | | | | +| ✅ | `divisorSources` | Array of source adapters for divisor values | `string[]` | | | | | +| | `divisorMinAnswers` | Minimum required divisor responses | `number` | | `1` | | | +| ✅ | `divisorInput` | JSON string payload for divisor sources | `string` | | | | | +| | `operation` | Mathematical operation to perform | `string` | `divide`, `multiply` | `divide` | | | -Each source in `sources` needs to have a defined `*_ADAPTER_URL` defined as an env var. +> **Note**: Use either Format 1 (operand1/operand2) OR Format 2 (dividend/divisor) - do not mix parameter names from different formats. -_E.g. for a request with `"operand1Sources": ["coingecko", "coinpaprika"]`, you will need to have pre-set the following env vars:_ +### Input Formats +**Sources**: Array of adapter names + +```javascript +;['coingecko', 'coinpaprika', 'coinbase'] ``` -COINGECKO_ADAPTER_URL=https://coingecko_adapter_url/ -COINPAPRIKA_ADAPTER_URL=https://coinpaprika_adapter_url/ + +**Input Objects**: JSON string containing the payload for source adapters + +```javascript +JSON.stringify({ + base: 'ETH', // or "from": "ETH" + quote: 'USD', // or "to": "USD" + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, +}) ``` -#### Sample Input +**Operation**: The mathematical operation to perform + +- `"divide"` - Returns `dividend / divisor` (implied price calculation) +- `"multiply"` - Returns `dividend * divisor` (price multiplication) + +## Sample Input + +### Format 1: operand1/operand2 (v2 computedPrice endpoint) + +**Division Example:** ```json { - "id": "1", "data": { - "operand1Sources": ["coingecko"], - "operand2Sources": ["coingecko"], - "operand1Input": { - "from": "LINK", - "to": "USD", - "overrides": { - "coingecko": { - "LINK": "chainlink" - } - } - }, - "operand2Input": { - "from": "ETH", - "to": "USD", - "overrides": { - "coingecko": { - "ETH": "ethereum" - } - } - }, + "operand1Sources": ["coingecko", "coinpaprika"], + "operand2Sources": ["coingecko", "coinpaprika"], + "operand1Input": "{\"base\":\"ETH\",\"quote\":\"USD\"}", + "operand2Input": "{\"base\":\"BTC\",\"quote\":\"USD\"}", "operation": "divide" } } ``` -#### Sample Output +**Multiplication Example:** ```json { - "jobRunID": "1", - "result": "0.005204390891874140333", - "statusCode": 200, "data": { - "result": "0.005204390891874140333" + "operand1Sources": ["coingecko"], + "operand2Sources": ["coingecko"], + "operand1Input": "{\"from\":\"LINK\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"LINK\":\"chainlink\"}}}", + "operand2Input": "{\"from\":\"ETH\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"ETH\":\"ethereum\"}}}", + "operation": "multiply" } } ``` -### impliedPrice endpoint +### Format 2: dividend/divisor (v2 impliedPrice endpoint) -This legacy endpoint is the default endpoint for backward compatibility. +**Division Example (Implied Price):** -#### Input Params +```json +{ + "data": { + "dividendSources": ["coingecko", "coinpaprika"], + "divisorSources": ["coingecko", "coinpaprika"], + "dividendInput": "{\"base\":\"ETH\",\"quote\":\"USD\"}", + "divisorInput": "{\"base\":\"BTC\",\"quote\":\"USD\"}", + "operation": "divide" + } +} +``` -| Required? | Name | Description | Options | Defaults to | -| :-------: | :------------------: | :-----------------------------------------------------------------------------------------------------: | :-----: | :---------: | -| ✅ | `dividendSources` | An array (string[]) or comma delimited list (string) of source adapters to query for the dividend value | | | -| | `dividendMinAnswers` | The minimum number of answers needed to return a value for the dividend | | `1` | -| ✅ | `dividendInput` | The payload to send to the dividend sources | | | -| ✅ | `divisorSources` | An array (string[]) or comma delimited list (string) of source adapters to query for the divisor value | | | -| | `divisorMinAnswers` | The minimum number of answers needed to return a value for the divisor | | `1` | -| ✅ | `divisorInput` | The payload to send to the divisor sources | | | +**Multiplication Example:** -Each source in `sources` needs to have a defined `*_ADAPTER_URL` defined as an env var. +```json +{ + "data": { + "dividendSources": ["coingecko"], + "divisorSources": ["coingecko"], + "dividendInput": "{\"base\":\"ETH\",\"quote\":\"USD\"}", + "divisorInput": "{\"base\":\"BTC\",\"quote\":\"USD\"}", + "operation": "multiply" + } +} +``` -_E.g. for a request with `"dividendSources": ["coingecko", "coinpaprika"]`, you will need to have pre-set the following env vars:_ +**Division with Default Operation (operation omitted):** -``` -COINGECKO_ADAPTER_URL=https://coingecko_adapter_url/ -COINPAPRIKA_ADAPTER_URL=https://coinpaprika_adapter_url/ +```json +{ + "data": { + "dividendSources": ["coingecko"], + "divisorSources": ["coingecko"], + "dividendInput": "{\"from\":\"LINK\",\"to\":\"USD\"}", + "divisorInput": "{\"from\":\"ETH\",\"to\":\"USD\"}" + } +} ``` -#### Sample Input +**Default Endpoint (no endpoint specified - v2 behavior):** ```json { - "id": "1", "data": { "dividendSources": ["coingecko"], "divisorSources": ["coingecko"], - "dividendInput": { - "from": "LINK", - "to": "USD", - "overrides": { - "coingecko": { - "LINK": "chainlink" - } - } - }, - "divisorInput": { - "from": "ETH", - "to": "USD", - "overrides": { - "coingecko": { - "ETH": "ethereum" - } - } - } + "dividendInput": "{\"from\":\"LINK\",\"to\":\"USD\"}", + "divisorInput": "{\"from\":\"ETH\",\"to\":\"USD\"}" } } ``` -#### Sample Output +> When using Format 2 (dividend/divisor), the `operation` parameter is optional and defaults to `"divide"`. When no endpoint is specified, it defaults to `impliedPrice` for full v2 backward compatibility. + +## Sample Output ```json { - "jobRunID": "1", - "result": "0.005204390891874140333", + "result": 0.0652, "statusCode": 200, "data": { - "result": "0.005204390891874140333" + "result": 0.0652 } } ``` + +## Calculation + +The adapter performs the following steps regardless of parameter format: + +1. **Data Fetching**: Fetches price data from the first set of sources using the first input payload + + - Format 1: `operand1Sources` with `operand1Input` + - Format 2: `dividendSources` with `dividendInput` + +2. **Data Fetching**: Fetches price data from the second set of sources using the second input payload + + - Format 1: `operand2Sources` with `operand2Input` + - Format 2: `divisorSources` with `divisorInput` + +3. **Median Calculation**: Calculates the median of each set of responses + +4. **Mathematical Operation**: Performs the specified operation: + - **Divide**: `first_median / second_median` (or `dividend_median / divisor_median`) + - **Multiply**: `first_median * second_median` (or `dividend_median * divisor_median`) + +## Reliability Features + +- **Circuit Breaker**: Automatically disables failing sources after consecutive failures +- **Retry Logic**: Configurable retry attempts with exponential backoff +- **Request Coalescing**: Prevents duplicate requests to the same source +- **Request Timeouts**: Configurable timeouts for source responses + +## Usage + +The adapter is designed to calculate implied prices and perform mathematical operations on price data from multiple sources. Common use cases include: + +- **Implied Price Calculation**: Calculate ETH/BTC implied price by dividing ETH/USD by BTC/USD +- **Portfolio Valuation**: Multiply token quantities by their USD prices +- **Cross-Rate Calculation**: Derive exchange rates between different currency pairs +- **Risk Metrics**: Calculate price ratios for volatility analysis + +The adapter supports both `from`/`to` and `base`/`quote` parameter formats for maximum compatibility with different price sources. + +## Testing + +The adapter includes comprehensive test coverage with 51 tests (37 unit tests and 14 integration tests): + +### Unit Tests + +- **`calculateMedian`**: Tests median calculation with various scenarios (odd/even arrays, sorting, precision, edge cases) +- **`normalizeInput`**: Tests input format conversion, property preservation, error handling +- **`parseSources`**: Tests array/string parsing, comma-delimited strings, whitespace handling + +### Integration Tests + +- **Backward Compatibility**: Full tests for both v2 parameter formats (`operand1`/`operand2` and `dividend`/`divisor`) +- **Full Request Cycle**: Tests complete adapter functionality with mocked HTTP transport +- **Operation Support**: Tests both `divide` and `multiply` operations with various data combinations +- **Input Format Compatibility**: Tests both `base`/`quote` and `from`/`to` formats +- **Override Support**: Tests that override configurations work correctly with both operations +- **Error Handling**: Comprehensive error scenario testing with proper status codes (400, 422, 500, etc.) +- **Edge Cases**: Tests invalid operations, missing parameters, various crypto pairs, and malformed inputs + +### Running Tests + +```bash +# All tests (51 total) +yarn test packages/composites/implied-price + +# Unit tests only (37 tests) +yarn test packages/composites/implied-price/test/unit + +# Integration tests only (14 tests) +yarn test packages/composites/implied-price/test/integration + +# Specific test pattern +yarn test packages/composites/implied-price --testNamePattern="median" +``` + +## Development + +### Building + +```bash +# From adapter directory +yarn build + +# From project root +yarn build +``` + +### Production Deployment + +For production deployment, configure the following environment variables for each source adapter you plan to use: + +```bash +COINGECKO_ADAPTER_URL=http://localhost:8080/coingecko +COINPAPRIKA_ADAPTER_URL=http://localhost:8080/coinpaprika +COINBASE_ADAPTER_URL=http://localhost:8080/coinbase +``` + +The adapter includes built-in reliability features such as circuit breakers, retry logic, and request coalescing that are automatically enabled in production environments. diff --git a/packages/composites/implied-price/package.json b/packages/composites/implied-price/package.json index 00746251d8..d963264062 100644 --- a/packages/composites/implied-price/package.json +++ b/packages/composites/implied-price/package.json @@ -30,18 +30,13 @@ "start": "yarn server:dist" }, "dependencies": { - "@chainlink/ea-bootstrap": "workspace:*", - "@chainlink/ea-test-helpers": "workspace:*", - "axios": "1.9.0", - "decimal.js": "^10.3.1", - "tslib": "^2.3.1" + "@chainlink/external-adapter-framework": "2.7.0", + "decimal.js": "^10.3.1" }, "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "22.14.1", - "@types/supertest": "2.0.16", "nock": "13.5.6", - "supertest": "6.2.4", "typescript": "5.8.3" } } diff --git a/packages/composites/implied-price/src/adapter.ts b/packages/composites/implied-price/src/adapter.ts deleted file mode 100644 index be86e65fd1..0000000000 --- a/packages/composites/implied-price/src/adapter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Builder } from '@chainlink/ea-bootstrap' -import type { - AdapterRequest, - APIEndpoint, - Config, - ExecuteFactory, - ExecuteWithConfig, -} from '@chainlink/ea-bootstrap' -import * as endpoints from './endpoint' -import { makeConfig } from './config' - -export const execute: ExecuteWithConfig = async ( - request, - context, - config, -) => { - return Builder.buildSelector( - request, - context, - config, - endpoints, - ) -} - -export const endpointSelector = ( - request: AdapterRequest, -): APIEndpoint => - Builder.selectEndpoint(request, makeConfig(), endpoints) - -export const makeExecute: ExecuteFactory = (config) => { - return async (request, context) => execute(request, context, config || makeConfig()) -} diff --git a/packages/composites/implied-price/src/config/index.ts b/packages/composites/implied-price/src/config/index.ts index 8331d14276..3db438c7c7 100644 --- a/packages/composites/implied-price/src/config/index.ts +++ b/packages/composites/implied-price/src/config/index.ts @@ -1,12 +1,63 @@ -import { Requester } from '@chainlink/ea-bootstrap' -import type { Config } from '@chainlink/ea-bootstrap' +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' export const NAME = 'IMPLIED_PRICE' export const DEFAULT_ENDPOINT = 'impliedPrice' -export const makeConfig = (prefix?: string): Config => { - return { - ...Requester.getDefaultConfig(prefix), - defaultEndpoint: DEFAULT_ENDPOINT, - } -} +export const config = new AdapterConfig( + { + SOURCE_TIMEOUT: { + description: 'The number of milliseconds to wait for source adapter responses', + type: 'number', + default: 10000, + }, + MAX_RETRIES: { + description: 'Maximum number of retries for failed source requests', + type: 'number', + default: 3, + }, + RETRY_DELAY: { + description: 'Delay between retries in milliseconds', + type: 'number', + default: 1000, + }, + SOURCE_CIRCUIT_BREAKER_THRESHOLD: { + description: 'Number of consecutive failures before activating circuit breaker for a source', + type: 'number', + default: 5, + }, + SOURCE_CIRCUIT_BREAKER_TIMEOUT: { + description: 'Circuit breaker timeout in milliseconds', + type: 'number', + default: 60000, + }, + REQUEST_COALESCING_ENABLED: { + description: 'Enable request coalescing to avoid duplicate requests to same sources', + type: 'boolean', + default: true, + }, + REQUEST_COALESCING_INTERVAL: { + description: 'Interval in milliseconds for request coalescing', + type: 'number', + default: 100, + }, + WARMUP_ENABLED: { + description: 'Enable warmup requests on startup', + type: 'boolean', + default: false, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10000, + }, + }, + { + envDefaultOverrides: { + CACHE_MAX_AGE: 60_000, + BACKGROUND_EXECUTE_TIMEOUT: 40_000, + METRICS_ENABLED: true, + API_TIMEOUT: 30000, + }, + }, +) diff --git a/packages/composites/implied-price/src/endpoint/computedPrice.ts b/packages/composites/implied-price/src/endpoint/computedPrice.ts index 3a2eb90c9d..3dee40c356 100644 --- a/packages/composites/implied-price/src/endpoint/computedPrice.ts +++ b/packages/composites/implied-price/src/endpoint/computedPrice.ts @@ -1,224 +1,119 @@ -import type { - AdapterRequest, - AxiosRequestConfig, - Config, - ExecuteWithConfig, - InputParameters, -} from '@chainlink/ea-bootstrap' -import { - AdapterError, - AdapterInputError, - AdapterResponseInvalidError, - Requester, - util, - Validator, -} from '@chainlink/ea-bootstrap' -import { AxiosResponse } from 'axios' -import Decimal from 'decimal.js' +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { transport } from '../transport/computedPrice' -export const supportedEndpoints = ['computedPrice'] - -export type SourceRequestOptions = { [source: string]: AxiosRequestConfig } - -export type TInputParameters = { - operand1Sources: string | string[] - operand1MinAnswers?: number - operand1Input: AdapterRequest - operand2Sources: string | string[] - operand2MinAnswers?: number - operand2Input: AdapterRequest - operation: string -} - -const inputParameters: InputParameters = { - operand1Sources: { - required: true, - description: - 'An array (string[]) or comma delimited list (string) of source adapters to query for the operand1 value', - }, - operand1MinAnswers: { - required: false, - type: 'number', - description: 'The minimum number of answers needed to return a value for the operand1', - default: 1, - }, - operand1Input: { - required: true, - type: 'object', - description: 'The payload to send to the operand1 sources', - }, - operand2Sources: { - required: true, - description: - 'An array (string[]) or comma delimited list (string) of source adapters to query for the operand2 value', - }, - operand2MinAnswers: { - required: false, - type: 'number', - description: 'The minimum number of answers needed to return a value for the operand2', - default: 1, +export const inputParameters = new InputParameters( + { + operand1Sources: { + required: true, + type: 'string', + array: true, + description: + 'An array (string[]) or comma delimited list (string) of source adapters to query for the operand1 value', + }, + operand1MinAnswers: { + required: false, + type: 'number', + description: 'The minimum number of answers needed to return a value for the operand1', + default: 1, + }, + operand1Input: { + required: true, + type: 'string', + description: 'The payload to send to the operand1 sources', + }, + operand2Sources: { + required: true, + type: 'string', + array: true, + description: + 'An array (string[]) or comma delimited list (string) of source adapters to query for the operand2 value', + }, + operand2MinAnswers: { + required: false, + type: 'number', + description: 'The minimum number of answers needed to return a value for the operand2', + default: 1, + }, + operand2Input: { + required: true, + type: 'string', + description: 'The payload to send to the operand2 sources', + }, + operation: { + required: true, + type: 'string', + description: 'The operation to perform on the operands', + options: ['divide', 'multiply'], + }, }, - operand2Input: { - required: true, - type: 'object', - description: 'The payload to send to the operand2 sources', - }, - operation: { - required: true, - type: 'string', - description: 'The operation to perform on the operands', - options: ['divide', 'multiply'], - }, -} - -export const execute: ExecuteWithConfig = (input, _, config) => { - const validator = new Validator(input, inputParameters) - return executeComputedPrice(validator.validated.id, validator.validated.data, config) -} - -export const getOperandSourceUrls = ({ - sources, - minAnswers, -}: { - sources: string[] - minAnswers: number -}): string[] => { - if (sources.length < minAnswers) { - throw new AdapterInputError({ - statusCode: 400, - message: `Not enough sources: got ${sources.length} sources, requiring at least ${minAnswers} answers`, - }) - } - const urls = sources - .map((source) => util.getURL(source.toUpperCase())) - .filter((url) => url !== undefined) - const missingUrlCount = minAnswers - urls.length - if (missingUrlCount > 0) { - const missingEnvVars = sources - .map((source) => `${source.toUpperCase()}_${util.ENV_ADAPTER_URL}`) - .filter((envVar) => util.getEnv(envVar) === undefined) - throw new AdapterError({ - statusCode: 500, - message: `Not enough sources configured. Make sure ${missingUrlCount} of the following are set in the environment: ${missingEnvVars.join( - ', ', - )}`, - }) - } - return urls -} - -export const executeComputedPrice = async ( - validatedId: string, - validatedData: TInputParameters, - config: Config, -) => { - const jobRunID = validatedId - const operand1Sources = parseSources(validatedData.operand1Sources) - const operand2Sources = parseSources(validatedData.operand2Sources) - const operand1MinAnswers = validatedData.operand1MinAnswers as number - const operand2MinAnswers = validatedData.operand2MinAnswers as number - const operand1Input = validatedData.operand1Input - const operand2Input = validatedData.operand2Input - const operation = validatedData.operation.toLowerCase() - // TODO: non-nullable default types - - const operand1Urls = getOperandSourceUrls({ - sources: operand1Sources, - minAnswers: operand1MinAnswers, - }) - const operand1Result = await getExecuteMedian( - jobRunID, - operand1Urls, - operand1Input, - operand1MinAnswers, - config, - ) - if (operand1Result.isZero()) { - throw new AdapterResponseInvalidError({ message: 'Operand 1 result is zero' }) - } - - const operand2Urls = getOperandSourceUrls({ - sources: operand2Sources, - minAnswers: operand2MinAnswers, - }) - const operand2Result = await getExecuteMedian( - jobRunID, - operand2Urls, - operand2Input, - operand2MinAnswers, - config, - ) - if (operand2Result.isZero()) { - throw new AdapterResponseInvalidError({ message: 'Operand 2 result is zero' }) - } - - let result: Decimal - if (operation === 'divide') { - result = operand1Result.div(operand2Result) - } else if (operation === 'multiply') { - result = operand1Result.mul(operand2Result) - } else { - throw new AdapterError({ - message: `Unsupported operation: ${operation}. This should not be possible because of input validation.`, - }) - } - - const data = { - operand1Result: operand1Result.toString(), - operand2Result: operand2Result.toString(), - result: result.toFixed(), - } - - const response = { data, status: 200 } - return Requester.success(jobRunID, response) -} - -export const parseSources = (sources: string | string[]): string[] => { - if (Array.isArray(sources)) { - return sources - } - return sources.split(',') -} - -const getExecuteMedian = async ( - jobRunID: string, - urls: string[], - request: AdapterRequest, - minAnswers: number, - config: Config, -): Promise => { - const responses = await Promise.allSettled( - urls.map( - async (url) => - await Requester.request({ - ...config.api, - method: 'post', - url, - data: { - id: jobRunID, - data: request, + [ + // Example using operand1/operand2 format - division + { + operand1Sources: ['coingecko'], + operand1MinAnswers: 1, + operand1Input: JSON.stringify({ + from: 'LINK', + to: 'USD', + overrides: { + coingecko: { + LINK: 'chainlink', }, - }), - ), - ) - const values = responses - .filter((result) => result.status === 'fulfilled' && 'value' in result) - .map( - (result) => - (result as PromiseFulfilledResult>>).value.data.result, - ) - if (values.length < minAnswers) - throw new AdapterResponseInvalidError({ - jobRunID, - message: `Not returning median: got ${values.length} answers, requiring min. ${minAnswers} answers`, - }) - return median(values) -} + }, + }), + operand2Sources: ['coingecko'], + operand2MinAnswers: 1, + operand2Input: JSON.stringify({ + from: 'ETH', + to: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'divide', + } as any, + // Example using operand1/operand2 format - multiplication + { + operand1Sources: ['coinbase', 'coingecko'], + operand1MinAnswers: 1, + operand1Input: JSON.stringify({ + base: 'ETH', + quote: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operand2Sources: ['coinbase', 'coingecko'], + operand2MinAnswers: 1, + operand2Input: JSON.stringify({ + base: 'BTC', + quote: 'USD', + overrides: { + coingecko: { + BTC: 'bitcoin', + }, + }, + }), + operation: 'multiply', + } as any, + ] as any, +) -export const median = (values: number[]): Decimal => { - if (values.length === 0) return new Decimal(0) - values.sort((a, b) => a - b) - const half = Math.floor(values.length / 2) - if (values.length % 2) return new Decimal(values[half]) - return new Decimal(values[half - 1] + values[half]).div(2) +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings } + +export const endpoint = new AdapterEndpoint({ + name: 'computedPrice', + aliases: ['computed'], + transport, + inputParameters, +}) diff --git a/packages/composites/implied-price/src/endpoint/impliedPrice.ts b/packages/composites/implied-price/src/endpoint/impliedPrice.ts index fc842024f2..b0386c3d33 100644 --- a/packages/composites/implied-price/src/endpoint/impliedPrice.ts +++ b/packages/composites/implied-price/src/endpoint/impliedPrice.ts @@ -1,82 +1,146 @@ -import { Validator } from '@chainlink/ea-bootstrap' -import type { - AdapterRequest, - ExecuteWithConfig, - Config, - InputParameters, - AxiosRequestConfig, -} from '@chainlink/ea-bootstrap' -import { executeComputedPrice } from './computedPrice' +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { transport } from '../transport/impliedPrice' -export const supportedEndpoints = ['impliedPrice'] - -export type SourceRequestOptions = { [source: string]: AxiosRequestConfig } - -export type TInputParameters = { - dividendSources: string | string[] - dividendMinAnswers?: number - dividendInput: AdapterRequest - divisorSources: string | string[] - divisorMinAnswers?: number - divisorInput: AdapterRequest -} - -const inputParameters: InputParameters = { - dividendSources: { - required: true, - description: - 'An array (string[]) or comma delimited list (string) of source adapters to query for the dividend value', - }, - dividendMinAnswers: { - required: false, - type: 'number', - description: 'The minimum number of answers needed to return a value for the dividend', - default: 1, - }, - dividendInput: { - required: true, - type: 'object', - description: 'The payload to send to the dividend sources', - }, - divisorSources: { - required: true, - description: - 'An array (string[]) or comma delimited list (string) of source adapters to query for the divisor value', +export const inputParameters = new InputParameters( + { + dividendSources: { + required: true, + type: 'string', + array: true, + description: + 'An array (string[]) or comma delimited list (string) of source adapters to query for the dividend value', + }, + dividendMinAnswers: { + required: false, + type: 'number', + description: 'The minimum number of answers needed to return a value for the dividend', + default: 1, + }, + dividendInput: { + required: true, + type: 'string', + description: 'The payload to send to the dividend sources', + }, + divisorSources: { + required: true, + type: 'string', + array: true, + description: + 'An array (string[]) or comma delimited list (string) of source adapters to query for the divisor value', + }, + divisorMinAnswers: { + required: false, + type: 'number', + description: 'The minimum number of answers needed to return a value for the divisor', + default: 1, + }, + divisorInput: { + required: true, + type: 'string', + description: 'The payload to send to the divisor sources', + }, + operation: { + required: false, + type: 'string', + description: 'The operation to perform on the operands', + options: ['divide', 'multiply'], + default: 'divide', + }, }, - divisorMinAnswers: { - required: false, - type: 'number', - description: 'The minimum number of answers needed to return a value for the divisor', - default: 1, - }, - divisorInput: { - required: true, - type: 'object', - description: 'The payload to send to the divisor sources', - }, -} - -export const execute: ExecuteWithConfig = async (input, _, config) => { - const validator = new Validator(input, inputParameters) + [ + // Example using dividend/divisor format - implied price (operation defaults to divide) + { + dividendSources: ['coingecko'], + dividendMinAnswers: 1, + dividendInput: JSON.stringify({ + from: 'LINK', + to: 'USD', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + divisorSources: ['coingecko'], + divisorMinAnswers: 1, + divisorInput: JSON.stringify({ + from: 'ETH', + to: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + // operation defaults to 'divide' for backward compatibility + } as any, + // Example using dividend/divisor format - explicit division + { + dividendSources: ['coinbase', 'coingecko'], + dividendMinAnswers: 1, + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + divisorSources: ['coinbase', 'coingecko'], + divisorMinAnswers: 1, + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + overrides: { + coingecko: { + BTC: 'bitcoin', + }, + }, + }), + operation: 'divide', + } as any, + // Example using dividend/divisor format - multiplication + { + dividendSources: ['coingecko'], + dividendMinAnswers: 1, + dividendInput: JSON.stringify({ + from: 'LINK', + to: 'USD', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + }), + divisorSources: ['coingecko'], + divisorMinAnswers: 1, + divisorInput: JSON.stringify({ + from: 'ETH', + to: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', + }, + }, + }), + operation: 'multiply', + } as any, + ] as any, +) - const { - dividendSources, - dividendMinAnswers, - dividendInput, - divisorSources, - divisorMinAnswers, - divisorInput, - } = validator.validated.data - - const validatedData = { - operand1Sources: dividendSources, - operand1MinAnswers: dividendMinAnswers, - operand1Input: dividendInput, - operand2Sources: divisorSources, - operand2MinAnswers: divisorMinAnswers, - operand2Input: divisorInput, - operation: 'divide', - } - - return executeComputedPrice(validator.validated.id, validatedData, config) +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings } + +export const endpoint = new AdapterEndpoint({ + name: 'impliedPrice', + aliases: ['implied'], + transport, + inputParameters, +}) diff --git a/packages/composites/implied-price/src/endpoint/index.ts b/packages/composites/implied-price/src/endpoint/index.ts index fddd5151fa..02b5997372 100644 --- a/packages/composites/implied-price/src/endpoint/index.ts +++ b/packages/composites/implied-price/src/endpoint/index.ts @@ -1,6 +1,2 @@ -import * as impliedPrice from './impliedPrice' - -export type TInputParameters = impliedPrice.TInputParameters - -export * as impliedPrice from './impliedPrice' -export * as computedPrice from './computedPrice' +export { endpoint as computedPrice } from './computedPrice' +export { endpoint as impliedPrice } from './impliedPrice' diff --git a/packages/composites/implied-price/src/index.ts b/packages/composites/implied-price/src/index.ts index f66570010f..d090ccc375 100644 --- a/packages/composites/implied-price/src/index.ts +++ b/packages/composites/implied-price/src/index.ts @@ -1,8 +1,13 @@ -import { expose } from '@chainlink/ea-bootstrap' -import { makeExecute } from './adapter' -import { makeConfig, NAME } from './config' +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config, DEFAULT_ENDPOINT, NAME } from './config' +import { computedPrice, impliedPrice } from './endpoint' -const adapterContext = { name: NAME } +export const adapter = new Adapter({ + defaultEndpoint: DEFAULT_ENDPOINT, + name: NAME, + config, + endpoints: [impliedPrice, computedPrice], +}) -const { server } = expose(adapterContext, makeExecute()) -export { NAME, makeExecute, makeConfig, server } +export const server = (): Promise => expose(adapter) diff --git a/packages/composites/implied-price/src/transport/computedPrice.ts b/packages/composites/implied-price/src/transport/computedPrice.ts new file mode 100644 index 0000000000..2a4612ea01 --- /dev/null +++ b/packages/composites/implied-price/src/transport/computedPrice.ts @@ -0,0 +1,389 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response' +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 { BaseEndpointTypes } from '../endpoint/computedPrice' +import { + PriceInput, + calculateMedian, + createRequestKey, + normalizeInput, + parseInput, + parseSources, +} from '../utils' + +const logger = makeLogger('ComputedPriceTransport') + +type RequestParams = { + operand1Sources: string[] + operand1MinAnswers: number + operand1Input: string + operand2Sources: string[] + operand2MinAnswers: number + operand2Input: string + operation: 'divide' | 'multiply' +} + +interface SourceResponse { + result: number + source: string + statusCode: number +} + +interface CircuitBreakerState { + failures: number + lastFailureTime: number + isOpen: boolean +} + +const circuitBreakerState = new Map() +const pendingRequests = new Map< + string, + { promise: Promise; timestamp: number } +>() + +export class ComputedPriceTransport extends SubscriptionTransport { + name!: string + responseCache!: ResponseCache + requester!: Requester + settings!: BaseEndpointTypes['Settings'] + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.responseCache = dependencies.responseCache + this.requester = dependencies.requester + this.settings = adapterSettings + this.name = transportName + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.CACHE_MAX_AGE + } + + /** + * Normalize operand1/operand2 parameters to dividend/divisor format for processing + */ + private normalizeParams(params: RequestParams): { + dividendSources: string | string[] + divisorSources: string | string[] + dividendInput: string + divisorInput: string + dividendMinAnswers: number + divisorMinAnswers: number + operation: string + } { + // operand1/operand2 format (computedPrice endpoint) - all parameters are required + return { + dividendSources: params.operand1Sources, + divisorSources: params.operand2Sources, + dividendInput: params.operand1Input, + divisorInput: params.operand2Input, + dividendMinAnswers: params.operand1MinAnswers || 1, + divisorMinAnswers: params.operand2MinAnswers || 1, + operation: params.operation, + } + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (entry) => this.handleSingleRequest(entry))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleSingleRequest(params: RequestParams): Promise { + try { + // Normalize parameters to dividend/divisor format for processing + const normalizedParams = this.normalizeParams(params) + + const dividendSources = parseSources(normalizedParams.dividendSources) + const divisorSources = parseSources(normalizedParams.divisorSources) + + const parsedDividendInput = parseInput(normalizedParams.dividendInput, 'dividendInput') + const parsedDivisorInput = parseInput(normalizedParams.divisorInput, 'divisorInput') + + const normalizedDividendInput = normalizeInput(parsedDividendInput) + const normalizedDivisorInput = normalizeInput(parsedDivisorInput) + + const dividendMinAnswers = normalizedParams.dividendMinAnswers || 1 + const divisorMinAnswers = normalizedParams.divisorMinAnswers || 1 + const operation = normalizedParams.operation.toLowerCase() + + logger.debug( + `Processing computed price calculation: ${JSON.stringify({ + dividendSources, + divisorSources, + normalizedDividendInput, + normalizedDivisorInput, + dividendMinAnswers, + divisorMinAnswers, + operation, + })}`, + ) + + // Fetch data from actual source adapters + const [dividendResults, divisorResults] = await Promise.all([ + this.fetchFromMultipleSources(dividendSources, normalizedDividendInput, dividendMinAnswers), + this.fetchFromMultipleSources(divisorSources, normalizedDivisorInput, divisorMinAnswers), + ]) + + // Calculate medians using the real median logic + const dividendMedian = calculateMedian(dividendResults.map((r) => r.result)) + const divisorMedian = calculateMedian(divisorResults.map((r) => r.result)) + + if (dividendMedian.isZero()) { + throw new Error('Dividend result is zero') + } + + if (divisorMedian.isZero()) { + throw new Error('Divisor result is zero') + } + + let result: typeof dividendMedian + if (operation === 'divide') { + result = dividendMedian.div(divisorMedian) + } else if (operation === 'multiply') { + result = dividendMedian.mul(divisorMedian) + } else { + throw new Error( + `Unsupported operation: ${operation}. This should not be possible because of input validation.`, + ) + } + + const computedPrice = Number(result.toFixed()) + + logger.info( + `Computed price calculated successfully: ${JSON.stringify({ + dividendResponses: dividendResults.length, + divisorResponses: divisorResults.length, + operation, + result: computedPrice, + })}`, + ) + + const response: AdapterResponse = { + result: computedPrice, + statusCode: 200, + data: { + result: computedPrice, + }, + timestamps: { + providerIndicatedTimeUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + }, + } + + await this.responseCache.write(this.name, [{ params, response }]) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`Failed to calculate computed price: ${JSON.stringify({ error: errorMessage })}`) + + let statusCode = 500 + if (errorMessage.includes('zero')) { + statusCode = 422 + } else if (errorMessage.includes('Invalid JSON') || errorMessage.includes('required')) { + statusCode = 400 + } else if (errorMessage.includes('Insufficient responses')) { + statusCode = 502 + } else if (errorMessage.includes('No URL configured')) { + statusCode = 503 + } else if (errorMessage.includes('timeout')) { + statusCode = 504 + } else if (errorMessage.includes('Unsupported operation')) { + statusCode = 400 + } + + const errorResponse: AdapterResponse = { + statusCode, + errorMessage, + timestamps: { + providerIndicatedTimeUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + }, + } + + await this.responseCache.write(this.name, [{ params, response: errorResponse }]) + } + } + + private async fetchFromMultipleSources( + sources: string[], + input: PriceInput, + minAnswers: number, + ): Promise { + const sourcePromises = sources.map(async (source) => { + const requestKey = createRequestKey(source, input) + + // Check for existing pending request (request coalescing) + const existingRequest = pendingRequests.get(requestKey) + const coalescingInterval = this.settings.REQUEST_COALESCING_INTERVAL || 100 + + if (existingRequest && Date.now() - existingRequest.timestamp < coalescingInterval) { + logger.debug(`Using coalesced request for ${source}`) + return existingRequest.promise + } else { + const promise = this.fetchFromSourceAdapter(source, input).catch((error) => { + logger.error( + `Source ${source} failed: ${JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error', + })}`, + ) + return null + }) + + pendingRequests.set(requestKey, { promise, timestamp: Date.now() }) + // Clean up after coalescing interval + setTimeout(() => pendingRequests.delete(requestKey), coalescingInterval) + return promise + } + }) + + const results = await Promise.all(sourcePromises) + const successfulResults = results.filter((result): result is SourceResponse => result !== null) + + if (successfulResults.length < minAnswers) { + throw new Error( + `Insufficient responses: got ${successfulResults.length}, required ${minAnswers}`, + ) + } + + return successfulResults + } + + private async fetchFromSourceAdapter(source: string, input: PriceInput): Promise { + if (this.isCircuitBreakerOpen(source)) { + throw new Error(`Circuit breaker is open for source: ${source}`) + } + + const sourceUrl = this.getSourceAdapterUrl(source) + const maxRetries = this.settings.MAX_RETRIES || 3 + const retryDelay = this.settings.RETRY_DELAY || 1000 + const timeout = this.settings.SOURCE_TIMEOUT || 10000 + + let lastError: Error | null = null + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = retryDelay * Math.pow(2, attempt - 1) + await sleep(delay) + logger.debug(`Retrying request to ${source}, attempt ${attempt}/${maxRetries}`) + } + + const requestConfig = { + method: 'POST' as const, + baseURL: sourceUrl, + url: '/', + data: { data: input }, + timeout, + headers: { 'Content-Type': 'application/json' }, + } + + const requesterResult = await this.requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + + const response = requesterResult.response + const statusCode = response.status || 200 + const responseData = response.data as any + + if (statusCode === 200 && responseData?.result !== undefined) { + this.updateCircuitBreakerState(source, true) + return { + result: responseData.result, + source, + statusCode, + } + } else { + throw new Error(`Invalid response from ${source}: status ${statusCode}, no result data`) + } + } catch (error) { + lastError = error as Error + + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + if (errorMessage.includes('timeout') || errorMessage.includes('ECONNABORTED')) { + logger.warn(`Timeout requesting from ${source}: ${JSON.stringify({ timeout, attempt })}`) + } else if (errorMessage.includes('status')) { + logger.warn( + `HTTP error from ${source}: ${JSON.stringify({ error: errorMessage, attempt })}`, + ) + } else { + logger.warn( + `Network error requesting from ${source}: ${JSON.stringify({ + message: errorMessage, + attempt, + })}`, + ) + } + } + } + + this.updateCircuitBreakerState(source, false) + throw lastError || new Error(`Failed to fetch from ${source} after ${maxRetries} retries`) + } + + private getSourceAdapterUrl(source: string): string { + const envKey = `${source.toUpperCase()}_ADAPTER_URL` + const url = this.settings[envKey as keyof BaseEndpointTypes['Settings']] || process.env[envKey] + + if (!url) { + throw new Error( + `No URL configured for source adapter: ${source}. Set ${envKey} environment variable.`, + ) + } + + return url as string + } + + private isCircuitBreakerOpen(source: string): boolean { + const state = circuitBreakerState.get(source) + if (!state || !state.isOpen) { + return false + } + + const timeoutMs = this.settings.SOURCE_CIRCUIT_BREAKER_TIMEOUT || 60000 + const timeSinceFailure = Date.now() - state.lastFailureTime + + if (timeSinceFailure > timeoutMs) { + state.isOpen = false + state.failures = 0 + return false + } + + return true + } + + private updateCircuitBreakerState(source: string, success: boolean): void { + const state = circuitBreakerState.get(source) || { + failures: 0, + lastFailureTime: 0, + isOpen: false, + } + + if (success) { + state.failures = 0 + state.isOpen = false + } else { + state.failures++ + state.lastFailureTime = Date.now() + + const threshold = this.settings.SOURCE_CIRCUIT_BREAKER_THRESHOLD || 5 + if (state.failures >= threshold) { + state.isOpen = true + logger.warn(`Circuit breaker opened for source ${source} after ${state.failures} failures`) + } + } + + circuitBreakerState.set(source, state) + } +} + +export const transport = new ComputedPriceTransport() diff --git a/packages/composites/implied-price/src/transport/impliedPrice.ts b/packages/composites/implied-price/src/transport/impliedPrice.ts new file mode 100644 index 0000000000..e2d7db690d --- /dev/null +++ b/packages/composites/implied-price/src/transport/impliedPrice.ts @@ -0,0 +1,389 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { ResponseCache } from '@chainlink/external-adapter-framework/cache/response' +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 { BaseEndpointTypes } from '../endpoint/impliedPrice' +import { + PriceInput, + calculateMedian, + createRequestKey, + normalizeInput, + parseInput, + parseSources, +} from '../utils' + +const logger = makeLogger('ImpliedPriceTransport') + +type RequestParams = { + dividendSources: string[] + dividendMinAnswers: number + dividendInput: string + divisorSources: string[] + divisorMinAnswers: number + divisorInput: string + operation: 'divide' | 'multiply' +} + +interface SourceResponse { + result: number + source: string + statusCode: number +} + +interface CircuitBreakerState { + failures: number + lastFailureTime: number + isOpen: boolean +} + +const circuitBreakerState = new Map() +const pendingRequests = new Map< + string, + { promise: Promise; timestamp: number } +>() + +export class ImpliedPriceTransport extends SubscriptionTransport { + name!: string + responseCache!: ResponseCache + requester!: Requester + settings!: BaseEndpointTypes['Settings'] + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.responseCache = dependencies.responseCache + this.requester = dependencies.requester + this.settings = adapterSettings + this.name = transportName + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.CACHE_MAX_AGE + } + + /** + * Extract parameters from dividend/divisor format (impliedPrice endpoint) + */ + private normalizeParams(params: RequestParams): { + dividendSources: string | string[] + divisorSources: string | string[] + dividendInput: string + divisorInput: string + dividendMinAnswers: number + divisorMinAnswers: number + operation: string + } { + // dividend/divisor format (impliedPrice endpoint) - operation defaults to 'divide' + return { + dividendSources: params.dividendSources, + divisorSources: params.divisorSources, + dividendInput: params.dividendInput, + divisorInput: params.divisorInput, + dividendMinAnswers: params.dividendMinAnswers || 1, + divisorMinAnswers: params.divisorMinAnswers || 1, + operation: params.operation || 'divide', + } + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (entry) => this.handleSingleRequest(entry))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleSingleRequest(params: RequestParams): Promise { + try { + // Normalize parameters to support both v2 endpoint formats + const normalizedParams = this.normalizeParams(params) + + const dividendSources = parseSources(normalizedParams.dividendSources) + const divisorSources = parseSources(normalizedParams.divisorSources) + + const parsedDividendInput = parseInput(normalizedParams.dividendInput, 'dividendInput') + const parsedDivisorInput = parseInput(normalizedParams.divisorInput, 'divisorInput') + + const normalizedDividendInput = normalizeInput(parsedDividendInput) + const normalizedDivisorInput = normalizeInput(parsedDivisorInput) + + const dividendMinAnswers = normalizedParams.dividendMinAnswers || 1 + const divisorMinAnswers = normalizedParams.divisorMinAnswers || 1 + const operation = normalizedParams.operation.toLowerCase() + + logger.debug( + `Processing computed price calculation: ${JSON.stringify({ + dividendSources, + divisorSources, + normalizedDividendInput, + normalizedDivisorInput, + dividendMinAnswers, + divisorMinAnswers, + operation, + })}`, + ) + + // Fetch data from actual source adapters + const [dividendResults, divisorResults] = await Promise.all([ + this.fetchFromMultipleSources(dividendSources, normalizedDividendInput, dividendMinAnswers), + this.fetchFromMultipleSources(divisorSources, normalizedDivisorInput, divisorMinAnswers), + ]) + + // Calculate medians using the real median logic + const dividendMedian = calculateMedian(dividendResults.map((r) => r.result)) + const divisorMedian = calculateMedian(divisorResults.map((r) => r.result)) + + if (dividendMedian.isZero()) { + throw new Error('Dividend result is zero') + } + + if (divisorMedian.isZero()) { + throw new Error('Divisor result is zero') + } + + let result: typeof dividendMedian + if (operation === 'divide') { + result = dividendMedian.div(divisorMedian) + } else if (operation === 'multiply') { + result = dividendMedian.mul(divisorMedian) + } else { + throw new Error( + `Unsupported operation: ${operation}. This should not be possible because of input validation.`, + ) + } + + const computedPrice = Number(result.toFixed()) + + logger.info( + `Computed price calculated successfully: ${JSON.stringify({ + dividendResponses: dividendResults.length, + divisorResponses: divisorResults.length, + operation, + result: computedPrice, + })}`, + ) + + const response: AdapterResponse = { + result: computedPrice, + statusCode: 200, + data: { + result: computedPrice, + }, + timestamps: { + providerIndicatedTimeUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + }, + } + + await this.responseCache.write(this.name, [{ params, response }]) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`Failed to calculate computed price: ${JSON.stringify({ error: errorMessage })}`) + + let statusCode = 500 + if (errorMessage.includes('zero')) { + statusCode = 422 + } else if (errorMessage.includes('Invalid JSON') || errorMessage.includes('required')) { + statusCode = 400 + } else if (errorMessage.includes('Insufficient responses')) { + statusCode = 502 + } else if (errorMessage.includes('No URL configured')) { + statusCode = 503 + } else if (errorMessage.includes('timeout')) { + statusCode = 504 + } else if (errorMessage.includes('Unsupported operation')) { + statusCode = 400 + } + + const errorResponse: AdapterResponse = { + statusCode, + errorMessage, + timestamps: { + providerIndicatedTimeUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + }, + } + + await this.responseCache.write(this.name, [{ params, response: errorResponse }]) + } + } + + private async fetchFromMultipleSources( + sources: string[], + input: PriceInput, + minAnswers: number, + ): Promise { + const sourcePromises = sources.map(async (source) => { + const requestKey = createRequestKey(source, input) + + // Check for existing pending request (request coalescing) + const existingRequest = pendingRequests.get(requestKey) + const coalescingInterval = this.settings.REQUEST_COALESCING_INTERVAL || 100 + + if (existingRequest && Date.now() - existingRequest.timestamp < coalescingInterval) { + logger.debug(`Using coalesced request for ${source}`) + return existingRequest.promise + } else { + const promise = this.fetchFromSourceAdapter(source, input).catch((error) => { + logger.error( + `Source ${source} failed: ${JSON.stringify({ + error: error instanceof Error ? error.message : 'Unknown error', + })}`, + ) + return null + }) + + pendingRequests.set(requestKey, { promise, timestamp: Date.now() }) + // Clean up after coalescing interval + setTimeout(() => pendingRequests.delete(requestKey), coalescingInterval) + return promise + } + }) + + const results = await Promise.all(sourcePromises) + const successfulResults = results.filter((result): result is SourceResponse => result !== null) + + if (successfulResults.length < minAnswers) { + throw new Error( + `Insufficient responses: got ${successfulResults.length}, required ${minAnswers}`, + ) + } + + return successfulResults + } + + private async fetchFromSourceAdapter(source: string, input: PriceInput): Promise { + if (this.isCircuitBreakerOpen(source)) { + throw new Error(`Circuit breaker is open for source: ${source}`) + } + + const sourceUrl = this.getSourceAdapterUrl(source) + const maxRetries = this.settings.MAX_RETRIES || 3 + const retryDelay = this.settings.RETRY_DELAY || 1000 + const timeout = this.settings.SOURCE_TIMEOUT || 10000 + + let lastError: Error | null = null + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = retryDelay * Math.pow(2, attempt - 1) + await sleep(delay) + logger.debug(`Retrying request to ${source}, attempt ${attempt}/${maxRetries}`) + } + + const requestConfig = { + method: 'POST' as const, + baseURL: sourceUrl, + url: '/', + data: { data: input }, + timeout, + headers: { 'Content-Type': 'application/json' }, + } + + const requesterResult = await this.requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + + const response = requesterResult.response + const statusCode = response.status || 200 + const responseData = response.data as any + + if (statusCode === 200 && responseData?.result !== undefined) { + this.updateCircuitBreakerState(source, true) + return { + result: responseData.result, + source, + statusCode, + } + } else { + throw new Error(`Invalid response from ${source}: status ${statusCode}, no result data`) + } + } catch (error) { + lastError = error as Error + + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + if (errorMessage.includes('timeout') || errorMessage.includes('ECONNABORTED')) { + logger.warn(`Timeout requesting from ${source}: ${JSON.stringify({ timeout, attempt })}`) + } else if (errorMessage.includes('status')) { + logger.warn( + `HTTP error from ${source}: ${JSON.stringify({ error: errorMessage, attempt })}`, + ) + } else { + logger.warn( + `Network error requesting from ${source}: ${JSON.stringify({ + message: errorMessage, + attempt, + })}`, + ) + } + } + } + + this.updateCircuitBreakerState(source, false) + throw lastError || new Error(`Failed to fetch from ${source} after ${maxRetries} retries`) + } + + private getSourceAdapterUrl(source: string): string { + const envKey = `${source.toUpperCase()}_ADAPTER_URL` + const url = this.settings[envKey as keyof BaseEndpointTypes['Settings']] || process.env[envKey] + + if (!url) { + throw new Error( + `No URL configured for source adapter: ${source}. Set ${envKey} environment variable.`, + ) + } + + return url as string + } + + private isCircuitBreakerOpen(source: string): boolean { + const state = circuitBreakerState.get(source) + if (!state || !state.isOpen) { + return false + } + + const timeoutMs = this.settings.SOURCE_CIRCUIT_BREAKER_TIMEOUT || 60000 + const timeSinceFailure = Date.now() - state.lastFailureTime + + if (timeSinceFailure > timeoutMs) { + state.isOpen = false + state.failures = 0 + return false + } + + return true + } + + private updateCircuitBreakerState(source: string, success: boolean): void { + const state = circuitBreakerState.get(source) || { + failures: 0, + lastFailureTime: 0, + isOpen: false, + } + + if (success) { + state.failures = 0 + state.isOpen = false + } else { + state.failures++ + state.lastFailureTime = Date.now() + + const threshold = this.settings.SOURCE_CIRCUIT_BREAKER_THRESHOLD || 5 + if (state.failures >= threshold) { + state.isOpen = true + logger.warn(`Circuit breaker opened for source ${source} after ${state.failures} failures`) + } + } + + circuitBreakerState.set(source, state) + } +} + +export const transport = new ImpliedPriceTransport() diff --git a/packages/composites/implied-price/src/utils/index.ts b/packages/composites/implied-price/src/utils/index.ts new file mode 100644 index 0000000000..6d609bf9b7 --- /dev/null +++ b/packages/composites/implied-price/src/utils/index.ts @@ -0,0 +1,108 @@ +import Decimal from 'decimal.js' + +export interface PriceInput { + base?: string + quote?: string + from?: string + to?: string + overrides?: Record> +} + +/** + * Parses source adapters from various input formats + * Supports both string arrays and comma-delimited strings + */ +export const parseSources = (sources: string | string[]): string[] => { + if (Array.isArray(sources)) { + return [...sources] + } + if (typeof sources === 'string') { + return sources.split(',').map((source) => source.trim()) + } + return String(sources) + .split(',') + .map((source) => source.trim()) +} + +/** + * Normalizes input parameters to support different parameter formats + * Converts from/to to base/quote while preserving existing base/quote + */ +export const normalizeInput = (input: PriceInput): PriceInput => { + if (!input) { + throw new Error('Input cannot be null or undefined') + } + + const normalized = { ...input } + + // Convert from/to format to base/quote format if needed + if (input.from && !input.base) { + normalized.base = input.from + } + if (input.to && !input.quote) { + normalized.quote = input.to + } + + return normalized +} + +/** + * Calculates median value from an array of numbers using high-precision arithmetic + */ +export const calculateMedian = (values: number[]): Decimal => { + if (values.length === 0) { + throw new Error('Cannot calculate median of empty array') + } + + const sortedValues = [...values].sort((a, b) => a - b) + const middleIndex = Math.floor(sortedValues.length / 2) + + if (sortedValues.length % 2 === 0) { + return new Decimal(sortedValues[middleIndex - 1]).add(sortedValues[middleIndex]).div(2) + } else { + return new Decimal(sortedValues[middleIndex]) + } +} + +/** + * Creates a unique request key for coalescing purposes + */ +export function createRequestKey(source: string, input: PriceInput): string { + const normalizedInput = normalizeInput(input) + const key = JSON.stringify({ source, input: normalizedInput }) + return Buffer.from(key).toString('base64') +} + +/** + * Safely parses input from various formats (object, JSON string, etc.) + * Handles different input formats for maximum flexibility + */ +export function parseInput(input: any, fieldName: string): PriceInput { + // Handle null/undefined + if (input == null) { + throw new Error(`${fieldName} is required`) + } + + // Handle object input (direct objects) + if (typeof input === 'object' && !Array.isArray(input)) { + return input as PriceInput + } + + // Handle JSON string input + if (typeof input === 'string') { + try { + const parsed = JSON.parse(input) + if (typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as PriceInput + } + throw new Error(`Invalid format in ${fieldName}: must be an object`) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in ${fieldName}: ${error.message}`) + } + throw error + } + } + + throw new Error(`${fieldName} must be an object or valid JSON string`) +} diff --git a/packages/composites/implied-price/test-payload.json b/packages/composites/implied-price/test-payload.json index 875a3421b1..807af3c08a 100644 --- a/packages/composites/implied-price/test-payload.json +++ b/packages/composites/implied-price/test-payload.json @@ -1,48 +1,33 @@ { - "requests": [{ - "endpoint": "computedPrice", - "operand1Sources": ["coingecko"], - "operand2Sources": ["coingecko"], - "operand1Input": { - "from": "LINK", - "to": "USD", - "overrides": { - "coingecko": { - "LINK": "chainlink" + "requests": [ + { + "endpoint": "computedPrice", + "operand1Sources": ["coingecko"], + "operand2Sources": ["coingecko"], + "operand1Input": "{\"from\":\"LINK\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"LINK\":\"chainlink\"}}}", + "operand2Input": "{\"from\":\"ETH\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"ETH\":\"ethereum\"}}}", + "operation": "divide" + }, + { + "endpoint": "impliedPrice", + "dividendSources": ["coingecko"], + "divisorSources": ["coingecko"], + "dividendInput": "{\"from\":\"LINK\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"LINK\":\"chainlink\"}}}", + "divisorInput": "{\"from\":\"ETH\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"ETH\":\"ethereum\"}}}" + }, + { + "dividendSources": ["coingecko"], + "divisorSources": ["coingecko"], + "dividendInput": "{\"from\":\"LINK\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"LINK\":\"chainlink\"}}}", + "divisorInput": "{\"from\":\"ETH\",\"to\":\"USD\",\"overrides\":{\"coingecko\":{\"ETH\":\"ethereum\"}}}" + }, + { + "endpoint": "computedPrice", + "dividendSources": ["coingecko", "coinpaprika"], + "divisorSources": ["coingecko", "coinpaprika"], + "dividendInput": "{\"base\":\"ETH\",\"quote\":\"USD\",\"overrides\":{\"coingecko\":{\"ETH\":\"ethereum\"}}}", + "divisorInput": "{\"base\":\"BTC\",\"quote\":\"USD\",\"overrides\":{\"coingecko\":{\"BTC\":\"bitcoin\"}}}", + "operation": "multiply" } - } - }, - "operand2Input": { - "from": "ETH", - "to": "USD", - "overrides": { - "coingecko": { - "ETH": "ethereum" - } - } - }, - "operation": "divide" - }, - { - "dividendSources": ["coingecko"], - "divisorSources": ["coingecko"], - "dividendInput": { - "from": "LINK", - "to": "USD", - "overrides": { - "coingecko": { - "LINK": "chainlink" - } - } - }, - "divisorInput": { - "from": "ETH", - "to": "USD", - "overrides": { - "coingecko": { - "ETH": "ethereum" - } - } - } - }] -} + ] +} \ No newline at end of file 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 deleted file mode 100644 index dc1f7c8962..0000000000 --- a/packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap +++ /dev/null @@ -1,320 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not enough configured sources to reach minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","not_configured_1","not_configured_2"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_CONFIGURED_1_ADAPTER_URL, NOT_CONFIGURED_2_ADAPTER_URL", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not enough sources to reach minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Not enough sources: got 1 sources, requiring at least 2 answers", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not reaching minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","failing"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Not returning median: got 1 answers, requiring min. 2 answers", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint computedPrice erroring calls returns error if operand1 has zero price 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","coinpaprika"],"operand2Sources":["coingecko","coinpaprika"],"operand1Input":{"from":"DEAD","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Operand 1 result is zero", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint computedPrice erroring calls returns error if operand2 has zero price 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","coinpaprika"],"operand2Sources":["coingecko","coinpaprika"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"DEAD","to":"USD"},"operation":"divide"}}", - "message": "Operand 2 result is zero", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint computedPrice successful calls can multiply operands 1`] = ` -{ - "data": { - "result": "75462.5725", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "75462.5725", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint computedPrice successful calls displays number > e+21 as fixed point rather than exponential 1`] = ` -{ - "data": { - "result": "17150000000000000000000000", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "17150000000000000000000000", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint computedPrice successful calls return success without comma separated sources 1`] = ` -{ - "data": { - "result": "0.0038975944001909025829", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "0.0038975944001909025829", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint computedPrice successful calls returns success with comma separated sources 1`] = ` -{ - "data": { - "result": "0.0038975944001909025829", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "0.0038975944001909025829", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request contains unsupported sources 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["NOT_REAL"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_REAL_ADAPTER_URL", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request data is empty 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice"}}", - "message": "Required parameter operand1Sources must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request is missing operand1 input 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand2Sources":["coingecko"],"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}", - "message": "Required parameter operand1Input must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request is missing operand2 input 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operation":"divide"}}", - "message": "Required parameter operand2Input must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint computedPrice validation error returns a validation error if the request is missing operation 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"}}}", - "message": "Required parameter operation must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if dividend has zero price 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","coinpaprika"],"divisorSources":["coingecko","coinpaprika"],"dividendInput":{"from":"DEAD","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Operand 1 result is zero", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if divisor has zero price 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","coinpaprika"],"divisorSources":["coingecko","coinpaprika"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"DEAD","to":"USD"}}}", - "message": "Operand 2 result is zero", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not enough configured sources to reach minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","not_configured_1","not_configured_2"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_CONFIGURED_1_ADAPTER_URL, NOT_CONFIGURED_2_ADAPTER_URL", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not enough sources to reach minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Not enough sources: got 1 sources, requiring at least 2 answers", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not reaching minAnswers 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","failing"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Not returning median: got 1 answers, requiring min. 2 answers", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint impliedPrice successful calls return success without comma separated sources 1`] = ` -{ - "data": { - "result": "0.0038975944001909025829", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "0.0038975944001909025829", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint impliedPrice successful calls returns success with comma separated sources 1`] = ` -{ - "data": { - "result": "0.0038975944001909025829", - }, - "jobRunID": "1", - "providerStatusCode": 200, - "result": "0.0038975944001909025829", - "statusCode": 200, -} -`; - -exports[`impliedPrice with endpoint impliedPrice validation error returns a validation error if the request contains unsupported sources 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["NOT_REAL"],"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_REAL_ADAPTER_URL", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 500, -} -`; - -exports[`impliedPrice with endpoint impliedPrice validation error returns a validation error if the request data is empty 1`] = ` -{ - "error": { - "feedID": "{"data":{}}", - "message": "Required parameter dividendSources must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint impliedPrice validation error returns a validation error if the request is missing dividend input 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko"],"divisorSources":["coingecko"],"divisorInput":{"from":"ETH","to":"USD"}}}", - "message": "Required parameter dividendInput must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; - -exports[`impliedPrice with endpoint impliedPrice validation error returns a validation error if the request is missing divisor input 1`] = ` -{ - "error": { - "feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko"],"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"}}}", - "message": "Required parameter divisorInput must be non-null and non-empty", - "name": "AdapterError", - }, - "jobRunID": "1", - "status": "errored", - "statusCode": 400, -} -`; diff --git a/packages/composites/implied-price/test/integration/adapter.test.ts b/packages/composites/implied-price/test/integration/adapter.test.ts index 656ba8312d..88e4cdef23 100644 --- a/packages/composites/implied-price/test/integration/adapter.test.ts +++ b/packages/composites/implied-price/test/integration/adapter.test.ts @@ -1,749 +1,382 @@ -import type { AdapterRequest } from '@chainlink/ea-bootstrap' -import { util } from '@chainlink/ea-bootstrap' -import type { SuiteContext } from '@chainlink/ea-test-helpers' -import { setupExternalAdapterTest } from '@chainlink/ea-test-helpers' -import { SuperTest, Test } from 'supertest' -import { server as startServer } from '../../src' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' import { - mockSuccessfulResponseBigNumberOperand, - mockSuccessfulResponseCoingecko, - mockSuccessfulResponseCoinpaprika, + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import nock from 'nock' +import { + mockCoinbaseSuccess, + mockCoingeckoSuccess, + mockCoinpaprikaSuccess, + mockFailingAdapter, + mockTimeoutAdapter, + mockZeroAdapterSuccess, } from './fixtures' -const setupEnvironment = (adapters: string[]) => { - const env = {} as { [key: string]: string } - for (const a of adapters) { - env[`${a.toUpperCase()}_${util.ENV_ADAPTER_URL}`] = `https://external.adapter.com/${a}` - } - return env -} - -describe('impliedPrice', () => { - const context: SuiteContext = { - req: null, - server: startServer, - } - const envVariables = setupEnvironment(['coingecko', 'coinpaprika', 'failing', 'bignumberoperand']) - setupExternalAdapterTest(envVariables, context) - describe('with endpoint computedPrice', () => { - const endpoint = 'computedPrice' - - describe('successful calls', () => { - const jobID = '1' - - it('return success without comma separated sources', 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', - }, - } - - 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('Computed Price Adapter', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + + // Set up environment variables for source adapters + process.env.COINGECKO_ADAPTER_URL = 'http://localhost:8080/coingecko' + process.env.COINPAPRIKA_ADAPTER_URL = 'http://localhost:8080/coinpaprika' + process.env.COINBASE_ADAPTER_URL = 'http://localhost:8080/coinbase' + process.env.FAILING_ADAPTER_URL = 'http://localhost:8080/failing' + process.env.ZERO_ADAPTER_URL = 'http://localhost:8080/zero-adapter' + process.env.TIMEOUT_ADAPTER_URL = 'http://localhost:8080/timeout' + process.env.BACKGROUND_EXECUTE_MS = '100' // Reduce background execution frequency + process.env.CACHE_MAX_AGE = '30000' // Longer cache to reduce redundant calls + + // Disable network connections to ensure tests use mocks + nock.disableNetConnect() + + // Reduce log noise in tests + process.env.LOG_LEVEL = 'error' + + // Mock Date.now for consistent timestamps + const mockDate = new Date('2022-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('../../src')).adapter as unknown as Adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) - it('returns success with comma separated sources', 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', - }, - } - - 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() - }) + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + nock.enableNetConnect() + spy.mockRestore() + }) - it('can multiply operands', 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', - }, - } - - 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() - }) + beforeEach(() => { + // Setup all mocks for each test + mockCoingeckoSuccess() + mockCoinpaprikaSuccess() + mockCoinbaseSuccess() + mockZeroAdapterSuccess() + mockFailingAdapter() + mockTimeoutAdapter() + }) - it('displays number > e+21 as fixed point rather than exponential', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - mockSuccessfulResponseBigNumberOperand() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'coinpaprika'], - operand2Sources: ['bignumberoperand'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'multiply', - }, - } - - 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() - expect(response.body.result).not.toContain('e+') - }) - }) + afterEach(() => { + nock.cleanAll() + }) - describe('erroring calls', () => { - const jobID = '1' - - it('returns error if not enough sources to reach minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko'], - operand1MinAnswers: 2, - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - 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() + describe('v2 Backward Compatibility', () => { + it('should work with operand1/operand2 format (v2 computedPrice endpoint)', async () => { + const response = await testAdapter.request({ + endpoint: 'computedPrice', + operand1Sources: ['coingecko', 'coinpaprika'], + operand2Sources: ['coingecko', 'coinpaprika'], + operand1Input: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + operand2Input: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns error if not enough configured sources to reach minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'not_configured_1', 'not_configured_2'], - operand1MinAnswers: 2, - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() - }) + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + expect(result.data.result).toBe(result.result) + }) - it('returns error if not reaching minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'failing'], - operand1MinAnswers: 2, - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() + it('should work with dividend/divisor format (v2 impliedPrice endpoint)', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko', 'coinpaprika'], + divisorSources: ['coingecko', 'coinpaprika'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns error if operand1 has zero price', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'coinpaprika'], - operand2Sources: ['coingecko', 'coinpaprika'], - operand1Input: { - from: 'DEAD', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() - }) + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + expect(result.data.result).toBe(result.result) + }) - it('returns error if operand2 has zero price', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko', 'coinpaprika'], - operand2Sources: ['coingecko', 'coinpaprika'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'DEAD', - to: 'USD', - }, - operation: 'divide', - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() + it('should default to divide operation when operation parameter is omitted (impliedPrice compatibility)', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['coingecko'], + dividendInput: JSON.stringify({ + from: 'LINK', + to: 'USD', + }), + divisorInput: JSON.stringify({ + from: 'ETH', + to: 'USD', + }), + // No operation parameter - should default to 'divide' }) + + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') }) - describe('validation error', () => { - const jobID = '1' + it('should work with operand1/operand2 multiply operation', async () => { + const response = await testAdapter.request({ + endpoint: 'computedPrice', + operand1Sources: ['coingecko'], + operand2Sources: ['coinpaprika'], + operand1Input: JSON.stringify({ + from: 'ETH', + to: 'USD', + }), + operand2Input: JSON.stringify({ + from: 'BTC', + to: 'USD', + }), + operation: 'multiply', + }) - it('returns a validation error if the request data is empty', async () => { - const data: AdapterRequest = { id: jobID, data: { endpoint } } + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + }) + }) - 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() + describe('computedPrice endpoint', () => { + it('should calculate computed price with base/quote format (JSON string input) - divide', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko', 'coinpaprika'], + divisorSources: ['coingecko', 'coinpaprika'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns a validation error if the request is missing operand1 input', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko'], - operand2Sources: ['coingecko'], - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - - 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.statusCode).toBe(200) + const result = response.json() + // With mock data: coingecko=4400.05, coinpaprika=4400.15 -> median 4400.1 + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + expect(result.data.result).toBe(result.result) + }) - it('returns a validation error if the request is missing operand2 input', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko'], - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operation: 'divide', - }, - } - - 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() + it('should calculate computed price with base/quote format (JSON string input) - multiply', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko', 'coinpaprika'], + divisorSources: ['coingecko', 'coinpaprika'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'multiply', }) - it('returns a validation error if the request is missing operation', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['coingecko'], - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - }, - } - - 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.statusCode).toBe(200) + const result = response.json() + // With multiplication: ETH median * BTC median + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + expect(result.data.result).toBe(result.result) + }) - it('returns a validation error if the request contains unsupported sources', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - operand1Sources: ['NOT_REAL'], - operand2Sources: ['coingecko'], - operand1Input: { - from: 'LINK', - to: 'USD', - }, - operand2Input: { - from: 'ETH', - to: 'USD', - }, - operation: 'divide', - }, - } - - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() + it('should calculate computed price with from/to format - divide', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko', 'coinpaprika'], + divisorSources: ['coingecko', 'coinpaprika'], + dividendInput: JSON.stringify({ + from: 'LINK', + to: 'USD', + }), + divisorInput: JSON.stringify({ + from: 'ETH', + to: 'USD', + }), + operation: 'divide', }) + + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') }) - }) - describe('with endpoint impliedPrice', () => { - const endpoint = 'impliedPrice' - - describe('successful calls', () => { - const jobID = '1' - - it('return success without comma separated sources', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko', 'coinpaprika'], - divisorSources: ['coingecko', 'coinpaprika'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', + it('should work with overrides - multiply', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['coingecko'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + overrides: { + coingecko: { + ETH: 'ethereum', }, }, - } - - 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 comma separated sources', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: 'coingecko,coinpaprika', - divisorSources: 'coingecko,coinpaprika', - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + overrides: { + coingecko: { + BTC: 'bitcoin', }, }, - } - - 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() + }), + operation: 'multiply', }) + + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') }) - describe('erroring calls', () => { - const jobID = '1' - - it('returns error if not enough sources to reach minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko'], - dividendMinAnswers: 2, - divisorSources: ['coingecko'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - 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() + it('should handle minimum answers requirement', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko', 'coinpaprika', 'coinbase'], + divisorSources: ['coingecko', 'coinpaprika', 'coinbase'], + dividendMinAnswers: 2, + divisorMinAnswers: 2, + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns error if not enough configured sources to reach minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko', 'not_configured_1', 'not_configured_2'], - dividendMinAnswers: 2, - divisorSources: ['coingecko'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() - }) + expect(response.statusCode).toBe(200) + const result = response.json() + expect(result.result).toBeDefined() + expect(typeof result.result).toBe('number') + }) - it('returns error if not reaching minAnswers', async () => { - mockSuccessfulResponseCoingecko() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko', 'failing'], - dividendMinAnswers: 2, - divisorSources: ['coingecko'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() + it('should handle missing required parameters (missing dividend sources)', async () => { + const response = await testAdapter.request({ + // Missing dividendSources (required for impliedPrice endpoint) + divisorSources: ['coingecko'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns error if dividend has zero price', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko', 'coinpaprika'], - divisorSources: ['coingecko', 'coinpaprika'], - dividendInput: { - from: 'DEAD', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() - }) + expect(response.statusCode).toBe(400) // Validation error + const result = response.json() + expect(result.message || result.errorMessage || JSON.stringify(result)).toContain( + 'dividendSources', + ) + }) - it('returns error if divisor has zero price', async () => { - mockSuccessfulResponseCoingecko() - mockSuccessfulResponseCoinpaprika() - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko', 'coinpaprika'], - divisorSources: ['coingecko', 'coinpaprika'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'DEAD', - to: 'USD', - }, - }, - } - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() + it('should handle invalid operation parameter', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['coingecko'], + dividendInput: JSON.stringify({ + base: 'ETH', + quote: 'USD', + }), + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'invalid_operation', }) + + expect(response.statusCode).toBe(400) + const result = response.json() + expect(result.message || result.errorMessage || JSON.stringify(result)).toContain('operation') }) - describe('validation error', () => { - const jobID = '1' + it('should validate JSON input parameters', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['coingecko'], + dividendInput: 'invalid-json-string', + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', + }) - it('returns a validation error if the request data is empty', async () => { - const data: AdapterRequest = { id: jobID, data: {} } + expect(response.statusCode).toBe(400) // Validation error for JSON parsing + const result = response.json() + expect(result.message || result.errorMessage || JSON.stringify(result)).toContain( + 'dividendInput', + ) + }) - 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() + it('should handle malformed JSON with descriptive error', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['coingecko'], + dividendInput: '{"base": "ETH", "quote":}', // Malformed JSON + divisorInput: JSON.stringify({ + base: 'BTC', + quote: 'USD', + }), + operation: 'divide', }) - it('returns a validation error if the request is missing dividend input', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko'], - divisorSources: ['coingecko'], - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - - 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.statusCode).toBe(400) // Validation error + const result = response.json() + expect(result.message || result.errorMessage || JSON.stringify(result)).toContain( + 'dividendInput', + ) + }) - it('returns a validation error if the request is missing divisor input', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['coingecko'], - divisorSources: ['coingecko'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - }, - } - - 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() + it('should handle division by zero scenario (insufficient responses from zero-adapter)', async () => { + const response = await testAdapter.request({ + dividendSources: ['coingecko'], + divisorSources: ['zero-adapter'], + dividendInput: JSON.stringify({ base: 'ETH', quote: 'USD' }), + divisorInput: JSON.stringify({ base: 'ZERO', quote: 'USD' }), + operation: 'divide', }) - it('returns a validation error if the request contains unsupported sources', async () => { - const data: AdapterRequest = { - id: jobID, - data: { - endpoint, - dividendSources: ['NOT_REAL'], - divisorSources: ['coingecko'], - dividendInput: { - from: 'LINK', - to: 'USD', - }, - divisorInput: { - from: 'ETH', - to: 'USD', - }, - }, - } - - const response = await (context.req as SuperTest) - .post('/') - .send(data) - .set('Accept', '*/*') - .set('Content-Type', 'application/json') - .expect('Content-Type', /json/) - .expect(500) - expect(response.body).toMatchSnapshot() - }) + expect(response.statusCode).toBe(400) // Insufficient responses error + const result = response.json() + expect(result.message || result.errorMessage || JSON.stringify(result)).toContain( + 'Insufficient responses', + ) }) }) }) diff --git a/packages/composites/implied-price/test/integration/fixtures.ts b/packages/composites/implied-price/test/integration/fixtures.ts index dec7f2e872..00827841ff 100644 --- a/packages/composites/implied-price/test/integration/fixtures.ts +++ b/packages/composites/implied-price/test/integration/fixtures.ts @@ -1,146 +1,108 @@ import nock from 'nock' -export const mockSuccessfulResponseCoingecko = (): nock.Scope => { - return nock('https://external.adapter.com') - .defaultReplyHeaders([ - 'X-Powered-By', - 'Express', - 'X-RateLimit-Limit', - '250', - 'X-RateLimit-Remaining', - '249', - 'Date', - 'Tue, 30 Nov 2021 05:33:00 GMT', - 'X-RateLimit-Reset', - '1638250385', - 'Content-Type', - 'application/json; charset=utf-8', - 'ETag', - 'W/"5b-336W6SWFMKfGuh90hvQDQ8pBArM"', - 'Connection', - 'close', - ]) - .post('/coingecko', { - id: '1', - data: { from: 'ETH', to: 'USD' }, +export const mockCoingeckoSuccess = (): nock.Scope => + nock('http://localhost:8080/coingecko', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: 4400.1, - maxAge: 30000, + .reply(200, () => ({ + result: 4400.05, statusCode: 200, - data: { result: 4400.1 }, - }) - .post('/coingecko', { - id: '1', - data: { from: 'LINK', to: 'USD' }, - }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: 17.1, - maxAge: 30000, - statusCode: 200, - data: { result: 17.1 }, - }) - .post('/coingecko', { - id: '1', - data: { from: 'DEAD', to: 'USD' }, - }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: 0, - maxAge: 30000, - statusCode: 200, - data: { result: 0 }, - }) -} + data: { result: 4400.05 }, + timestamps: { + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: Date.now(), + }, + })) + .persist() -export const mockSuccessfulResponseCoinpaprika = (): nock.Scope => - nock('https://external.adapter.com') - .defaultReplyHeaders([ - 'X-Powered-By', - 'Express', - 'X-RateLimit-Limit', - '250', - 'X-RateLimit-Remaining', - '249', - 'Date', - 'Tue, 30 Nov 2021 05:33:00 GMT', - 'X-RateLimit-Reset', - '1638250383', - 'Content-Type', - 'application/json; charset=utf-8', - 'ETag', - 'W/"6b-lQYlgZnpNzhbNYmlxSm02Pk34qo"', - 'Connection', - 'close', - ]) - .post('/coinpaprika', { - id: '1', - data: { from: 'ETH', to: 'USD' }, +export const mockCoinpaprikaSuccess = (): nock.Scope => + nock('http://localhost:8080/coinpaprika', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: 4400.2, - maxAge: 30000, + .reply(200, () => ({ + result: 4400.15, statusCode: 200, - data: { result: 4400.2 }, - }) - .post('/coinpaprika', { - id: '1', - data: { from: 'LINK', to: 'USD' }, + data: { result: 4400.15 }, + timestamps: { + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: Date.now(), + }, + })) + .persist() + +export const mockCoinbaseSuccess = (): nock.Scope => + nock('http://localhost:8080/coinbase', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: 17.2, - maxAge: 30000, + .reply(200, () => ({ + result: 4399.95, statusCode: 200, - data: { result: 17.2 }, - }) - .post('/coinpaprika', { - id: '1', - data: { from: 'DEAD', to: 'USD' }, + data: { result: 4399.95 }, + timestamps: { + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: Date.now(), + }, + })) + .persist() + +export const mockZeroAdapterSuccess = (): nock.Scope => + nock('http://localhost:8080/zero-adapter', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, + .reply(200, () => ({ result: 0, - maxAge: 30000, statusCode: 200, data: { result: 0 }, + timestamps: { + providerDataReceivedUnixMs: Date.now(), + providerDataStreamEstablishedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: Date.now(), + }, + })) + .persist() + +export const mockFailingAdapter = (): nock.Scope => + nock('http://localhost:8080/failing', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) + .reply(500, () => ({ error: 'Source adapter error' })) + .persist() -export const mockSuccessfulResponseBigNumberOperand = (): nock.Scope => - nock('https://external.adapter.com') - .defaultReplyHeaders([ - 'X-Powered-By', - 'Express', - 'X-RateLimit-Limit', - '250', - 'X-RateLimit-Remaining', - '249', - 'Date', - 'Tue, 30 Nov 2021 05:33:00 GMT', - 'X-RateLimit-Reset', - '1638250383', - 'Content-Type', - 'application/json; charset=utf-8', - 'ETag', - 'W/"6b-lQYlgZnpNzhbNYmlxSm02Pk34qo"', - 'Connection', - 'close', - ]) - .post('/bignumberoperand') - .reply(200, { - jobRunID: '1', - providerStatusCode: 200, - result: '1_000_000_000_000_000_000_000_000', // e24 - maxAge: 30000, - statusCode: 200, - data: { result: '1_000_000_000_000_000_000_000_000' }, +export const mockTimeoutAdapter = (): nock.Scope => + nock('http://localhost:8080/timeout', { + encodedQueryParams: true, + }) + .post('/', (body) => { + // Validate request body structure + return body && typeof body.data === 'object' && body.data !== null }) + .delay(30000) // Simulate timeout + .reply(200, () => ({ + result: 1000, + statusCode: 200, + data: { result: 1000 }, + })) + .persist() diff --git a/packages/composites/implied-price/test/unit/adapter.test.ts b/packages/composites/implied-price/test/unit/adapter.test.ts index b6e1e2f5f7..2f199e33a3 100644 --- a/packages/composites/implied-price/test/unit/adapter.test.ts +++ b/packages/composites/implied-price/test/unit/adapter.test.ts @@ -1,25 +1,287 @@ -import { median, parseSources } from '../../src/endpoint/computedPrice' +import { Decimal } from 'decimal.js' +import { calculateMedian, normalizeInput, parseInput, parseSources } from '../../src/utils' -describe('parseSources', () => { - it('parses an array of sources', () => { - expect(parseSources(['coingecko', 'coinpaprika'])).toEqual(['coingecko', 'coinpaprika']) - }) +describe('Computed Price Utility Functions', () => { + describe('calculateMedian', () => { + it('should calculate median for odd number of values', () => { + const values = [1, 3, 5] + const result = calculateMedian(values) + expect(Number(result.toFixed())).toBe(3) + }) + + it('should calculate median for even number of values', () => { + const values = [1, 2, 3, 4] + const result = calculateMedian(values) + expect(Number(result.toFixed(1))).toBe(2.5) // (2 + 3) / 2 = 2.5 + }) + + it('should handle single value', () => { + const values = [42] + const result = calculateMedian(values) + expect(Number(result.toFixed())).toBe(42) + }) + + it('should handle two values', () => { + const values = [10, 20] + const result = calculateMedian(values) + expect(Number(result.toFixed())).toBe(15) // (10 + 20) / 2 = 15 + }) + + it('should throw error for empty array', () => { + expect(() => calculateMedian([])).toThrow('Cannot calculate median of empty array') + }) - it('parses a list of sources', () => { - expect(parseSources('coingecko,coinpaprika')).toEqual(['coingecko', 'coinpaprika']) + it('should handle decimal precision correctly', () => { + const values = [4400.1, 4400.2, 4399.8] + const result = calculateMedian(values) + expect(Number(result.toFixed(1))).toBe(4400.1) + }) + + it('should sort values before calculating median', () => { + const values = [5, 1, 9, 3, 7] // Unsorted + const result = calculateMedian(values) + expect(Number(result.toFixed())).toBe(5) // Middle value when sorted: [1,3,5,7,9] + }) + + it('should handle negative values', () => { + const values = [-10, -5, 0, 5, 10] + const result = calculateMedian(values) + expect(Number(result.toFixed())).toBe(0) + }) + + it('should return Decimal instance', () => { + const values = [1, 2, 3] + const result = calculateMedian(values) + expect(result).toBeInstanceOf(Decimal) + }) }) -}) -describe('median', () => { - it('gets the median of a list of numbers', () => { - expect(median([1, 2, 3]).toNumber()).toEqual(2) + describe('normalizeInput', () => { + it('should normalize from/to to base/quote', () => { + const input = { from: 'ETH', to: 'USD' } + const result = normalizeInput(input) + expect(result).toEqual({ + from: 'ETH', + to: 'USD', + base: 'ETH', + quote: 'USD', + }) + }) + + it('should preserve existing base/quote', () => { + const input = { base: 'BTC', quote: 'USD' } + const result = normalizeInput(input) + expect(result).toEqual({ base: 'BTC', quote: 'USD' }) + }) + + it('should not override existing base/quote with from/to', () => { + const input = { from: 'ETH', to: 'USD', base: 'BTC', quote: 'EUR' } + const result = normalizeInput(input) + expect(result).toEqual({ + from: 'ETH', + to: 'USD', + base: 'BTC', // Should not be overwritten + quote: 'EUR', // Should not be overwritten + }) + }) + + it('should preserve all other properties', () => { + const input = { + from: 'LINK', + to: 'USD', + overrides: { + coingecko: { + LINK: 'chainlink', + }, + }, + } + const result = normalizeInput(input) + expect(result.overrides).toEqual(input.overrides) + expect(result.from).toBe('LINK') + expect(result.to).toBe('USD') + expect(result.base).toBe('LINK') // Should be normalized + expect(result.quote).toBe('USD') // Should be normalized + }) + + it('should handle only from parameter', () => { + const input = { from: 'ETH' } + const result = normalizeInput(input) + expect(result).toEqual({ + from: 'ETH', + base: 'ETH', + }) + }) + + it('should handle only to parameter', () => { + const input = { to: 'USD' } + const result = normalizeInput(input) + expect(result).toEqual({ + to: 'USD', + quote: 'USD', + }) + }) + + it('should throw error for null input', () => { + expect(() => normalizeInput(null as any)).toThrow('Input cannot be null or undefined') + }) + + it('should throw error for undefined input', () => { + expect(() => normalizeInput(undefined as any)).toThrow('Input cannot be null or undefined') + }) + + it('should handle empty object', () => { + const input = {} + const result = normalizeInput(input) + expect(result).toEqual({}) + }) }) - it('gets the median with only one number', () => { - expect(median([1]).toNumber()).toEqual(1) + describe('parseSources', () => { + it('should return array as-is', () => { + const sources = ['coingecko', 'coinpaprika'] + const result = parseSources(sources) + expect(result).toEqual(['coingecko', 'coinpaprika']) + expect(result).not.toBe(sources) // Should return new array + }) + + it('should parse comma-delimited string', () => { + const sources = 'coingecko,coinpaprika,coinbase' + const result = parseSources(sources) + expect(result).toEqual(['coingecko', 'coinpaprika', 'coinbase']) + }) + + it('should handle single source string', () => { + const sources = 'coingecko' + const result = parseSources(sources) + expect(result).toEqual(['coingecko']) + }) + + it('should trim whitespace from comma-delimited strings', () => { + const sources = ' coingecko , coinpaprika , coinbase ' + const result = parseSources(sources) + expect(result).toEqual(['coingecko', 'coinpaprika', 'coinbase']) + }) + + it('should handle empty string', () => { + const sources = '' + const result = parseSources(sources) + expect(result).toEqual(['']) + }) + + it('should handle string with only commas', () => { + const sources = ',,' + const result = parseSources(sources) + expect(result).toEqual(['', '', '']) + }) + + it('should handle mixed whitespace', () => { + const sources = 'coingecko, ,coinpaprika' + const result = parseSources(sources) + expect(result).toEqual(['coingecko', '', 'coinpaprika']) + }) + + it('should handle non-string types by converting to string', () => { + const sources = 123 as any + const result = parseSources(sources) + expect(result).toEqual(['123']) + }) + + it('should preserve array reference behavior', () => { + const sources1 = ['coingecko'] + const sources2 = ['coingecko'] + const result1 = parseSources(sources1) + const result2 = parseSources(sources2) + + expect(result1).toEqual(result2) + expect(result1).not.toBe(result2) + }) }) - it('gets the median with an even amount of numbers', () => { - expect(median([1, 2]).toNumber()).toEqual(1.5) + describe('parseInput', () => { + it('should handle object inputs directly', () => { + const input = { base: 'ETH', quote: 'USD' } + const result = parseInput(input, 'testInput') + expect(result).toEqual({ base: 'ETH', quote: 'USD' }) + }) + + it('should parse valid JSON string inputs', () => { + const input = JSON.stringify({ base: 'BTC', quote: 'USD' }) + const result = parseInput(input, 'testInput') + expect(result).toEqual({ base: 'BTC', quote: 'USD' }) + }) + + it('should handle inputs with overrides', () => { + const input = { + base: 'LINK', + quote: 'USD', + overrides: { + coingecko: { LINK: 'chainlink' }, + }, + } + const result = parseInput(input, 'testInput') + expect(result).toEqual(input) + }) + + it('should throw error for null/undefined inputs', () => { + expect(() => parseInput(null, 'testInput')).toThrow('testInput is required') + expect(() => parseInput(undefined, 'testInput')).toThrow('testInput is required') + }) + + it('should throw error for malformed JSON strings', () => { + const input = '{"base": "ETH", "quote":}' + expect(() => parseInput(input, 'testInput')).toThrow('Invalid JSON in testInput') + }) + + it('should throw error for non-object JSON (arrays)', () => { + const input = JSON.stringify(['not', 'an', 'object']) + expect(() => parseInput(input, 'testInput')).toThrow( + 'Invalid format in testInput: must be an object', + ) + }) + + it('should throw error for non-object JSON (primitives)', () => { + const input = JSON.stringify('just a string') + expect(() => parseInput(input, 'testInput')).toThrow( + 'Invalid format in testInput: must be an object', + ) + }) + + it('should throw error for invalid input types', () => { + expect(() => parseInput(123, 'testInput')).toThrow( + 'testInput must be an object or valid JSON string', + ) + expect(() => parseInput(true, 'testInput')).toThrow( + 'testInput must be an object or valid JSON string', + ) + }) + + it('should handle complex nested objects', () => { + const input = { + base: 'ETH', + quote: 'USD', + overrides: { + coingecko: { ETH: 'ethereum' }, + coinpaprika: { ETH: 'eth-ethereum' }, + }, + metadata: { + precision: 18, + description: 'Ethereum', + }, + } + const result = parseInput(input, 'testInput') + expect(result).toEqual(input) + }) + + it('should preserve all properties when parsing JSON strings', () => { + const originalInput = { + base: 'BTC', + quote: 'USD', + overrides: { coingecko: { BTC: 'bitcoin' } }, + customProperty: 'customValue', + } + const jsonString = JSON.stringify(originalInput) + const result = parseInput(jsonString, 'testInput') + expect(result).toEqual(originalInput) + }) }) }) diff --git a/packages/composites/implied-price/tsconfig.json b/packages/composites/implied-price/tsconfig.json index 583b5803b1..502dea7833 100644 --- a/packages/composites/implied-price/tsconfig.json +++ b/packages/composites/implied-price/tsconfig.json @@ -6,5 +6,5 @@ }, "include": ["src/**/*"], "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"], - "references": [{ "path": "../../core/test-helpers" }, { "path": "../../core/bootstrap" }] + "references": [{ "path": "../../core/test-helpers" }] } diff --git a/yarn.lock b/yarn.lock index bda2746caf..500011a3c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4536,16 +4536,11 @@ __metadata: version: 0.0.0-use.local resolution: "@chainlink/implied-price-adapter@workspace:packages/composites/implied-price" dependencies: - "@chainlink/ea-bootstrap": "workspace:*" - "@chainlink/ea-test-helpers": "workspace:*" + "@chainlink/external-adapter-framework": "npm:2.7.0" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.14.1" - "@types/supertest": "npm:2.0.16" - axios: "npm:1.9.0" decimal.js: "npm:^10.3.1" nock: "npm:13.5.6" - supertest: "npm:6.2.4" - tslib: "npm:^2.3.1" typescript: "npm:5.8.3" languageName: unknown linkType: soft