Skip to content

Commit 3e6f0d7

Browse files
Feat/OPDATA-6934 nobi ea (#5020)
* OPDATA-6934 nobi EA initial commit base transport * OPDATA-6934 nobi EA initial commit with multi transport manager * readme and removing unused code * add changeset * change test wsEndpoint * update to latest framework * update to use framework v2.17.1 which includes customSubscriptionMessages builder * add unit test for router * remove custom docs * regenerate readme
1 parent 5be0184 commit 3e6f0d7

19 files changed

Lines changed: 588 additions & 0 deletions

File tree

.changeset/public-bugs-go.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/nobi-adapter': major
3+
---
4+
5+
Nobi initial release

.pnp.cjs

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sources/nobi/CHANGELOG.md

Whitespace-only changes.

packages/sources/nobi/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# NOBI
2+
3+
![0.0.0](https://img.shields.io/github/package-json/v/smartcontractkit/external-adapters-js?filename=packages/sources/nobi/package.json) ![v3](https://img.shields.io/badge/framework%20version-v3-blueviolet)
4+
5+
This document was generated automatically. Please see [README Generator](../../scripts#readme-generator) for more info.
6+
7+
## Environment Variables
8+
9+
| Required? | Name | Description | Type | Options | Default |
10+
| :-------: | :-----------------------------: | :-----------------------------------------------------------------------------------------------------: | :----: | :-----: | :-----------------------------: |
11+
|| API_KEY | An API key for Data Provider | string | | |
12+
| | WS_API_ENDPOINT | WS endpoint for Data Provider | string | | `wss://ws.price.usenobi.com/v2` |
13+
| | MAX_SUBSCRIPTIONS_PER_TRANSPORT | The maximum number of currency pairs to route through a single transport before routing to the next one | number | | `100` |
14+
15+
---
16+
17+
## Data Provider Rate Limits
18+
19+
There are no rate limits for this adapter.
20+
21+
---
22+
23+
## Input Parameters
24+
25+
| Required? | Name | Description | Type | Options | Default |
26+
| :-------: | :------: | :-----------------: | :----: | :------------------------------------------------: | :-----: |
27+
| | endpoint | The endpoint to use | string | [price](#price-endpoint), [state](#price-endpoint) | `price` |
28+
29+
## Price Endpoint
30+
31+
Supported names for this endpoint are: `price`, `state`.
32+
33+
### Input Params
34+
35+
| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With |
36+
| :-------: | :---: | :------------: | :--------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: |
37+
|| base | `coin`, `from` | The symbol of symbols of the currency to query | string | | | | |
38+
|| quote | `market`, `to` | The symbol of the currency to convert to | string | | | | |
39+
40+
### Example
41+
42+
Request:
43+
44+
```json
45+
{
46+
"data": {
47+
"endpoint": "price",
48+
"base": "BTC",
49+
"quote": "USD"
50+
}
51+
}
52+
```
53+
54+
---
55+
56+
MIT License

packages/sources/nobi/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@chainlink/nobi-adapter",
3+
"version": "0.0.0",
4+
"description": "Chainlink nobi adapter.",
5+
"keywords": [
6+
"Chainlink",
7+
"LINK",
8+
"blockchain",
9+
"oracle",
10+
"nobi"
11+
],
12+
"main": "dist/index.js",
13+
"types": "dist/index.d.ts",
14+
"files": [
15+
"dist"
16+
],
17+
"repository": {
18+
"url": "https://github.com/smartcontractkit/external-adapters-js",
19+
"type": "git"
20+
},
21+
"license": "MIT",
22+
"scripts": {
23+
"clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo",
24+
"prepack": "yarn build",
25+
"build": "tsc -b",
26+
"server": "node -e 'require(\"./index.js\").server()'",
27+
"server:dist": "node -e 'require(\"./dist/index.js\").server()'",
28+
"start": "yarn server:dist"
29+
},
30+
"devDependencies": {
31+
"@sinonjs/fake-timers": "9.1.2",
32+
"@types/jest": "^29.5.14",
33+
"@types/node": "22.14.1",
34+
"@types/sinonjs__fake-timers": "8.1.5",
35+
"nock": "13.5.6",
36+
"typescript": "5.8.3"
37+
},
38+
"dependencies": {
39+
"@chainlink/external-adapter-framework": "2.17.1",
40+
"tslib": "2.4.1"
41+
}
42+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AdapterConfig } from '@chainlink/external-adapter-framework/config'
2+
3+
export const config = new AdapterConfig(
4+
{
5+
API_KEY: {
6+
description: 'An API key for Data Provider',
7+
type: 'string',
8+
required: true,
9+
sensitive: true,
10+
},
11+
WS_API_ENDPOINT: {
12+
description: 'WS endpoint for Data Provider',
13+
type: 'string',
14+
default: 'wss://ws.price.usenobi.com/v2',
15+
sensitive: false,
16+
},
17+
MAX_SUBSCRIPTIONS_PER_TRANSPORT: {
18+
description:
19+
'The maximum number of currency pairs to route through a single transport before routing to the next one',
20+
type: 'number',
21+
default: 100,
22+
sensitive: false,
23+
},
24+
},
25+
{
26+
envDefaultOverrides: {
27+
WS_HEARTBEAT_INTERVAL_MS: 10_000,
28+
},
29+
},
30+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { endpoint as price } from './price'
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
AdapterEndpoint,
3+
priceEndpointInputParametersDefinition,
4+
} from '@chainlink/external-adapter-framework/adapter'
5+
import { TransportRoutes } from '@chainlink/external-adapter-framework/transports'
6+
import { makeLogger, SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util'
7+
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
8+
import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
9+
import { config } from '../config'
10+
import { buildNobiWsTransport } from '../transport/price'
11+
12+
const logger = makeLogger('NobiWsEndpoint')
13+
14+
// Hardcoded on purpose, documented max connections per key from the data provider
15+
const MAX_TRANSPORTS = 10
16+
17+
// Framework requires transport names to contain only lowercase letters
18+
const indexToTransportName = (i: number): string => `ws${String.fromCharCode(97 + i)}`
19+
20+
export const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [
21+
{
22+
base: 'BTC',
23+
quote: 'USD',
24+
},
25+
])
26+
27+
export type BaseEndpointTypes = {
28+
Parameters: typeof inputParameters.definition
29+
Response: SingleNumberResultResponse
30+
Settings: typeof config.settings
31+
}
32+
33+
// We register MAX_TRANSPORTS transports on the endpoint
34+
// Requests will be routed to transports in order up to
35+
// config.MAX_SUBSCRIPTIONS_PER_TRANSPORT subscriptions then rollover to the next transport
36+
const transportRoutes = new TransportRoutes<BaseEndpointTypes>()
37+
for (let i = 0; i < MAX_TRANSPORTS; i++) {
38+
transportRoutes.register(indexToTransportName(i), buildNobiWsTransport())
39+
}
40+
41+
// requestMapping is intentionally add-only (no decrement on unsubscribe) to keep routing stable
42+
// for long-lived pairs. The underlying transport handles subscribe/unsubscribe independently.
43+
export const requestMapping: Map<string, string> = new Map()
44+
45+
export const routeRequest = (
46+
base: string,
47+
quote: string,
48+
maxTransports: number,
49+
maxSubscriptionsPerTransport: number,
50+
): string => {
51+
// find if key is in the mapping already
52+
const mappingKey = `${base}/${quote}`
53+
const transportName = requestMapping.get(mappingKey)
54+
if (transportName) {
55+
logger.debug(`Routing mapping found for ${mappingKey}: transportName = ${transportName}`)
56+
return transportName
57+
}
58+
59+
// if not found, find the first transport with available capacity and assign it in the mapping
60+
const transportIndex = Math.floor(requestMapping.size / maxSubscriptionsPerTransport)
61+
62+
// return 429 if we've reached max capacity on all transports (MAX_TRANSPORTS * MAX_SUBSCRIPTIONS_PER_TRANSPORT)
63+
if (transportIndex >= maxTransports) {
64+
throw new AdapterError({
65+
statusCode: 429,
66+
message: `All transports are at full capacity, the EA has reached the maximum number of active transports (${maxTransports}) and subscriptions per transport (${maxSubscriptionsPerTransport})`,
67+
})
68+
}
69+
70+
// assign the new transport in the mapping and return it
71+
const newTransportName = indexToTransportName(transportIndex)
72+
requestMapping.set(mappingKey, newTransportName)
73+
logger.debug(
74+
`Routing new ${mappingKey} to ${newTransportName}, current requestMapping size = ${requestMapping.size}`,
75+
)
76+
77+
return newTransportName
78+
}
79+
80+
export const endpoint = new AdapterEndpoint({
81+
name: 'price',
82+
aliases: ['state'],
83+
inputParameters,
84+
transportRoutes,
85+
defaultTransport: indexToTransportName(0),
86+
customRouter: (req, settings) =>
87+
routeRequest(
88+
req.requestContext.data.base,
89+
req.requestContext.data.quote,
90+
MAX_TRANSPORTS,
91+
settings.MAX_SUBSCRIPTIONS_PER_TRANSPORT,
92+
),
93+
})

packages/sources/nobi/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { expose, ServerInstance } from '@chainlink/external-adapter-framework'
2+
import { Adapter } from '@chainlink/external-adapter-framework/adapter'
3+
import { config } from './config'
4+
import { price } from './endpoint'
5+
6+
export const adapter = new Adapter({
7+
defaultEndpoint: price.name,
8+
name: 'NOBI',
9+
config,
10+
endpoints: [price],
11+
})
12+
13+
export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports'
2+
import { makeLogger } from '@chainlink/external-adapter-framework/util/logger'
3+
import { BaseEndpointTypes } from '../endpoint/price'
4+
5+
const logger = makeLogger('NobiWsTransport')
6+
7+
export type WSResponse = {
8+
asset_code: string
9+
block_time: string
10+
price: string
11+
base_symbol: string
12+
quote_symbol: string
13+
depth_usd_plus: string
14+
depth_usd_min: string
15+
volume_7d_usd: string
16+
}
17+
18+
export type WsTransportTypes = BaseEndpointTypes & {
19+
Provider: {
20+
WsMessage: WSResponse
21+
}
22+
}
23+
24+
const constructNobiSymbol = (params: { base: string; quote: string }): string =>
25+
`Crypto:${params.base}/${params.quote}`
26+
27+
export const buildNobiWsTransport = () =>
28+
new WebSocketTransport<WsTransportTypes>({
29+
url: (context) => context.adapterSettings.WS_API_ENDPOINT,
30+
options: (context) => ({
31+
headers: {
32+
'X-API-KEY': context.adapterSettings.API_KEY,
33+
},
34+
}),
35+
handlers: {
36+
heartbeat: (connection) => {
37+
connection.send(JSON.stringify({ method: 'ping' }))
38+
},
39+
message: (message) => {
40+
if (
41+
!message.asset_code ||
42+
!message.price ||
43+
!message.base_symbol ||
44+
!message.quote_symbol
45+
) {
46+
return
47+
}
48+
49+
const result = Number(message.price)
50+
if (isNaN(result)) {
51+
logger.warn(`Received non-numeric price for ${message.asset_code}: ${message.price}`)
52+
return
53+
}
54+
55+
return [
56+
{
57+
params: {
58+
base: message.base_symbol,
59+
quote: message.quote_symbol,
60+
},
61+
response: {
62+
result,
63+
data: { result },
64+
timestamps: {
65+
providerIndicatedTimeUnixMs: new Date(message.block_time).getTime(),
66+
},
67+
},
68+
},
69+
]
70+
},
71+
},
72+
builders: {
73+
customSubscriptionMessages: (_context, subscriptions) => {
74+
const messages: unknown[] = []
75+
76+
if (subscriptions.new.length > 0) {
77+
messages.push({
78+
method: 'subscribe',
79+
params: { pairs: subscriptions.desired.map(constructNobiSymbol) },
80+
})
81+
}
82+
83+
if (subscriptions.stale.length > 0) {
84+
messages.push({
85+
method: 'unsubscribe',
86+
params: { pairs: subscriptions.stale.map(constructNobiSymbol) },
87+
})
88+
}
89+
90+
return messages
91+
},
92+
},
93+
})

0 commit comments

Comments
 (0)