diff --git a/.changeset/heavy-stingrays-breathe.md b/.changeset/heavy-stingrays-breathe.md new file mode 100644 index 0000000000..3dc6927b07 --- /dev/null +++ b/.changeset/heavy-stingrays-breathe.md @@ -0,0 +1,5 @@ +--- +'@chainlink/coinmetrics-lwba-adapter': major +--- + +Initial Release of the coinmetrics-lwba EA diff --git a/.pnp.cjs b/.pnp.cjs index eed8e08dcd..80ed4731e8 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -378,6 +378,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/coinmetrics-adapter",\ "reference": "workspace:packages/sources/coinmetrics"\ },\ + {\ + "name": "@chainlink/coinmetrics-lwba-adapter",\ + "reference": "workspace:packages/sources/coinmetrics-lwba"\ + },\ {\ "name": "@chainlink/coinpaprika-adapter",\ "reference": "workspace:packages/sources/coinpaprika"\ @@ -1001,6 +1005,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/coinlore-adapter", ["workspace:packages/sources/coinlore"]],\ ["@chainlink/coinmarketcap-adapter", ["workspace:packages/sources/coinmarketcap"]],\ ["@chainlink/coinmetrics-adapter", ["workspace:packages/sources/coinmetrics"]],\ + ["@chainlink/coinmetrics-lwba-adapter", ["workspace:packages/sources/coinmetrics-lwba"]],\ ["@chainlink/coinpaprika-adapter", ["workspace:packages/sources/coinpaprika"]],\ ["@chainlink/coinranking-adapter", ["workspace:packages/sources/coinranking"]],\ ["@chainlink/conflux-adapter", ["workspace:packages/targets/conflux"]],\ @@ -5971,6 +5976,23 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/coinmetrics-lwba-adapter", [\ + ["workspace:packages/sources/coinmetrics-lwba", {\ + "packageLocation": "./packages/sources/coinmetrics-lwba/",\ + "packageDependencies": [\ + ["@chainlink/coinmetrics-lwba-adapter", "workspace:packages/sources/coinmetrics-lwba"],\ + ["@chainlink/external-adapter-framework", "npm:2.6.0"],\ + ["@sinonjs/fake-timers", "npm:9.1.2"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["@types/sinonjs__fake-timers", "npm:8.1.5"],\ + ["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/coinpaprika-adapter", [\ ["workspace:packages/sources/coinpaprika", {\ "packageLocation": "./packages/sources/coinpaprika/",\ diff --git a/packages/sources/coinmetrics-lwba/CHANGELOG.md b/packages/sources/coinmetrics-lwba/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/coinmetrics-lwba/README.md b/packages/sources/coinmetrics-lwba/README.md new file mode 100644 index 0000000000..7f231ad126 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for coinmetrics-lwba + +This README will be generated automatically when code is merged to `main`. If you would like to generate a preview of the README, please run `yarn generate:readme coinmetrics-lwba`. diff --git a/packages/sources/coinmetrics-lwba/package.json b/packages/sources/coinmetrics-lwba/package.json new file mode 100644 index 0000000000..fcaacc7993 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/package.json @@ -0,0 +1,42 @@ +{ + "name": "@chainlink/coinmetrics-lwba-adapter", + "version": "0.0.0", + "description": "Chainlink coinmetrics-lwba adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "coinmetrics-lwba" + ], + "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" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.6.0", + "tslib": "2.4.1" + }, + "devDependencies": { + "@sinonjs/fake-timers": "9.1.2", + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "@types/sinonjs__fake-timers": "8.1.5", + "nock": "13.5.6", + "typescript": "5.8.3" + } +} diff --git a/packages/sources/coinmetrics-lwba/src/config/index.ts b/packages/sources/coinmetrics-lwba/src/config/index.ts new file mode 100644 index 0000000000..8e6e0189da --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/config/index.ts @@ -0,0 +1,22 @@ +// Quote values are used to find a dynamic property in the DP response, in the form of ReferenceRate{quote} + +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'The coinmetrics API key', + type: 'string', + required: true, + sensitive: true, + }, + WS_API_ENDPOINT: { + description: 'The websocket url for coinmetrics', + type: 'string', + default: 'wss://api.coinmetrics.io/v4', + }, + API_ENDPOINT: { + description: 'The API url for coinmetrics', + type: 'string', + default: 'https://api.coinmetrics.io/v4', + }, +}) diff --git a/packages/sources/coinmetrics-lwba/src/endpoint/crypto-lwba.ts b/packages/sources/coinmetrics-lwba/src/endpoint/crypto-lwba.ts new file mode 100644 index 0000000000..0c401c79eb --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/endpoint/crypto-lwba.ts @@ -0,0 +1,28 @@ +import { + LwbaEndpoint, + LwbaResponseDataFields, + lwbaEndpointInputParametersDefinition, +} from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { wsTransport } from '../transport/crypto-lwba' + +export const inputParameters = new InputParameters(lwbaEndpointInputParametersDefinition, [ + { + base: 'ETH', + quote: 'USD', + }, +]) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Settings: typeof config.settings + Response: LwbaResponseDataFields +} + +export const endpoint = new LwbaEndpoint({ + name: 'crypto-lwba', + aliases: ['crypto', 'price'], + transport: wsTransport, + inputParameters, +}) diff --git a/packages/sources/coinmetrics-lwba/src/endpoint/index.ts b/packages/sources/coinmetrics-lwba/src/endpoint/index.ts new file mode 100644 index 0000000000..e9e93db040 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as cryptoLwba } from './crypto-lwba' diff --git a/packages/sources/coinmetrics-lwba/src/index.ts b/packages/sources/coinmetrics-lwba/src/index.ts new file mode 100644 index 0000000000..5ed23d36ae --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/index.ts @@ -0,0 +1,23 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { cryptoLwba } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: cryptoLwba.name, + name: 'COINMETRICS_LWBA', + config, + endpoints: [cryptoLwba], + rateLimiting: { + tiers: { + community: { + rateLimit1m: 100, + }, + paid: { + rateLimit1s: 300, + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/coinmetrics-lwba/src/transport/crypto-lwba.ts b/packages/sources/coinmetrics-lwba/src/transport/crypto-lwba.ts new file mode 100644 index 0000000000..86b452d7c1 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/transport/crypto-lwba.ts @@ -0,0 +1,124 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports/websocket' +import { + makeLogger, + PartialAdapterResponse, + ProviderResult, + ProviderResultGenerics, +} from '@chainlink/external-adapter-framework/util' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' +import { BaseEndpointTypes, inputParameters } from '../endpoint/crypto-lwba' +import { logPossibleSolutionForKnownErrors } from './error-handling' +import { ResponseError } from './types' + +const logger = makeLogger('CoinMetrics Crypto LWBA WS') + +export type MultiVarResult = { + params: TypeFromDefinition + response: PartialAdapterResponse +} + +export type WsCryptoLwbaSuccessResponse = { + pair: string + time: string + ask_price: string + ask_size: string + bid_price: string + bid_size: string + mid_price: string + spread: string + cm_sequence_id: string +} +export type WsCryptoLwbaErrorResponse = { + error: ResponseError +} +export type WsCryptoLwbaWarningResponse = { + warning: { + type: string + message: string + } +} +export type WsCryptoLwbaReorgResponse = { + time: string + asset: string + height: number + hash: string + parent_hash: string + type: 'reorg' + cm_sequence_id: number +} + +export type WsPairQuoteMessage = + | WsCryptoLwbaSuccessResponse + | WsCryptoLwbaWarningResponse + | WsCryptoLwbaErrorResponse + | WsCryptoLwbaReorgResponse + +export type WsTransportTypes = BaseEndpointTypes & { + Provider: { + WsMessage: WsPairQuoteMessage + } +} +export const calculateUrl = ( + context: EndpointContext, + desiredSubs: (typeof inputParameters.validated)[], +): string => { + const { API_KEY, WS_API_ENDPOINT } = context.adapterSettings + + const generated = new URL('/v4/timeseries-stream/asset-quotes', WS_API_ENDPOINT) + const assets = [...new Set(desiredSubs.map((pair) => pair.base.toLowerCase()))].sort().join(',') + generated.searchParams.append('assets', assets) + + generated.searchParams.append('api_key', API_KEY) + logger.debug(`Generated URL: ${generated.toString()}`) + return generated.toString() +} + +export const handleCryptoLwbaMessage = ( + message: WsPairQuoteMessage, +): MultiVarResult[] | undefined => { + if ('error' in message) { + logger.error(message, `Error response from websocket`) + logPossibleSolutionForKnownErrors(message.error) + } else if ('warning' in message) { + logger.warn(message, `Warning response from websocket`) + } else if ('type' in message && message.type === 'reorg') { + logger.info(message, `Reorg response from websocket`) + } else if ('mid_price' in message) { + const [base, quote] = message.pair.split('-') + const res = Number(message.mid_price) + return [ + { + params: { + base, + quote, + }, + response: { + result: null, + data: { + bid: Number(message.bid_price), + mid: res, + ask: Number(message.ask_price), + }, + timestamps: { + providerIndicatedTimeUnixMs: new Date(message.time).getTime(), + }, + }, + }, + ] + } else { + logger.warn(message, 'Unknown message type from websocket') + } + return undefined +} + +export const wsTransport = new WebSocketTransport({ + url: (context, desiredSubs) => { + return calculateUrl(context, desiredSubs) + }, + handlers: { + message(message: WsPairQuoteMessage): ProviderResult[] | undefined { + return handleCryptoLwbaMessage(message) + }, + }, +}) diff --git a/packages/sources/coinmetrics-lwba/src/transport/error-handling.ts b/packages/sources/coinmetrics-lwba/src/transport/error-handling.ts new file mode 100644 index 0000000000..069e2c5f1e --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/transport/error-handling.ts @@ -0,0 +1,21 @@ +import { makeLogger } from '@chainlink/external-adapter-framework/util' +import { ResponseError } from './types' + +const logger = makeLogger('CoinMetrics Crypto error handling') + +export const logPossibleSolutionForKnownErrors = (error: ResponseError) => { + if (error['type'] === 'wrong_credentials') { + logger.error(`There is something wrong with your credentials. + Possible Solution: + 1. Doublecheck your supplied credentials. + 2. Contact Data Provider to ensure your subscription is active + 3. If credentials are supplied under the node licensing agreement with Chainlink Labs, please make contact with us and we will look into it.`) + } + + if (error['type'] === 'bad_parameter') { + logger.error(`This error indicates that the supplied asset or metric parameter is incorrect. + Possible Solution: + 1. Confirm you are using the same symbol found in the job spec with the correct case. + 2. There maybe an issue with the job spec or the Data Provider may have delisted the asset. Reach out to Chainlink Labs.`) + } +} diff --git a/packages/sources/coinmetrics-lwba/src/transport/types.ts b/packages/sources/coinmetrics-lwba/src/transport/types.ts new file mode 100644 index 0000000000..da2de8a971 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/src/transport/types.ts @@ -0,0 +1,4 @@ +export type ResponseError = { + type: string + message: string +} diff --git a/packages/sources/coinmetrics-lwba/test-payload.json b/packages/sources/coinmetrics-lwba/test-payload.json new file mode 100644 index 0000000000..ebbcc3aa54 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/test-payload.json @@ -0,0 +1,9 @@ +{ + "requests": [ + { + "from": "ETH", + "to": "USD", + "endpoint": "crypto-lwba" + } + ] +} \ No newline at end of file diff --git a/packages/sources/coinmetrics-lwba/test/integration/__snapshots__/adapter-ws.test.ts.snap b/packages/sources/coinmetrics-lwba/test/integration/__snapshots__/adapter-ws.test.ts.snap new file mode 100644 index 0000000000..efdb1eb876 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/test/integration/__snapshots__/adapter-ws.test.ts.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`websocket lwba endpoint should return error (invariant violation) 1`] = ` +{ + "error": { + "message": "Invariant violation. Mid price must be between bid and ask prices. Got: (bid: 1562.3384315992228, mid: 1562.3733948803842, ask: 1562.2733948803843)", + "name": "AdapterLWBAError", + }, + "status": "errored", + "statusCode": 500, +} +`; + +exports[`websocket lwba endpoint should return success 1`] = ` +{ + "data": { + "ask": 1562.4083581615457, + "bid": 1562.3384315992228, + "mid": 1562.3733948803842, + }, + "result": null, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1024, + "providerDataStreamEstablishedUnixMs": 1010, + "providerIndicatedTimeUnixMs": 1678248273750, + }, +} +`; diff --git a/packages/sources/coinmetrics-lwba/test/integration/adapter-ws.test.ts b/packages/sources/coinmetrics-lwba/test/integration/adapter-ws.test.ts new file mode 100644 index 0000000000..5997faa7a0 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/test/integration/adapter-ws.test.ts @@ -0,0 +1,85 @@ +import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' +import { + mockWebSocketProvider, + MockWebsocketServer, + setEnvVariables, + TestAdapter, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import FakeTimers from '@sinonjs/fake-timers' +import { mockCryptoLwbaWebSocketServer } from './fixtures' + +describe('websocket', () => { + let mockWsServerLwba: MockWebsocketServer | undefined + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + const wsEndpointLwba = 'ws://localhost:9090/v4/timeseries-stream/asset-quotes' + const dataLwba = { + endpoint: 'crypto-lwba', + base: 'ETH', + quote: 'USD', + } + + beforeAll(async () => { + 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['WS_API_ENDPOINT'] = wsEndpointLwba + process.env['API_KEY'] = 'fake-api-key' + + // Start mock web socket server + mockWebSocketProvider(WebSocketClassProvider) + mockWsServerLwba = mockCryptoLwbaWebSocketServer(wsEndpointLwba) + + const adapter = (await import('./../../src')).adapter + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + clock: FakeTimers.install(), + testAdapter: {} as TestAdapter, + }) + + // Send initial request to start background execute and wait for cache to be filled with results + await testAdapter.request(dataLwba) + await testAdapter.waitForCache(1) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + mockWsServerLwba?.close() + testAdapter.clock?.uninstall() + await testAdapter.api.close() + }) + + describe('lwba endpoint', () => { + it('should return success', async () => { + const response = await testAdapter.request(dataLwba) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error (empty body)', async () => { + const response = await testAdapter.request({}) + expect(response.statusCode).toEqual(400) + }) + + it('should return error (empty data)', async () => { + const response = await testAdapter.request({ endpoint: 'crypto-lwba' }) + expect(response.statusCode).toEqual(400) + }) + + it('should return error (empty base)', async () => { + const response = await testAdapter.request({ endpoint: 'crypto-lwba', quote: 'USD' }) + expect(response.statusCode).toEqual(400) + }) + + it('should return error (empty quote)', async () => { + const response = await testAdapter.request({ endpoint: 'crypto-lwba', base: 'ETH' }) + expect(response.statusCode).toEqual(400) + }) + + it('should return error (invariant violation)', async () => { + // fast forward to next message (which contains an invariant violation) + testAdapter.clock.tick(1000) + const response = await testAdapter.request(dataLwba) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/coinmetrics-lwba/test/integration/fixtures.ts b/packages/sources/coinmetrics-lwba/test/integration/fixtures.ts new file mode 100644 index 0000000000..260f7ad398 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/test/integration/fixtures.ts @@ -0,0 +1,31 @@ +import { MockWebsocketServer } from '@chainlink/external-adapter-framework/util/testing-utils' +import { WsCryptoLwbaSuccessResponse } from '../../src/transport/crypto-lwba' + +const wsLwbaResponseBody: WsCryptoLwbaSuccessResponse = { + pair: 'eth-usd', + time: '2023-03-08T04:04:33.750000000Z', + ask_price: '1562.4083581615457', + ask_size: '31.63132041', + bid_price: '1562.3384315992228', + bid_size: '64.67517577', + mid_price: '1562.3733948803842', + spread: '0.000044756626394287605', + cm_sequence_id: '282', +} + +export const mockCryptoLwbaWebSocketServer = (URL: string) => { + const mockWsServer = new MockWebsocketServer(URL, { mock: false }) + mockWsServer.on('connection', (socket) => { + const parseMessage = () => { + setTimeout(() => socket.send(JSON.stringify(wsLwbaResponseBody)), 10) + + const wsLwbaResponseBodyInvariantViolation = { + ...wsLwbaResponseBody, + ask_price: Number(wsLwbaResponseBody.mid_price) - 0.1, + } + setTimeout(() => socket.send(JSON.stringify(wsLwbaResponseBodyInvariantViolation)), 50) + } + parseMessage() + }) + return mockWsServer +} diff --git a/packages/sources/coinmetrics-lwba/tsconfig.json b/packages/sources/coinmetrics-lwba/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/coinmetrics-lwba/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/coinmetrics-lwba/tsconfig.test.json b/packages/sources/coinmetrics-lwba/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/coinmetrics-lwba/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/yarn.lock b/yarn.lock index 5aee4d1775..27fdf04b24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3170,6 +3170,21 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/coinmetrics-lwba-adapter@workspace:packages/sources/coinmetrics-lwba": + version: 0.0.0-use.local + resolution: "@chainlink/coinmetrics-lwba-adapter@workspace:packages/sources/coinmetrics-lwba" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.6.0" + "@sinonjs/fake-timers": "npm:9.1.2" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + "@types/sinonjs__fake-timers": "npm:8.1.5" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/coinpaprika-adapter@workspace:*, @chainlink/coinpaprika-adapter@workspace:packages/sources/coinpaprika": version: 0.0.0-use.local resolution: "@chainlink/coinpaprika-adapter@workspace:packages/sources/coinpaprika"