diff --git a/.changeset/beige-timers-design.md b/.changeset/beige-timers-design.md new file mode 100644 index 0000000000..8b31cfadbc --- /dev/null +++ b/.changeset/beige-timers-design.md @@ -0,0 +1,5 @@ +--- +'@chainlink/coinpaprika-state-adapter': major +--- + +Add coinpaprika-state adapter for real-time state price streaming diff --git a/.pnp.cjs b/.pnp.cjs index b15aa83dca..ab01f65167 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -374,6 +374,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/coinpaprika-adapter",\ "reference": "workspace:packages/sources/coinpaprika"\ },\ + {\ + "name": "@chainlink/coinpaprika-state-adapter",\ + "reference": "workspace:packages/sources/coinpaprika-state"\ + },\ {\ "name": "@chainlink/coinranking-adapter",\ "reference": "workspace:packages/sources/coinranking"\ @@ -1021,6 +1025,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/coinmetrics-adapter", ["workspace:packages/sources/coinmetrics"]],\ ["@chainlink/coinmetrics-lwba-adapter", ["workspace:packages/sources/coinmetrics-lwba"]],\ ["@chainlink/coinpaprika-adapter", ["workspace:packages/sources/coinpaprika"]],\ + ["@chainlink/coinpaprika-state-adapter", ["workspace:packages/sources/coinpaprika-state"]],\ ["@chainlink/coinranking-adapter", ["workspace:packages/sources/coinranking"]],\ ["@chainlink/covid-tracker-adapter", ["workspace:packages/sources/covid-tracker"]],\ ["@chainlink/cryptex-adapter", ["workspace:packages/sources/cryptex"]],\ @@ -6060,6 +6065,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/coinpaprika-state-adapter", [\ + ["workspace:packages/sources/coinpaprika-state", {\ + "packageLocation": "./packages/sources/coinpaprika-state/",\ + "packageDependencies": [\ + ["@chainlink/coinpaprika-state-adapter", "workspace:packages/sources/coinpaprika-state"],\ + ["@chainlink/external-adapter-framework", "npm:2.7.2"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["nock", "npm:13.5.6"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/coinranking-adapter", [\ ["workspace:packages/sources/coinranking", {\ "packageLocation": "./packages/sources/coinranking/",\ diff --git a/packages/sources/coinpaprika-state/CHANGELOG.md b/packages/sources/coinpaprika-state/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/coinpaprika-state/README.md b/packages/sources/coinpaprika-state/README.md new file mode 100644 index 0000000000..e936c8b6ec --- /dev/null +++ b/packages/sources/coinpaprika-state/README.md @@ -0,0 +1,82 @@ +# COINPAPRIKA_STATE + +![2.7.0](https://img.shields.io/github/package-json/v/smartcontractkit/external-adapters-js?filename=packages/sources/coinpaprika-state/package.json) ![v3](https://img.shields.io/badge/framework%20version-v3-blueviolet) + +This document was generated automatically. Please see [Input Parameters](#Input-Parameters) for a list of environments variables and [Schemas](#Schemas) for additional examples. + +## Environment Variables + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :---------------------: | :---------------------------------------------------------------------------------------: | :----: | :-----: | :-------------------------------------------------: | +| ✅ | `API_KEY` | An API key for Coinpaprika | string | | | +| | `API_ENDPOINT` | An API endpoint for Coinpaprika | string | | `https://chainlink-streaming.dexpaprika.com/stream` | +| | `BACKGROUND_EXECUTE_MS` | The amount of time the background execute should sleep before performing the next request | number | | `3000` | +| | `REQUEST_TIMEOUT_MS` | Timeout for HTTP requests to the provider in milliseconds | number | | `60000` | +| | `RECONNECT_DELAY_MS` | Base delay for reconnection attempts in milliseconds | number | | `5000` | + +--- + +## Input Parameters + +Every EA supports base input parameters from [this list](https://github.com/smartcontractkit/ea-framework-js/blob/main/src/config/index.ts) + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------: | :-----------------: | :----: | :-----: | :-----: | +| | endpoint | The endpoint to use | string | | | + +## Coinpaprika-state Endpoint + +`coinpaprika-state` is the only supported name for this endpoint. + +### Input Params + +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :-----: | :------------: | :--------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | +| ✅ | `base` | `coin`, `from` | The symbol of the currency to query | string | | | | | +| ✅ | `quote` | `market`, `to` | The symbol of the currency to convert to | string | | | | | + +### Example + +Request: + +```json +{ + "id": "1", + "data": { + "base": "LUSD", + "quote": "USD" + }, + "debug": { + "cacheKey": "YlEjKJJLVmjXzFKQjFjVtKmQWlM=" + } +} +``` + +Response: + +```json +{ + "jobRunID": "1", + "data": { + "result": 1.000979, + "timestamp": 1758888503 + }, + "result": 1.000979, + "statusCode": 200, + "timestamps": { + "providerDataRequestedUnixMs": 1758888508939, + "providerDataReceivedUnixMs": 1758888508939, + "providerIndicatedTimeUnixMs": 1758888503000 + } +} +``` + +--- + +## Known Issues + +See [known-issues.md](./known-issues.md) for detailed information about streaming data, connection management, and error handling. + +--- + +MIT License diff --git a/packages/sources/coinpaprika-state/package.json b/packages/sources/coinpaprika-state/package.json new file mode 100644 index 0000000000..86065cd134 --- /dev/null +++ b/packages/sources/coinpaprika-state/package.json @@ -0,0 +1,40 @@ +{ + "name": "@chainlink/coinpaprika-state-adapter", + "version": "0.0.0", + "description": "Chainlink coinpaprika-state adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "coinpaprika-state" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.7.2", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/coinpaprika-state/src/config/index.ts b/packages/sources/coinpaprika-state/src/config/index.ts new file mode 100644 index 0000000000..ffde622d9f --- /dev/null +++ b/packages/sources/coinpaprika-state/src/config/index.ts @@ -0,0 +1,31 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'An API key for Coinpaprika', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Coinpaprika', + type: 'string', + default: 'https://chainlink-streaming.dexpaprika.com/stream', + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 3_000, + }, + REQUEST_TIMEOUT_MS: { + description: 'Timeout for HTTP requests to the provider in milliseconds', + type: 'number', + default: 60_000, + }, + RECONNECT_DELAY_MS: { + description: 'Base delay for reconnection attempts in milliseconds', + type: 'number', + default: 5_000, + }, +}) diff --git a/packages/sources/coinpaprika-state/src/endpoint/coinpaprika-state.ts b/packages/sources/coinpaprika-state/src/endpoint/coinpaprika-state.ts new file mode 100644 index 0000000000..6eb459b579 --- /dev/null +++ b/packages/sources/coinpaprika-state/src/endpoint/coinpaprika-state.ts @@ -0,0 +1,35 @@ +import { + PriceEndpoint, + priceEndpointInputParametersDefinition, +} 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 { coinpaprikaSubscriptionTransport } from '../transport/coinpaprika-state' + +export const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [ + { + base: 'LUSD', + quote: 'USD', + }, +]) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings +} + +export const endpoint = new PriceEndpoint({ + name: 'price', + aliases: ['coinpaprika-state', 'state'], + transport: coinpaprikaSubscriptionTransport, + inputParameters, + requestTransforms: [ + (req) => { + req.requestContext.data.base = req.requestContext.data.base.toUpperCase() + req.requestContext.data.quote = req.requestContext.data.quote.toUpperCase() + return req + }, + ], +}) diff --git a/packages/sources/coinpaprika-state/src/endpoint/index.ts b/packages/sources/coinpaprika-state/src/endpoint/index.ts new file mode 100644 index 0000000000..8bfc4cab68 --- /dev/null +++ b/packages/sources/coinpaprika-state/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as coinpaprikaState } from './coinpaprika-state' diff --git a/packages/sources/coinpaprika-state/src/index.ts b/packages/sources/coinpaprika-state/src/index.ts new file mode 100644 index 0000000000..2b9186148a --- /dev/null +++ b/packages/sources/coinpaprika-state/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { PriceAdapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { coinpaprikaState } from './endpoint' + +export const adapter = new PriceAdapter({ + defaultEndpoint: coinpaprikaState.name, + name: 'COINPAPRIKA_STATE', + config, + endpoints: [coinpaprikaState], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/coinpaprika-state/src/transport/coinpaprika-state.ts b/packages/sources/coinpaprika-state/src/transport/coinpaprika-state.ts new file mode 100644 index 0000000000..47e3cc29d4 --- /dev/null +++ b/packages/sources/coinpaprika-state/src/transport/coinpaprika-state.ts @@ -0,0 +1,353 @@ +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 { Readable } from 'node:stream' +import { BaseEndpointTypes, inputParameters } from '../endpoint/coinpaprika-state' +import { toNumber } from '../utils' +import { SSEParser } from './sse' + +const logger = makeLogger('CoinpaprikaStateTransport') + +const COINPAPRIKA_STATE_EVENT_TYPE = 't_s' + +type RequestParams = typeof inputParameters.validated + +// Type union string | number is intentional for defensive programming. +// Coinpaprika documentation indicates numeric values are encoded as strings, +// but this union handles both cases to ensure robustness. +interface CoinpaprikaStreamData { + block_time: string | number + base_token_symbol: string + quote_symbol: string + volume_7d_usd: string | number + market_depth_plus_1_usd: string | number + market_depth_minus_1_usd: string | number + state_price: string | number +} + +export type TransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: Array<{ base: string; quote: string }> + ResponseBody: CoinpaprikaStreamData + } +} + +/** + * Single-connection SSE transport that batches all pairs into one POST and streams state_price ticks into the cache. + * + * TODO: Switch to StreamingTransport to get automatic subscription deltas + * */ +export class CoinpaprikaStateTransport extends SubscriptionTransport { + name!: string + responseCache!: ResponseCache + requester!: Requester + + // single SSE conn for all pairs + private isConnected = false + private currentAbortController: AbortController | null = null + private activePairs: Map = new Map() + private sseParser: SSEParser | null = null + + async initialize( + dependencies: TransportDependencies, + adapterSettings: TransportTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.requester = dependencies.requester + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + // build current pairs map + const currentPairs = new Map() + for (const e of entries) { + currentPairs.set(`${e.base}/${e.quote}`, e) + } + + // detect changes in the requested pair set (triggers reconnect if changed) + if (this.havePairsChanged(currentPairs) || !this.isConnected) { + logger.debug(`Updating stream: ${currentPairs.size} pairs`) + await this.updateStream(context, currentPairs) + } + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + private havePairsChanged(pairs: Map): boolean { + if (pairs.size !== this.activePairs.size) { + return true + } + for (const [key] of pairs) { + if (!this.activePairs.has(key)) { + return true + } + } + return false + } + + private async updateStream( + context: EndpointContext, + pairs: Map, + ) { + // close existing connection if any + if (this.isConnected && this.currentAbortController) { + logger.debug('Closing existing SSE connection') + this.currentAbortController.abort() + this.currentAbortController = null + this.isConnected = false + } + // update active pairs + this.activePairs = new Map(pairs) + + if (pairs.size === 0) { + logger.debug('No pairs to stream') + return + } + + await this.createSSEConnection(context) + } + + private buildSSERequest( + context: EndpointContext, + pairsArray: Array<{ base: string; quote: string }>, + signal: AbortSignal, + ): { + url: string + method: string + headers: Record + data: Array<{ base: string; quote: string }> + responseType: 'stream' + signal: AbortSignal + timeout: number + validateStatus: () => boolean + } { + return { + url: context.adapterSettings.API_ENDPOINT, + method: 'POST', + headers: { + Authorization: context.adapterSettings.API_KEY, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + data: pairsArray, + responseType: 'stream', + signal, + timeout: context.adapterSettings.REQUEST_TIMEOUT_MS, + validateStatus: () => true, + } + } + + private async createSSEConnection(context: EndpointContext) { + const pairsArray = Array.from(this.activePairs.values()).map((p) => ({ + base: p.base, + quote: p.quote, + })) + + logger.debug( + `Opening SSE connection for ${pairsArray.length} pairs: ${pairsArray + .map((p) => `${p.base}/${p.quote}`) + .join(', ')}`, + ) + + this.currentAbortController = new AbortController() + + try { + const req = this.buildSSERequest(context, pairsArray, this.currentAbortController.signal) + const key = `coinpaprika-state/stream:${[...this.activePairs.keys()].join(',')}` + const { response } = await this.requester.request(key, req) + const httpResp = response + + if (httpResp.status !== 200 || !httpResp.data) { + await this.handleSSEConnectionError(httpResp) + return + } + + const stream: Readable = httpResp.data as Readable + let aborted = false + this.sseParser = new SSEParser(COINPAPRIKA_STATE_EVENT_TYPE) + + const onData = async (chunk: Buffer) => { + const raw = chunk.toString('utf8') + logger.debug(`Raw price state update message received:\n${raw}`) + + if (this.sseParser) { + this.sseParser.push(raw, (evt, data) => { + void this.handleParsedSSEEvent(evt, data) + }) + } + } + + const onError = (err: Error) => { + if (err.name === 'CanceledError' || err.name === 'AbortError') { + aborted = true + } else { + logger.error(`Stream error: ${err}`) + } + } + + const onEnd = async () => { + stream.off('data', onData) + stream.off('error', onError) + this.sseParser?.reset() + + // mark closed immediately to prevent races + this.currentAbortController = null + this.isConnected = false + + if (!aborted && this.activePairs.size > 0) { + logger.info('SSE ended; will reconnect on next backgroundHandler cycle') + } + } + + // wire events + stream.on('data', (chunk) => { + void onData(chunk as Buffer) + }) + stream.once('error', onError) + stream.once('end', onEnd) + + // Mark as connected after successful setup + this.isConnected = true + } catch (error) { + this.currentAbortController = null + this.isConnected = false + logger.error(`Failed to create SSE connection: ${error}`) + return + } + } + + private async handleSSEConnectionError(httpResp: any): Promise { + // try to read error body if available + let errDetail = `HTTP error! status: ${httpResp.status}` + let rawErrBody = '' + try { + const text = await new Promise((resolve) => { + let buf = '' + ;(httpResp.data as Readable) + .on('data', (c: Buffer) => (buf += c.toString('utf8'))) + .on('end', () => resolve(buf)) + .on('error', () => resolve('')) + }) + rawErrBody = text + try { + const j = JSON.parse(text) + errDetail = `Provider error: ${JSON.stringify(j)}` + } catch { + logger.error(`Provider error body (not JSON): ${rawErrBody}`) + } + } catch (error) { + logger.debug(`Failed to read error response body: ${(error as Error).message}`) + } + + const mappedStatus = httpResp.status === 500 ? 502 : httpResp.status + + const errorResponse: AdapterResponse = { + statusCode: mappedStatus, + errorMessage: errDetail, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + + for (const params of this.activePairs.values()) { + await this.responseCache.write(this.name, [{ params, response: errorResponse }]) + } + + this.currentAbortController = null + this.isConnected = false + } + + private async handleStreamData(param: RequestParams, streamData: CoinpaprikaStreamData) { + const statePrice = toNumber(streamData.state_price) + const blockTime = toNumber(streamData.block_time) + + // guard against bad payloads + if (!Number.isFinite(statePrice) || !Number.isFinite(blockTime) || blockTime <= 0) { + logger.warn( + `Bad numeric fields for ${param.base}/${param.quote}: ${JSON.stringify(streamData)}`, + ) + return + } + + const response: AdapterResponse = { + statusCode: 200, + result: statePrice, + data: { + result: statePrice, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: blockTime * 1000, + }, + } + logger.debug(`tick ${param.base}/${param.quote}=${statePrice} t=${blockTime}`) + logger.trace(JSON.stringify(response)) + + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + _params: RequestParams, + ): Promise> { + // This fallback shouldn't be called in normal operation - data should come from cache via backgroundHandler + logger.debug('Foreground request hit fallback: no cached data yet') + return { + statusCode: 504, + errorMessage: + 'No cached data available. Streaming transport is initializing or waiting for data.', + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: TransportTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } + + async close(): Promise { + logger.debug('Closing SSE connection') + + if (this.isConnected && this.currentAbortController) { + this.currentAbortController.abort() + this.currentAbortController = null + this.isConnected = false + } + this.activePairs.clear() + + if (this.sseParser) { + this.sseParser.reset() + } + this.sseParser = null + } + + private async handleParsedSSEEvent(eventType: string, rawData: string): Promise { + if (eventType !== COINPAPRIKA_STATE_EVENT_TYPE) { + logger.debug(`Skipping event type: ${eventType}`) + return + } + + try { + const streamData: CoinpaprikaStreamData = JSON.parse(rawData) + const pairKey = `${streamData.base_token_symbol.toUpperCase()}/${streamData.quote_symbol.toUpperCase()}` + const params = this.activePairs.get(pairKey) + if (params) { + await this.handleStreamData(params, streamData) + } else { + logger.warn(`Received data for untracked pair: ${pairKey}`) + } + } catch (err) { + logger.debug(`Failed to parse SSE data: ${rawData} | Error: ${(err as Error).message}`) + } + } +} + +export const coinpaprikaSubscriptionTransport = new CoinpaprikaStateTransport() diff --git a/packages/sources/coinpaprika-state/src/transport/sse.ts b/packages/sources/coinpaprika-state/src/transport/sse.ts new file mode 100644 index 0000000000..662ca145e4 --- /dev/null +++ b/packages/sources/coinpaprika-state/src/transport/sse.ts @@ -0,0 +1,58 @@ +/** + * Minimal SSE parser that accumulates chunked lines and emits complete events. + * - Supports "event:" name (optional; defaults to 't_s') + * - Supports multi-line "data:" blocks + * - Ignores comment/heartbeat lines starting with ':' + * + * TODO: Could use existing libraries like eventsource/sse-decoder instead + */ +export class SSEParser { + private buffer = '' + private dataLines: string[] = [] + private currentEvent: string | null = null + private readonly defaultEvent: string + + constructor(defaultEvent: string) { + this.defaultEvent = defaultEvent + } + + /** + * Push a chunk and emit parsed events via callback. + * The callback is invoked once per complete event frame. + */ + push(chunk: string, onEvent: (eventType: string, data: string) => void): void { + this.buffer += chunk + const lines = this.buffer.split('\n') + this.buffer = lines.pop() || '' + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, '') + + if (line.startsWith(':')) continue + + if (line.startsWith('event:')) { + this.currentEvent = line.slice(6).trim() + continue + } + if (line.startsWith('data:')) { + this.dataLines.push(line.slice(5).trim()) + continue + } + + // end of an event + if (line.trim() === '' && this.dataLines.length > 0) { + const rawData = this.dataLines.join('\n') + this.dataLines = [] + const evt = this.currentEvent ?? this.defaultEvent + this.currentEvent = null + onEvent(evt, rawData) + } + } + } + + reset(): void { + this.buffer = '' + this.dataLines = [] + this.currentEvent = null + } +} diff --git a/packages/sources/coinpaprika-state/src/utils.ts b/packages/sources/coinpaprika-state/src/utils.ts new file mode 100644 index 0000000000..f3ffd8baf7 --- /dev/null +++ b/packages/sources/coinpaprika-state/src/utils.ts @@ -0,0 +1,19 @@ +/** + * Utility functions for coinpaprika-state adapter + */ + +/** + * Coerces a value to a number, handling string conversion and null/undefined cases + * @param value - The value to convert to a number + * @returns A finite number, or NaN if conversion fails + */ +export function toNumber(value: string | number | undefined | null): number { + if (typeof value === 'number') { + return value + } + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value) + return Number.isFinite(n) ? n : NaN + } + return NaN +} diff --git a/packages/sources/coinpaprika-state/test-payload.json b/packages/sources/coinpaprika-state/test-payload.json new file mode 100644 index 0000000000..e0615a2670 --- /dev/null +++ b/packages/sources/coinpaprika-state/test-payload.json @@ -0,0 +1,16 @@ +{ + "requests": [ + { + "base": "LUSD", + "quote": "USD" + }, + { + "base": "EURA", + "quote": "USD" + }, + { + "base": "ALETH", + "quote": "USD" + } + ] +} \ No newline at end of file diff --git a/packages/sources/coinpaprika-state/test/integration/adapter.test.ts b/packages/sources/coinpaprika-state/test/integration/adapter.test.ts new file mode 100644 index 0000000000..f235bad9bd --- /dev/null +++ b/packages/sources/coinpaprika-state/test/integration/adapter.test.ts @@ -0,0 +1,327 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import nock from 'nock' +import { + endSSEStream, + mockStreamPost, + mockStreamPostAnyBody, + mockStreamPostRawAnyBody, + mockStreamPostRawMatchingBody, + sseEventChunk, + waitFor, +} from './fixtures' + +jest.setTimeout(10000) + +describe('coinpaprika-state adapter', () => { + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + let outSpy: jest.SpyInstance, errSpy: jest.SpyInstance + + beforeAll(async () => { + outSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true) + errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true) + + process.env.LOG_LEVEL = 'silent' + process.env.EA_LOG_LEVEL = 'silent' + process.env.PINO_LOG_LEVEL = 'silent' + process.env.METRICS_ENABLED = 'false' + process.env.RETRY = '0' + + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.API_KEY = 'TEST-KEY' + process.env.API_ENDPOINT = 'http://localhost:1234/stream' + process.env.BACKGROUND_EXECUTE_MS = '100' + + const adapter = (await import('../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + await testAdapter.api.close() + setEnvVariables(oldEnv) + outSpy.mockRestore() + errSpy.mockRestore() + }) + + it('happy path: streams ticks and serves latest state_price for LUSD/USD', async () => { + const { scope, stream } = mockStreamPost({ + apiBase: 'http://localhost:1234', + pairs: [{ base: 'LUSD', quote: 'USD' }], + events: [ + { + block_time: 1756224311, + base_token_symbol: 'LUSD', + quote_symbol: 'USD', + state_price: 1.0005, + volume_7d_usd: 1234.56, + market_depth_plus_1_usd: 0, + market_depth_minus_1_usd: 0, + }, + { + block_time: 1756224313, + base_token_symbol: 'LUSD', + quote_symbol: 'USD', + state_price: 1.0007, + volume_7d_usd: 2222, + market_depth_plus_1_usd: 0, + market_depth_minus_1_usd: 0, + }, + ], + }) + + await waitFor(async () => { + const r = await testAdapter.request({ base: 'LUSD', quote: 'USD' }) + expect(r.statusCode).toBe(200) + expect(r.json().result).toBeCloseTo(1.0007, 3) + expect(r.json().timestamps.providerIndicatedTimeUnixMs).toBe(1756224313000) + }) + + endSSEStream(stream) + scope.done() + }) + + it('returns 504 when no data available', async () => { + const scope = nock('http://localhost:1234') + .post('/stream') + .matchHeader('authorization', 'TEST-KEY') + .reply(200, () => ':heartbeat\n\n', { 'Content-Type': 'text/event-stream' }) + .persist() + + await new Promise((resolve) => setTimeout(resolve, 300)) + + const response = await testAdapter.request({ + base: 'UNKNOWN_X', + quote: 'USD', + }) + + expect(response.statusCode).toBe(504) + scope.persist(false) + nock.cleanAll() + }) + + it('provider 401 bubbles through', async () => { + const scope = nock('http://localhost:1234') + .post('/stream') + .matchHeader('authorization', 'TEST-KEY') + .reply(401, { error: 'Unauthorized', details: 'Invalid API key' }) + .persist() + + await new Promise((resolve) => setTimeout(resolve, 300)) + + const response = await testAdapter.request({ + base: 'ETH401', + quote: 'USD', + }) + + expect(response.statusCode).toBe(401) + scope.persist(false) + nock.cleanAll() + }) + + it('multi-pair batching: caches all pairs independently', async () => { + const { scope, stream } = mockStreamPostAnyBody({ + apiBase: 'http://localhost:1234', + events: [ + { block_time: 10, base_token_symbol: 'LUSD', quote_symbol: 'USD', state_price: '1.01' }, + { block_time: 12, base_token_symbol: 'EURA', quote_symbol: 'USD', state_price: '1.02' }, + { block_time: 14, base_token_symbol: 'LUSD', quote_symbol: 'USD', state_price: '1.03' }, + ], + }) + + void testAdapter.request({ base: 'LUSD', quote: 'USD' }) + void testAdapter.request({ base: 'EURA', quote: 'USD' }) + + await waitFor(async () => { + const r1 = await testAdapter.request({ base: 'LUSD', quote: 'USD' }) + const r2 = await testAdapter.request({ base: 'EURA', quote: 'USD' }) + expect(r1.statusCode).toBe(200) + expect(r2.statusCode).toBe(200) + expect(r1.json().result).toBeCloseTo(1.03) + expect(r2.json().result).toBeCloseTo(1.02) + }) + + endSSEStream(stream) + scope.done() + }) + + it('pair-set change triggers reconnect immediately (closes existing stream)', async () => { + const BASE1 = 'LUSD_A' + const BASE2 = 'EURA_A' + + const { scope: s1, stream: st1 } = mockStreamPostRawAnyBody({ + apiBase: 'http://localhost:1234', + chunks: [ + sseEventChunk({ + block_time: 20, + base_token_symbol: BASE1, + quote_symbol: 'USD', + state_price: 1.0, + }), + ], + }) + + void testAdapter.request({ base: BASE1, quote: 'USD' }) + await waitFor(async () => { + const r = await testAdapter.request({ base: BASE1, quote: 'USD' }) + expect(r.statusCode).toBe(200) + expect(r.json().result).toBeCloseTo(1.0, 3) + }) + + const { scope: s2, stream: st2 } = mockStreamPostAnyBody({ + apiBase: 'http://localhost:1234', + events: [ + { + block_time: 22, + base_token_symbol: BASE2, + quote_symbol: 'USD', + state_price: 1.11, + }, + ], + }) + + void testAdapter.request({ base: BASE2, quote: 'USD' }) + await waitFor(async () => { + const r = await testAdapter.request({ base: BASE2, quote: 'USD' }) + expect(r.statusCode).toBe(200) + expect(r.json().result).toBeCloseTo(1.11, 3) + }) + + endSSEStream(st2) + endSSEStream(st1) + s1.done() + s2.done() + }) + + it('ignores events for unknown pairs and non-t_s event types', async () => { + const { scope, stream } = mockStreamPostRawAnyBody({ + apiBase: 'http://localhost:1234', + chunks: [ + sseEventChunk({ + block_time: 30, + base_token_symbol: 'BTC', + quote_symbol: 'JPY', + state_price: 9000000, + }), + sseEventChunk({ ping: true }, 'heartbeat'), + ], + }) + + const r = await testAdapter.request({ base: 'XYZ', quote: 'USD' }) + expect(r.statusCode).toBe(504) + + endSSEStream(stream) + scope.done() + }) + + it('malformed JSON is skipped, keeping last good value only', async () => { + const BASE = 'LUSD2' + const QUOTE = 'USD' + + const { scope, stream } = mockStreamPostRawMatchingBody({ + apiBase: 'http://localhost:1234', + chunks: [ + sseEventChunk('{not-json}'), + sseEventChunk({ + block_time: 40, + base_token_symbol: BASE, + quote_symbol: QUOTE, + state_price: '1.2', + }), + ], + matchBody: (body) => + Array.isArray(body) && + body.some((p: { base?: string; quote?: string }) => p?.base === BASE && p?.quote === QUOTE), + }) + + void testAdapter.request({ base: BASE, quote: QUOTE }) + + await waitFor(async () => { + expect(scope.isDone()).toBe(true) + }) + + await waitFor(async () => { + const r = await testAdapter.request({ base: BASE, quote: QUOTE }) + expect(r.statusCode).toBe(200) + expect(r.json().result).toBeCloseTo(1.2) + }) + + endSSEStream(stream) + }) + + it('foreground fallback returns 502/504 when no cached data exists yet', async () => { + const res = await testAdapter.request({ base: 'ZZZ_NO_CACHE', quote: 'USD' }) + expect([502, 504]).toContain(res.statusCode) + }) + + it('error mapping: 500 -> 502', async () => { + const scope = nock('http://localhost:1234').post('/stream').reply(500, { error: 'x' }).persist() + await new Promise((r) => setTimeout(r, 200)) + const res = await testAdapter.request({ base: 'ERR500', quote: 'USD' }) + expect(res.statusCode).toBe(502) + scope.persist(false) + nock.cleanAll() + }) + + it('error mapping: 429 preserved', async () => { + const scope = nock('http://localhost:1234') + .post('/stream') + .reply(429, { error: 'too many' }) + .persist() + await new Promise((r) => setTimeout(r, 200)) + const res = await testAdapter.request({ base: 'ERR429', quote: 'USD' }) + expect(res.statusCode).toBe(429) + scope.persist(false) + nock.cleanAll() + }) + + it('error mapping: 400 preserved', async () => { + const scope = nock('http://localhost:1234') + .post('/stream') + .reply(400, { error: 'bad' }) + .persist() + await new Promise((r) => setTimeout(r, 200)) + const res = await testAdapter.request({ base: 'ERR400', quote: 'USD' }) + expect(res.statusCode).toBe(400) + scope.persist(false) + nock.cleanAll() + }) + + it('string to number coercion works for state_price and block_time', async () => { + const BASE = 'COERCE_B' + const { scope, stream } = mockStreamPostAnyBody({ + apiBase: 'http://localhost:1234', + events: [ + { + block_time: '12345', + base_token_symbol: BASE, + quote_symbol: 'USD', + state_price: '1.2345', + }, + ], + }) + scope.persist() + + void testAdapter.request({ base: BASE, quote: 'USD' }) + await new Promise((r) => setTimeout(r, 200)) + + await waitFor(async () => { + const r = await testAdapter.request({ base: BASE, quote: 'USD' }) + expect(r.statusCode).toBe(200) + expect(r.json().result).toBeCloseTo(1.2345, 4) + expect(r.json().timestamps.providerIndicatedTimeUnixMs).toBe(12345000) + }, 10_000) + + await waitFor(async () => { + expect(scope.isDone()).toBe(true) + }) + + endSSEStream(stream) + scope.persist(false) + }) +}) diff --git a/packages/sources/coinpaprika-state/test/integration/fixtures.ts b/packages/sources/coinpaprika-state/test/integration/fixtures.ts new file mode 100644 index 0000000000..5aaf0d27a0 --- /dev/null +++ b/packages/sources/coinpaprika-state/test/integration/fixtures.ts @@ -0,0 +1,184 @@ +import nock from 'nock' +import { PassThrough } from 'stream' + +export const sseEventChunk = (payload: object | string, event = 't_s') => { + const dataStr = typeof payload === 'string' ? payload : JSON.stringify(payload) + return `event: ${event}\n` + `data: ${dataStr}\n\n` +} + +const makeRawSSEStream = (chunks: string[]) => { + const stream = new PassThrough() + for (const ch of chunks) stream.write(ch) + return stream +} + +const makeSSEStream = (events: object[]) => { + return makeRawSSEStream(events.map((ev) => sseEventChunk(ev))) +} + +export const endSSEStream = (stream?: PassThrough) => { + if (!stream) return + try { + stream.end() + } catch { + // Ignore errors when ending stream + } + try { + stream.destroy() + } catch { + // Ignore errors when destroying stream + } +} + +export const mockStreamPost = ({ + apiBase, + pairs, + events, + authHeader = 'TEST-KEY', +}: { + apiBase: string + pairs: Array<{ base: string; quote: string }> + events: object[] + authHeader?: string +}) => { + const scope = nock(apiBase, { + reqheaders: { + authorization: (v) => v === authHeader, + accept: (v) => (v || '').toLowerCase().includes('text/event-stream'), + 'content-type': (v) => (v || '').toLowerCase().includes('application/json'), + }, + }) + + const stream = makeSSEStream(events) + + scope + .post('/stream', (body) => Array.isArray(body) && body.length === pairs.length) + .reply(200, () => stream, { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Transfer-Encoding': 'chunked', + }) + + return { scope, stream } +} + +export const mockStreamPostAnyBody = ({ + apiBase, + events, + authHeader = 'TEST-KEY', +}: { + apiBase: string + events: object[] + authHeader?: string +}) => { + const scope = nock(apiBase, { + reqheaders: { + authorization: (v) => v === authHeader, + accept: (v) => (v || '').toLowerCase().includes('text/event-stream'), + 'content-type': (v) => (v || '').toLowerCase().includes('application/json'), + }, + }) + + const stream = makeSSEStream(events) + + scope.post('/stream').reply(200, () => stream, { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Transfer-Encoding': 'chunked', + }) + + return { scope, stream } +} + +export const mockStreamPostRawAnyBody = ({ + apiBase, + chunks, + authHeader = 'TEST-KEY', +}: { + apiBase: string + chunks: string[] + authHeader?: string +}) => { + const scope = nock(apiBase, { + reqheaders: { + authorization: (v) => v === authHeader, + accept: (v) => (v || '').toLowerCase().includes('text/event-stream'), + 'content-type': (v) => (v || '').toLowerCase().includes('application/json'), + }, + }) + + const stream = makeRawSSEStream(chunks) + + scope.post('/stream').reply(200, () => stream, { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Transfer-Encoding': 'chunked', + }) + + return { scope, stream } +} + +export const waitFor = async ( + fn: () => Promise, + timeoutMs = 5000, + stepMs = 150, +): Promise => { + const start = Date.now() + let lastErr: unknown + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await fn() + } catch (e) { + lastErr = e + if (Date.now() - start > timeoutMs) throw lastErr + await new Promise((r) => setTimeout(r, stepMs)) + } + } +} + +export const mockStreamPostRawMatchingBody = ({ + apiBase, + chunks, + matchBody, + authHeader = 'TEST-KEY', + firstDelayMs = 10, +}: { + apiBase: string + chunks: string[] + matchBody: (body: unknown) => boolean + authHeader?: string + firstDelayMs?: number +}) => { + const scope = nock(apiBase, { + reqheaders: { + authorization: (v) => v === authHeader, + accept: (v) => (v || '').toLowerCase().includes('text/event-stream'), + 'content-type': (v) => (v || '').toLowerCase().includes('application/json'), + }, + }) + const stream = new PassThrough() + setTimeout(() => { + for (const ch of chunks) stream.write(ch) + }, firstDelayMs) + + scope + .post('/stream', (body) => { + try { + return matchBody(body) + } catch { + return false + } + }) + .reply(200, () => stream, { + 'Content-Type': 'text/event-stream', + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Transfer-Encoding': 'chunked', + }) + + return { scope, stream } +} diff --git a/packages/sources/coinpaprika-state/test/unit/sse.test.ts b/packages/sources/coinpaprika-state/test/unit/sse.test.ts new file mode 100644 index 0000000000..42a7462f9f --- /dev/null +++ b/packages/sources/coinpaprika-state/test/unit/sse.test.ts @@ -0,0 +1,34 @@ +import { SSEParser } from '../../src/transport/sse' + +describe('SSEParser', () => { + it('parses single event with default type', () => { + const p = new SSEParser('t_s') + const events: Array<{ type: string; data: string }> = [] + p.push('data: {"x":1}\n\n', (t, d) => events.push({ type: t, data: d })) + expect(events).toEqual([{ type: 't_s', data: '{"x":1}' }]) + }) + + it('parses multi-line data and explicit event name', () => { + const p = new SSEParser('t_s') + const events: Array<{ t: string; d: string }> = [] + p.push('event: t_s\ndata: {"a":1\n', () => { + // No-op callback for partial data + }) + p.push('data: ,"b":2}\n\n', (t, d) => events.push({ t, d })) + expect(events).toEqual([{ t: 't_s', d: '{"a":1\n,"b":2}' }]) + }) + + it('ignores comments and keeps trailing partial line in buffer', () => { + const p = new SSEParser('t_s') + const events: Array<{ t: string; d: string }> = [] + p.push(':heartbeat\n', () => { + // No-op callback for heartbeat + }) + p.push('data: {"ok":true}\n', () => { + // No-op callback for partial data + }) + p.push('\n', (t, d) => events.push({ t, d })) + expect(events.length).toBe(1) + expect(events[0].d).toBe('{"ok":true}') + }) +}) diff --git a/packages/sources/coinpaprika-state/test/unit/utils.test.ts b/packages/sources/coinpaprika-state/test/unit/utils.test.ts new file mode 100644 index 0000000000..7475eb1d7e --- /dev/null +++ b/packages/sources/coinpaprika-state/test/unit/utils.test.ts @@ -0,0 +1,44 @@ +import { toNumber } from '../../src/utils' + +describe('utils', () => { + describe('toNumber', () => { + it('should return number when input is already a number', () => { + expect(toNumber(42)).toBe(42) + expect(toNumber(3.14)).toBe(3.14) + expect(toNumber(0)).toBe(0) + expect(toNumber(-10)).toBe(-10) + }) + + it('should convert valid string numbers to numbers', () => { + expect(toNumber('42')).toBe(42) + expect(toNumber('3.14')).toBe(3.14) + expect(toNumber('0')).toBe(0) + expect(toNumber('-10')).toBe(-10) + expect(toNumber(' 123 ')).toBe(123) // with whitespace + }) + + it('should return NaN for invalid string inputs', () => { + expect(toNumber('not a number')).toBe(NaN) + expect(toNumber('abc123')).toBe(NaN) + expect(toNumber('')).toBe(NaN) + expect(toNumber(' ')).toBe(NaN) // only whitespace + }) + + it('should return NaN for null and undefined inputs', () => { + expect(toNumber(null)).toBe(NaN) + expect(toNumber(undefined)).toBe(NaN) + }) + + it('should handle edge cases consistently', () => { + // Our implementation treats non-finite numbers as NaN for safety + expect(toNumber('Infinity')).toBe(NaN) + expect(toNumber('-Infinity')).toBe(NaN) + expect(toNumber('NaN')).toBe(NaN) + }) + + it('should handle very large numbers correctly', () => { + expect(Number.isFinite(toNumber('1e308'))).toBe(true) // large but finite + expect(toNumber('1e400')).toBe(NaN) // becomes Infinity, but we return NaN for safety + }) + }) +}) diff --git a/packages/sources/coinpaprika-state/tsconfig.json b/packages/sources/coinpaprika-state/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/coinpaprika-state/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/coinpaprika-state/tsconfig.test.json b/packages/sources/coinpaprika-state/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/coinpaprika-state/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 85d0d28ba2..4aa660da41 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -260,6 +260,9 @@ { "path": "./sources/coinpaprika" }, + { + "path": "./sources/coinpaprika-state" + }, { "path": "./sources/coinranking" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 72b9a735b6..cf542758cd 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -260,6 +260,9 @@ { "path": "./sources/coinpaprika/tsconfig.test.json" }, + { + "path": "./sources/coinpaprika-state/tsconfig.test.json" + }, { "path": "./sources/coinranking/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 128f4ee39f..790c3fc56e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3238,6 +3238,19 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/coinpaprika-state-adapter@workspace:packages/sources/coinpaprika-state": + version: 0.0.0-use.local + resolution: "@chainlink/coinpaprika-state-adapter@workspace:packages/sources/coinpaprika-state" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.7.2" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/coinranking-adapter@workspace:*, @chainlink/coinranking-adapter@workspace:packages/sources/coinranking": version: 0.0.0-use.local resolution: "@chainlink/coinranking-adapter@workspace:packages/sources/coinranking"