diff --git a/.changeset/lazy-ducks-pay.md b/.changeset/lazy-ducks-pay.md new file mode 100644 index 0000000000..3df943c44d --- /dev/null +++ b/.changeset/lazy-ducks-pay.md @@ -0,0 +1,5 @@ +--- +'@chainlink/icap-adapter': minor +--- + +Seperate ICAP from TP diff --git a/.changeset/popular-queens-yawn.md b/.changeset/popular-queens-yawn.md new file mode 100644 index 0000000000..c86f08f8d2 --- /dev/null +++ b/.changeset/popular-queens-yawn.md @@ -0,0 +1,5 @@ +--- +'@chainlink/tp-adapter': minor +--- + +Combined TP and ICAP EAs into a single EA and removed ICAP.URL must have query param appended as selector in bridge URL, eg: https://:8080?streamName=icapThis change will save subscription costs as all data for both DPs is sent on 1 WS connection and each additional connection requires additional subscriptions (and cost).Should be backwards compatible for TP ONLY diff --git a/.pnp.cjs b/.pnp.cjs index 81734915fa..aed0c964a8 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -9302,7 +9302,10 @@ const RAW_RUNTIME_STATE = ["@types/jest", "npm:27.5.2"],\ ["@types/node", "npm:16.18.96"],\ ["@types/sinonjs__fake-timers", "npm:8.1.5"],\ + ["@types/supertest", "npm:2.0.16"],\ ["decimal.js", "npm:10.4.3"],\ + ["mock-socket", "npm:9.3.1"],\ + ["supertest", "npm:6.2.4"],\ ["tslib", "npm:2.4.1"],\ ["typescript", "patch:typescript@npm%3A5.0.4#optional!builtin::version=5.0.4&hash=b5f058"]\ ],\ diff --git a/.yarn/cache/@nomicfoundation-edr-darwin-arm64-npm-0.3.5-1d9a58a391-10.zip b/.yarn/cache/@nomicfoundation-edr-darwin-arm64-npm-0.3.5-1d9a58a391-10.zip new file mode 100644 index 0000000000..cab68a4ebf Binary files /dev/null and b/.yarn/cache/@nomicfoundation-edr-darwin-arm64-npm-0.3.5-1d9a58a391-10.zip differ diff --git a/.yarn/cache/@nomicfoundation-edr-linux-x64-gnu-npm-0.3.5-9de0955a33-10.zip b/.yarn/cache/@nomicfoundation-edr-linux-x64-gnu-npm-0.3.5-9de0955a33-10.zip deleted file mode 100644 index 43ce8515d1..0000000000 Binary files a/.yarn/cache/@nomicfoundation-edr-linux-x64-gnu-npm-0.3.5-9de0955a33-10.zip and /dev/null differ diff --git a/.yarn/cache/@nomicfoundation-solidity-analyzer-darwin-arm64-npm-0.1.1-269bd960f5-10.zip b/.yarn/cache/@nomicfoundation-solidity-analyzer-darwin-arm64-npm-0.1.1-269bd960f5-10.zip new file mode 100644 index 0000000000..a5b9459829 Binary files /dev/null and b/.yarn/cache/@nomicfoundation-solidity-analyzer-darwin-arm64-npm-0.1.1-269bd960f5-10.zip differ diff --git a/.yarn/cache/@nomicfoundation-solidity-analyzer-linux-x64-gnu-npm-0.1.1-d68b54567f-10.zip b/.yarn/cache/@nomicfoundation-solidity-analyzer-linux-x64-gnu-npm-0.1.1-d68b54567f-10.zip deleted file mode 100644 index d7f10dc401..0000000000 Binary files a/.yarn/cache/@nomicfoundation-solidity-analyzer-linux-x64-gnu-npm-0.1.1-d68b54567f-10.zip and /dev/null differ diff --git a/.yarn/cache/@rometools-cli-darwin-arm64-npm-12.1.3-e1f412f8be-10.zip b/.yarn/cache/@rometools-cli-darwin-arm64-npm-12.1.3-e1f412f8be-10.zip new file mode 100644 index 0000000000..9cac8566a2 Binary files /dev/null and b/.yarn/cache/@rometools-cli-darwin-arm64-npm-12.1.3-e1f412f8be-10.zip differ diff --git a/.yarn/cache/@rometools-cli-linux-x64-npm-12.1.3-a343b37dfc-10.zip b/.yarn/cache/@rometools-cli-linux-x64-npm-12.1.3-a343b37dfc-10.zip deleted file mode 100644 index 37789e54c5..0000000000 Binary files a/.yarn/cache/@rometools-cli-linux-x64-npm-12.1.3-a343b37dfc-10.zip and /dev/null differ diff --git a/.yarn/cache/fsevents-patch-19706e7e35-10.zip b/.yarn/cache/fsevents-patch-19706e7e35-10.zip new file mode 100644 index 0000000000..aff1ab12ce Binary files /dev/null and b/.yarn/cache/fsevents-patch-19706e7e35-10.zip differ diff --git a/.yarn/cache/fsevents-patch-afc6995412-10.zip b/.yarn/cache/fsevents-patch-afc6995412-10.zip new file mode 100644 index 0000000000..34871c571d Binary files /dev/null and b/.yarn/cache/fsevents-patch-afc6995412-10.zip differ diff --git a/packages/sources/icap/src/endpoint/price.ts b/packages/sources/icap/src/endpoint/price.ts index bba3e5f974..fa53e47ce4 100644 --- a/packages/sources/icap/src/endpoint/price.ts +++ b/packages/sources/icap/src/endpoint/price.ts @@ -1,5 +1,11 @@ -import { generateInputParams, GeneratePriceOptions, generateTransport } from '@chainlink/tp-adapter' +import { GeneratePriceOptions } from '@chainlink/tp-adapter' import { ForexPriceEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { + priceEndpointInputParametersDefinition, + PriceEndpointInputParametersDefinition, +} from '@chainlink/external-adapter-framework/adapter' +import { generateTransport } from '../transport/price' const options: GeneratePriceOptions = { sourceName: 'icapSource', @@ -7,6 +13,30 @@ const options: GeneratePriceOptions = { sourceOptions: ['BGK', 'GBL', 'HKG', 'JHB'], } +export const generateInputParams = ( + generatePriceOptions: GeneratePriceOptions, +): InputParameters => + new InputParameters( + { + ...priceEndpointInputParametersDefinition, + [generatePriceOptions.sourceName]: { + description: `Source of price data for this price pair on the ${generatePriceOptions.streamName} stream`, + default: 'GBL', + required: false, + type: 'string', + ...(generatePriceOptions.sourceOptions + ? { options: generatePriceOptions.sourceOptions } + : {}), + }, + }, + [ + { + base: 'EUR', + quote: 'USD', + }, + ], + ) + const inputParameters = generateInputParams(options) const transport = generateTransport(options) diff --git a/packages/sources/icap/src/transport/price.ts b/packages/sources/icap/src/transport/price.ts new file mode 100644 index 0000000000..b2f0dccaa8 --- /dev/null +++ b/packages/sources/icap/src/transport/price.ts @@ -0,0 +1,210 @@ +import Decimal from 'decimal.js' +import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports/websocket' +import { makeLogger, ProviderResult } from '@chainlink/external-adapter-framework/util' +import { BaseEndpointTypes, GeneratePriceOptions } from '@chainlink/tp-adapter' + +const logger = makeLogger('TpIcapPrice') + +type WsMessage = { + msg: 'auth' | 'sub' + pro?: string + rec: string // example: FXSPTEURUSDSPT:GBL.BIL.QTE.RTM!IC + sta: number + img?: number + fvs?: { + CCY1?: string // example: "EUR" + CCY2?: string // example: "USD" + ACTIV_DATE?: string // example: "2023-03-06" + TIMACT?: string // example: "15:00:00" + BID?: number + ASK?: number + MID_PRICE?: number + } +} + +export type WsTransportTypes = BaseEndpointTypes & { + Provider: { + WsMessage: WsMessage + } +} + +const isNum = (i: number | undefined) => typeof i === 'number' + +let providerDataStreamEstablishedUnixMs: number + +/* +TP and ICAP EAs currently do not receive asset prices during off-market hours. When a heartbeat message is received during these hours, +we update the TTL of cache entries that EA is requested to provide a price during off-market hours. + */ +const updateTTL = async (transport: WebSocketTransport, ttl: number) => { + const params = await transport.subscriptionSet.getAll() + transport.responseCache.writeTTL(transport.name, params, ttl) +} + +export const generateTransport = (generatePriceOptions: GeneratePriceOptions) => { + const tpTransport = new WebSocketTransport({ + url: ({ adapterSettings: { WS_API_ENDPOINT } }) => WS_API_ENDPOINT, + handlers: { + open: (connection, { adapterSettings: { WS_API_USERNAME, WS_API_PASSWORD } }) => { + logger.debug('Opening WS connection') + + return new Promise((resolve) => { + connection.addEventListener('message', (event: MessageEvent) => { + const { msg, sta } = JSON.parse(event.data.toString()) + if (msg === 'auth' && sta === 1) { + logger.info('Got logged in response, connection is ready') + providerDataStreamEstablishedUnixMs = Date.now() + resolve() + } + }) + const options = { + msg: 'auth', + user: WS_API_USERNAME, + pass: WS_API_PASSWORD, + mode: 'broadcast', + } + connection.send(JSON.stringify(options)) + }) + }, + message: (message, context) => { + logger.debug({ msg: 'Received message from WS', message }) + + const providerDataReceivedUnixMs = Date.now() + + if (!('msg' in message) || message.msg === 'auth') return [] + + const { fvs, rec, sta } = message + + if (!fvs || !rec || sta !== 1) { + logger.debug({ msg: 'Missing expected field `fvs` or `rec` from `sub` message', message }) + return [] + } + + // Check for a heartbeat message, refresh the TTLs of all requested entries in the cache + if (rec.includes('HBHHH')) { + logger.debug({ + msg: 'Received heartbeat message from WS, updating TTLs of active entries', + message, + }) + updateTTL(tpTransport, context.adapterSettings.CACHE_MAX_AGE) + return [] + } + + const ticker = parseRec(rec) + if (!ticker) { + logger.debug({ msg: `Invalid symbol: ${rec}`, message }) + return [] + } + + if (ticker.stream !== generatePriceOptions.streamName) { + logger.debug({ + msg: `Only ${generatePriceOptions.streamName} forex prices accepted on this adapter. Filtering out this message.`, + message, + }) + return [] + } + + const { ASK, BID, MID_PRICE } = fvs + + if (!isNum(MID_PRICE) && !(isNum(BID) && isNum(ASK))) { + const errorMessage = '`sub` message did not include required price fields' + logger.debug({ errorMessage, message }) + return [] + } + + const result = + MID_PRICE || + new Decimal(ASK as number) + .add(BID as number) + .div(2) + .toNumber() + + const response = { + result, + data: { + result, + }, + timestamps: { + providerDataReceivedUnixMs, + providerDataStreamEstablishedUnixMs, + providerIndicatedTimeUnixMs: undefined, + }, + } + + // Cache both the base and the full ticker string. The full ticker is to + // accomodate cases where there are multiple instruments for a single base + // (e.g. forwards like CEFWDXAUUSDSPT06M:LDN.BIL.QTE.RTM!TP, CEFWDXAUUSDSPT02Y:LDN.BIL.QTE.RTM!TP, CEFWDXAUUSDSPT03M:LDN.BIL.QTE.RTM!TP, etc). + // It is expected that for such cases, the exact ticker will be provided as + // an override. + // e.g. request body = {"data":{"endpoint":"forex","from":"CHF","to":"USD","overrides":{"tp":{"CHF":"FXSPTUSDAEDSPT:GBL.BIL.QTE.RTM!TP"}}}} + return [ + { + params: { + base: ticker.base, + quote: ticker.quote, + [generatePriceOptions.sourceName]: ticker.source, + }, + response, + }, + { + params: { + base: rec, + quote: ticker.quote, + [generatePriceOptions.sourceName]: ticker.source, + }, + response, + }, + ] as unknown as ProviderResult[] + }, + }, + }) + return tpTransport +} + +// mapping OTRWTS to WTIUSD specifically for caching with quote = USD +const marketBaseQuoteOverrides: Record = { + CEOILOTRWTS: 'CEOILWTIUSD', +} + +type Ticker = { + market: string + base: string + quote: string + source: string + stream: string +} + +/* +For example, if rec = 'FXSPTCHFSEKSPT:GBL.BIL.QTE.RTM!IC', then the parsed output is +{ + market: 'FXSPT', + base: 'CHF', + quote: 'SEK', + source: 'GBL', + stream: 'IC' +} +*/ +export const parseRec = (rec: string): Ticker | null => { + const [symbol, rec1] = rec.split(':') + if (!rec1) { + return null + } + + const [sources, stream] = rec1.split('!') + if (!stream) { + return null + } + + let marketBaseQuote = symbol.slice(0, 11) + if (marketBaseQuote in marketBaseQuoteOverrides) { + marketBaseQuote = marketBaseQuoteOverrides[marketBaseQuote] + } + + return { + market: marketBaseQuote.slice(0, 5), + base: marketBaseQuote.slice(5, 8), + quote: marketBaseQuote.slice(8, 11), + source: sources.split('.')[0], + stream, + } +} diff --git a/packages/sources/tp/README.md b/packages/sources/tp/README.md index bd69c747cb..e4c1a47f3c 100644 --- a/packages/sources/tp/README.md +++ b/packages/sources/tp/README.md @@ -8,9 +8,9 @@ This document was generated automatically. Please see [README Generator](../../s ### Concurrent connections -Context: TP and ICAP EAs use the same credentials, and often there are issues with the set of credentials not having concurrent (ie: 2+) connections enabled. +Context: Often there are issues with the set of credentials not having concurrent (ie: 2+) connections enabled. -- With both TP and ICAP EAs off, try the following commands to check if this is the case: +- With all EA instances off, try the following commands to check if this is the case: ```bash wscat -c 'ws://json.mktdata.portal.apac.parametasolutions.com:12000' @@ -56,11 +56,12 @@ Supported names for this endpoint are: `commodities`, `forex`, `price`. ### Input Params -| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | -| :-------: | :------: | :------------: | :-------------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | -| ✅ | base | `coin`, `from` | The symbol of symbols of the currency to query | string | | | | | -| ✅ | quote | `market`, `to` | The symbol of the currency to convert to | string | | | | | -| | tpSource | | Source of price data for this price pair on the TP stream | string | | `GBL` | | | +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :--------: | :------------: | :----------------------------------------------------: | :----: | :--------: | :-----: | :--------: | :------------: | +| ✅ | base | `coin`, `from` | The symbol of symbols of the currency to query | string | | | | | +| ✅ | quote | `market`, `to` | The symbol of the currency to convert to | string | | | | | +| | streamName | `source` | TP or ICAP | string | `IC`, `TP` | `TP` | | | +| | sourceName | `tpSource` | Source of price data for this price pair on the stream | string | | `GBL` | | | ### Example @@ -71,7 +72,9 @@ Request: "data": { "endpoint": "price", "base": "EUR", - "quote": "USD" + "quote": "USD", + "streamName": "TP", + "sourceName": "GBL" } } ``` diff --git a/packages/sources/tp/docs/known-issues.md b/packages/sources/tp/docs/known-issues.md index 9189da312f..e49262ea77 100644 --- a/packages/sources/tp/docs/known-issues.md +++ b/packages/sources/tp/docs/known-issues.md @@ -2,9 +2,9 @@ ### Concurrent connections -Context: TP and ICAP EAs use the same credentials, and often there are issues with the set of credentials not having concurrent (ie: 2+) connections enabled. +Context: Often there are issues with the set of credentials not having concurrent (ie: 2+) connections enabled. -- With both TP and ICAP EAs off, try the following commands to check if this is the case: +- With all EA instances off, try the following commands to check if this is the case: ```bash wscat -c 'ws://json.mktdata.portal.apac.parametasolutions.com:12000' diff --git a/packages/sources/tp/package.json b/packages/sources/tp/package.json index 1e8053fc17..abf36c7e84 100644 --- a/packages/sources/tp/package.json +++ b/packages/sources/tp/package.json @@ -38,6 +38,9 @@ "@types/jest": "27.5.2", "@types/node": "16.18.96", "@types/sinonjs__fake-timers": "8.1.5", + "@types/supertest": "2.0.16", + "mock-socket": "9.3.1", + "supertest": "6.2.4", "typescript": "5.0.4" } } diff --git a/packages/sources/tp/src/config/includes.json b/packages/sources/tp/src/config/includes.json index a4c19154e0..31d4b18932 100644 --- a/packages/sources/tp/src/config/includes.json +++ b/packages/sources/tp/src/config/includes.json @@ -1,4 +1,15 @@ [ + { + "from": "CAD", + "to": "USD", + "includes": [ + { + "from": "USD", + "to": "CAD", + "inverse": true + } + ] + }, { "from": "AED", "to": "USD", diff --git a/packages/sources/tp/src/endpoint/index.ts b/packages/sources/tp/src/endpoint/index.ts index 87cff34c41..91f04d809b 100644 --- a/packages/sources/tp/src/endpoint/index.ts +++ b/packages/sources/tp/src/endpoint/index.ts @@ -1 +1,6 @@ -export { generateInputParams, priceEndpoint, GeneratePriceOptions } from './price' +export { + generateInputParams, + priceEndpoint, + GeneratePriceOptions, + BaseEndpointTypes, +} from './price' diff --git a/packages/sources/tp/src/endpoint/price.ts b/packages/sources/tp/src/endpoint/price.ts index 6676c632ca..6cc298af66 100644 --- a/packages/sources/tp/src/endpoint/price.ts +++ b/packages/sources/tp/src/endpoint/price.ts @@ -9,53 +9,68 @@ import { WebsocketTransportGenerics } from '@chainlink/external-adapter-framewor import { InputParameters } from '@chainlink/external-adapter-framework/validation' import { generateTransport } from '../transport/price' +export type QueryParams = { + streamName: 'TP' | 'IC' +} + export type BaseEndpointTypes = WebsocketTransportGenerics & { Parameters: typeof inputParameters.definition Response: SingleNumberResultResponse Settings: typeof config.settings } +// Used by ICAP EA only, remove after EA is removed export type GeneratePriceOptions = { sourceName: 'tpSource' | 'icapSource' streamName: 'TP' | 'IC' sourceOptions?: string[] } -export const generateInputParams = ( - generatePriceOptions: GeneratePriceOptions, -): InputParameters => +export const generateInputParams = (): InputParameters => new InputParameters( { ...priceEndpointInputParametersDefinition, - [generatePriceOptions.sourceName]: { - description: `Source of price data for this price pair on the ${generatePriceOptions.streamName} stream`, + streamName: { + aliases: ['source'], + description: "TP ('TP') or ICAP ('IC')", + options: ['TP', 'IC'], + default: 'TP', + required: false, + type: 'string', + }, + sourceName: { + aliases: ['tpSource'], // for backward compatibility, icapSource is not used + description: `Source of price data for this price pair on the stream`, default: 'GBL', required: false, type: 'string', - ...(generatePriceOptions.sourceOptions - ? { options: generatePriceOptions.sourceOptions } - : {}), }, }, [ { base: 'EUR', quote: 'USD', + streamName: 'TP', + sourceName: 'GBL', }, ], ) -const tpOptions: GeneratePriceOptions = { - sourceName: 'tpSource', - streamName: 'TP', -} as const - -const inputParameters = generateInputParams(tpOptions) -const wsTransport = generateTransport(tpOptions) +const inputParameters = generateInputParams() +const wsTransport = generateTransport() export const priceEndpoint = new PriceEndpoint({ name: 'price', aliases: ['commodities', 'forex'], transport: wsTransport, inputParameters, + requestTransforms: [ + (req) => { + // use query param streamName as replacement due to combination + const rq = req.query as QueryParams + if (rq.streamName) { + req.requestContext.data.streamName = rq.streamName.toUpperCase() + } + }, + ], }) diff --git a/packages/sources/tp/src/transport/price.ts b/packages/sources/tp/src/transport/price.ts index d762206962..3843412a90 100644 --- a/packages/sources/tp/src/transport/price.ts +++ b/packages/sources/tp/src/transport/price.ts @@ -1,7 +1,7 @@ import Decimal from 'decimal.js' import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports/websocket' import { makeLogger, ProviderResult } from '@chainlink/external-adapter-framework/util' -import { BaseEndpointTypes, GeneratePriceOptions } from '../endpoint/price' +import { BaseEndpointTypes } from '../endpoint/price' const logger = makeLogger('TpIcapPrice') @@ -33,7 +33,7 @@ const isNum = (i: number | undefined) => typeof i === 'number' let providerDataStreamEstablishedUnixMs: number /* -TP and ICAP EAs currently do not receive asset prices during off-market hours. When a heartbeat message is received during these hours, +EAs currently do not receive asset prices during off-market hours. When a heartbeat message is received during these hours, we update the TTL of cache entries that EA is requested to provide a price during off-market hours. */ const updateTTL = async (transport: WebSocketTransport, ttl: number) => { @@ -41,7 +41,7 @@ const updateTTL = async (transport: WebSocketTransport, ttl: n transport.responseCache.writeTTL(transport.name, params, ttl) } -export const generateTransport = (generatePriceOptions: GeneratePriceOptions) => { +export const generateTransport = () => { const tpTransport = new WebSocketTransport({ url: ({ adapterSettings: { WS_API_ENDPOINT } }) => WS_API_ENDPOINT, handlers: { @@ -96,14 +96,6 @@ export const generateTransport = (generatePriceOptions: GeneratePriceOptions) => return [] } - if (ticker.stream !== generatePriceOptions.streamName) { - logger.debug({ - msg: `Only ${generatePriceOptions.streamName} forex prices accepted on this adapter. Filtering out this message.`, - message, - }) - return [] - } - const { ASK, BID, MID_PRICE } = fvs if (!isNum(MID_PRICE) && !(isNum(BID) && isNum(ASK))) { @@ -142,7 +134,8 @@ export const generateTransport = (generatePriceOptions: GeneratePriceOptions) => params: { base: ticker.base, quote: ticker.quote, - [generatePriceOptions.sourceName]: ticker.source, + streamName: ticker.stream, + sourceName: ticker.source, }, response, }, @@ -150,7 +143,8 @@ export const generateTransport = (generatePriceOptions: GeneratePriceOptions) => params: { base: rec, quote: ticker.quote, - [generatePriceOptions.sourceName]: ticker.source, + streamName: ticker.stream, + sourceName: ticker.source, }, response, }, diff --git a/packages/sources/tp/test/integration/__snapshots__/icap_adapter.test.ts.snap b/packages/sources/tp/test/integration/__snapshots__/icap_adapter.test.ts.snap new file mode 100644 index 0000000000..e42ecac0cb --- /dev/null +++ b/packages/sources/tp/test/integration/__snapshots__/icap_adapter.test.ts.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Price Endpoint should return error on empty base 1`] = ` +{ + "error": { + "message": "[Param: base] param is required but no value was provided", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + +exports[`Price Endpoint should return error on empty body 1`] = ` +{ + "error": { + "message": "[Param: base] param is required but no value was provided", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + +exports[`Price Endpoint should return error on empty data 1`] = ` +{ + "error": { + "message": "[Param: base] param is required but no value was provided", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + +exports[`Price Endpoint should return error on empty quote 1`] = ` +{ + "error": { + "message": "[Param: quote] param is required but no value was provided", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + +exports[`Price Endpoint should return error when queried for TP price 1`] = ` +{ + "error": { + "message": "The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 504, +} +`; + +exports[`Price Endpoint should return error when queried for stale price 1`] = ` +{ + "error": { + "message": "The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 504, +} +`; + +exports[`Price Endpoint should return price 1`] = ` +{ + "data": { + "result": 1.0539, + }, + "result": 1.0539, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1678242600000, + "providerDataStreamEstablishedUnixMs": 1678242600000, + }, +} +`; + +exports[`Price Endpoint should return price for inverse pair 1`] = ` +{ + "data": { + "result": 1.239771881973717, + }, + "result": 1.239771881973717, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1678242600000, + "providerDataStreamEstablishedUnixMs": 1678242600000, + }, +} +`; + +exports[`Price Endpoint should return price for specific source 1`] = ` +{ + "data": { + "result": 1.55, + }, + "result": 1.55, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1678242600000, + "providerDataStreamEstablishedUnixMs": 1678242600000, + }, +} +`; + +exports[`Price Endpoint should succeed with CAD/USD 1`] = ` +{ + "data": { + "result": 0.16, + }, + "result": 0.16, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1678242600000, + "providerDataStreamEstablishedUnixMs": 1678242600000, + }, +} +`; + +exports[`Price Endpoint should succeed with USD/CAD 1`] = ` +{ + "data": { + "result": 6.25, + }, + "result": 6.25, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1678242600000, + "providerDataStreamEstablishedUnixMs": 1678242600000, + }, +} +`; diff --git a/packages/sources/tp/test/integration/icap_adapter.test.ts b/packages/sources/tp/test/integration/icap_adapter.test.ts new file mode 100644 index 0000000000..236dd8b483 --- /dev/null +++ b/packages/sources/tp/test/integration/icap_adapter.test.ts @@ -0,0 +1,128 @@ +import * as process from 'process' +import { AddressInfo } from 'net' +import { + mockWebSocketProvider, + mockPriceWebSocketServer, + createAdapter, + setEnvVariables, +} from './setup' +import request, { SuperTest, Test } from 'supertest' +import { Server } from 'mock-socket' +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { sleep } from '@chainlink/external-adapter-framework/util' +import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' +import { mockConnectionTime } from './icap_fixtures' +import { PriceEndpointInputParametersDefinition } from '@chainlink/external-adapter-framework/adapter' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' + +type AdapterRequest = { + data?: TypeFromDefinition +} + +describe('Price Endpoint', () => { + let fastify: ServerInstance | undefined + let req: SuperTest + let mockPriceWsServer: Server | undefined + let spy: jest.SpyInstance + + const makeRequest = (body: AdapterRequest) => + req + .post('') + .send(body) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + + jest.setTimeout(10000) + + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + const wsEndpoint = 'ws://localhost:9099' + + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env['WS_SUBSCRIPTION_TTL'] = '5000' + process.env['CACHE_MAX_AGE'] = '5000' + process.env['CACHE_POLLING_MAX_RETRIES'] = '0' + process.env['METRICS_ENABLED'] = 'false' + process.env['WS_API_USERNAME'] = 'test-username' + process.env['WS_API_PASSWORD'] = 'test-password' + process.env['WS_API_ENDPOINT'] = wsEndpoint + + spy = jest.spyOn(Date, 'now').mockReturnValue(mockConnectionTime.getTime()) + + mockWebSocketProvider(WebSocketClassProvider) + mockPriceWsServer = mockPriceWebSocketServer(wsEndpoint) + + fastify = await expose(createAdapter()) + req = request( + `http://localhost:${(fastify?.server.address() as AddressInfo).port}?streamName=ic`, + ) + + // Send initial request to start background execute + await req.post('/').send({ data: { base: 'JPY', quote: 'USD' } }) + await sleep(5000) + }) + + afterAll((done) => { + spy.mockRestore() + setEnvVariables(oldEnv) + mockPriceWsServer?.close() + fastify?.close(done()) + }) + + it('should succeed with CAD/USD', async () => { + const response = await makeRequest({ data: { base: 'CAD', quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should succeed with USD/CAD', async () => { + const response = await makeRequest({ data: { base: 'USD', quote: 'CAD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return price', async () => { + const response = await makeRequest({ data: { base: 'EUR', quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return price for inverse pair', async () => { + const response = await makeRequest({ data: { base: 'IDR', quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return price for specific source', async () => { + const response = await makeRequest({ data: { base: 'EUR', quote: 'USD', sourceName: 'BGK' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error when queried for TP price', async () => { + const response = await makeRequest({ data: { base: 'ABC', quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error when queried for stale price', async () => { + const response = await makeRequest({ data: { base: 'JPY', quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error on empty body', async () => { + const response = await makeRequest({}) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error on empty data', async () => { + const response = await makeRequest({ data: {} }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error on empty base', async () => { + const response = await makeRequest({ data: { quote: 'USD' } }) + expect(response.body).toMatchSnapshot() + }, 30000) + + it('should return error on empty quote', async () => { + const response = await makeRequest({ data: { base: 'EUR' } }) + expect(response.body).toMatchSnapshot() + }, 30000) +}) diff --git a/packages/sources/tp/test/integration/icap_fixtures.ts b/packages/sources/tp/test/integration/icap_fixtures.ts new file mode 100644 index 0000000000..911a26f1ff --- /dev/null +++ b/packages/sources/tp/test/integration/icap_fixtures.ts @@ -0,0 +1,128 @@ +export const mockConnectionTime = new Date('2023-03-08T02:30:00.000Z') + +export const mockSubscribeResponse = { msg: 'auth', sta: 1 } + +export const mockUSDCADResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTUSDCADSPT:GBL.BIL.QTE.RTM!IC', + sta: 1, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 6.2, + BID: 6.3, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'USD', + MID_PRICE: 6.25, + FID_515: 'SPT', + CCY2: 'CAD', + }, +} + +export const mockPriceResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTEURUSDSPT:GBL.BIL.QTE.RTM!IC', + sta: 1, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 1.054, + BID: 1.0538, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'EUR', + MID_PRICE: 1.0539, + FID_515: 'SPT', + CCY2: 'USD', + }, +} + +export const mockSeparateSourcePriceResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTEURUSDSPT:BGK.BIL.QTE.RTM!IC', + sta: 1, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 1.02, + BID: 2.01, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'EUR', + MID_PRICE: 1.55, + FID_515: 'SPT', + CCY2: 'USD', + }, +} + +export const mockTPPriceResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTEURUSDSPT:GBL.BIL.QTE.RTM!TP', + sta: 1, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 1.234, + BID: 1.567, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'EUR', + MID_PRICE: 1.345, + FID_515: 'SPT', + CCY2: 'USD', + }, +} + +export const mockInversePriceResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTUSDIDRSPT:GBL.BIL.QTE.RTM!IC', + sta: 1, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 0.8064, + BID: 0.8068, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'USD', + MID_PRICE: 0.8066, + FID_515: 'SPT', + CCY2: 'IDR', + }, +} + +export const mockStalePriceResponse = { + msg: 'sub', + pro: 'OMM', + rec: 'FXSPTJPYUSDSPT:GBL.BIL.QTE.RTM!IC', + sta: 0, + img: 1, + fvs: { + VALUE_DT1: null, + ASK: 0.0073, + BID: 0.0073, + ACTIV_DATE: '2023-03-08', + TIMACT: '02:31:00', + CCY1: 'JPY', + MID_PRICE: 0.0073, + FID_515: 'SPT', + CCY2: 'USD', + }, +} + +export const adapterResponse = { + result: 1.0539, + statusCode: 200, + data: { result: 1.0539 }, + timestamps: { + providerDataReceivedUnixMs: mockConnectionTime.getTime(), + providerDataStreamEstablishedUnixMs: mockConnectionTime.getTime(), + providerIndicatedTimeUnixMs: undefined, + }, +} diff --git a/packages/sources/tp/test/integration/setup.ts b/packages/sources/tp/test/integration/setup.ts new file mode 100644 index 0000000000..4a9a4aeeac --- /dev/null +++ b/packages/sources/tp/test/integration/setup.ts @@ -0,0 +1,91 @@ +import * as process from 'process' +import { SuperTest, Test } from 'supertest' +import { Server, WebSocket } from 'mock-socket' +import { + mockUSDCADResponse, + mockTPPriceResponse, + mockInversePriceResponse, + mockPriceResponse, + mockStalePriceResponse, + mockSubscribeResponse, + mockSeparateSourcePriceResponse, +} from './icap_fixtures' +import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' +import { PriceAdapter } from '@chainlink/external-adapter-framework/adapter' +import { priceEndpoint } from '../../src/endpoint' +import { ServerInstance } from '@chainlink/external-adapter-framework' + +import { config } from '../../src/config' +import includes from '../../src/config/includes.json' + +export type SuiteContext = { + req: SuperTest | null + server: () => Promise + fastify?: ServerInstance +} + +export type EnvVariables = { [key: string]: string } + +export type TestOptions = { cleanNock?: boolean; fastify?: boolean } + +/** + * Sets the mocked websocket instance in the provided provider class. + * We need this here, because the tests will connect using their instance of WebSocketClassProvider; + * fetching from this library to the \@chainlink/ea-bootstrap package would access _another_ instance + * of the same constructor. Although it should be a singleton, dependencies are different so that + * means that the static classes themselves are also different. + * + * @param provider - singleton WebSocketClassProvider + */ +export const mockWebSocketProvider = (provider: typeof WebSocketClassProvider): void => { + // Extend mock WebSocket class to bypass protocol headers error + class MockWebSocket extends WebSocket { + constructor(url: string, protocol: string | string[] | Record | undefined) { + super(url, protocol instanceof Object ? undefined : protocol) + } + // This is part of the 'ws' node library but not the common interface, but it's used in our WS transport + removeAllListeners() { + for (const eventType in this.listeners) { + // We have to manually check because the mock-socket library shares this instance, and adds the server listeners to the same obj + if (!eventType.startsWith('server')) { + delete this.listeners[eventType] + } + } + } + } + + // Need to disable typing, the mock-socket impl does not implement the ws interface fully + provider.set(MockWebSocket as any) // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export const mockPriceWebSocketServer = (URL: string): Server => { + const mockWsServer = new Server(URL, { mock: false }) + mockWsServer.on('connection', (socket) => { + socket.send(JSON.stringify(mockSubscribeResponse)) + socket.on('message', () => { + socket.send(JSON.stringify(mockStalePriceResponse)) + socket.send(JSON.stringify(mockUSDCADResponse)) + socket.send(JSON.stringify(mockPriceResponse)) + socket.send(JSON.stringify(mockInversePriceResponse)) + socket.send(JSON.stringify(mockTPPriceResponse)) + socket.send(JSON.stringify(mockSeparateSourcePriceResponse)) + }) + }) + return mockWsServer +} + +export const createAdapter = () => { + return new PriceAdapter({ + name: 'TEST', + defaultEndpoint: 'price', + endpoints: [priceEndpoint], + config: config, + includes: includes, + }) +} + +export function setEnvVariables(envVariables: NodeJS.ProcessEnv): void { + for (const key in envVariables) { + process.env[key] = envVariables[key] + } +} diff --git a/yarn.lock b/yarn.lock index 242f84180c..7f8a983999 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6180,7 +6180,10 @@ __metadata: "@types/jest": "npm:27.5.2" "@types/node": "npm:16.18.96" "@types/sinonjs__fake-timers": "npm:8.1.5" + "@types/supertest": "npm:2.0.16" decimal.js: "npm:^10.3.1" + mock-socket: "npm:9.3.1" + supertest: "npm:6.2.4" tslib: "npm:^2.3.1" typescript: "npm:5.0.4" languageName: unknown