Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/public-bugs-go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/nobi-adapter': major
---

Nobi initial release
22 changes: 22 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
62 changes: 62 additions & 0 deletions packages/sources/nobi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# NOBI

![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)

This document was generated automatically. Please see [README Generator](../../scripts#readme-generator) for more info.

## Additional Env Variables

These env vars are available to configure the amount of WebSocket transports and subscriptions per transport that the adapter will manage. The defaults should be sufficient for most use cases:

- `MAX_TRANSPORTS`: The maximum number of WebSocket transports to manage. Default is 10.
- `MAX_SUBSCRIPTIONS_PER_TRANSPORT`: The maximum number of currency pairs to route through a single transport before routing to the next one. Default is 100.

## Environment Variables

| Required? | Name | Description | Type | Options | Default |
| :-------: | :-------------: | :---------------------------: | :----: | :-----: | :-----------------------------: |
| ✅ | API_KEY | An API key for Data Provider | string | | |
| | WS_API_ENDPOINT | WS endpoint for Data Provider | string | | `wss://ws.price.usenobi.com/v2` |

---

## 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 | [price](#price-endpoint), [state](#price-endpoint) | `price` |

## Price Endpoint

Supported names for this endpoint are: `price`, `state`.

### Input Params

| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With |
| :-------: | :---: | :--------------------------------: | :--------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: |
| ✅ | base | `coin`, `from`, `market`, `symbol` | The symbol of symbols of the currency to query | string | | | | |
| ✅ | quote | `convert`, `to` | The symbol of the currency to convert to | string | | | | |

### Example

Request:

```json
{
"data": {
"endpoint": "price",
"base": "BTC",
"quote": "USD"
}
}
```

---

MIT License
6 changes: 6 additions & 0 deletions packages/sources/nobi/docs/custom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## Additional Env Variables

These env vars are available to configure the amount of WebSocket transports and subscriptions per transport that the adapter will manage. The defaults should be sufficient for most use cases:

- `MAX_TRANSPORTS`: The maximum number of WebSocket transports to manage. Default is 10.
- `MAX_SUBSCRIPTIONS_PER_TRANSPORT`: The maximum number of currency pairs to route through a single transport before routing to the next one. Default is 100.
42 changes: 42 additions & 0 deletions packages/sources/nobi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@chainlink/nobi-adapter",
"version": "0.0.0",
"description": "Chainlink nobi adapter.",
"keywords": [
"Chainlink",
"LINK",
"blockchain",
"oracle",
"nobi"
],
"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": "^29.5.14",
"@types/node": "22.14.1",
"@types/sinonjs__fake-timers": "8.1.5",
"nock": "13.5.6",
"typescript": "5.8.3"
},
"dependencies": {
"@chainlink/external-adapter-framework": "2.14.6",
"tslib": "2.4.1"
}
}
29 changes: 29 additions & 0 deletions packages/sources/nobi/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { AdapterConfig } from '@chainlink/external-adapter-framework/config'

export const config = new AdapterConfig(
{
API_KEY: {
description: 'An API key for Data Provider',
type: 'string',
required: true,
sensitive: true,
},
WS_API_ENDPOINT: {
description: 'WS endpoint for Data Provider',
type: 'string',
default: 'wss://ws.price.usenobi.com/v2',
sensitive: false,
},
MAX_SUBSCRIPTIONS_PER_TRANSPORT: {
description: 'The maximum number of subscriptions per transport',
type: 'number',
default: 100,
sensitive: false,
},
},
{
envDefaultOverrides: {
WS_HEARTBEAT_INTERVAL_MS: 10_000,
},
},
)
1 change: 1 addition & 0 deletions packages/sources/nobi/src/endpoint/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { endpoint as price } from './price'
74 changes: 74 additions & 0 deletions packages/sources/nobi/src/endpoint/price.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
AdapterEndpoint,
priceEndpointInputParametersDefinition,
} from '@chainlink/external-adapter-framework/adapter'
import { TransportRoutes } from '@chainlink/external-adapter-framework/transports'
import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
import { config } from '../config'
import { buildNobiWsTransport } from '../transport/price'

// Hardcoded on purpose, documented max connections from the data provider
const MAX_TRANSPORTS = 10

export const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [
{
base: 'BTC',
quote: 'USD',
},
])

export type BaseEndpointTypes = {
Parameters: typeof inputParameters.definition
Response: SingleNumberResultResponse
Settings: typeof config.settings
}

// We register MAX_TRANSPORTS transports on the endpoint
// Requests will be routed to the first transport available up to
// config.MAX_SUBSCRIPTIONS_PER_TRANSPORT subscriptions then the onto the next transport and so on
const transportRoutes = new TransportRoutes<BaseEndpointTypes>()
for (let i = 0; i < MAX_TRANSPORTS; i++) {
transportRoutes.register(`ws${i}`, buildNobiWsTransport())
}

// Maps request cache key -> the transport it's assigned to
const requestMapping: Map<string, string> = new Map()

export const endpoint = new AdapterEndpoint({
name: 'price',
aliases: ['state'],
inputParameters,
transportRoutes,
customRouter: (req, settings): string => {
const transportName = requestMapping.get(req.requestContext.cacheKey)

// If the cache key is in the mapping, the request has already been routed to a transport
// and we want to keep routing all following requests to the same one
if (transportName) {
return transportName
}

// If the cache key is not in the mapping, we need to find a transport to route the request to
// No need to loop, we can just find the right one by the capacity we have
const transportIndex = Math.floor(
requestMapping.size / settings.MAX_SUBSCRIPTIONS_PER_TRANSPORT,
)
const newTransportName = `ws${transportIndex}`

// If we exceed the max capacity of all transports, we can throw an error or route to a default transport
if (transportIndex >= MAX_TRANSPORTS) {
throw new AdapterError({
statusCode: 429,
message: `All transports are at full capacity, the EA has reached the maximum number of active transports (${MAX_TRANSPORTS}) and subscriptions per transport (${settings.MAX_SUBSCRIPTIONS_PER_TRANSPORT})`,
})
}

// Add the cache key to the mapping with the correct transport we routed to
// This should be thread safe as it's within this synchronous execution context
requestMapping.set(req.requestContext.cacheKey, newTransportName)

return newTransportName
},
})
13 changes: 13 additions & 0 deletions packages/sources/nobi/src/index.ts
Original file line number Diff line number Diff line change
@@ -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: 'NOBI',
config,
endpoints: [price],
})

export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
97 changes: 97 additions & 0 deletions packages/sources/nobi/src/transport/price.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports'
import { makeLogger } from '@chainlink/external-adapter-framework/util/logger'
import { BaseEndpointTypes } from '../endpoint/price'

const logger = makeLogger('NobiWsTransport')

export type WSResponse = {
asset_code: string
block_time: string
price: string
base_symbol: string
quote_symbol: string
depth_usd_plus: string
depth_usd_min: string
volume_7d_usd: string
}

export type WsTransportTypes = BaseEndpointTypes & {
Provider: {
WsMessage: WSResponse
}
}

const constructNobiSymbol = (params: { base: string; quote: string }): string => {
return `Crypto:${params.base}/${params.quote}`
}

// This is what we want to do: an instance of the base websocket transport
export const buildNobiWsTransport = () =>
new WebSocketTransport<WsTransportTypes>({
url: (context) => context.adapterSettings.WS_API_ENDPOINT,
options: (context) => ({
headers: {
'X-API-KEY': context.adapterSettings.API_KEY,
},
}),
handlers: {
// heartbeat msg sent at every WS_HEARTBEAT_INTERVAL_MS
heartbeat: (connection) => {
connection.send(
JSON.stringify({
method: 'ping',
}),
)
},
message: (message) => {
// bypass messages like subscription response that don't have an asset_code, symbols, or price
if (
!message.asset_code ||
!message.price ||
!message.base_symbol ||
!message.quote_symbol
) {
return
}

const result = Number(message.price)
if (isNaN(result)) {
logger.warn(`Received non-numeric price for ${message.asset_code}: ${message.price}`)
return
}
const dateMs = new Date(message.block_time)

return [
{
params: {
base: message.base_symbol,
quote: message.quote_symbol,
},
response: {
result,
data: {
result,
},
timestamps: {
providerIndicatedTimeUnixMs: dateMs.getTime(),
},
},
},
]
},
},
builders: {
// Nobi's WS API requires sending the full list of pairs to subscribe, keep track of activePairs
batchSubscribeMessage: (params) => ({
method: 'subscribe',
params: {
pairs: params.map((p) => constructNobiSymbol(p)),
},
}),
// Nobi's API does not require sending the full list of pairs to unsubscribe, just the pairs to remove
batchUnsubscribeMessage: (params) => ({
method: 'unsubscribe',
params: { pairs: params.map((p) => constructNobiSymbol(p)) },
}),
},
})
Loading