diff --git a/.changeset/slow-cycles-perform.md b/.changeset/slow-cycles-perform.md new file mode 100644 index 0000000000..b4f287878c --- /dev/null +++ b/.changeset/slow-cycles-perform.md @@ -0,0 +1,5 @@ +--- +'@chainlink/tiingo-state-adapter': minor +--- + +Add adapter for Tiingo state. diff --git a/.pnp.cjs b/.pnp.cjs index ab72079703..c9f0dd9ef3 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -806,6 +806,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/tiingo-adapter",\ "reference": "workspace:packages/sources/tiingo"\ },\ + {\ + "name": "@chainlink/tiingo-state-adapter",\ + "reference": "workspace:packages/sources/tiingo-state"\ + },\ {\ "name": "@chainlink/tp-adapter",\ "reference": "workspace:packages/sources/tp"\ @@ -1105,6 +1109,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/the-network-firm-adapter", ["workspace:packages/sources/the-network-firm"]],\ ["@chainlink/therundown-adapter", ["workspace:packages/sources/therundown"]],\ ["@chainlink/tiingo-adapter", ["workspace:packages/sources/tiingo"]],\ + ["@chainlink/tiingo-state-adapter", ["workspace:packages/sources/tiingo-state"]],\ ["@chainlink/token-allocation-adapter", ["workspace:packages/non-deployable/token-allocation"]],\ ["@chainlink/token-allocation-test-adapter", ["workspace:packages/non-deployable/token-allocation-test"]],\ ["@chainlink/tp-adapter", ["workspace:packages/sources/tp"]],\ @@ -9293,6 +9298,23 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/tiingo-state-adapter", [\ + ["workspace:packages/sources/tiingo-state", {\ + "packageLocation": "./packages/sources/tiingo-state/",\ + "packageDependencies": [\ + ["@chainlink/tiingo-state-adapter", "workspace:packages/sources/tiingo-state"],\ + ["@chainlink/external-adapter-framework", "npm:1.4.0"],\ + ["@sinonjs/fake-timers", "npm:9.1.2"],\ + ["@types/jest", "npm:27.5.2"],\ + ["@types/node", "npm:16.11.68"],\ + ["@types/sinonjs__fake-timers", "npm:8.1.5"],\ + ["nock", "npm:13.5.4"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.0.4#optional!builtin::version=5.0.4&hash=b5f058"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/token-allocation-adapter", [\ ["workspace:packages/non-deployable/token-allocation", {\ "packageLocation": "./packages/non-deployable/token-allocation/",\ diff --git a/packages/sources/tiingo-state/CHANGELOG.md b/packages/sources/tiingo-state/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/tiingo-state/README.md b/packages/sources/tiingo-state/README.md new file mode 100644 index 0000000000..530dc5c012 --- /dev/null +++ b/packages/sources/tiingo-state/README.md @@ -0,0 +1,55 @@ +# TIINGO_STATE + +![0.0.1](https://img.shields.io/github/package-json/v/smartcontractkit/external-adapters-js?filename=packages/sources/tiingo-state/package.json) ![v3](https://img.shields.io/badge/framework%20version-v3-blueviolet) + +This document was generated automatically. Please see [README Generator](../../scripts#readme-generator) for more info. + +## Environment Variables + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :-------------: | :---------------------------: | :----: | :-----: | :--------------------: | +| | WS_API_ENDPOINT | websocket endpoint for tiingo | string | | `wss://api.tiingo.com` | +| ✅ | API_KEY | API key for tiingo | string | | | + +--- + +## Data Provider Rate Limits + +There are no rate limits for this adapter. + +--- + +## Input Parameters + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------: | :-----------------: | :----: | :---------------------------------------------------------------------------: | :-----: | +| | endpoint | The endpoint to use | string | [crypto](#price-endpoint), [price](#price-endpoint), [state](#price-endpoint) | `price` | + +## Price Endpoint + +Supported names for this endpoint are: `crypto`, `price`, `state`. + +### 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 | | | | | + +### Example + +Request: + +```json +{ + "data": { + "endpoint": "price", + "base": "wstETH", + "quote": "ETH" + } +} +``` + +--- + +MIT License diff --git a/packages/sources/tiingo-state/package.json b/packages/sources/tiingo-state/package.json new file mode 100644 index 0000000000..35a044edb5 --- /dev/null +++ b/packages/sources/tiingo-state/package.json @@ -0,0 +1,42 @@ +{ + "name": "@chainlink/tiingo-state-adapter", + "version": "0.0.1", + "description": "Chainlink tiingo-state adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "tiingo-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": { + "@sinonjs/fake-timers": "9.1.2", + "@types/jest": "27.5.2", + "@types/node": "16.11.68", + "@types/sinonjs__fake-timers": "8.1.5", + "nock": "13.5.4", + "typescript": "5.0.4" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "1.4.0", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/tiingo-state/src/config/index.ts b/packages/sources/tiingo-state/src/config/index.ts new file mode 100644 index 0000000000..58d0519949 --- /dev/null +++ b/packages/sources/tiingo-state/src/config/index.ts @@ -0,0 +1,23 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig( + { + WS_API_ENDPOINT: { + description: 'websocket endpoint for tiingo', + default: 'wss://api.tiingo.com', + type: 'string', + }, + API_KEY: { + description: 'API key for tiingo', + type: 'string', + required: true, + sensitive: true, + }, + }, + { + envDefaultOverrides: { + CACHE_MAX_AGE: 150_000, // see known issues in readme + WS_SUBSCRIPTION_TTL: 180_000, + }, + }, +) diff --git a/packages/sources/tiingo-state/src/endpoint/index.ts b/packages/sources/tiingo-state/src/endpoint/index.ts new file mode 100644 index 0000000000..11a44912b4 --- /dev/null +++ b/packages/sources/tiingo-state/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as price } from './price' diff --git a/packages/sources/tiingo-state/src/endpoint/price.ts b/packages/sources/tiingo-state/src/endpoint/price.ts new file mode 100644 index 0000000000..7dcd21579c --- /dev/null +++ b/packages/sources/tiingo-state/src/endpoint/price.ts @@ -0,0 +1,30 @@ +import { + PriceEndpoint, + priceEndpointInputParametersDefinition, +} from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { TransportRoutes } from '@chainlink/external-adapter-framework/transports' +import { wsTransport } from '../transport/price' +import { config } from '../config' + +export const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [ + { + base: 'wstETH', + quote: 'ETH', + }, +]) + +export type BaseCryptoEndpointTypes = { + Parameters: typeof inputParameters.definition + Settings: typeof config.settings + Response: SingleNumberResultResponse +} + +export const endpoint = new PriceEndpoint({ + name: 'price', + aliases: ['crypto', 'state'], + transportRoutes: new TransportRoutes().register('ws', wsTransport), + defaultTransport: 'ws', + inputParameters: inputParameters, +}) diff --git a/packages/sources/tiingo-state/src/index.ts b/packages/sources/tiingo-state/src/index.ts new file mode 100644 index 0000000000..eb382e3831 --- /dev/null +++ b/packages/sources/tiingo-state/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { price } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: price.name, + name: 'TIINGO_STATE', + config, + endpoints: [price], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/tiingo-state/src/transport/price.ts b/packages/sources/tiingo-state/src/transport/price.ts new file mode 100644 index 0000000000..a2deefa200 --- /dev/null +++ b/packages/sources/tiingo-state/src/transport/price.ts @@ -0,0 +1,63 @@ +import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports' +import { BaseCryptoEndpointTypes } from '../endpoint/price' + +interface Message { + service: string + messageType: string + data: [string, string, string, string, number] +} + +const tickerIndex = 1 +const dateIndex = 2 +const priceIndex = 4 + +type WsTransportTypes = BaseCryptoEndpointTypes & { + Provider: { + WsMessage: Message + } +} + +export const wsTransport = new WebSocketTransport({ + url: (context) => { + return `${context.adapterSettings.WS_API_ENDPOINT}/crypto-synth-state` + }, + handlers: { + message(message) { + if (!message?.data?.length || message.messageType !== 'A' || !message.data[priceIndex]) { + return [] + } + const [base, quote] = message.data[tickerIndex].split('/') + return [ + { + params: { base, quote }, + response: { + data: { + result: message.data[priceIndex], + }, + result: message.data[priceIndex], + timestamps: { + providerIndicatedTimeUnixMs: new Date(message.data[dateIndex]).getTime(), + }, + }, + }, + ] + }, + }, + + builders: { + subscribeMessage: (params, context) => { + return { + eventName: 'subscribe', + authorization: context.adapterSettings.API_KEY, + eventData: { thresholdLevel: 8, tickers: [`${params.base}/${params.quote}`] }, + } + }, + unsubscribeMessage: (params, context) => { + return { + eventName: 'unsubscribe', + authorization: context.adapterSettings.API_KEY, + eventData: { thresholdLevel: 8, tickers: [`${params.base}/${params.quote}`] }, + } + }, + }, +}) diff --git a/packages/sources/tiingo-state/test-payload.json b/packages/sources/tiingo-state/test-payload.json new file mode 100644 index 0000000000..51c1cfe424 --- /dev/null +++ b/packages/sources/tiingo-state/test-payload.json @@ -0,0 +1,6 @@ +{ + "requests": [{ + "from": "wstETH", + "to": "ETH" + }] +} diff --git a/packages/sources/tiingo-state/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/tiingo-state/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..bce77a9c37 --- /dev/null +++ b/packages/sources/tiingo-state/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`websocket crypto state 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[`websocket crypto state 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[`websocket crypto state 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[`websocket crypto state endpoint should return error on invalid pair 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[`websocket crypto state endpoint should return success 1`] = ` +{ + "data": { + "result": 1.1807636997924935, + }, + "result": 1.1807636997924935, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1018, + "providerDataStreamEstablishedUnixMs": 1010, + "providerIndicatedTimeUnixMs": 1646249828102, + }, +} +`; diff --git a/packages/sources/tiingo-state/test/integration/adapter.test.ts b/packages/sources/tiingo-state/test/integration/adapter.test.ts new file mode 100644 index 0000000000..6893a7c19e --- /dev/null +++ b/packages/sources/tiingo-state/test/integration/adapter.test.ts @@ -0,0 +1,79 @@ +import { + TestAdapter, + setEnvVariables, + mockWebSocketProvider, + MockWebsocketServer, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import FakeTimers from '@sinonjs/fake-timers' +import { mockWebsocketServer } from './fixtures' +import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' + +describe('websocket', () => { + let mockWsServer: MockWebsocketServer | undefined + let testAdapter: TestAdapter + const wsEndpoint = 'ws://localhost:9090' + let oldEnv: NodeJS.ProcessEnv + + const priceData = { + base: 'wsteth', + quote: 'eth', + } + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env['WS_API_ENDPOINT'] = wsEndpoint + process.env['API_KEY'] = 'fake-api-key' + + // Start mock web socket server + mockWebSocketProvider(WebSocketClassProvider) + mockWsServer = mockWebsocketServer(wsEndpoint + '/crypto-synth-state') + + 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(priceData) + await testAdapter.waitForCache(1) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + mockWsServer?.close() + testAdapter.clock?.uninstall() + await testAdapter.api.close() + }) + + describe('crypto state endpoint', () => { + it('should return success', async () => { + const response = await testAdapter.request(priceData) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error on empty data', async () => { + const response = await testAdapter.request({}) + expect(response.statusCode).toEqual(400) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error on empty base', async () => { + const response = await testAdapter.request({ quote: 'ETH' }) + expect(response.statusCode).toEqual(400) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error on empty quote', async () => { + const response = await testAdapter.request({ base: 'wstETH' }) + expect(response.statusCode).toEqual(400) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error on invalid pair', async () => { + const response = await testAdapter.request({ base: 'ABC', quote: 'XYZ' }) + expect(response.statusCode).toEqual(504) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/tiingo-state/test/integration/fixtures.ts b/packages/sources/tiingo-state/test/integration/fixtures.ts new file mode 100644 index 0000000000..5ffcf6c2bd --- /dev/null +++ b/packages/sources/tiingo-state/test/integration/fixtures.ts @@ -0,0 +1,17 @@ +import { MockWebsocketServer } from '@chainlink/external-adapter-framework/util/testing-utils' + +export const mockWebsocketServer = (URL: string): MockWebsocketServer => { + const wsResponse = { + service: 'crypto_data', + messageType: 'A', + data: ['SA', 'wsteth/eth', '2022-03-02T19:37:08.102119+00:00', 'tiingo', 1.1807636997924935], + } + const mockWsServer = new MockWebsocketServer(URL, { mock: false }) + mockWsServer.on('connection', (socket) => { + socket.on('message', () => { + socket.send(JSON.stringify(wsResponse)) + }) + }) + + return mockWsServer +} diff --git a/packages/sources/tiingo-state/tsconfig.json b/packages/sources/tiingo-state/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/tiingo-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/tiingo-state/tsconfig.test.json b/packages/sources/tiingo-state/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/tiingo-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 2a581c3bd0..2ea273296e 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -584,6 +584,9 @@ { "path": "./sources/tiingo" }, + { + "path": "./sources/tiingo-state" + }, { "path": "./sources/tp" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 62a6720870..3ce672da2f 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -584,6 +584,9 @@ { "path": "./sources/tiingo/tsconfig.test.json" }, + { + "path": "./sources/tiingo-state/tsconfig.test.json" + }, { "path": "./sources/tp/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index e76d80a8c9..c4e62b9093 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6160,6 +6160,21 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/tiingo-state-adapter@workspace:packages/sources/tiingo-state": + version: 0.0.0-use.local + resolution: "@chainlink/tiingo-state-adapter@workspace:packages/sources/tiingo-state" + dependencies: + "@chainlink/external-adapter-framework": "npm:1.4.0" + "@sinonjs/fake-timers": "npm:9.1.2" + "@types/jest": "npm:27.5.2" + "@types/node": "npm:16.11.68" + "@types/sinonjs__fake-timers": "npm:8.1.5" + nock: "npm:13.5.4" + tslib: "npm:2.4.1" + typescript: "npm:5.0.4" + languageName: unknown + linkType: soft + "@chainlink/token-allocation-adapter@workspace:*, @chainlink/token-allocation-adapter@workspace:packages/non-deployable/token-allocation": version: 0.0.0-use.local resolution: "@chainlink/token-allocation-adapter@workspace:packages/non-deployable/token-allocation"