From 968a7b97bab2549a33f9827302b5394bea6892ec Mon Sep 17 00:00:00 2001 From: mayowa Date: Sat, 29 Nov 2025 00:49:14 +0100 Subject: [PATCH 1/8] add r25 ea --- packages/sources/r25/README.md | 97 +++++++++++ packages/sources/r25/package.json | 42 +++++ packages/sources/r25/src/config/index.ts | 21 +++ packages/sources/r25/src/endpoint/index.ts | 1 + packages/sources/r25/src/endpoint/nav.ts | 39 +++++ packages/sources/r25/src/index.ts | 20 +++ .../r25/src/transport/authentication.ts | 52 ++++++ packages/sources/r25/src/transport/nav.ts | 87 ++++++++++ .../__snapshots__/adapter.test.ts.snap | 42 +++++ .../r25/test/integration/adapter.test.ts | 125 ++++++++++++++ .../sources/r25/test/integration/fixtures.ts | 36 ++++ .../r25/test/unit/authentication.test.ts | 158 ++++++++++++++++++ packages/sources/r25/tsconfig.json | 9 + packages/sources/r25/tsconfig.test.json | 5 + 14 files changed, 734 insertions(+) create mode 100644 packages/sources/r25/README.md create mode 100644 packages/sources/r25/package.json create mode 100644 packages/sources/r25/src/config/index.ts create mode 100644 packages/sources/r25/src/endpoint/index.ts create mode 100644 packages/sources/r25/src/endpoint/nav.ts create mode 100644 packages/sources/r25/src/index.ts create mode 100644 packages/sources/r25/src/transport/authentication.ts create mode 100644 packages/sources/r25/src/transport/nav.ts create mode 100644 packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap create mode 100644 packages/sources/r25/test/integration/adapter.test.ts create mode 100644 packages/sources/r25/test/integration/fixtures.ts create mode 100644 packages/sources/r25/test/unit/authentication.test.ts create mode 100644 packages/sources/r25/tsconfig.json create mode 100644 packages/sources/r25/tsconfig.test.json diff --git a/packages/sources/r25/README.md b/packages/sources/r25/README.md new file mode 100644 index 0000000000..c8d255de03 --- /dev/null +++ b/packages/sources/r25/README.md @@ -0,0 +1,97 @@ +# Chainlink R25 External Adapter + +This adapter fetches the latest NAV (Net Asset Value) via R25's REST API and returns a single numeric result with timestamps for both Data Feeds and Data Streams. + +## Configuration + +The adapter takes the following environment variables: + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------------: | :-----------------------------------------: | :----: | :-----: | :-------------------: | +| ✅ | `API_KEY` | An API key for R25 | string | | | +| ✅ | `API_SECRET` | An API secret for R25 used to sign requests | string | | | +| | `API_ENDPOINT` | An API endpoint for R25 | string | | `https://app.r25.xyz` | + +## Input Parameters + +### `nav` endpoint + +Supported names for this endpoint are: `nav`, `price`. + +#### Input Params + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :---------: | :---------------------------------: | :----: | :-----: | :-----: | +| ✅ | `chainType` | The chain type (e.g., polygon, sui) | string | | | +| ✅ | `tokenName` | The token name (e.g., rcusdp) | string | | | + +### Example + +Request: + +```json +{ + "data": { + "endpoint": "nav", + "chainType": "polygon", + "tokenName": "rcusdp" + } +} +``` + +Response: + +```json +{ + "data": { + "result": 1.020408163265306 + }, + "result": 1.020408163265306, + "timestamps": { + "providerIndicatedTimeUnixMs": 1731344153448 + }, + "statusCode": 200 +} +``` + +## Rate Limiting + +The adapter implements rate limiting of 5 requests/second per IP as specified in the R25 API documentation. + +## Authentication + +The adapter automatically generates the following headers for authentication: + +- `x-api-key`: API key +- `x-utc-timestamp`: Current timestamp in milliseconds +- `x-signature`: HMAC-SHA256 signature + +### Signature Algorithm + +The signature is generated using the HMAC-SHA256 algorithm. The signature string is constructed as follows: + +``` +{method}\n{path}\n{sorted_params}\n{timestamp}\n{api_key} +``` + +Where: + +- `method`: HTTP method in lowercase (e.g., "get") +- `path`: Request path (e.g., "/api/public/current/nav") +- `sorted_params`: Query parameters sorted by key in lexicographical order, formatted as key=value, joined with & +- `timestamp`: Current UTC timestamp in milliseconds +- `api_key`: API key + +The HMAC-SHA256 hash is computed using the `API_SECRET` as the secret key, and the result is hex-encoded. + +## API Response Mapping + +### Data Feeds mapping + +- `answer` = `currentNav` (from API response) + +### Data Streams mapping + +- `navPerShare` = `currentNav` +- `aum` = `totalAsset` +- `navDate` = `lastUpdate` diff --git a/packages/sources/r25/package.json b/packages/sources/r25/package.json new file mode 100644 index 0000000000..973a2918bd --- /dev/null +++ b/packages/sources/r25/package.json @@ -0,0 +1,42 @@ +{ + "name": "@chainlink/r25-adapter", + "version": "1.0.0", + "description": "Chainlink r25 adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "r25" + ], + "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.8.0", + "crypto-js": "4.2.0", + "tslib": "2.4.1" + }, + "devDependencies": { + "@types/crypto-js": "4.2.2", + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + } +} diff --git a/packages/sources/r25/src/config/index.ts b/packages/sources/r25/src/config/index.ts new file mode 100644 index 0000000000..36f599845b --- /dev/null +++ b/packages/sources/r25/src/config/index.ts @@ -0,0 +1,21 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'An API key for R25', + type: 'string', + required: true, + sensitive: true, + }, + API_SECRET: { + description: 'An API secret for R25 used to sign requests', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for R25', + type: 'string', + default: 'https://app.r25.xyz', + }, +}) diff --git a/packages/sources/r25/src/endpoint/index.ts b/packages/sources/r25/src/endpoint/index.ts new file mode 100644 index 0000000000..a6277e0b25 --- /dev/null +++ b/packages/sources/r25/src/endpoint/index.ts @@ -0,0 +1 @@ +export * as nav from './nav' diff --git a/packages/sources/r25/src/endpoint/nav.ts b/packages/sources/r25/src/endpoint/nav.ts new file mode 100644 index 0000000000..96a644ec4b --- /dev/null +++ b/packages/sources/r25/src/endpoint/nav.ts @@ -0,0 +1,39 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { httpTransport } from '../transport/nav' + +export const inputParameters = new InputParameters( + { + chainType: { + type: 'string', + description: 'The chain type (e.g., polygon, sui)', + required: true, + }, + tokenName: { + type: 'string', + description: 'The token name (e.g., rcusdp)', + required: true, + }, + }, + [ + { + chainType: 'polygon', + tokenName: 'rcusdp', + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'nav', + aliases: ['price'], + transport: httpTransport, + inputParameters, +}) diff --git a/packages/sources/r25/src/index.ts b/packages/sources/r25/src/index.ts new file mode 100644 index 0000000000..ed2857c0fe --- /dev/null +++ b/packages/sources/r25/src/index.ts @@ -0,0 +1,20 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { endpoint as navEndpoint } from './endpoint/nav' + +export const adapter = new Adapter({ + defaultEndpoint: navEndpoint.name, + name: 'R25', + config, + endpoints: [navEndpoint], + rateLimiting: { + tiers: { + default: { + rateLimit1s: 5, //5 requests per second + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/r25/src/transport/authentication.ts b/packages/sources/r25/src/transport/authentication.ts new file mode 100644 index 0000000000..e32cc82f90 --- /dev/null +++ b/packages/sources/r25/src/transport/authentication.ts @@ -0,0 +1,52 @@ +import CryptoJS from 'crypto-js' + +/** + * Generate the HMAC-SHA256 signature for R25 API requests. + * + * The signature string is constructed as: + * {method}\n{path}\n{sorted_params}\n{timestamp}\n{api_key} + * + * Where: + * - method: HTTP method in lowercase (e.g., "get") + * - path: Request path (e.g., "/api/public/current/nav") + * - sorted_params: Query parameters sorted by key, formatted as key=value, joined with & + * - timestamp: Current UTC timestamp in milliseconds + * - api_key: API key + */ +export const getRequestHeaders = ({ + method, + path, + params, + apiKey, + secret, + timestamp, +}: { + method: string + path: string + params: Record + apiKey: string + secret: string + timestamp: number +}): Record => { + // Sort parameters by key in lexicographical order and format as key=value + const sortedParams = Object.keys(params) + .sort() + .map((key) => `${key}=${params[key]}`) + .join('&') + + const stringToSign = [ + method.toLowerCase(), + path, + sortedParams, + timestamp.toString(), + apiKey, + ].join('\n') + + const signature = CryptoJS.HmacSHA256(stringToSign, secret).toString(CryptoJS.enc.Hex) + + return { + 'x-api-key': apiKey, + 'x-utc-timestamp': timestamp.toString(), + 'x-signature': signature, + } +} diff --git a/packages/sources/r25/src/transport/nav.ts b/packages/sources/r25/src/transport/nav.ts new file mode 100644 index 0000000000..32ce871f6d --- /dev/null +++ b/packages/sources/r25/src/transport/nav.ts @@ -0,0 +1,87 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports' +import { BaseEndpointTypes } from '../endpoint/nav' +import { getRequestHeaders } from './authentication' + +export interface ResponseSchema { + code: string + success: boolean + message: string + data: { + lastUpdate: string + tokenName: string + chainType: string + totalSupply: number + totalAsset: number + currentNav: string + } +} + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: ResponseSchema + } +} + +export const httpTransport = new HttpTransport({ + prepareRequests: (params, config) => { + return params.map((param) => { + const method = 'GET' + const path = '/api/public/current/nav' + const timestamp = Date.now() + + const queryParams = { + chainType: param.chainType, + tokenName: param.tokenName, + } + + const headers = getRequestHeaders({ + method, + path, + params: queryParams, + apiKey: config.API_KEY, + secret: config.API_SECRET, + timestamp, + }) + + return { + params: [param], + request: { + baseURL: config.API_ENDPOINT, + url: path, + params: queryParams, + headers, + }, + } + }) + }, + parseResponse: (params, response) => { + return params.map((param) => { + if (!response.data.success) { + return { + params: param, + response: { + errorMessage: response.data.message || 'Request failed', + statusCode: 502, + }, + } + } + + const currentNav = Number(response.data.data.currentNav) + const lastUpdate = response.data.data.lastUpdate + + return { + params: param, + response: { + result: currentNav, + data: { + result: currentNav, + }, + timestamps: { + providerIndicatedTimeUnixMs: new Date(lastUpdate).getTime(), + }, + }, + } + }) + }, +}) diff --git a/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..9aef209cbf --- /dev/null +++ b/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint should return error for invalid token 1`] = ` +{ + "errorMessage": "Token not found", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute nav endpoint should return success 1`] = ` +{ + "data": { + "result": 1.020408163265306, + }, + "result": 1.020408163265306, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + "providerIndicatedTimeUnixMs": 1762880153448, + }, +} +`; + +exports[`execute nav endpoint should return success using price alias 1`] = ` +{ + "data": { + "result": 1.020408163265306, + }, + "result": 1.020408163265306, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + "providerIndicatedTimeUnixMs": 1762880153448, + }, +} +`; diff --git a/packages/sources/r25/test/integration/adapter.test.ts b/packages/sources/r25/test/integration/adapter.test.ts new file mode 100644 index 0000000000..771055d8e3 --- /dev/null +++ b/packages/sources/r25/test/integration/adapter.test.ts @@ -0,0 +1,125 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { mockNavResponseFailure, 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.API_KEY = 'test-api-key' + process.env.API_SECRET = 'test-api-secret' + 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', + chainType: 'polygon', + tokenName: 'rcusdp', + } + + mockNavResponseSuccess() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success using price alias', async () => { + const data = { + endpoint: 'price', + chainType: 'polygon', + tokenName: 'rcusdp', + } + + mockNavResponseSuccess() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error for invalid token', async () => { + const data = { + endpoint: 'nav', + chainType: 'polygon', + tokenName: 'invalid', + } + + mockNavResponseFailure() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should include timestamp from API response', async () => { + const data = { + endpoint: 'nav', + chainType: 'polygon', + tokenName: 'rcusdp', + } + + mockNavResponseSuccess() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + const json = response.json() + expect(json.timestamps).toBeDefined() + expect(json.timestamps.providerIndicatedTimeUnixMs).toBeDefined() + expect(typeof json.timestamps.providerIndicatedTimeUnixMs).toBe('number') + }) + + it('should parse currentNav as a number', async () => { + const data = { + endpoint: 'nav', + chainType: 'polygon', + tokenName: 'rcusdp', + } + + mockNavResponseSuccess() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + const json = response.json() + expect(typeof json.result).toBe('number') + expect(json.result).toBe(1.020408163265306) + expect(json.data.result).toBe(json.result) + }) + + it('should handle missing required parameters', async () => { + const data = { + endpoint: 'nav', + // Missing chainType and tokenName + } + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(400) + expect(response.json().error).toBeDefined() + }) + }) +}) diff --git a/packages/sources/r25/test/integration/fixtures.ts b/packages/sources/r25/test/integration/fixtures.ts new file mode 100644 index 0000000000..f43e91858c --- /dev/null +++ b/packages/sources/r25/test/integration/fixtures.ts @@ -0,0 +1,36 @@ +import nock from 'nock' + +export const mockNavResponseSuccess = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'polygon', tokenName: 'rcusdp' }) + .reply(200, { + code: 'R9999_9999', + success: true, + message: 'Success', + data: { + lastUpdate: '2025-11-11T16:55:53.448+00:00', + tokenName: 'rcusd', + chainType: 'chain', + totalSupply: 98, + totalAsset: 100, + currentNav: '1.020408163265306', + }, + }) + .persist() + +export const mockNavResponseFailure = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'polygon', tokenName: 'invalid' }) + .reply(200, { + code: 'R9999_0001', + success: false, + message: 'Token not found', + data: {}, + }) + .persist() diff --git a/packages/sources/r25/test/unit/authentication.test.ts b/packages/sources/r25/test/unit/authentication.test.ts new file mode 100644 index 0000000000..da6db5512a --- /dev/null +++ b/packages/sources/r25/test/unit/authentication.test.ts @@ -0,0 +1,158 @@ +import CryptoJS from 'crypto-js' +import { getRequestHeaders } from '../../src/transport/authentication' + +describe('authentication', () => { + describe('getRequestHeaders', () => { + it('should generate correct signature for example from documentation', () => { + // Example from R25 API documentation + const method = 'GET' + const path = '/api/public/current/nav' + const params = { + chainType: 'chain', + tokenName: 'rcusd', + } + const timestamp = 1731344153448 + const apiKey = 'xxx' + const secret = 'xxxxxxxx' + + // Expected signature string from documentation: + // get + // /api/public/current/nav + // chainType=chain&tokenName=rcusd + // 1731344153448 + // xxx + const expectedStringToSign = [ + 'get', + '/api/public/current/nav', + 'chainType=chain&tokenName=rcusd', + '1731344153448', + 'xxx', + ].join('\n') + + const expectedSignature = CryptoJS.HmacSHA256(expectedStringToSign, secret).toString( + CryptoJS.enc.Hex, + ) + + const headers = getRequestHeaders({ + method, + path, + params, + apiKey, + secret, + timestamp, + }) + + expect(headers['x-api-key']).toBe(apiKey) + expect(headers['x-utc-timestamp']).toBe(timestamp.toString()) + expect(headers['x-signature']).toBe(expectedSignature) + }) + + it('should generate consistent signatures for the same input', () => { + const method = 'GET' + const path = '/api/public/current/nav' + const params = { + chainType: 'polygon', + tokenName: 'rcusdp', + } + const timestamp = 1234567890123 + const apiKey = 'test-api-key' + const secret = 'test-secret' + + const headers1 = getRequestHeaders({ + method, + path, + params, + apiKey, + secret, + timestamp, + }) + + const headers2 = getRequestHeaders({ + method, + path, + params, + apiKey, + secret, + timestamp, + }) + + expect(headers1['x-signature']).toBe(headers2['x-signature']) + }) + + it('should sort query parameters alphabetically', () => { + const method = 'GET' + const path = '/api/public/current/nav' + // Intentionally unsorted parameters + const params = { + tokenName: 'rcusdp', + chainType: 'polygon', + } + const timestamp = 1234567890123 + const apiKey = 'test-api-key' + const secret = 'test-secret' + + // The signature should be the same regardless of the order params are provided + const headers1 = getRequestHeaders({ + method, + path, + params, + apiKey, + secret, + timestamp, + }) + + // Try with sorted params + const sortedParams = { + chainType: 'polygon', + tokenName: 'rcusdp', + } + + const headers2 = getRequestHeaders({ + method, + path, + params: sortedParams, + apiKey, + secret, + timestamp, + }) + + expect(headers1['x-signature']).toBe(headers2['x-signature']) + }) + + it('should use lowercase method name', () => { + const method = 'GET' + const path = '/api/public/current/nav' + const params = { + chainType: 'polygon', + tokenName: 'rcusdp', + } + const timestamp = 1234567890123 + const apiKey = 'test-api-key' + const secret = 'test-secret' + + const headers = getRequestHeaders({ + method, + path, + params, + apiKey, + secret, + timestamp, + }) + + // Verify it's using lowercase by checking signature matches expected + const expectedStringToSign = [ + 'get', // lowercase + path, + 'chainType=polygon&tokenName=rcusdp', + timestamp.toString(), + apiKey, + ].join('\n') + + const expectedSignature = CryptoJS.HmacSHA256(expectedStringToSign, secret).toString( + CryptoJS.enc.Hex, + ) + + expect(headers['x-signature']).toBe(expectedSignature) + }) + }) +}) diff --git a/packages/sources/r25/tsconfig.json b/packages/sources/r25/tsconfig.json new file mode 100644 index 0000000000..2c84c1fcb2 --- /dev/null +++ b/packages/sources/r25/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/r25/tsconfig.test.json b/packages/sources/r25/tsconfig.test.json new file mode 100644 index 0000000000..39a2608843 --- /dev/null +++ b/packages/sources/r25/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "exclude": [] +} From 0251fa771c70bba8e60b50a0b03a15f7a5b0982c Mon Sep 17 00:00:00 2001 From: mayowa Date: Sat, 29 Nov 2025 00:50:41 +0100 Subject: [PATCH 2/8] add dependecy locks --- yarn.lock | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/yarn.lock b/yarn.lock index 57b2963a55..3f6394d8ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5379,6 +5379,21 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/r25-adapter@workspace:packages/sources/r25": + version: 0.0.0-use.local + resolution: "@chainlink/r25-adapter@workspace:packages/sources/r25" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.8.0" + "@types/crypto-js": "npm:4.2.2" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + crypto-js: "npm:4.2.0" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/readme-test-adapter@workspace:packages/scripts/src/generate-readme/test/integration/readme-test-adapter": version: 0.0.0-use.local resolution: "@chainlink/readme-test-adapter@workspace:packages/scripts/src/generate-readme/test/integration/readme-test-adapter" From f879e1eadd06ad538f60487d778d445d62224774 Mon Sep 17 00:00:00 2001 From: mayowa Date: Sun, 30 Nov 2025 10:02:28 +0100 Subject: [PATCH 3/8] add changeset --- .changeset/fresh-ducks-remain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-ducks-remain.md diff --git a/.changeset/fresh-ducks-remain.md b/.changeset/fresh-ducks-remain.md new file mode 100644 index 0000000000..287e442da3 --- /dev/null +++ b/.changeset/fresh-ducks-remain.md @@ -0,0 +1,5 @@ +--- +'@chainlink/r25-adapter': major +--- + +This change contains an external adapter for the r25 endpoint From 3b089e3680d819071e5c71af22fb90f522e32b8a Mon Sep 17 00:00:00 2001 From: mayowa Date: Sun, 30 Nov 2025 10:10:45 +0100 Subject: [PATCH 4/8] update deps --- .pnp.cjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.pnp.cjs b/.pnp.cjs index c2920477db..cd4fbc7b0e 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -746,6 +746,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/por-indexer-adapter",\ "reference": "workspace:packages/sources/por-indexer"\ },\ + {\ + "name": "@chainlink/r25-adapter",\ + "reference": "workspace:packages/sources/r25"\ + },\ {\ "name": "@chainlink/renvm-address-set-adapter",\ "reference": "workspace:packages/sources/renvm-address-set"\ @@ -1121,6 +1125,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/por-address-list-adapter", ["workspace:packages/sources/por-address-list"]],\ ["@chainlink/por-indexer-adapter", ["workspace:packages/sources/por-indexer"]],\ ["@chainlink/proof-of-reserves-adapter", ["workspace:packages/composites/proof-of-reserves"]],\ + ["@chainlink/r25-adapter", ["workspace:packages/sources/r25"]],\ ["@chainlink/readme-test-adapter", ["workspace:packages/scripts/src/generate-readme/test/integration/readme-test-adapter"]],\ ["@chainlink/reduce-adapter", ["workspace:packages/non-deployable/reduce"]],\ ["@chainlink/renvm-address-set-adapter", ["workspace:packages/sources/renvm-address-set"]],\ @@ -8398,6 +8403,23 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/r25-adapter", [\ + ["workspace:packages/sources/r25", {\ + "packageLocation": "./packages/sources/r25/",\ + "packageDependencies": [\ + ["@chainlink/r25-adapter", "workspace:packages/sources/r25"],\ + ["@chainlink/external-adapter-framework", "npm:2.8.0"],\ + ["@types/crypto-js", "npm:4.2.2"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["crypto-js", "npm:4.2.0"],\ + ["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/readme-test-adapter", [\ ["workspace:packages/scripts/src/generate-readme/test/integration/readme-test-adapter", {\ "packageLocation": "./packages/scripts/src/generate-readme/test/integration/readme-test-adapter/",\ From e2480b041569ca5879967c8f579fd434b60d660e Mon Sep 17 00:00:00 2001 From: mayowa Date: Sun, 30 Nov 2025 10:21:39 +0100 Subject: [PATCH 5/8] update ea deps --- .pnp.cjs | 2 +- packages/sources/r25/package.json | 2 +- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pnp.cjs b/.pnp.cjs index cd4fbc7b0e..12c73d565d 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -8408,7 +8408,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/sources/r25/",\ "packageDependencies": [\ ["@chainlink/r25-adapter", "workspace:packages/sources/r25"],\ - ["@chainlink/external-adapter-framework", "npm:2.8.0"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.1"],\ ["@types/crypto-js", "npm:4.2.2"],\ ["@types/jest", "npm:29.5.14"],\ ["@types/node", "npm:22.14.1"],\ diff --git a/packages/sources/r25/package.json b/packages/sources/r25/package.json index 973a2918bd..0ea9632760 100644 --- a/packages/sources/r25/package.json +++ b/packages/sources/r25/package.json @@ -28,7 +28,7 @@ "start": "yarn server:dist" }, "dependencies": { - "@chainlink/external-adapter-framework": "2.8.0", + "@chainlink/external-adapter-framework": "2.11.1", "crypto-js": "4.2.0", "tslib": "2.4.1" }, diff --git a/yarn.lock b/yarn.lock index 3f6394d8ab..0e85390a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5383,7 +5383,7 @@ __metadata: version: 0.0.0-use.local resolution: "@chainlink/r25-adapter@workspace:packages/sources/r25" dependencies: - "@chainlink/external-adapter-framework": "npm:2.8.0" + "@chainlink/external-adapter-framework": "npm:2.11.1" "@types/crypto-js": "npm:4.2.2" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.14.1" From eab3972d5065d85b986068f8166c6ee1b6c19219 Mon Sep 17 00:00:00 2001 From: mayowa Date: Mon, 1 Dec 2025 18:00:30 +0100 Subject: [PATCH 6/8] refactor ea to the easier to read --- packages/sources/r25/README.md | 97 ---------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 packages/sources/r25/README.md diff --git a/packages/sources/r25/README.md b/packages/sources/r25/README.md deleted file mode 100644 index c8d255de03..0000000000 --- a/packages/sources/r25/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# Chainlink R25 External Adapter - -This adapter fetches the latest NAV (Net Asset Value) via R25's REST API and returns a single numeric result with timestamps for both Data Feeds and Data Streams. - -## Configuration - -The adapter takes the following environment variables: - -| Required? | Name | Description | Type | Options | Default | -| :-------: | :------------: | :-----------------------------------------: | :----: | :-----: | :-------------------: | -| ✅ | `API_KEY` | An API key for R25 | string | | | -| ✅ | `API_SECRET` | An API secret for R25 used to sign requests | string | | | -| | `API_ENDPOINT` | An API endpoint for R25 | string | | `https://app.r25.xyz` | - -## Input Parameters - -### `nav` endpoint - -Supported names for this endpoint are: `nav`, `price`. - -#### Input Params - -| Required? | Name | Description | Type | Options | Default | -| :-------: | :---------: | :---------------------------------: | :----: | :-----: | :-----: | -| ✅ | `chainType` | The chain type (e.g., polygon, sui) | string | | | -| ✅ | `tokenName` | The token name (e.g., rcusdp) | string | | | - -### Example - -Request: - -```json -{ - "data": { - "endpoint": "nav", - "chainType": "polygon", - "tokenName": "rcusdp" - } -} -``` - -Response: - -```json -{ - "data": { - "result": 1.020408163265306 - }, - "result": 1.020408163265306, - "timestamps": { - "providerIndicatedTimeUnixMs": 1731344153448 - }, - "statusCode": 200 -} -``` - -## Rate Limiting - -The adapter implements rate limiting of 5 requests/second per IP as specified in the R25 API documentation. - -## Authentication - -The adapter automatically generates the following headers for authentication: - -- `x-api-key`: API key -- `x-utc-timestamp`: Current timestamp in milliseconds -- `x-signature`: HMAC-SHA256 signature - -### Signature Algorithm - -The signature is generated using the HMAC-SHA256 algorithm. The signature string is constructed as follows: - -``` -{method}\n{path}\n{sorted_params}\n{timestamp}\n{api_key} -``` - -Where: - -- `method`: HTTP method in lowercase (e.g., "get") -- `path`: Request path (e.g., "/api/public/current/nav") -- `sorted_params`: Query parameters sorted by key in lexicographical order, formatted as key=value, joined with & -- `timestamp`: Current UTC timestamp in milliseconds -- `api_key`: API key - -The HMAC-SHA256 hash is computed using the `API_SECRET` as the secret key, and the result is hex-encoded. - -## API Response Mapping - -### Data Feeds mapping - -- `answer` = `currentNav` (from API response) - -### Data Streams mapping - -- `navPerShare` = `currentNav` -- `aum` = `totalAsset` -- `navDate` = `lastUpdate` From 5d717ef7c2c156a7d8d20d7445d403d038f72e46 Mon Sep 17 00:00:00 2001 From: mayowa Date: Mon, 1 Dec 2025 18:01:38 +0100 Subject: [PATCH 7/8] refactor ea --- packages/sources/r25/package.json | 2 +- packages/sources/r25/src/endpoint/index.ts | 2 +- packages/sources/r25/src/endpoint/nav.ts | 1 - .../r25/src/transport/authentication.ts | 18 +++--- packages/sources/r25/src/transport/nav.ts | 15 +++-- .../__snapshots__/adapter.test.ts.snap | 25 ++++++--- .../r25/test/integration/adapter.test.ts | 55 +++++++++++++------ .../sources/r25/test/integration/fixtures.ts | 32 ++++++++++- .../r25/test/unit/authentication.test.ts | 18 +----- 9 files changed, 105 insertions(+), 63 deletions(-) diff --git a/packages/sources/r25/package.json b/packages/sources/r25/package.json index 0ea9632760..42e8a336ee 100644 --- a/packages/sources/r25/package.json +++ b/packages/sources/r25/package.json @@ -1,6 +1,6 @@ { "name": "@chainlink/r25-adapter", - "version": "1.0.0", + "version": "0.0.0", "description": "Chainlink r25 adapter.", "keywords": [ "Chainlink", diff --git a/packages/sources/r25/src/endpoint/index.ts b/packages/sources/r25/src/endpoint/index.ts index a6277e0b25..0b91aa2c62 100644 --- a/packages/sources/r25/src/endpoint/index.ts +++ b/packages/sources/r25/src/endpoint/index.ts @@ -1 +1 @@ -export * as nav from './nav' +export { endpoint as nav } from './nav' diff --git a/packages/sources/r25/src/endpoint/nav.ts b/packages/sources/r25/src/endpoint/nav.ts index 96a644ec4b..277f1ddd51 100644 --- a/packages/sources/r25/src/endpoint/nav.ts +++ b/packages/sources/r25/src/endpoint/nav.ts @@ -33,7 +33,6 @@ export type BaseEndpointTypes = { export const endpoint = new AdapterEndpoint({ name: 'nav', - aliases: ['price'], transport: httpTransport, inputParameters, }) diff --git a/packages/sources/r25/src/transport/authentication.ts b/packages/sources/r25/src/transport/authentication.ts index e32cc82f90..2f0a5b195f 100644 --- a/packages/sources/r25/src/transport/authentication.ts +++ b/packages/sources/r25/src/transport/authentication.ts @@ -1,5 +1,14 @@ import CryptoJS from 'crypto-js' +export interface GetRequestHeadersParams { + method: string + path: string + params: Record + apiKey: string + secret: string + timestamp: number +} + /** * Generate the HMAC-SHA256 signature for R25 API requests. * @@ -20,14 +29,7 @@ export const getRequestHeaders = ({ apiKey, secret, timestamp, -}: { - method: string - path: string - params: Record - apiKey: string - secret: string - timestamp: number -}): Record => { +}: GetRequestHeadersParams): Record => { // Sort parameters by key in lexicographical order and format as key=value const sortedParams = Object.keys(params) .sort() diff --git a/packages/sources/r25/src/transport/nav.ts b/packages/sources/r25/src/transport/nav.ts index 32ce871f6d..94bbd87d57 100644 --- a/packages/sources/r25/src/transport/nav.ts +++ b/packages/sources/r25/src/transport/nav.ts @@ -57,28 +57,27 @@ export const httpTransport = new HttpTransport({ }, parseResponse: (params, response) => { return params.map((param) => { - if (!response.data.success) { + const apiResponse = response.data as ResponseSchema + if (!apiResponse.success) { return { params: param, response: { - errorMessage: response.data.message || 'Request failed', + errorMessage: apiResponse.message || 'Request failed', statusCode: 502, }, } } - const currentNav = Number(response.data.data.currentNav) - const lastUpdate = response.data.data.lastUpdate - + const result = Number(apiResponse.data.currentNav) return { params: param, response: { - result: currentNav, + result, data: { - result: currentNav, + result, }, timestamps: { - providerIndicatedTimeUnixMs: new Date(lastUpdate).getTime(), + providerIndicatedTimeUnixMs: new Date(apiResponse.data.lastUpdate).getTime(), }, }, } diff --git a/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap index 9aef209cbf..012ac53170 100644 --- a/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/sources/r25/test/integration/__snapshots__/adapter.test.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`execute nav endpoint should return error for invalid token 1`] = ` +exports[`execute nav endpoint should return error for invalid chainType 1`] = ` { - "errorMessage": "Token not found", + "errorMessage": "Invalid chainType combination", "statusCode": 502, "timestamps": { "providerDataReceivedUnixMs": 978347471111, @@ -11,22 +11,29 @@ exports[`execute nav endpoint should return error for invalid token 1`] = ` } `; -exports[`execute nav endpoint should return success 1`] = ` +exports[`execute nav endpoint should return error for invalid chainType and tokenName 1`] = ` { - "data": { - "result": 1.020408163265306, + "errorMessage": "Invalid tokenName combination", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, }, - "result": 1.020408163265306, - "statusCode": 200, +} +`; + +exports[`execute nav endpoint should return error for invalid token 1`] = ` +{ + "errorMessage": "Invalid tokenName combination", + "statusCode": 502, "timestamps": { "providerDataReceivedUnixMs": 978347471111, "providerDataRequestedUnixMs": 978347471111, - "providerIndicatedTimeUnixMs": 1762880153448, }, } `; -exports[`execute nav endpoint should return success using price alias 1`] = ` +exports[`execute nav endpoint should return success 1`] = ` { "data": { "result": 1.020408163265306, diff --git a/packages/sources/r25/test/integration/adapter.test.ts b/packages/sources/r25/test/integration/adapter.test.ts index 771055d8e3..32736ddc2f 100644 --- a/packages/sources/r25/test/integration/adapter.test.ts +++ b/packages/sources/r25/test/integration/adapter.test.ts @@ -3,7 +3,12 @@ import { setEnvVariables, } from '@chainlink/external-adapter-framework/util/testing-utils' import * as nock from 'nock' -import { mockNavResponseFailure, mockNavResponseSuccess } from './fixtures' +import { + mockNavResponseInvalidChainType, + mockNavResponseInvalidChainTypeAndTokenName, + mockNavResponseInvalidToken, + mockNavResponseSuccess, +} from './fixtures' describe('execute', () => { let spy: jest.SpyInstance @@ -49,20 +54,6 @@ describe('execute', () => { expect(response.json()).toMatchSnapshot() }) - it('should return success using price alias', async () => { - const data = { - endpoint: 'price', - chainType: 'polygon', - tokenName: 'rcusdp', - } - - mockNavResponseSuccess() - - const response = await testAdapter.request(data) - expect(response.statusCode).toBe(200) - expect(response.json()).toMatchSnapshot() - }) - it('should return error for invalid token', async () => { const data = { endpoint: 'nav', @@ -70,7 +61,7 @@ describe('execute', () => { tokenName: 'invalid', } - mockNavResponseFailure() + mockNavResponseInvalidToken() const response = await testAdapter.request(data) expect(response.statusCode).toBe(502) @@ -121,5 +112,37 @@ describe('execute', () => { expect(response.statusCode).toBe(400) expect(response.json().error).toBeDefined() }) + + it('should return error for invalid chainType', async () => { + const data = { + endpoint: 'nav', + chainType: 'invalid', + tokenName: 'rcusdp', + } + + mockNavResponseInvalidChainType() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + const json = response.json() + expect(json.errorMessage).toBe('Invalid chainType combination') + expect(json).toMatchSnapshot() + }) + + it('should return error for invalid chainType and tokenName', async () => { + const data = { + endpoint: 'nav', + chainType: 'invalid', + tokenName: 'invalid', + } + + mockNavResponseInvalidChainTypeAndTokenName() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + const json = response.json() + expect(json.errorMessage).toBe('Invalid tokenName combination') + expect(json).toMatchSnapshot() + }) }) }) diff --git a/packages/sources/r25/test/integration/fixtures.ts b/packages/sources/r25/test/integration/fixtures.ts index f43e91858c..26b6584926 100644 --- a/packages/sources/r25/test/integration/fixtures.ts +++ b/packages/sources/r25/test/integration/fixtures.ts @@ -21,7 +21,7 @@ export const mockNavResponseSuccess = (): nock.Scope => }) .persist() -export const mockNavResponseFailure = (): nock.Scope => +export const mockNavResponseInvalidToken = (): nock.Scope => nock('https://app.r25.xyz', { encodedQueryParams: true, }) @@ -30,7 +30,35 @@ export const mockNavResponseFailure = (): nock.Scope => .reply(200, { code: 'R9999_0001', success: false, - message: 'Token not found', + message: 'Invalid tokenName combination', + data: {}, + }) + .persist() + +export const mockNavResponseInvalidChainType = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'invalid', tokenName: 'rcusdp' }) + .reply(200, { + code: 'R9999_0002', + success: false, + message: 'Invalid chainType combination', + data: {}, + }) + .persist() + +export const mockNavResponseInvalidChainTypeAndTokenName = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'invalid', tokenName: 'invalid' }) + .reply(200, { + code: 'R9999_0001', + success: false, + message: 'Invalid tokenName combination', data: {}, }) .persist() diff --git a/packages/sources/r25/test/unit/authentication.test.ts b/packages/sources/r25/test/unit/authentication.test.ts index da6db5512a..453a5d88a0 100644 --- a/packages/sources/r25/test/unit/authentication.test.ts +++ b/packages/sources/r25/test/unit/authentication.test.ts @@ -16,23 +16,7 @@ describe('authentication', () => { const secret = 'xxxxxxxx' // Expected signature string from documentation: - // get - // /api/public/current/nav - // chainType=chain&tokenName=rcusd - // 1731344153448 - // xxx - const expectedStringToSign = [ - 'get', - '/api/public/current/nav', - 'chainType=chain&tokenName=rcusd', - '1731344153448', - 'xxx', - ].join('\n') - - const expectedSignature = CryptoJS.HmacSHA256(expectedStringToSign, secret).toString( - CryptoJS.enc.Hex, - ) - + const expectedSignature = '208966c881a8194fd63b6107c7b9cdbf4c49cc4e0b29b68bcbe18cf7c273bcf7' const headers = getRequestHeaders({ method, path, From 6eff0057a5c2442388717420864833d921d500e5 Mon Sep 17 00:00:00 2001 From: mayowa Date: Tue, 2 Dec 2025 20:24:29 +0100 Subject: [PATCH 8/8] refactor and add tests to check error cases --- .../r25/src/transport/authentication.ts | 13 +- packages/sources/r25/src/transport/nav.ts | 13 +- .../__snapshots__/error-codes.test.ts.snap | 23 +++ .../r25/test/integration/error-codes.test.ts | 142 ++++++++++++++++++ .../sources/r25/test/integration/fixtures.ts | 70 ++++++++- 5 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 packages/sources/r25/test/integration/__snapshots__/error-codes.test.ts.snap create mode 100644 packages/sources/r25/test/integration/error-codes.test.ts diff --git a/packages/sources/r25/src/transport/authentication.ts b/packages/sources/r25/src/transport/authentication.ts index 2f0a5b195f..8b0bba6bc5 100644 --- a/packages/sources/r25/src/transport/authentication.ts +++ b/packages/sources/r25/src/transport/authentication.ts @@ -22,14 +22,11 @@ export interface GetRequestHeadersParams { * - timestamp: Current UTC timestamp in milliseconds * - api_key: API key */ -export const getRequestHeaders = ({ - method, - path, - params, - apiKey, - secret, - timestamp, -}: GetRequestHeadersParams): Record => { +export const getRequestHeaders = ( + getRequestHeadersParams: GetRequestHeadersParams, +): Record => { + const { method, path, params, apiKey, secret, timestamp } = getRequestHeadersParams + // Sort parameters by key in lexicographical order and format as key=value const sortedParams = Object.keys(params) .sort() diff --git a/packages/sources/r25/src/transport/nav.ts b/packages/sources/r25/src/transport/nav.ts index 94bbd87d57..4b3b9b9d66 100644 --- a/packages/sources/r25/src/transport/nav.ts +++ b/packages/sources/r25/src/transport/nav.ts @@ -13,13 +13,17 @@ export interface ResponseSchema { totalSupply: number totalAsset: number currentNav: string - } + } | null +} + +export interface ErrorResponseSchema { + error: string } export type HttpTransportTypes = BaseEndpointTypes & { Provider: { RequestBody: never - ResponseBody: ResponseSchema + ResponseBody: ResponseSchema | ErrorResponseSchema } } @@ -58,11 +62,12 @@ export const httpTransport = new HttpTransport({ parseResponse: (params, response) => { return params.map((param) => { const apiResponse = response.data as ResponseSchema - if (!apiResponse.success) { + if ('error' in response.data || !apiResponse.success || !apiResponse.data) { + const errorResponse = response.data as ErrorResponseSchema return { params: param, response: { - errorMessage: apiResponse.message || 'Request failed', + errorMessage: apiResponse.message || errorResponse.error, statusCode: 502, }, } diff --git a/packages/sources/r25/test/integration/__snapshots__/error-codes.test.ts.snap b/packages/sources/r25/test/integration/__snapshots__/error-codes.test.ts.snap new file mode 100644 index 0000000000..3c1724a327 --- /dev/null +++ b/packages/sources/r25/test/integration/__snapshots__/error-codes.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint error codes should handle internal server error (Error #5) 1`] = ` +{ + "errorMessage": "System busy, please try again later.", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute nav endpoint error codes should handle supply query failed error (Error #8) 1`] = ` +{ + "errorMessage": "internal error", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/r25/test/integration/error-codes.test.ts b/packages/sources/r25/test/integration/error-codes.test.ts new file mode 100644 index 0000000000..d9690c65fc --- /dev/null +++ b/packages/sources/r25/test/integration/error-codes.test.ts @@ -0,0 +1,142 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { + mockNavResponseAuthenticationFailed, + mockNavResponseExpiredTimestamp, + mockNavResponseInternalServerError, + mockNavResponseParamsMissing, + mockNavResponseSignatureFailed, + mockNavResponseSupplyQueryFailed, +} 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.API_KEY = 'test-api-key' + process.env.API_SECRET = 'test-api-secret' + 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() + }) + + afterEach(() => { + nock.cleanAll() + }) + + describe('nav endpoint error codes', () => { + it('should handle params missing error - causes 504 (Error #1)', async () => { + const data = { + endpoint: 'nav', + chainType: 'base', + tokenName: 'rcusdc', + } + + mockNavResponseParamsMissing() + // Wait for background execution to attempt and fail + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(504) + const json = response.json() + expect(json.error).toBeDefined() + }) + + it('should handle expired timestamp error - causes 504 (Error #2)', async () => { + const data = { + endpoint: 'nav', + chainType: 'arbitrum', + tokenName: 'rcusd', + } + + mockNavResponseExpiredTimestamp() + await new Promise((resolve) => setTimeout(resolve, 300)) + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(504) + const json = response.json() + expect(json.error).toBeDefined() + }) + + it('should handle authentication failed error - causes 504 (Error #3)', async () => { + const data = { + endpoint: 'nav', + chainType: 'optimism', + tokenName: 'rcusd', + } + + mockNavResponseAuthenticationFailed() + await new Promise((resolve) => setTimeout(resolve, 300)) + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(504) + const json = response.json() + expect(json.error).toBeDefined() + }) + + it('should handle signature verification failed error - causes 504 (Error #4)', async () => { + const data = { + endpoint: 'nav', + chainType: 'avalanche', + tokenName: 'rcusd', + } + + mockNavResponseSignatureFailed() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(504) + const json = response.json() + expect(json.error).toBeDefined() + }) + + it('should handle internal server error (Error #5)', async () => { + const data = { + endpoint: 'nav', + chainType: 'polygon', + tokenName: 'rcusdp', + } + + mockNavResponseInternalServerError() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + const json = response.json() + expect(json.errorMessage).toBe('System busy, please try again later.') + expect(json).toMatchSnapshot() + }) + + it('should handle supply query failed error (Error #8)', async () => { + const data = { + endpoint: 'nav', + chainType: 'ethereum', + tokenName: 'rcusd', + } + + mockNavResponseSupplyQueryFailed() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(502) + const json = response.json() + expect(json.errorMessage).toBe('internal error') + expect(json).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/r25/test/integration/fixtures.ts b/packages/sources/r25/test/integration/fixtures.ts index 26b6584926..67409c8211 100644 --- a/packages/sources/r25/test/integration/fixtures.ts +++ b/packages/sources/r25/test/integration/fixtures.ts @@ -33,7 +33,6 @@ export const mockNavResponseInvalidToken = (): nock.Scope => message: 'Invalid tokenName combination', data: {}, }) - .persist() export const mockNavResponseInvalidChainType = (): nock.Scope => nock('https://app.r25.xyz', { @@ -47,7 +46,6 @@ export const mockNavResponseInvalidChainType = (): nock.Scope => message: 'Invalid chainType combination', data: {}, }) - .persist() export const mockNavResponseInvalidChainTypeAndTokenName = (): nock.Scope => nock('https://app.r25.xyz', { @@ -61,4 +59,70 @@ export const mockNavResponseInvalidChainTypeAndTokenName = (): nock.Scope => message: 'Invalid tokenName combination', data: {}, }) - .persist() + +export const mockNavResponseAuthenticationFailed = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'optimism', tokenName: 'rcusd' }) + .reply(401, { + error: 'authentication failed', + }) + +export const mockNavResponseSignatureFailed = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'avalanche', tokenName: 'rcusd' }) + .reply(401, { + error: 'signature failed', + }) + +export const mockNavResponseInternalServerError = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'polygon', tokenName: 'rcusdp' }) + .reply(200, { + code: 'R0005_00001', + success: false, + message: 'System busy, please try again later.', + data: null, + }) + +export const mockNavResponseSupplyQueryFailed = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'ethereum', tokenName: 'rcusd' }) + .reply(200, { + code: 'R0000_00001', + success: false, + message: 'internal error', + data: null, + }) + +export const mockNavResponseExpiredTimestamp = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + }) + .get('/api/public/current/nav') + .query({ chainType: 'arbitrum', tokenName: 'rcusd' }) + .reply(400, { + error: 'expired timestamp', + }) + +export const mockNavResponseParamsMissing = (): nock.Scope => + nock('https://app.r25.xyz', { + encodedQueryParams: true, + badheaders: ['x-api-key'], + }) + .get('/api/public/current/nav') + .query({ chainType: 'base', tokenName: 'rcusdc' }) + .reply(400, { + error: 'params missing', + })