diff --git a/.pnp.cjs b/.pnp.cjs index eef0fd263a..1c3abb46bf 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -366,6 +366,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/eodhistoricaldata-adapter",\ "reference": "workspace:packages/sources/eodhistoricaldata"\ },\ + {\ + "name": "@chainlink/equitize-adapter",\ + "reference": "workspace:packages/sources/equitize"\ + },\ {\ "name": "@chainlink/eth-balance-adapter",\ "reference": "workspace:packages/sources/eth-balance"\ @@ -832,6 +836,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/elwood-adapter", ["workspace:packages/sources/elwood"]],\ ["@chainlink/enzyme-adapter", ["workspace:packages/sources/enzyme"]],\ ["@chainlink/eodhistoricaldata-adapter", ["workspace:packages/sources/eodhistoricaldata"]],\ + ["@chainlink/equitize-adapter", ["workspace:packages/sources/equitize"]],\ ["@chainlink/eth-balance-adapter", ["workspace:packages/sources/eth-balance"]],\ ["@chainlink/eth-beacon-adapter", ["workspace:packages/sources/eth-beacon"]],\ ["@chainlink/etherscan-adapter", ["workspace:packages/sources/etherscan"]],\ @@ -5914,6 +5919,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/equitize-adapter", [\ + ["workspace:packages/sources/equitize", {\ + "packageLocation": "./packages/sources/equitize/",\ + "packageDependencies": [\ + ["@chainlink/equitize-adapter", "workspace:packages/sources/equitize"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.4"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["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/eth-balance-adapter", [\ ["workspace:packages/sources/eth-balance", {\ "packageLocation": "./packages/sources/eth-balance/",\ @@ -6153,7 +6173,7 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }],\ ["npm:2.9.0", {\ - "packageLocation": "./.yarn/cache/@chainlink-external-adapter-framework-npm-2.9.0-664e8a533b-36152824af.zip/node_modules/@chainlink/external-adapter-framework/",\ + "packageLocation": "./.yarn/unplugged/@chainlink-external-adapter-framework-npm-2.9.0-664e8a533b/node_modules/@chainlink/external-adapter-framework/",\ "packageDependencies": [\ ["@chainlink/external-adapter-framework", "npm:2.9.0"],\ ["ajv", "npm:8.17.1"],\ diff --git a/package.json b/package.json index ed3d28f320..cdd6f919ca 100644 --- a/package.json +++ b/package.json @@ -77,5 +77,10 @@ "resolutions": { "ethereum-cryptography@^1.1.2": "patch:ethereum-cryptography@npm%3A1.1.2#./.yarn/patches/ethereum-cryptography-npm-1.1.2-c16cfd7e8a.patch", "ethereum-cryptography@^1.0.3": "patch:ethereum-cryptography@npm%3A1.1.2#./.yarn/patches/ethereum-cryptography-npm-1.1.2-c16cfd7e8a.patch" + }, + "dependenciesMeta": { + "@chainlink/external-adapter-framework@2.9.0": { + "unplugged": true + } } } diff --git a/packages/sources/equitize/CHANGELOG.md b/packages/sources/equitize/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/equitize/README.md b/packages/sources/equitize/README.md new file mode 100644 index 0000000000..91ab1d4455 --- /dev/null +++ b/packages/sources/equitize/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for example-adapter + +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 example-adapter`. diff --git a/packages/sources/equitize/package.json b/packages/sources/equitize/package.json new file mode 100644 index 0000000000..3f03789334 --- /dev/null +++ b/packages/sources/equitize/package.json @@ -0,0 +1,40 @@ +{ + "name": "@chainlink/equitize-adapter", + "version": "0.0.0", + "description": "Chainlink equitize adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "equitize" + ], + "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": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.11.4", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/equitize/src/config/index.ts b/packages/sources/equitize/src/config/index.ts new file mode 100644 index 0000000000..d043772c9a --- /dev/null +++ b/packages/sources/equitize/src/config/index.ts @@ -0,0 +1,9 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_ENDPOINT: { + description: 'An API endpoint for Equitize', + type: 'string', + default: 'https://staging-mint-5ylweigmlq-el.a.run.app', + }, +}) diff --git a/packages/sources/equitize/src/config/overrides.json b/packages/sources/equitize/src/config/overrides.json new file mode 100644 index 0000000000..2ffa2cb154 --- /dev/null +++ b/packages/sources/equitize/src/config/overrides.json @@ -0,0 +1,3 @@ +{ + "equitize": {} +} diff --git a/packages/sources/equitize/src/endpoint/index.ts b/packages/sources/equitize/src/endpoint/index.ts new file mode 100644 index 0000000000..0b91aa2c62 --- /dev/null +++ b/packages/sources/equitize/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as nav } from './nav' diff --git a/packages/sources/equitize/src/endpoint/nav.ts b/packages/sources/equitize/src/endpoint/nav.ts new file mode 100644 index 0000000000..c4a3e8b89a --- /dev/null +++ b/packages/sources/equitize/src/endpoint/nav.ts @@ -0,0 +1,26 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { + EmptyInputParameters, + InputParameters, +} from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { httpTransport } from '../transport/nav' + +export const inputParameters = new InputParameters({}, []) + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: SingleNumberResultResponse & { + Data: { + ripcord: boolean + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'nav', + transport: httpTransport, + inputParameters, +}) diff --git a/packages/sources/equitize/src/index.ts b/packages/sources/equitize/src/index.ts new file mode 100644 index 0000000000..46f08daea8 --- /dev/null +++ b/packages/sources/equitize/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: 'EQUITIZE', + config, + endpoints: [nav], + rateLimiting: { + tiers: { + default: { + rateLimit1s: 1, + rateLimit1m: 60, + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/equitize/src/transport/nav.ts b/packages/sources/equitize/src/transport/nav.ts new file mode 100644 index 0000000000..5c105d59d6 --- /dev/null +++ b/packages/sources/equitize/src/transport/nav.ts @@ -0,0 +1,102 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports' +import { BaseEndpointTypes } from '../endpoint/nav' + +export interface ResponseSchema { + accountName: string + NAV: string + updatedAt: string + ripcord: boolean + ripcordDetails: string[] +} + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: ResponseSchema + } +} + +/** + * Extracts timestamp from provider response + */ +export const extractTimestamp = (updatedAt: string): number => { + return new Date(updatedAt).getTime() +} + +/** + * Parses NAV value from string to number + */ +export const parseNavValue = (nav: string): number => { + return Number(nav) +} + +/** + * Builds request configuration structure + */ +export const buildRequestConfig = (apiEndpoint: string) => { + return { + baseURL: apiEndpoint, + url: '/nav', + headers: { + accept: 'application/json', + }, + } +} + +/** + * Builds error response structure when no data is returned + */ +export const buildErrorResponse = () => { + return { + errorMessage: 'The data provider did not return any value', + statusCode: 502, + } +} + +/** + * Builds successful response data structure + */ +export const buildSuccessResponseData = (data: ResponseSchema) => { + const result = parseNavValue(data.NAV) + const timestamps = { + providerIndicatedTimeUnixMs: extractTimestamp(data.updatedAt), + } + + return { + result, + data: { + result, + ripcord: data.ripcord, + }, + timestamps, + } +} + +export const httpTransport = new HttpTransport({ + prepareRequests: (params, config) => { + return params.map((param) => { + return { + params: [param], + request: buildRequestConfig(config.API_ENDPOINT), + } + }) + }, + + parseResponse: (params, response) => { + if (!response.data) { + return params.map((param) => { + return { + params: param, + response: buildErrorResponse(), + } + }) + } + + return params.map((param) => { + return { + params: param, + response: buildSuccessResponseData(response.data), + } + }) + }, +}) diff --git a/packages/sources/equitize/test-payload.json b/packages/sources/equitize/test-payload.json new file mode 100644 index 0000000000..40d63c38ab --- /dev/null +++ b/packages/sources/equitize/test-payload.json @@ -0,0 +1,7 @@ +{ + "requests": [ + { + "endpoint": "nav" + } + ] +} diff --git a/packages/sources/equitize/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/equitize/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..1de57435ae --- /dev/null +++ b/packages/sources/equitize/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint happy path should return success 1`] = ` +{ + "data": { + "result": 146.51, + "ripcord": false, + }, + "result": 146.51, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + "providerIndicatedTimeUnixMs": 1771898690593, + }, +} +`; + +exports[`execute nav endpoint happy path should use nav as default endpoint 1`] = ` +{ + "data": { + "result": 146.51, + "ripcord": false, + }, + "result": 146.51, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + "providerIndicatedTimeUnixMs": 1771898690593, + }, +} +`; + +exports[`execute nav endpoint upstream failures should return error when upstream returns 5xx 1`] = ` +{ + "error": { + "message": "The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 504, +} +`; + +exports[`execute nav endpoint validation errors should return error for invalid endpoint 1`] = ` +{ + "error": { + "message": "Adapter does not have a "invalid" endpoint.", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 404, +} +`; diff --git a/packages/sources/equitize/test/integration/adapter.test.ts b/packages/sources/equitize/test/integration/adapter.test.ts new file mode 100644 index 0000000000..899bb9587a --- /dev/null +++ b/packages/sources/equitize/test/integration/adapter.test.ts @@ -0,0 +1,88 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import nock from 'nock' +import { mockResponseServerError, mockResponseSuccess } from './fixtures' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + + 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() + // Clear the EA cache between tests + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + }) + + describe('nav endpoint', () => { + // Test validation errors first (doesn't touch the cache for nav endpoint) + describe('validation errors', () => { + it('should return error for invalid endpoint', async () => { + const response = await testAdapter.request({ endpoint: 'invalid' }) + expect(response.statusCode).toBe(404) + expect(response.json()).toMatchSnapshot() + }) + }) + + // Test upstream failures before happy path to ensure clean cache + describe('upstream failures', () => { + it('should return error when upstream returns 5xx', async () => { + const data = { + endpoint: 'nav', + } + mockResponseServerError() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(504) + expect(response.json()).toMatchSnapshot() + }) + }) + + // Test happy path last + describe('happy path', () => { + it('should return success', async () => { + const data = { + endpoint: 'nav', + } + mockResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should use nav as default endpoint', async () => { + mockResponseSuccess() + const response = await testAdapter.request({}) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) + }) +}) diff --git a/packages/sources/equitize/test/integration/fixtures.ts b/packages/sources/equitize/test/integration/fixtures.ts new file mode 100644 index 0000000000..92a0310280 --- /dev/null +++ b/packages/sources/equitize/test/integration/fixtures.ts @@ -0,0 +1,43 @@ +import nock from 'nock' + +const API_ENDPOINT = 'https://staging-mint-5ylweigmlq-el.a.run.app' + +export const mockResponseSuccess = (): nock.Scope => + nock(API_ENDPOINT, { + encodedQueryParams: true, + }) + .get('/nav') + .reply( + 200, + () => ({ + accountName: 'SYNTHESYS', + NAV: '146.51', + updatedAt: '2026-02-24T02:04:50.593Z', + ripcord: false, + ripcordDetails: [], + }), + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() + +export const mockResponseServerError = (): nock.Scope => + nock(API_ENDPOINT, { + encodedQueryParams: true, + }) + .get('/nav') + .reply(500, { error: 'Internal Server Error' }, [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + ]) + .persist() diff --git a/packages/sources/equitize/test/unit/nav.test.ts b/packages/sources/equitize/test/unit/nav.test.ts new file mode 100644 index 0000000000..d76431ddd2 --- /dev/null +++ b/packages/sources/equitize/test/unit/nav.test.ts @@ -0,0 +1,139 @@ +import { + buildErrorResponse, + buildRequestConfig, + buildSuccessResponseData, + extractTimestamp, + parseNavValue, + ResponseSchema, +} from '../../src/transport/nav' + +describe('nav transport', () => { + describe('extractTimestamp', () => { + it('returns unix timestamp in milliseconds for valid ISO date', () => { + const result = extractTimestamp('2026-02-24T02:04:50.593Z') + expect(result).toBe(1771898690593) + }) + + it('returns unix timestamp for date without milliseconds', () => { + const result = extractTimestamp('2023-01-15T10:30:00Z') + expect(result).toBe(1673778600000) + }) + + it('returns NaN for invalid date string', () => { + const result = extractTimestamp('invalid-date') + expect(result).toBeNaN() + }) + }) + + describe('parseNavValue', () => { + it('parses string NAV to number', () => { + expect(parseNavValue('146.51')).toBe(146.51) + }) + + it('parses integer NAV string to number', () => { + expect(parseNavValue('100')).toBe(100) + }) + + it('parses zero NAV string', () => { + expect(parseNavValue('0')).toBe(0) + }) + + it('returns NaN for non-numeric string', () => { + expect(parseNavValue('invalid')).toBeNaN() + }) + + it('parses negative NAV string', () => { + expect(parseNavValue('-50.25')).toBe(-50.25) + }) + }) + + describe('buildRequestConfig', () => { + it('builds correct request configuration with baseURL', () => { + const result = buildRequestConfig('https://api.example.com') + + expect(result.baseURL).toBe('https://api.example.com') + }) + + it('builds correct request configuration with url path', () => { + const result = buildRequestConfig('https://api.example.com') + + expect(result.url).toBe('/nav') + }) + + it('builds correct request configuration with headers', () => { + const result = buildRequestConfig('https://api.example.com') + + expect(result.headers).toEqual({ accept: 'application/json' }) + }) + }) + + describe('buildErrorResponse', () => { + it('returns correct error message', () => { + const result = buildErrorResponse() + + expect(result.errorMessage).toBe('The data provider did not return any value') + }) + + it('returns correct status code', () => { + const result = buildErrorResponse() + + expect(result.statusCode).toBe(502) + }) + }) + + describe('buildSuccessResponseData', () => { + const mockResponseData: ResponseSchema = { + accountName: 'SYNTHESYS', + NAV: '146.51', + updatedAt: '2026-02-24T02:04:50.593Z', + ripcord: false, + ripcordDetails: [], + } + + it('returns parsed NAV as result', () => { + const result = buildSuccessResponseData(mockResponseData) + + expect(result.result).toBe(146.51) + }) + + it('returns result in data field', () => { + const result = buildSuccessResponseData(mockResponseData) + + expect(result.data.result).toBe(146.51) + }) + + it('returns ripcord false in data field', () => { + const result = buildSuccessResponseData(mockResponseData) + + expect(result.data.ripcord).toBe(false) + }) + + it('returns correct timestamp', () => { + const result = buildSuccessResponseData(mockResponseData) + + expect(result.timestamps.providerIndicatedTimeUnixMs).toBe(1771898690593) + }) + + it('returns ripcord true when set in data', () => { + const dataWithRipcord: ResponseSchema = { + ...mockResponseData, + ripcord: true, + } + + const result = buildSuccessResponseData(dataWithRipcord) + + expect(result.data.ripcord).toBe(true) + }) + + it('handles integer NAV value', () => { + const dataWithIntNAV: ResponseSchema = { + ...mockResponseData, + NAV: '100', + } + + const result = buildSuccessResponseData(dataWithIntNAV) + + expect(result.result).toBe(100) + }) + }) +}) diff --git a/packages/sources/equitize/tsconfig.json b/packages/sources/equitize/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/equitize/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/equitize/tsconfig.test.json b/packages/sources/equitize/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/equitize/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 fdfd8590b0..edea65a299 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -257,6 +257,9 @@ { "path": "./sources/eodhistoricaldata" }, + { + "path": "./sources/equitize" + }, { "path": "./sources/eth-balance" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 34b47d0d3a..2b3b52dac9 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -257,6 +257,9 @@ { "path": "./sources/eodhistoricaldata/tsconfig.test.json" }, + { + "path": "./sources/equitize/tsconfig.test.json" + }, { "path": "./sources/eth-balance/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 91d0f297f8..e082c79c28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3312,6 +3312,19 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/equitize-adapter@workspace:packages/sources/equitize": + version: 0.0.0-use.local + resolution: "@chainlink/equitize-adapter@workspace:packages/sources/equitize" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.11.4" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/eth-balance-adapter@workspace:*, @chainlink/eth-balance-adapter@workspace:packages/sources/eth-balance": version: 0.0.0-use.local resolution: "@chainlink/eth-balance-adapter@workspace:packages/sources/eth-balance" @@ -3613,6 +3626,9 @@ __metadata: ts-node: "npm:10.9.2" typescript: "npm:5.8.3" yo: "npm:4.3.1" + dependenciesMeta: + "@chainlink/external-adapter-framework@2.9.0": + unplugged: true languageName: unknown linkType: soft