diff --git a/.changeset/shiny-points-melt.md b/.changeset/shiny-points-melt.md new file mode 100644 index 0000000000..f1ddd2124b --- /dev/null +++ b/.changeset/shiny-points-melt.md @@ -0,0 +1,5 @@ +--- +'@chainlink/nav-libre-adapter': major +--- + +Adds NAV adapter for LibreCapital diff --git a/.pnp.cjs b/.pnp.cjs index 22bc5752c6..4d9c76f507 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -714,6 +714,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/nav-generic-adapter",\ "reference": "workspace:packages/sources/nav-generic"\ },\ + {\ + "name": "@chainlink/nav-libre-adapter",\ + "reference": "workspace:packages/sources/nav-libre"\ + },\ {\ "name": "@chainlink/ncfx-adapter",\ "reference": "workspace:packages/sources/ncfx"\ @@ -1131,6 +1135,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/mycryptoapi-adapter", ["workspace:packages/sources/mycryptoapi"]],\ ["@chainlink/nav-consulting-adapter", ["workspace:packages/sources/nav-consulting"]],\ ["@chainlink/nav-generic-adapter", ["workspace:packages/sources/nav-generic"]],\ + ["@chainlink/nav-libre-adapter", ["workspace:packages/sources/nav-libre"]],\ ["@chainlink/ncfx-adapter", ["workspace:packages/sources/ncfx"]],\ ["@chainlink/nexus-kiln-adapter", ["workspace:packages/composites/nexus-kiln"]],\ ["@chainlink/nft-blue-chip-adapter", ["workspace:packages/sources/nft-blue-chip"]],\ @@ -8060,6 +8065,25 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/nav-libre-adapter", [\ + ["workspace:packages/sources/nav-libre", {\ + "packageLocation": "./packages/sources/nav-libre/",\ + "packageDependencies": [\ + ["@chainlink/nav-libre-adapter", "workspace:packages/sources/nav-libre"],\ + ["@chainlink/external-adapter-framework", "npm:2.6.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"],\ + ["date-fns", "npm:4.1.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"],\ + ["uuid", "npm:11.1.0"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/ncfx-adapter", [\ ["workspace:packages/sources/ncfx", {\ "packageLocation": "./packages/sources/ncfx/",\ @@ -38378,6 +38402,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["uuid", [\ + ["npm:11.1.0", {\ + "packageLocation": "./.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:11.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/unplugged/uuid-npm-2.0.1-a78a0c30dd/node_modules/uuid/",\ "packageDependencies": [\ diff --git a/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip b/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip new file mode 100644 index 0000000000..6acc3b0429 Binary files /dev/null and b/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip differ diff --git a/packages/sources/nav-libre/CHANGELOG.md b/packages/sources/nav-libre/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/nav-libre/README.md b/packages/sources/nav-libre/README.md new file mode 100644 index 0000000000..263497465a --- /dev/null +++ b/packages/sources/nav-libre/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for nav-libre + +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 nav-libre`. diff --git a/packages/sources/nav-libre/package.json b/packages/sources/nav-libre/package.json new file mode 100644 index 0000000000..4a373e56ac --- /dev/null +++ b/packages/sources/nav-libre/package.json @@ -0,0 +1,44 @@ +{ + "name": "@chainlink/nav-libre-adapter", + "version": "0.0.0", + "description": "Chainlink nav-libre adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "nav-libre" + ], + "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/crypto-js": "^4", + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.6.0", + "crypto-js": "^4.2.0", + "date-fns": "^4.1.0", + "tslib": "2.4.1", + "uuid": "^11.1.0" + } +} diff --git a/packages/sources/nav-libre/src/config/index.ts b/packages/sources/nav-libre/src/config/index.ts new file mode 100644 index 0000000000..c96cd9bdcd --- /dev/null +++ b/packages/sources/nav-libre/src/config/index.ts @@ -0,0 +1,35 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig( + { + API_KEY: { + description: 'An API key for Data Provider', + type: 'string', + required: true, + sensitive: true, + }, + SECRET_KEY: { + description: 'A key for Data Provider used in hashing the API key', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Data Provider', + type: 'string', + default: 'https://api.navfundservices.com', + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 120_000, // one call per two minute + }, + }, + { + envDefaultOverrides: { + CACHE_MAX_AGE: 900_000, // 15 minute cache + RETRY: 0, // Disables retry on Framework + }, + }, +) diff --git a/packages/sources/nav-libre/src/endpoint/index.ts b/packages/sources/nav-libre/src/endpoint/index.ts new file mode 100644 index 0000000000..0b91aa2c62 --- /dev/null +++ b/packages/sources/nav-libre/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as nav } from './nav' diff --git a/packages/sources/nav-libre/src/endpoint/nav.ts b/packages/sources/nav-libre/src/endpoint/nav.ts new file mode 100644 index 0000000000..5d0b519f9f --- /dev/null +++ b/packages/sources/nav-libre/src/endpoint/nav.ts @@ -0,0 +1,37 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { navLibreTransport } from '../transport/nav' + +export const inputParameters = new InputParameters( + { + globalFundID: { + required: true, + type: 'number', + description: 'The global fund ID for the Libre fund', + }, + }, + [ + { + globalFundID: 1234, + }, + ], +) +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: number + Data: { + navPerShare: number + navDate: string + globalFundID: number + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'nav', + transport: navLibreTransport, + inputParameters, +}) diff --git a/packages/sources/nav-libre/src/index.ts b/packages/sources/nav-libre/src/index.ts new file mode 100644 index 0000000000..57f2671d83 --- /dev/null +++ b/packages/sources/nav-libre/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { nav } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: nav.name, + name: 'NAV_LIBRE', + config, + endpoints: [nav], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/nav-libre/src/transport/authentication.ts b/packages/sources/nav-libre/src/transport/authentication.ts new file mode 100644 index 0000000000..ee9c1f5f93 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/authentication.ts @@ -0,0 +1,33 @@ +import CryptoJS from 'crypto-js' +import { v4 as uuidv4 } from 'uuid' + +/** + * Generate the necessary headers for calling the NAV API with a 5-minute-valid signature. + */ +export const getRequestHeaders = ({ + method, + path, + body, + apiKey, + secret, +}: { + method: string + path: string + body: string + apiKey: string + secret: string +}) => { + const utcNow = new Date().toUTCString() + const nonce = uuidv4() + const contentHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Base64) + const stringToSign = [apiKey, path, method, utcNow, nonce, contentHash].join(';') + + // Compute the HMAC-SHA256 signature, Base64-encoded + const signature = CryptoJS.HmacSHA256(stringToSign, secret).toString(CryptoJS.enc.Base64) + + return { + 'x-date': utcNow, + 'x-content-sha256': contentHash, + 'x-hmac256-signature': `${apiKey};${nonce};${signature}`, + } +} diff --git a/packages/sources/nav-libre/src/transport/date-utils.ts b/packages/sources/nav-libre/src/transport/date-utils.ts new file mode 100644 index 0000000000..74c1b046f7 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/date-utils.ts @@ -0,0 +1,44 @@ +import { differenceInBusinessDays, format, isValid, parse, subBusinessDays } from 'date-fns' + +// Date format used by the NavLibre API +export const DATE_FORMAT = 'MM-dd-yyyy' +export const MAX_BUSINESS_DAYS = 7 + +/** + * Parse a string in MM-DD-YYYY format. + * Throws if the string is missing or malformed. + */ +export function parseDateString(dateStr: string): Date { + const parsed = parse(dateStr, DATE_FORMAT, new Date()) + if (!isValid(parsed)) { + throw new Error(`date must be in ${DATE_FORMAT} format: got "${dateStr}"`) + } + return new Date(Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())) +} + +/** + * Guarantee the (from -> to) span is <= `maxBusinessDays`. + * + * If the gap is larger, shift `from` forward so it sits exactly + * `maxBusinessDays` business days before `to` and returns the new `from`. + * + * Returns the original `from` if the gap is smaller or equal. + * + * Example: 7-day limit + * from = 2025-06-25 (Wed) + * to = 2025-07-10 (Thu) + * span = 11 business days -> newFrom = 2025-07-01 + */ +export function clampStartByBusinessDays( + from: Date, + to: Date, + maxBusinessDays = MAX_BUSINESS_DAYS, +): Date { + const span = differenceInBusinessDays(to, from) + return span > maxBusinessDays ? subBusinessDays(to, maxBusinessDays) : from +} + +/** Convenience formatter so every outbound string is consistent. */ +export function toDateString(d: Date): string { + return format(d, DATE_FORMAT) +} diff --git a/packages/sources/nav-libre/src/transport/fund-dates.ts b/packages/sources/nav-libre/src/transport/fund-dates.ts new file mode 100644 index 0000000000..fdb1b12963 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/fund-dates.ts @@ -0,0 +1,51 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { getRequestHeaders } from './authentication' + +export interface FundDatesResponse { + LogID: number + FromDate: string + ToDate: string +} + +export const getFundDates = async ({ + globalFundID, + baseURL, + apiKey, + secret, + requester, +}: { + globalFundID: number + baseURL: string + apiKey: string + secret: string + requester: Requester +}): Promise => { + const method = 'GET' + const url = `/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates?globalFundID=${globalFundID}` + const requestConfig = { + baseURL: baseURL, + url: url, + method: method, + headers: getRequestHeaders({ + method: method, + path: url, + body: '', + apiKey: apiKey, + secret: secret, + }), + } + + const sourceResponse = await requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + if (!sourceResponse.response.data) { + throw new AdapterError({ + statusCode: 400, + message: `No fund found`, + }) + } + + return sourceResponse.response.data +} diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts new file mode 100644 index 0000000000..00e98e65b5 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -0,0 +1,78 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { getRequestHeaders } from './authentication' + +interface FundResponse { + Data: { + 'Trading Level Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'NAV Per Share': number + 'Next NAV Price': number + 'Accounting Date': string + 'Ending Balance': number + }[] +} + +export const getFund = async ({ + globalFundID, + fromDate, + toDate, + baseURL, + apiKey, + secret, + requester, +}: { + globalFundID: number + fromDate: string + toDate: string + baseURL: string + apiKey: string + secret: string + requester: Requester +}): Promise => { + const method = 'GET' + const url = `/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund?globalFundID=${globalFundID}&fromDate=${fromDate}&toDate=${toDate}` + + const requestConfig = { + baseURL: baseURL, + url: url, + method: method, + headers: getRequestHeaders({ + method: method, + path: url, + body: '', + apiKey: apiKey, + secret: secret, + }), + } + + const response = await requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + + if ( + !response.response.data || + !Array.isArray(response.response.data.Data) || + response.response.data.Data.length === 0 + ) { + throw new AdapterError({ + statusCode: 400, + message: `No fund found`, + }) + } + + return response.response.data.Data +} diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts new file mode 100644 index 0000000000..24a7bd2260 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -0,0 +1,125 @@ +import { BaseEndpointTypes, inputParameters } from '../endpoint/nav' +import { getFund } from './fund' +import { getFundDates } from './fund-dates' + +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { clampStartByBusinessDays, parseDateString, toDateString } from './date-utils' +const logger = makeLogger('NavLibreTransport') + +type RequestParams = typeof inputParameters.validated + +export class NavLibreTransport extends SubscriptionTransport { + config!: BaseEndpointTypes['Settings'] + endpointName!: string + name!: string + requester!: Requester + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.endpointName = endpointName + this.requester = dependencies.requester + this.config = adapterSettings + } + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(context, param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(_context: EndpointContext, param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterInputError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + param: RequestParams, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + logger.debug(`Handling request for globalFundID: ${param.globalFundID}`) + const { FromDate: earliestPossibleFromStr, ToDate: latestPossibleToStr } = await getFundDates({ + globalFundID: param.globalFundID, + baseURL: this.config.API_ENDPOINT, + apiKey: this.config.API_KEY, + secret: this.config.SECRET_KEY, + requester: this.requester, + }) + + const earliestPossibleFrom = parseDateString(earliestPossibleFromStr) + const latestPossibleTo = parseDateString(latestPossibleToStr) + + // Clamp to trailing-7-business-days window + const preferredFrom = clampStartByBusinessDays( + earliestPossibleFrom, + latestPossibleTo, + 7, // 7 business days + ) + + logger.debug( + `Fetching NAV for globalFundID: ${param.globalFundID} from ${preferredFrom} to ${latestPossibleTo}`, + ) + const fund = await getFund({ + globalFundID: param.globalFundID, + fromDate: toDateString(preferredFrom), + toDate: toDateString(latestPossibleTo), + baseURL: this.config.API_ENDPOINT, + apiKey: this.config.API_KEY, + secret: this.config.SECRET_KEY, + requester: this.requester, + }) + + const ACCOUNTING_DATE_KEY = 'Accounting Date' + const NAV_PER_SHARE_KEY = 'NAV Per Share' + // Find the latest NAV entry by Accounting Date + const latest = fund.reduce((latestRow, row) => + parseDateString(row[ACCOUNTING_DATE_KEY]) > parseDateString(latestRow[ACCOUNTING_DATE_KEY]) + ? row + : latestRow, + ) + // Assumes UTC + const providerIndicatedTimeUnixMs = parseDateString(latest[ACCOUNTING_DATE_KEY]).getTime() + return { + statusCode: 200, + result: latest[NAV_PER_SHARE_KEY], + data: { + globalFundID: param.globalFundID, + navPerShare: latest[NAV_PER_SHARE_KEY], + navDate: latest[ACCOUNTING_DATE_KEY], + }, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs, + }, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const navLibreTransport = new NavLibreTransport() diff --git a/packages/sources/nav-libre/test-payload.json b/packages/sources/nav-libre/test-payload.json new file mode 100644 index 0000000000..6c48d1af28 --- /dev/null +++ b/packages/sources/nav-libre/test-payload.json @@ -0,0 +1,5 @@ +{ + "requests": [{ + "globalFundID": 1234, + }] +} diff --git a/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..584d8a0c1b --- /dev/null +++ b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint should return success 1`] = ` +{ + "data": { + "globalFundID": 1234, + "navDate": "01-01-2024", + "navPerShare": 123.45, + }, + "result": 123.45, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978307200000, + "providerDataRequestedUnixMs": 978307200000, + "providerIndicatedTimeUnixMs": 1704067200000, + }, +} +`; diff --git a/packages/sources/nav-libre/test/integration/adapter.test.ts b/packages/sources/nav-libre/test/integration/adapter.test.ts new file mode 100644 index 0000000000..597858ecc7 --- /dev/null +++ b/packages/sources/nav-libre/test/integration/adapter.test.ts @@ -0,0 +1,49 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { 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)) + process.env.API_KEY = process.env.API_KEY ?? 'fake-api-key' + process.env.SECRET_KEY = 'SOME_SECRET_KEY' + process.env.BACKGROUND_EXECUTE_MS = process.env.BACKGROUND_EXECUTE_MS ?? '0' + const mockDate = new Date('2001-01-01T00:00:00.000Z') + 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 = { + globalFundID: 1234, + endpoint: 'nav', + transport: 'rest', + } + mockResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/nav-libre/test/integration/fixtures.ts b/packages/sources/nav-libre/test/integration/fixtures.ts new file mode 100644 index 0000000000..e06ba15f6e --- /dev/null +++ b/packages/sources/nav-libre/test/integration/fixtures.ts @@ -0,0 +1,67 @@ +import nock from 'nock' + +export const mockResponseSuccess = (): nock.Scope => + nock('https://api.navfundservices.com', { + encodedQueryParams: true, + }) + .get('/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund') + .query(true) + .reply( + 200, + { + Data: [ + { + 'Trading Level Net ROR': { + DTD: 0.1, + MTD: 0.2, + QTD: 0.3, + YTD: 0.4, + ITD: 0.5, + }, + 'Net ROR': { + DTD: 0.6, + MTD: 0.7, + QTD: 0.8, + YTD: 0.9, + ITD: 1.0, + }, + 'NAV Per Share': 123.45, + 'Next NAV Price': 124.56, + 'Accounting Date': '01-01-2024', + 'Ending Balance': 1000000, + }, + ], + }, + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() + .get('/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates') + .query(true) + .reply( + 200, + { + LogID: 123456, + FromDate: '01-01-2024', + ToDate: '01-31-2024', + }, + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() diff --git a/packages/sources/nav-libre/test/unit/date-utils.test.ts b/packages/sources/nav-libre/test/unit/date-utils.test.ts new file mode 100644 index 0000000000..e6fd0eb3f2 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/date-utils.test.ts @@ -0,0 +1,59 @@ +import { differenceInBusinessDays, parse } from 'date-fns' +import { + clampStartByBusinessDays, + DATE_FORMAT, + MAX_BUSINESS_DAYS, + parseDateString, + toDateString, +} from '../../src/transport/date-utils' + +describe('date-utils', () => { + // Force UTC so tests behave the same everywhere + beforeAll(() => { + process.env.TZ = 'UTC' + }) + + describe('parseDateString', () => { + it('parses a valid MM-dd-yyyy string', () => { + const input = '07-11-2025' + const parsed = parseDateString(input) + expect(parsed).toBeInstanceOf(Date) + expect(parsed.getUTCFullYear()).toBe(2025) + expect(parsed.getUTCMonth()).toBe(6) // July is 6 (zero‑based) + expect(parsed.getUTCDate()).toBe(11) + }) + + it('throws for malformed input', () => { + const badInput = '2025-07-11' + expect(() => parseDateString(badInput)).toThrow( + `date must be in ${DATE_FORMAT} format: got "${badInput}"`, + ) + }) + }) + + describe('clampStartByBusinessDays', () => { + const to = parse('07-15-2025', DATE_FORMAT, new Date()) + + it('returns the original from date when within the limit', () => { + const from = parse('07-07-2025', DATE_FORMAT, new Date()) // 6 business days before + const result = clampStartByBusinessDays(from, to, MAX_BUSINESS_DAYS) + expect(result).toEqual(from) + }) + + it('clamps when span exceeds the limit', () => { + const from = parse('07-01-2025', DATE_FORMAT, new Date()) // >7 business days + const result = clampStartByBusinessDays(from, to, MAX_BUSINESS_DAYS) + // Should be exactly MAX_BUSINESS_DAYS business days before `to` + const span = differenceInBusinessDays(to, result) + expect(span).toBe(MAX_BUSINESS_DAYS) + }) + }) + + describe('toDateString', () => { + it('formats a Date back to MM-dd-yyyy', () => { + const date = new Date(Date.UTC(2025, 6, 11)) + const formatted = toDateString(date) + expect(formatted).toBe('07-11-2025') + }) + }) +}) diff --git a/packages/sources/nav-libre/test/unit/fund-dates.test.ts b/packages/sources/nav-libre/test/unit/fund-dates.test.ts new file mode 100644 index 0000000000..2de5cc05b9 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/fund-dates.test.ts @@ -0,0 +1,48 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { FundDatesResponse, getFundDates } from '../../src/transport/fund-dates' + +describe('getFundDates', () => { + const mockRequester = { + request: jest.fn(), + } as unknown as Requester + + const mockResponse: FundDatesResponse = { + LogID: 1, + FromDate: '01-01-2023', + ToDate: '01-31-2023', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns fund dates on success', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: mockResponse }, + }) + const result = await getFundDates({ + globalFundID: 123, + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }) + + expect(result).toEqual(mockResponse) + }) + + it('throws if no data returned', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: undefined }, + }) + await expect( + getFundDates({ + globalFundID: 123, + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }), + ).rejects.toThrow() + }) +}) diff --git a/packages/sources/nav-libre/test/unit/fund.test.ts b/packages/sources/nav-libre/test/unit/fund.test.ts new file mode 100644 index 0000000000..b47741f394 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/fund.test.ts @@ -0,0 +1,54 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { getFund } from '../../src/transport/fund' + +describe('getFund', () => { + const mockRequester = { + request: jest.fn(), + } as unknown as Requester + + const mockResponse = [ + { + 'Accounting Date': '01-01-2023', + 'NAV Per Share': 123.45, + }, + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns fund data on success', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { + data: { Data: mockResponse }, + }, + }) + const result = await getFund({ + globalFundID: 123, + fromDate: '01-01-2000', + toDate: '01-05-2000', + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }) + expect(result).toEqual(mockResponse) + }) + + it('throws if no data returned', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: undefined }, + }) + await expect( + getFund({ + globalFundID: 123, + fromDate: '01-01-2000', + toDate: '01-05-2000', + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }), + ).rejects.toThrow() + }) +}) diff --git a/packages/sources/nav-libre/test/unit/nav.test.ts b/packages/sources/nav-libre/test/unit/nav.test.ts new file mode 100644 index 0000000000..0176647a86 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/nav.test.ts @@ -0,0 +1,157 @@ +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' + +import { BaseEndpointTypes, inputParameters as navInputParams } from '../../src/endpoint/nav' +import { NavLibreTransport } from '../../src/transport/nav' + +LoggerFactoryProvider.set() + +const FUND_ID = 123 +const transportName = 'nav_transport' +const endpointName = 'nav' + +let transport: NavLibreTransport + +// adapter settings stub +const adapterSettings = makeStub('adapterSettings', { + API_ENDPOINT: 'https://api.navfund.com', + API_KEY: 'apiKey', + SECRET_KEY: 'secret', + BACKGROUND_EXECUTE_MS: 0, + WARMUP_SUBSCRIPTION_TTL: 10_000, +} as unknown as BaseEndpointTypes['Settings']) + +// requester stub that we'll control per‑test +const requester = makeStub('requester', { request: jest.fn() }) +const responseCache = makeStub('responseCache', { write: jest.fn() }) +const dependencies = makeStub('dependencies', { + requester, + responseCache, + subscriptionSetFactory: { buildSet: jest.fn() }, +} as unknown as TransportDependencies) + +// helper to pull the cached response written in handleRequest +const getCachedResponse = () => (responseCache.write.mock.calls[0][1] as any)[0].response + +const FUND_DATES_RES = makeStub('fundDatesRes', { + response: { + data: { LogID: 1, FromDate: '06-01-2025', ToDate: '07-01-2025' }, + }, +}) + +const FUND_ROWS = [ + { 'NAV Per Share': 50, 'Accounting Date': '06-10-2025' }, + { 'NAV Per Share': 150, 'Accounting Date': '06-25-2025' }, +] + +const FUND_RES = makeStub('fundRes', { + response: { + data: { Data: FUND_ROWS }, + }, +}) + +describe('NavLibreTransport – handleRequest', () => { + beforeEach(async () => { + transport = new NavLibreTransport() as unknown as InstanceType + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + jest.resetAllMocks() + }) + + it('returns latest NAV and writes to cache', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) + requester.request.mockResolvedValueOnce(FUND_RES) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + expect(responseCache.write).toHaveBeenCalledTimes(1) + + const cached = getCachedResponse() + expect(cached).toEqual({ + statusCode: 200, + result: 150, + data: { + globalFundID: FUND_ID, + navPerShare: 150, + navDate: '06-25-2025', + }, + timestamps: expect.objectContaining({ + providerDataRequestedUnixMs: expect.any(Number), + providerDataReceivedUnixMs: expect.any(Number), + providerIndicatedTimeUnixMs: expect.any(Number), + }), + }) + expect(requester.request).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('/GetAccountingDataDates'), + }), + ) + + expect(requester.request).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('/GetOfficialNAVAndPerformanceReturnsForFund'), + }), + ) + }) + + it('maps downstream AdapterError to 502 response', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) // first OK + requester.request.mockRejectedValueOnce(new AdapterError({ message: 'boom' })) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + expect(responseCache.write).toHaveBeenCalledTimes(1) + const cached = getCachedResponse() + expect(cached.statusCode).toBe(500) + expect(cached.errorMessage).toContain('boom') + }) + + it('uses provider earliestFrom when span <= 7 business days', async () => { + const shortSpanDates = makeStub('dates', { + response: { data: { LogID: 1, FromDate: '06-28-2025', ToDate: '07-01-2025' } }, + }) + requester.request.mockResolvedValueOnce(shortSpanDates) + + const fundRows = [ + { 'NAV Per Share': 42, 'Accounting Date': '06-30-2025' }, + { 'NAV Per Share': 43, 'Accounting Date': '07-01-2025' }, + ] + const fundRes = makeStub('fundRes', { response: { data: { Data: fundRows } } }) + requester.request.mockResolvedValueOnce(fundRes) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + expect(requester.request).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('fromDate=06-28-2025'), + }), + ) + }) + + it('caches 400 when Fund rows are empty', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) + requester.request.mockResolvedValueOnce( + makeStub('emptyFund', { response: { data: { Data: [] } } }), + ) + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + const cached = getCachedResponse() + expect(cached.statusCode).toBe(400) + expect(cached.errorMessage).toMatch(/No fund found/i) + }) +}) diff --git a/packages/sources/nav-libre/tsconfig.json b/packages/sources/nav-libre/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/nav-libre/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/nav-libre/tsconfig.test.json b/packages/sources/nav-libre/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/nav-libre/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 d7187ebd5f..4e731587e6 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -515,6 +515,9 @@ { "path": "./sources/nav-generic" }, + { + "path": "./sources/nav-libre" + }, { "path": "./sources/ncfx" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index ea592c2aef..5caa99f56f 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -515,6 +515,9 @@ { "path": "./sources/nav-generic/tsconfig.test.json" }, + { + "path": "./sources/nav-libre/tsconfig.test.json" + }, { "path": "./sources/ncfx/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 2b9231ff64..4865778f68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5008,6 +5008,23 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre": + version: 0.0.0-use.local + resolution: "@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.6.0" + "@types/crypto-js": "npm:^4" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + crypto-js: "npm:^4.2.0" + date-fns: "npm:^4.1.0" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + uuid: "npm:^11.1.0" + languageName: unknown + linkType: soft + "@chainlink/ncfx-adapter@workspace:*, @chainlink/ncfx-adapter@workspace:packages/sources/ncfx": version: 0.0.0-use.local resolution: "@chainlink/ncfx-adapter@workspace:packages/sources/ncfx" @@ -12505,7 +12522,7 @@ __metadata: languageName: node linkType: hard -"@types/crypto-js@npm:4.2.2": +"@types/crypto-js@npm:4.2.2, @types/crypto-js@npm:^4": version: 4.2.2 resolution: "@types/crypto-js@npm:4.2.2" checksum: 10/a40fc5a9219fd33f54ba3e094c5e5ab2904d3106681a76f1029bb038976591e9c8959099963bf4474fde21c2d8d00c1f896445206a3a58f85588f9cb1bd96a9a @@ -16420,7 +16437,7 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:4.2.0": +"crypto-js@npm:4.2.0, crypto-js@npm:^4.2.0": version: 4.2.0 resolution: "crypto-js@npm:4.2.0" checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 @@ -16614,7 +16631,7 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:4.1.0": +"date-fns@npm:4.1.0, date-fns@npm:^4.1.0": version: 4.1.0 resolution: "date-fns@npm:4.1.0" checksum: 10/d5f6e9de5bbc52310f786099e18609289ed5e30af60a71e0646784c8185ddd1d0eebcf7c96b7faaaefc4a8366f3a3a4244d099b6d0866ee2bec80d1361e64342 @@ -32133,6 +32150,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.1.0": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + languageName: node + linkType: hard + "uuid@npm:^3.3.2": version: 3.4.0 resolution: "uuid@npm:3.4.0"