diff --git a/.changeset/heavy-readers-accept.md b/.changeset/heavy-readers-accept.md new file mode 100644 index 00000000000..ffd66e56a09 --- /dev/null +++ b/.changeset/heavy-readers-accept.md @@ -0,0 +1,5 @@ +--- +'@chainlink/infralabs-adapter': major +--- + +First build with support for API authentication, signature validation, and KMS integration diff --git a/.changeset/quiet-otters-verify.md b/.changeset/quiet-otters-verify.md new file mode 100644 index 00000000000..82dd517b1c9 --- /dev/null +++ b/.changeset/quiet-otters-verify.md @@ -0,0 +1,5 @@ +--- +'@chainlink/infralabs-adapter': major +--- + +Adapted to Infralabs' new nested response format and replaced live AWS KMS key lookups with hardcoded, rotatable public keys configured via `INFRALABS_PUBLIC_KEYS`. Removed the `KMS_*`/`AWS_*` settings and the `@aws-sdk/client-kms` dependency. diff --git a/.pnp.cjs b/.pnp.cjs index 9600a067631..870a3f93602 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -442,6 +442,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/ignition-address-list-adapter",\ "reference": "workspace:packages/sources/ignition-address-list"\ },\ + {\ + "name": "@chainlink/infralabs-adapter",\ + "reference": "workspace:packages/sources/infralabs"\ + },\ {\ "name": "@chainlink/instruxi-sxt-adapter",\ "reference": "workspace:packages/sources/instruxi-sxt"\ @@ -835,6 +839,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/ice-adapter", ["workspace:packages/sources/ice"]],\ ["@chainlink/ignition-address-list-adapter", ["workspace:packages/sources/ignition-address-list"]],\ ["@chainlink/implied-price-adapter", ["workspace:packages/composites/implied-price"]],\ + ["@chainlink/infralabs-adapter", ["workspace:packages/sources/infralabs"]],\ ["@chainlink/instruxi-sxt-adapter", ["workspace:packages/sources/instruxi-sxt"]],\ ["@chainlink/ion.au-adapter", ["workspace:packages/sources/ion.au"]],\ ["@chainlink/ix-trust-sync-adapter", ["workspace:packages/sources/ix-trust-sync"]],\ @@ -6304,6 +6309,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/infralabs-adapter", [\ + ["workspace:packages/sources/infralabs", {\ + "packageLocation": "./packages/sources/infralabs/",\ + "packageDependencies": [\ + ["@chainlink/external-adapter-framework", "npm:2.17.1"],\ + ["@chainlink/infralabs-adapter", "workspace:packages/sources/infralabs"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["nock", "npm:13.5.6"],\ + ["tslib", "npm:2.6.3"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/instruxi-sxt-adapter", [\ ["workspace:packages/sources/instruxi-sxt", {\ "packageLocation": "./packages/sources/instruxi-sxt/",\ diff --git a/packages/sources/infralabs/README.md b/packages/sources/infralabs/README.md new file mode 100644 index 00000000000..3013f4dea62 --- /dev/null +++ b/packages/sources/infralabs/README.md @@ -0,0 +1,57 @@ +# INFRALABS + +![1.0.0](https://img.shields.io/github/package-json/v/smartcontractkit/external-adapters-js?filename=packages/sources/infralabs/package.json) ![v3](https://img.shields.io/badge/framework%20version-v3-blueviolet) + +This document was generated automatically. Please see [README Generator](../../scripts#readme-generator) for more info. + +## Environment Variables + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :-----------------------: | :---------------------------------------------------------------------------: | :-----: | :-----: | :--------------------------------------------------------: | +| ✅ | API_KEY | Infralabs API key (shared across all endpoints) | string | | | +| | USHP_API_ENDPOINT | Infralabs USHP index API URL | string | | `https://ushp-index-interface.staging.infralabs.xyz/index` | +| | USHP_MAX_STALENESS_SECS | Maximum age in seconds for the USHP index value before it is considered stale | number | | `3600000` | +| | BACKGROUND_EXECUTE_MS | Milliseconds between background data refreshes | number | | `10000` | +| | KMS_KEY_TTL_MS | Milliseconds before a cached KMS public key is considered expired | number | | `60000` | +| | KMS_REGION | AWS region where the Infralabs KMS key is hosted | string | | `us-east-1` | +| ✅ | AWS_ACCESS_KEY_ID | AWS access key ID for KMS authentication | string | | | +| ✅ | AWS_SECRET_ACCESS_KEY | AWS secret access key for KMS authentication | string | | | +| | KMS_VERIFICATION_DISABLED | Disable KMS signature verification | boolean | | `true` | + +--- + +## Data Provider Rate Limits + +There are no rate limits for this adapter. + +--- + +## Input Parameters + +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------: | :-----------------: | :----: | :--------------------: | :-----: | +| | endpoint | The endpoint to use | string | [ushp](#ushp-endpoint) | `ushp` | + +## Ushp Endpoint + +`ushp` is the only supported name for this endpoint. + +### Input Params + +There are no input parameters for this endpoint. + +### Example + +Request: + +```json +{ + "data": { + "endpoint": "ushp" + } +} +``` + +--- + +MIT License diff --git a/packages/sources/infralabs/package.json b/packages/sources/infralabs/package.json new file mode 100644 index 00000000000..8e0c5d28322 --- /dev/null +++ b/packages/sources/infralabs/package.json @@ -0,0 +1,40 @@ +{ + "name": "@chainlink/infralabs-adapter", + "version": "1.0.0", + "description": "Chainlink external adapter for Infralabs indices", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "infralabs" + ], + "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.17.1", + "tslib": "2.6.3" + } +} diff --git a/packages/sources/infralabs/src/config/index.ts b/packages/sources/infralabs/src/config/index.ts new file mode 100644 index 00000000000..38362f8398a --- /dev/null +++ b/packages/sources/infralabs/src/config/index.ts @@ -0,0 +1,32 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +// TODO change to prod default once ready +export const STAGING_USHP_API_ENDPOINT = 'https://ushp-index-interface.staging.infralabs.xyz/index' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'Infralabs API key (shared across all endpoints)', + type: 'string', + required: true, + sensitive: true, + }, + USHP_API_ENDPOINT: { + description: 'Infralabs USHP index API URL', + type: 'string', + default: STAGING_USHP_API_ENDPOINT, + }, + USHP_MAX_STALENESS_SECS: { + description: 'Maximum age in seconds for the USHP index value before it is considered stale', + type: 'number', + default: 3_600_000, + }, + INFRALABS_PUBLIC_KEYS: { + description: + 'JSON array of PEM-encoded public keys used to verify Infralabs response signatures. ' + + 'List multiple keys during a rotation window (old + new) for zero-downtime rotation — ' + + 'a response is accepted if it verifies against any configured key.', + type: 'string', + required: true, + sensitive: false, + }, +}) diff --git a/packages/sources/infralabs/src/endpoint/index.ts b/packages/sources/infralabs/src/endpoint/index.ts new file mode 100644 index 00000000000..5e38bf42ff6 --- /dev/null +++ b/packages/sources/infralabs/src/endpoint/index.ts @@ -0,0 +1 @@ +export { ushpEndpoint as ushp } from './ushp' diff --git a/packages/sources/infralabs/src/endpoint/types.ts b/packages/sources/infralabs/src/endpoint/types.ts new file mode 100644 index 00000000000..c9e2fd1f5ab --- /dev/null +++ b/packages/sources/infralabs/src/endpoint/types.ts @@ -0,0 +1,23 @@ +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' + +export const inputParameters = new InputParameters({}) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Settings: typeof config.settings + Provider: { + RequestBody: never + ResponseBody: string + } + Response: { + Result: string + Data: { + price: number + rawValue: string + scale: number + lastUpdatedAt: number + signature: string + } + } +} diff --git a/packages/sources/infralabs/src/endpoint/ushp.ts b/packages/sources/infralabs/src/endpoint/ushp.ts new file mode 100644 index 00000000000..a15fa046595 --- /dev/null +++ b/packages/sources/infralabs/src/endpoint/ushp.ts @@ -0,0 +1,9 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { ushpTransport } from '../transport/ushp' +import { BaseEndpointTypes, inputParameters } from './types' + +export const ushpEndpoint = new AdapterEndpoint({ + name: 'ushp', + transport: ushpTransport, + inputParameters, +}) diff --git a/packages/sources/infralabs/src/index.ts b/packages/sources/infralabs/src/index.ts new file mode 100644 index 00000000000..f1b62287d36 --- /dev/null +++ b/packages/sources/infralabs/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 { ushp } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: ushp.name, + name: 'INFRALABS', + config, + endpoints: [ushp], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/infralabs/src/transport/infralabs.ts b/packages/sources/infralabs/src/transport/infralabs.ts new file mode 100644 index 00000000000..b5860c46c1a --- /dev/null +++ b/packages/sources/infralabs/src/transport/infralabs.ts @@ -0,0 +1,87 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports/http' +import { BaseEndpointTypes, inputParameters } from '../endpoint/types' +import { extractSignedPayload, isFresh, isSaneSignature, parsePublicKeys, rescale } from './utils' + +type RequestParams = typeof inputParameters.validated + +interface InfralabsResponse { + data: { + index_name: string + value: string + scale: string + timestamp: string + schema_version: string + } + signature: string +} + +export function createInfralabsTransport( + apiEndpointFn: (s: BaseEndpointTypes['Settings']) => string, + maxStalenessFn: (s: BaseEndpointTypes['Settings']) => number, +): HttpTransport { + return new HttpTransport({ + prepareRequests: (params, adapterSettings) => ({ + params, + request: { + url: apiEndpointFn(adapterSettings), + method: 'GET', + headers: { Authorization: `ApiKey ${adapterSettings.API_KEY}` }, + responseType: 'text', + }, + }), + parseResponse: (params: RequestParams[], response, adapterSettings) => { + try { + const rawResponseBody = response.data as unknown as string + const responseBody = JSON.parse(rawResponseBody) as InfralabsResponse + + const publicKeys = parsePublicKeys(adapterSettings.INFRALABS_PUBLIC_KEYS) + const signedPayload = extractSignedPayload(rawResponseBody) + if (!isSaneSignature(signedPayload, publicKeys, responseBody.signature)) { + throw new Error('Signature verification failed') + } + + const maxStaleness = maxStalenessFn(adapterSettings) + if (!isFresh(responseBody.data.timestamp, maxStaleness, Date.now())) { + throw new Error('Price is stale') + } + + const scale = parseInt(responseBody.data.scale, 10) + const result = rescale(responseBody.data.value, scale) + + return params.map((param) => ({ + params: param, + response: { + result: result.toString(), + data: { + price: Number(result) / 10 ** 8, + rawValue: responseBody.data.value, + scale, + lastUpdatedAt: parseInt(responseBody.data.timestamp, 10), + signature: responseBody.signature, + }, + statusCode: 200, + timestamps: { + providerDataRequestedUnixMs: 0, // overwritten by the framework with real request timing + providerDataReceivedUnixMs: 0, // overwritten by the framework with real request timing + providerIndicatedTimeUnixMs: parseInt(responseBody.data.timestamp, 10) * 1000, + }, + }, + })) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + return params.map((param) => ({ + params: param, + response: { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + }, + })) + } + }, + }) +} diff --git a/packages/sources/infralabs/src/transport/ushp.ts b/packages/sources/infralabs/src/transport/ushp.ts new file mode 100644 index 00000000000..c8599e66d6f --- /dev/null +++ b/packages/sources/infralabs/src/transport/ushp.ts @@ -0,0 +1,6 @@ +import { createInfralabsTransport } from './infralabs' + +export const ushpTransport = createInfralabsTransport( + (s) => s.USHP_API_ENDPOINT, + (s) => s.USHP_MAX_STALENESS_SECS, +) diff --git a/packages/sources/infralabs/src/transport/utils.ts b/packages/sources/infralabs/src/transport/utils.ts new file mode 100644 index 00000000000..8c30aaf85cb --- /dev/null +++ b/packages/sources/infralabs/src/transport/utils.ts @@ -0,0 +1,70 @@ +import * as crypto from 'crypto' + +const OUTPUT_DECIMALS = 8 + +const DATA_FIELD_PATTERN = /"data"\s*:\s*(\{[^}]*\})/ + +/** + * Extracts the raw substring of the "data" object from the response body, preserving the + * exact bytes that were signed. Using a substring match rather than parse→stringify avoids + * any serialisation mismatch between Python's json.dumps (which they sign with) and JS JSON.stringify. + */ +export function extractSignedPayload(rawResponseBody: string): string { + const match = rawResponseBody.match(DATA_FIELD_PATTERN) + if (!match) { + throw new Error('Response body is missing a "data" field to verify') + } + return match[1] +} + +/** Parses the INFRALABS_PUBLIC_KEYS config value (a JSON array of PEM-encoded public keys). */ +export function parsePublicKeys(rawConfigValue: string): crypto.KeyObject[] { + let pemKeys: unknown + try { + pemKeys = JSON.parse(rawConfigValue) + } catch { + throw new Error('INFRALABS_PUBLIC_KEYS must be a JSON array of PEM-encoded public key strings') + } + if (!Array.isArray(pemKeys) || pemKeys.length === 0) { + throw new Error( + 'INFRALABS_PUBLIC_KEYS must be a non-empty JSON array of PEM-encoded public key strings', + ) + } + return pemKeys.map((pem) => crypto.createPublicKey(pem)) +} + +/** Verifies that `signature` (base64) over `signedPayload` matches any of `publicKeys` using SHA-256. */ +export function isSaneSignature( + signedPayload: string, + publicKeys: crypto.KeyObject[], + signature: string, +): boolean { + const messageBytes = Buffer.from(signedPayload, 'utf-8') + const signatureBytes = Buffer.from(signature, 'base64') + + return publicKeys.some((publicKey) => + crypto.createVerify('SHA256').update(messageBytes).verify(publicKey, signatureBytes), + ) +} + +/** Returns true if the provider timestamp is within `maxAgeSecs` of `nowMs`. */ +export function isFresh(timestamp: string, maxAgeSecs: number, nowMs: number): boolean { + const ageSecs = Math.floor(nowMs / 1000) - parseInt(timestamp, 10) // nowMs (ms) → seconds; timestamp is Unix seconds + return ageSecs <= maxAgeSecs +} + +/** + * Rescales an integer string from `fromScale` implied decimal places to + * OUTPUT_DECIMALS (8) using BigInt arithmetic to avoid floating-point loss. + * + * Examples: + * rescale('1003968325', 9) → 100396832n (9 → 8: divide by 10) + * rescale('10039683', 8) → 10039683n (8 → 8: identity) + * rescale('1003968', 7) → 100396800n (7 → 8: multiply by 10) + */ +export function rescale(value: string, fromScale: number): bigint { + const diff = fromScale - OUTPUT_DECIMALS + if (diff > 0) return BigInt(value) / 10n ** BigInt(diff) + if (diff < 0) return BigInt(value) * 10n ** BigInt(-diff) + return BigInt(value) +} diff --git a/packages/sources/infralabs/test-payload.json b/packages/sources/infralabs/test-payload.json new file mode 100644 index 00000000000..050a3f7dd1e --- /dev/null +++ b/packages/sources/infralabs/test-payload.json @@ -0,0 +1,7 @@ +{ + "requests": [ + { + "endpoint": "ushp" + } + ] +} diff --git a/packages/sources/infralabs/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/infralabs/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 00000000000..89e352bd94a --- /dev/null +++ b/packages/sources/infralabs/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute ushp endpoint should return 502 when no configured key matches the signature 1`] = ` +{ + "errorMessage": "Signature verification failed", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704107471111, + "providerDataRequestedUnixMs": 1704107471111, + }, +} +`; + +exports[`execute ushp endpoint should return 502 when the price is stale 1`] = ` +{ + "errorMessage": "Price is stale", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704107471111, + "providerDataRequestedUnixMs": 1704107471111, + }, +} +`; + +exports[`execute ushp endpoint should return 502 when the signature does not match the response body 1`] = ` +{ + "errorMessage": "Signature verification failed", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704107471111, + "providerDataRequestedUnixMs": 1704107471111, + }, +} +`; + +exports[`execute ushp endpoint should return a rescaled result for a valid response 1`] = ` +{ + "data": { + "lastUpdatedAt": 1704103871, + "price": 1.00396832, + "rawValue": "1003968325", + "scale": 9, + }, + "result": "100396832", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704107471111, + "providerDataRequestedUnixMs": 1704107471111, + "providerIndicatedTimeUnixMs": 1704103871000, + }, +} +`; diff --git a/packages/sources/infralabs/test/integration/adapter.test.ts b/packages/sources/infralabs/test/integration/adapter.test.ts new file mode 100644 index 00000000000..fa686c1ea96 --- /dev/null +++ b/packages/sources/infralabs/test/integration/adapter.test.ts @@ -0,0 +1,119 @@ +import { + setEnvVariables, + TestAdapter, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as crypto from 'crypto' +import nock from 'nock' +import { createFixtures, MOCK_DATE } from './fixtures' + +const TEST_API_HOST = 'http://test-infralabs.local' +const TEST_API_PATH = '/index' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + let fixtures: ReturnType + let rotatedFixtures: ReturnType + + jest.setTimeout(10000) + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + const rotated = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + + fixtures = createFixtures(privateKey) + rotatedFixtures = createFixtures(rotated.privateKey) + + const publicKeyPem = publicKey.export({ format: 'pem', type: 'spki' }) + const rotatedPublicKeyPem = rotated.publicKey.export({ format: 'pem', type: 'spki' }) + + process.env['API_KEY'] = 'test-api-key' + process.env['USHP_API_ENDPOINT'] = `${TEST_API_HOST}${TEST_API_PATH}` + process.env['USHP_MAX_STALENESS_SECS'] = '90000' + // Two keys configured at once, as during a zero-downtime rotation window. + process.env['INFRALABS_PUBLIC_KEYS'] = JSON.stringify([publicKeyPem, rotatedPublicKeyPem]) + process.env['METRICS_ENABLED'] = 'false' + + spy = jest.spyOn(Date, 'now').mockReturnValue(MOCK_DATE.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() + spy.mockRestore() + nock.cleanAll() + jest.clearAllMocks() + }) + + beforeEach(() => { + nock.cleanAll() + testAdapter.mockCache?.cache.clear() + }) + + describe('ushp endpoint', () => { + it('should return a rescaled result for a valid response', async () => { + nock(TEST_API_HOST) + .get(TEST_API_PATH) + .matchHeader('authorization', 'ApiKey test-api-key') + .reply(200, fixtures.success) + + const response = await testAdapter.request({ endpoint: 'ushp' }) + const json = response.json() + + expect(response.statusCode).toBe(200) + expect(typeof json.data?.signature).toBe('string') + expect(json.data?.signature.length).toBeGreaterThan(0) + const { signature: _sig, ...dataWithoutSignature } = json.data + expect({ ...json, data: dataWithoutSignature }).toMatchSnapshot() + }) + + it('should return a rescaled result when signed with a rotated (second configured) key', async () => { + nock(TEST_API_HOST).get(TEST_API_PATH).reply(200, rotatedFixtures.success) + + const response = await testAdapter.request({ endpoint: 'ushp' }) + + expect(response.statusCode).toBe(200) + }) + + it('should return 502 when the price is stale', async () => { + nock(TEST_API_HOST).get(TEST_API_PATH).reply(200, fixtures.stale) + + const response = await testAdapter.request({ endpoint: 'ushp' }) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should return 502 when the signature does not match the response body', async () => { + nock(TEST_API_HOST).get(TEST_API_PATH).reply(200, fixtures.badSig) + + const response = await testAdapter.request({ endpoint: 'ushp' }) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should return 502 when no configured key matches the signature', async () => { + const { privateKey: unrelatedPrivateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'P-256', + }) + const unrelatedFixtures = createFixtures(unrelatedPrivateKey) + + nock(TEST_API_HOST).get(TEST_API_PATH).reply(200, unrelatedFixtures.success) + + const response = await testAdapter.request({ endpoint: 'ushp' }) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/infralabs/test/integration/fixtures.ts b/packages/sources/infralabs/test/integration/fixtures.ts new file mode 100644 index 00000000000..a196e6510bb --- /dev/null +++ b/packages/sources/infralabs/test/integration/fixtures.ts @@ -0,0 +1,53 @@ +import * as crypto from 'crypto' + +// Fixed mock date: tests use jest.spyOn(Date, 'now') to return this value. +// Must be after 2018-01-01 so that providerIndicatedTimeUnixMs passes framework validation. +export const MOCK_DATE = new Date('2024-01-01T11:11:11.111Z') +const MOCK_TIMESTAMP_SECS = Math.floor(MOCK_DATE.getTime() / 1000) + +// 1 hour before mock now — well within any reasonable USHP_MAX_STALENESS_SECS +export const FRESH_TIMESTAMP = String(MOCK_TIMESTAMP_SECS - 3600) +// Epoch — will always be stale +export const STALE_TIMESTAMP = '1' + +export const VALUE = '1003968325' +export const SCALE = '9' + +// Expected rescaled result: 1003968325 / 10^(9-8) = 100396832 +export const EXPECTED_RESULT = '100396832' +export const EXPECTED_PRICE = 1.00396832 + +function makeResponse( + opts: { timestamp: string; tamperedValue?: string }, + privateKey: crypto.KeyObject, +): string { + // The server signs the compact JSON encoding of "data"; we replicate that exactly here so + // signature verification matches byte-for-byte, then optionally swap in a tampered value + // AFTER signing so the final body no longer matches the (still valid-looking) signature. + const data = { + index_name: 'USHP', + value: VALUE, + scale: SCALE, + timestamp: opts.timestamp, + schema_version: '1', + } + const dataJson = JSON.stringify(data) + const signature = crypto + .createSign('SHA256') + .update(Buffer.from(dataJson, 'utf-8')) + .sign(privateKey, 'base64') + + const finalData = opts.tamperedValue ? { ...data, value: opts.tamperedValue } : data + return `{"data":${JSON.stringify(finalData)},"signature":"${signature}"}` +} + +export function createFixtures(privateKey: crypto.KeyObject) { + return { + // Valid response, recent timestamp + success: makeResponse({ timestamp: FRESH_TIMESTAMP }, privateKey), + // Valid signature but timestamp is far in the past + stale: makeResponse({ timestamp: STALE_TIMESTAMP }, privateKey), + // Signature is valid for the original value, but value has been tampered after signing + badSig: makeResponse({ timestamp: FRESH_TIMESTAMP, tamperedValue: '9999999999' }, privateKey), + } +} diff --git a/packages/sources/infralabs/test/unit/utils.test.ts b/packages/sources/infralabs/test/unit/utils.test.ts new file mode 100644 index 00000000000..4917a47123e --- /dev/null +++ b/packages/sources/infralabs/test/unit/utils.test.ts @@ -0,0 +1,157 @@ +import * as crypto from 'crypto' +import { + extractSignedPayload, + isFresh, + isSaneSignature, + parsePublicKeys, + rescale, +} from '../../src/transport/utils' + +describe('extractSignedPayload', () => { + it('extracts the raw "data" substring, preserving exact bytes', () => { + const raw = '{"data":{"value":"1","scale":"9"},"signature":"abc123"}' + expect(extractSignedPayload(raw)).toBe('{"value":"1","scale":"9"}') + }) + + it('extracts "data" with Python-style spacing intact', () => { + const raw = '{"data": {"value": "1", "scale": "9"}, "signature": "abc123"}' + expect(extractSignedPayload(raw)).toBe('{"value": "1", "scale": "9"}') + }) + + it('throws when the "data" field is missing', () => { + expect(() => extractSignedPayload('{"signature":"abc123"}')).toThrow( + 'Response body is missing a "data" field to verify', + ) + }) +}) + +describe('parsePublicKeys', () => { + let pem: string + + beforeAll(() => { + const { publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + pem = publicKey.export({ format: 'pem', type: 'spki' }) + }) + + it('parses a JSON array containing a single PEM key', () => { + const keys = parsePublicKeys(JSON.stringify([pem])) + expect(keys).toHaveLength(1) + expect(keys[0].asymmetricKeyType).toBe('ec') + }) + + it('parses a JSON array containing multiple PEM keys', () => { + const keys = parsePublicKeys(JSON.stringify([pem, pem])) + expect(keys).toHaveLength(2) + }) + + it('throws on malformed JSON', () => { + expect(() => parsePublicKeys('not json')).toThrow( + 'INFRALABS_PUBLIC_KEYS must be a JSON array of PEM-encoded public key strings', + ) + }) + + it('throws on an empty array', () => { + expect(() => parsePublicKeys('[]')).toThrow( + 'INFRALABS_PUBLIC_KEYS must be a non-empty JSON array of PEM-encoded public key strings', + ) + }) +}) + +describe('rescale', () => { + it('divides when fromScale > 8 (the typical case: scale=9)', () => { + // 1003968325 / 10^(9-8) = 100396832 + expect(rescale('1003968325', 9).toString()).toBe('100396832') + }) + + it('returns the value unchanged when fromScale equals 8', () => { + expect(rescale('100396832', 8).toString()).toBe('100396832') + }) + + it('multiplies when fromScale < 8', () => { + // 1003968 * 10^(8-7) = 10039680 + expect(rescale('1003968', 7).toString()).toBe('10039680') + }) + + it('handles a large scale difference (e.g. scale=18)', () => { + // 10^17 / 10^(18-8) = 10^17 / 10^10 = 10^7 + expect(rescale('100000000000000000', 18).toString()).toBe('10000000') + }) + + it('preserves BigInt precision — no floating-point rounding', () => { + // 99999999999999999 / 10 = 9999999999999999 (safe with BigInt, lossy with float64) + expect(rescale('99999999999999999', 9).toString()).toBe('9999999999999999') + }) +}) + +describe('isFresh', () => { + const nowMs = 1_704_107_471_000 // 2024-01-01T11:11:11.000Z + const maxAgeSecs = 90_000 // 25 hours + + it('returns true when the value is fresh', () => { + const timestamp = String(Math.floor(nowMs / 1000) - 3_600) // 1 hour ago + expect(isFresh(timestamp, maxAgeSecs, nowMs)).toBe(true) + }) + + it('returns true when the value is exactly at the staleness boundary', () => { + const timestamp = String(Math.floor(nowMs / 1000) - maxAgeSecs) + expect(isFresh(timestamp, maxAgeSecs, nowMs)).toBe(true) + }) + + it('returns false when the value is one second past the boundary', () => { + const timestamp = String(Math.floor(nowMs / 1000) - maxAgeSecs - 1) + expect(isFresh(timestamp, maxAgeSecs, nowMs)).toBe(false) + }) + + it('returns false for a very old timestamp', () => { + expect(isFresh('1', maxAgeSecs, nowMs)).toBe(false) + }) +}) + +describe('isSaneSignature', () => { + const signedPayload = + '{"value":"1003968325","scale":"9","timestamp":"1704103871","index_name":"USHP"}' + let publicKeys: crypto.KeyObject[] + let privateKey: crypto.KeyObject + + beforeAll(() => { + const pair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + publicKeys = [pair.publicKey] + privateKey = pair.privateKey + }) + + function sign(payload: string, key: crypto.KeyObject = privateKey): string { + return crypto.createSign('SHA256').update(Buffer.from(payload, 'utf-8')).sign(key, 'base64') + } + + it('returns true for a valid signature', () => { + const sig = sign(signedPayload) + expect(isSaneSignature(signedPayload, publicKeys, sig)).toBe(true) + }) + + it('returns true when the signature matches the second of multiple configured keys', () => { + const { publicKey: otherPublicKey, privateKey: otherPrivateKey } = crypto.generateKeyPairSync( + 'ec', + { namedCurve: 'P-256' }, + ) + const sig = sign(signedPayload, otherPrivateKey) + expect(isSaneSignature(signedPayload, [...publicKeys, otherPublicKey], sig)).toBe(true) + }) + + it('returns false when the payload has been tampered with', () => { + const sig = sign(signedPayload) + const tamperedPayload = signedPayload.replace('"value":"1003968325"', '"value":"9999999999"') + expect(isSaneSignature(tamperedPayload, publicKeys, sig)).toBe(false) + }) + + it('returns false when the signature is for a key not in the configured list', () => { + const { privateKey: otherPrivateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'P-256', + }) + const sig = sign(signedPayload, otherPrivateKey) + expect(isSaneSignature(signedPayload, publicKeys, sig)).toBe(false) + }) + + it('returns false for a corrupted signature', () => { + expect(isSaneSignature(signedPayload, publicKeys, 'bm90YXZhbGlkc2lnbmF0dXJl')).toBe(false) + }) +}) diff --git a/packages/sources/infralabs/tsconfig.json b/packages/sources/infralabs/tsconfig.json new file mode 100644 index 00000000000..f59363fd76c --- /dev/null +++ b/packages/sources/infralabs/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/infralabs/tsconfig.test.json b/packages/sources/infralabs/tsconfig.test.json new file mode 100644 index 00000000000..e3de28cb5c0 --- /dev/null +++ b/packages/sources/infralabs/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 1e049d45247..72af31f933b 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -347,6 +347,9 @@ { "path": "./sources/lo-tech" }, + { + "path": "./sources/infralabs" + }, { "path": "./sources/lotus" }, diff --git a/yarn.lock b/yarn.lock index b7a49ee9921..7a6af566d2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3770,6 +3770,19 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/infralabs-adapter@workspace:packages/sources/infralabs": + version: 0.0.0-use.local + resolution: "@chainlink/infralabs-adapter@workspace:packages/sources/infralabs" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.17.1" + "@types/jest": "npm:29.5.14" + "@types/node": "npm:22.14.1" + nock: "npm:13.5.6" + tslib: "npm:2.6.3" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/instruxi-sxt-adapter@workspace:packages/sources/instruxi-sxt": version: 0.0.0-use.local resolution: "@chainlink/instruxi-sxt-adapter@workspace:packages/sources/instruxi-sxt"