From 805468bb897609126d60ec267b8d21cbd6d7c3cf Mon Sep 17 00:00:00 2001 From: mmcallister-cll <139181225+mmcallister-cll@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:22:00 -0500 Subject: [PATCH] Feat/DF-20625 apex (#3672) * DF-20625 apex EA initial commit * add changeset * review fixes --- .changeset/chilled-shrimps-happen.md | 5 + .pnp.cjs | 21 ++ packages/sources/apex/CHANGELOG.md | 0 packages/sources/apex/README.md | 3 + packages/sources/apex/package.json | 41 ++++ packages/sources/apex/src/config/index.ts | 45 ++++ packages/sources/apex/src/endpoint/index.ts | 1 + packages/sources/apex/src/endpoint/nav.ts | 32 +++ packages/sources/apex/src/index.ts | 21 ++ packages/sources/apex/src/transport/nav.ts | 211 ++++++++++++++++++ packages/sources/apex/test-payload.json | 5 + .../__snapshots__/adapter.test.ts.snap | 16 ++ .../apex/test/integration/adapter.test.ts | 54 +++++ .../sources/apex/test/integration/fixtures.ts | 58 +++++ packages/sources/apex/tsconfig.json | 9 + packages/sources/apex/tsconfig.test.json | 7 + packages/tsconfig.json | 3 + packages/tsconfig.test.json | 3 + yarn.lock | 14 ++ 19 files changed, 549 insertions(+) create mode 100644 .changeset/chilled-shrimps-happen.md create mode 100644 packages/sources/apex/CHANGELOG.md create mode 100644 packages/sources/apex/README.md create mode 100644 packages/sources/apex/package.json create mode 100644 packages/sources/apex/src/config/index.ts create mode 100644 packages/sources/apex/src/endpoint/index.ts create mode 100644 packages/sources/apex/src/endpoint/nav.ts create mode 100644 packages/sources/apex/src/index.ts create mode 100644 packages/sources/apex/src/transport/nav.ts create mode 100644 packages/sources/apex/test-payload.json create mode 100644 packages/sources/apex/test/integration/__snapshots__/adapter.test.ts.snap create mode 100644 packages/sources/apex/test/integration/adapter.test.ts create mode 100644 packages/sources/apex/test/integration/fixtures.ts create mode 100644 packages/sources/apex/tsconfig.json create mode 100755 packages/sources/apex/tsconfig.test.json diff --git a/.changeset/chilled-shrimps-happen.md b/.changeset/chilled-shrimps-happen.md new file mode 100644 index 0000000000..37a3389020 --- /dev/null +++ b/.changeset/chilled-shrimps-happen.md @@ -0,0 +1,5 @@ +--- +'@chainlink/apex-adapter': major +--- + +Apex EA Initial Commit diff --git a/.pnp.cjs b/.pnp.cjs index e5dabe64aa..6ec10a50c1 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -242,6 +242,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/ap-election-adapter",\ "reference": "workspace:packages/sources/ap-election"\ },\ + {\ + "name": "@chainlink/apex-adapter",\ + "reference": "workspace:packages/sources/apex"\ + },\ {\ "name": "@chainlink/avalanche-platform-adapter",\ "reference": "workspace:packages/sources/avalanche-platform"\ @@ -962,6 +966,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/anchorage-adapter", ["workspace:packages/sources/anchorage"]],\ ["@chainlink/anyblock-adapter", ["workspace:packages/sources/anyblock"]],\ ["@chainlink/ap-election-adapter", ["workspace:packages/sources/ap-election"]],\ + ["@chainlink/apex-adapter", ["workspace:packages/sources/apex"]],\ ["@chainlink/apy-finance-adapter", ["workspace:packages/composites/apy-finance"]],\ ["@chainlink/apy-finance-test-adapter", ["workspace:packages/composites/apy-finance-test"]],\ ["@chainlink/augur-adapter", ["workspace:packages/composites/augur"]],\ @@ -5279,6 +5284,22 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/apex-adapter", [\ + ["workspace:packages/sources/apex", {\ + "packageLocation": "./packages/sources/apex/",\ + "packageDependencies": [\ + ["@chainlink/apex-adapter", "workspace:packages/sources/apex"],\ + ["@chainlink/external-adapter-framework", "npm:2.0.0"],\ + ["@types/jest", "npm:27.5.2"],\ + ["@types/node", "npm:22.12.0"],\ + ["axios", "npm:1.7.9"],\ + ["nock", "npm:13.5.6"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/apy-finance-adapter", [\ ["workspace:packages/composites/apy-finance", {\ "packageLocation": "./packages/composites/apy-finance/",\ diff --git a/packages/sources/apex/CHANGELOG.md b/packages/sources/apex/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/apex/README.md b/packages/sources/apex/README.md new file mode 100644 index 0000000000..335f68f506 --- /dev/null +++ b/packages/sources/apex/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for apex + +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 apex`. diff --git a/packages/sources/apex/package.json b/packages/sources/apex/package.json new file mode 100644 index 0000000000..4c94cc418b --- /dev/null +++ b/packages/sources/apex/package.json @@ -0,0 +1,41 @@ +{ + "name": "@chainlink/apex-adapter", + "version": "0.0.0", + "description": "Chainlink apex adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "apex" + ], + "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": { + "@types/jest": "27.5.2", + "@types/node": "22.12.0", + "nock": "13.5.6", + "typescript": "5.6.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.0.0", + "axios": "1.7.9", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/apex/src/config/index.ts b/packages/sources/apex/src/config/index.ts new file mode 100644 index 0000000000..a0222c5142 --- /dev/null +++ b/packages/sources/apex/src/config/index.ts @@ -0,0 +1,45 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + CLIENT_ID: { + description: 'Data Provider client ID', + type: 'string', + required: true, + sensitive: true, + }, + CLIENT_SECRET: { + description: 'Data Provider client secret', + type: 'string', + required: true, + sensitive: true, + }, + SCOPE: { + description: 'Scope of credentials', + type: 'string', + required: true, + sensitive: true, + }, + GRANT_TYPE: { + description: 'Grant type for credentials', + type: 'string', + required: true, + sensitive: true, + }, + NAV_API_ENDPOINT: { + description: 'An API endpoint for Data Provider', + type: 'string', + default: 'https://api.apexgroup.com/v1/reports/NAV', + }, + AUTH_API_ENDPOINT: { + description: 'An auth API endpoint for Data Provider', + type: 'string', + required: true, + sensitive: true, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10_000, + }, +}) diff --git a/packages/sources/apex/src/endpoint/index.ts b/packages/sources/apex/src/endpoint/index.ts new file mode 100644 index 0000000000..0b91aa2c62 --- /dev/null +++ b/packages/sources/apex/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as nav } from './nav' diff --git a/packages/sources/apex/src/endpoint/nav.ts b/packages/sources/apex/src/endpoint/nav.ts new file mode 100644 index 0000000000..31bfdb2a99 --- /dev/null +++ b/packages/sources/apex/src/endpoint/nav.ts @@ -0,0 +1,32 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { config } from '../config' +import { navTransport } from '../transport/nav' + +export const inputParameters = new InputParameters( + { + accountName: { + required: true, + type: 'string', + description: 'The account name to query', + }, + }, + [ + { + accountName: 'EXAMPLE', + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'nav', + transport: navTransport, + inputParameters, +}) diff --git a/packages/sources/apex/src/index.ts b/packages/sources/apex/src/index.ts new file mode 100644 index 0000000000..923287aacb --- /dev/null +++ b/packages/sources/apex/src/index.ts @@ -0,0 +1,21 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { nav } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: nav.name, + name: 'APEX', + config, + endpoints: [nav], + rateLimiting: { + tiers: { + default: { + rateLimit1m: 6, + note: 'Setting reasonable default limits', + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/apex/src/transport/nav.ts b/packages/sources/apex/src/transport/nav.ts new file mode 100644 index 0000000000..ef2222184e --- /dev/null +++ b/packages/sources/apex/src/transport/nav.ts @@ -0,0 +1,211 @@ +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { BaseEndpointTypes, inputParameters } from '../endpoint/nav' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { makeLogger, AdapterResponse, sleep } from '@chainlink/external-adapter-framework/util' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AxiosResponse } from 'axios' + +const logger = makeLogger('NavTransport') + +type RequestParams = typeof inputParameters.validated + +export interface AuthRequestSchema { + client_id: string + client_secret: string + scope: string + grant_type: string +} + +export interface AuthResponseSchema { + token_type: string + expires_in: number + ext_expires_in: number + access_token: string +} + +export interface NavResponseSchema { + accountName: string + totalReserve: number + currency: string + timestamp: string + ripCord: string + ripCordDetails: string[] +} + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: NavResponseSchema + } +} + +interface TokenDuration { + token: string + expiryTimestampMs: number +} + +class NavTransport extends SubscriptionTransport { + requester!: Requester + settings!: HttpTransportTypes['Settings'] + latestToken: TokenDuration | undefined + + override async initialize( + dependencies: TransportDependencies, + adapterSettings: HttpTransportTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.requester = dependencies.requester + this.settings = adapterSettings + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + params: RequestParams, + ): Promise> { + const { accountName } = params + const providerDataRequestedUnixMs = Date.now() + + const token = await this.getToken() + const navResponse = await this.getNav(accountName, token) + + if (navResponse.ripCord) { + throw new AdapterError({ + statusCode: 502, + message: `ripcord pulled: ${JSON.stringify(navResponse.ripCordDetails)}`, + }) + } + const result = navResponse.totalReserve + + return { + data: { + result, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: new Date(navResponse.timestamp).getTime(), + }, + } + } + + async getToken(): Promise { + const now = Date.now() + const buffer = 2 * this.settings.BACKGROUND_EXECUTE_MS + + // if latestToken is missing or expired/expiring within buffer, grab a new token + if (!this.latestToken || now > this.latestToken.expiryTimestampMs - buffer) { + await this.requestAuth() + } + + if (!this.latestToken) { + throw new AdapterError({ + statusCode: 502, + message: 'Unable to getToken', + }) + } + + return this.latestToken.token + } + + async requestAuth(): Promise> { + const startTimeMs = Date.now() + + const baseURL = this.settings.AUTH_API_ENDPOINT + const formData = new FormData() + formData.append('client_id', this.settings.CLIENT_ID) + formData.append('client_secret', this.settings.CLIENT_SECRET) + formData.append('scope', this.settings.SCOPE) + formData.append('grant_type', this.settings.GRANT_TYPE) + + const requestConfig = { + method: 'POST', + baseURL, + headers: { + 'Content-Type': 'multipart/form-data', + }, + data: formData, + } + const a = await this.requester.request(baseURL, requestConfig) + if (a.response?.status != 200) { + throw new AdapterError({ + statusCode: 502, + message: 'Unable to auth', + providerStatusCode: a.response.status, + }) + } + + this.latestToken = { + token: a.response.data.access_token, + expiryTimestampMs: startTimeMs + a.response.data.expires_in * 1000, + } + + logger.debug('Successfully fetched token') + return a.response + } + + async getNav(accountName: string, token: string): Promise { + const baseURL = this.settings.NAV_API_ENDPOINT + const headers = { + Authorization: `Bearer ${token}`, + } + const requestConfig = { + baseURL, + headers, + params: { + accountName, + }, + } + const a = await this.requester.request(baseURL, requestConfig) + if (a.response.status == 401) { + throw new AdapterError({ + statusCode: 502, + message: 'Auth invalid, will retry next background execute', + providerStatusCode: a.response.status, + }) + } else if (a.response.status != 200) { + throw new AdapterError({ + statusCode: 502, + message: 'Unexpected response', + providerStatusCode: a.response.status, + }) + } + + return a.response.data as NavResponseSchema + } + + getSubscriptionTtlFromConfig(adapterSettings: HttpTransportTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const navTransport = new NavTransport() diff --git a/packages/sources/apex/test-payload.json b/packages/sources/apex/test-payload.json new file mode 100644 index 0000000000..8d1b69c4a1 --- /dev/null +++ b/packages/sources/apex/test-payload.json @@ -0,0 +1,5 @@ +{ + "requests": [{ + "accountName": "2bf9b361-c41d-4bdc-aaac-0820e4b2a5b5" + }] +} diff --git a/packages/sources/apex/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/apex/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..126b14f5ed --- /dev/null +++ b/packages/sources/apex/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint should return success 1`] = ` +{ + "data": { + "result": 1.234567, + }, + "result": 1.234567, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + "providerIndicatedTimeUnixMs": 1738648800000, + }, +} +`; diff --git a/packages/sources/apex/test/integration/adapter.test.ts b/packages/sources/apex/test/integration/adapter.test.ts new file mode 100644 index 0000000000..d993789559 --- /dev/null +++ b/packages/sources/apex/test/integration/adapter.test.ts @@ -0,0 +1,54 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { mockAuthResponseSuccess, mockNavResponseSuccess } from './fixtures' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.NAV_API_ENDPOINT = process.env.NAV_API_ENDPOINT ?? 'http://nav.com' + process.env.AUTH_API_ENDPOINT = process.env.AUTH_API_ENDPOINT ?? 'http://auth.com' + process.env.CLIENT_ID = process.env.CLIENT_ID ?? 'clientId' + process.env.CLIENT_SECRET = process.env.CLIENT_SECRET ?? 'secret' + process.env.SCOPE = process.env.SCOPE ?? 'scope' + process.env.GRANT_TYPE = process.env.GRANT_TYPE ?? 'grant-type' + process.env.BACKGROUND_EXECUTE_MS = '0' + + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('nav endpoint', () => { + it('should return success', async () => { + const data = { + endpoint: 'nav', + accountName: 'accountName', + } + mockAuthResponseSuccess() + mockNavResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/apex/test/integration/fixtures.ts b/packages/sources/apex/test/integration/fixtures.ts new file mode 100644 index 0000000000..8bbcb33afe --- /dev/null +++ b/packages/sources/apex/test/integration/fixtures.ts @@ -0,0 +1,58 @@ +import nock from 'nock' + +export const mockAuthResponseSuccess = (): nock.Scope => + nock('http://auth.com', { + encodedQueryParams: true, + }) + .post('/') + .reply( + 200, + () => ({ + token_type: 'Bearer', + expires_in: 3599, + ext_expires_in: 3599, + access_token: 'token', + }), + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() + +export const mockNavResponseSuccess = (): nock.Scope => + nock('http://nav.com', { + encodedQueryParams: true, + }) + .get('/') + .query({ + accountName: 'accountName', + }) + .reply( + 200, + () => ({ + accountName: 'accountName', + totalReserve: 1.234567, + currency: 'USD', + timestamp: '2025-02-04T06:00:00.000Z', + ripCord: false, + ripCordDetails: [], + }), + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() diff --git a/packages/sources/apex/tsconfig.json b/packages/sources/apex/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/apex/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/apex/tsconfig.test.json b/packages/sources/apex/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/apex/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 83656be1f2..8b6891966a 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -161,6 +161,9 @@ { "path": "./sources/ap-election" }, + { + "path": "./sources/apex" + }, { "path": "./sources/avalanche-platform" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 46e71cda7d..ca710faf44 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -161,6 +161,9 @@ { "path": "./sources/ap-election/tsconfig.test.json" }, + { + "path": "./sources/apex/tsconfig.test.json" + }, { "path": "./sources/avalanche-platform/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 13b8856256..f5eb4d1d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2506,6 +2506,20 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/apex-adapter@workspace:packages/sources/apex": + version: 0.0.0-use.local + resolution: "@chainlink/apex-adapter@workspace:packages/sources/apex" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.0.0" + "@types/jest": "npm:27.5.2" + "@types/node": "npm:22.12.0" + axios: "npm:1.7.9" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.6.3" + languageName: unknown + linkType: soft + "@chainlink/apy-finance-adapter@workspace:packages/composites/apy-finance": version: 0.0.0-use.local resolution: "@chainlink/apy-finance-adapter@workspace:packages/composites/apy-finance"