diff --git a/.pnp.cjs b/.pnp.cjs index eef0fd263af..fc2551bf033 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -314,6 +314,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/coinranking-adapter",\ "reference": "workspace:packages/sources/coinranking"\ },\ + {\ + "name": "@chainlink/copper-adapter",\ + "reference": "workspace:packages/sources/copper"\ + },\ {\ "name": "@chainlink/covid-tracker-adapter",\ "reference": "workspace:packages/sources/covid-tracker"\ @@ -812,6 +816,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/coinpaprika-adapter", ["workspace:packages/sources/coinpaprika"]],\ ["@chainlink/coinpaprika-state-adapter", ["workspace:packages/sources/coinpaprika-state"]],\ ["@chainlink/coinranking-adapter", ["workspace:packages/sources/coinranking"]],\ + ["@chainlink/copper-adapter", ["workspace:packages/sources/copper"]],\ ["@chainlink/covid-tracker-adapter", ["workspace:packages/sources/covid-tracker"]],\ ["@chainlink/crypto-volatility-index-adapter", ["workspace:packages/composites/crypto-volatility-index"]],\ ["@chainlink/cryptoapis-adapter", ["workspace:packages/sources/cryptoapis"]],\ @@ -5468,6 +5473,26 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@chainlink/copper-adapter", [\ + ["workspace:packages/sources/copper", {\ + "packageLocation": "./packages/sources/copper/",\ + "packageDependencies": [\ + ["@chainlink/copper-adapter", "workspace:packages/sources/copper"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.4"],\ + ["@sinonjs/fake-timers", "npm:9.1.2"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["@types/sinonjs__fake-timers", "npm:8.1.5"],\ + ["axios", "npm:1.13.4"],\ + ["decimal.js", "npm:10.5.0"],\ + ["ethers", "npm:6.15.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/covid-tracker-adapter", [\ ["workspace:packages/sources/covid-tracker", {\ "packageLocation": "./packages/sources/covid-tracker/",\ @@ -6153,7 +6178,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 ed3d28f3206..cdd6f919ca5 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/copper/CHANGELOG.md b/packages/sources/copper/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/sources/copper/README.md b/packages/sources/copper/README.md new file mode 100644 index 00000000000..91ab1d4455d --- /dev/null +++ b/packages/sources/copper/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/copper/package.json b/packages/sources/copper/package.json new file mode 100644 index 00000000000..09da2c150fd --- /dev/null +++ b/packages/sources/copper/package.json @@ -0,0 +1,45 @@ +{ + "name": "@chainlink/copper-adapter", + "version": "0.0.0", + "description": "Chainlink copper adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "copper" + ], + "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": { + "@sinonjs/fake-timers": "9.1.2", + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "@types/sinonjs__fake-timers": "8.1.5", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.11.4", + "axios": "1.13.4", + "decimal.js": "10.5.0", + "ethers": "^6.13.2", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/copper/src/config/index.ts b/packages/sources/copper/src/config/index.ts new file mode 100644 index 00000000000..48b7307083c --- /dev/null +++ b/packages/sources/copper/src/config/index.ts @@ -0,0 +1,88 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + COPPER_API_KEY: { + description: 'Copper API key for authentication', + type: 'string', + required: true, + sensitive: true, + }, + COPPER_API_SECRET: { + description: 'Copper API secret for HMAC signature generation', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'Copper platform API endpoint', + type: 'string', + default: 'https://api.copper.co/platform', + }, + ETHEREUM_RPC_URL: { + description: 'Ethereum RPC URL for reading Chainlink price feeds', + type: 'string', + required: true, + sensitive: true, + }, + ETHEREUM_CHAIN_ID: { + description: 'Ethereum chain ID', + type: 'number', + default: 1, + }, + BTC_USD_FEED_ADDRESS: { + description: 'Chainlink BTC/USD price feed address on Ethereum mainnet', + type: 'string', + default: '0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c', + }, + ETH_USD_FEED_ADDRESS: { + description: 'Chainlink ETH/USD price feed address on Ethereum mainnet', + type: 'string', + default: '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419', + }, + SOL_USD_FEED_ADDRESS: { + description: 'Chainlink SOL/USD price feed address on Ethereum mainnet', + type: 'string', + default: '0x4ffC43a60e009B551865A93d232E33Fce9f01507', + }, + USDC_USD_FEED_ADDRESS: { + description: 'Chainlink USDC/USD price feed address on Ethereum mainnet', + type: 'string', + default: '0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6', + }, + USDT_USD_FEED_ADDRESS: { + description: 'Chainlink USDT/USD price feed address on Ethereum mainnet', + type: 'string', + default: '0x3E7d1eAB13ad0104d2750B8863b489D65364e32D', + }, + USYC_USD_FEED_ADDRESS: { + description: 'Chainlink USYC/USD price feed address on Ethereum mainnet', + type: 'string', + required: true, + }, + OUSG_USD_FEED_ADDRESS: { + description: 'Chainlink OUSG/USD price feed address on Ethereum mainnet', + type: 'string', + required: true, + }, + JTRSY_USD_FEED_ADDRESS: { + description: 'Chainlink JTRSY/USD price feed address on Ethereum mainnet', + type: 'string', + required: true, + }, + SUPERSTATE_API_ENDPOINT: { + description: 'Superstate API endpoint for USTB NAV pricing', + type: 'string', + default: 'https://api.superstate.co', + }, + USTB_FUND_ID: { + description: 'Superstate USTB fund ID', + type: 'number', + default: 1, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10000, + }, +}) diff --git a/packages/sources/copper/src/config/overrides.json b/packages/sources/copper/src/config/overrides.json new file mode 100644 index 00000000000..47b32ad4ded --- /dev/null +++ b/packages/sources/copper/src/config/overrides.json @@ -0,0 +1,3 @@ +{ + "copper": {} +} diff --git a/packages/sources/copper/src/endpoint/index.ts b/packages/sources/copper/src/endpoint/index.ts new file mode 100644 index 00000000000..913cc2b9f26 --- /dev/null +++ b/packages/sources/copper/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as reserves } from './reserves' diff --git a/packages/sources/copper/src/endpoint/reserves.ts b/packages/sources/copper/src/endpoint/reserves.ts new file mode 100644 index 00000000000..7fc4300a034 --- /dev/null +++ b/packages/sources/copper/src/endpoint/reserves.ts @@ -0,0 +1,18 @@ +import { + PoRProviderEndpoint, + PoRProviderResponse, +} from '@chainlink/external-adapter-framework/adapter/por' +import { EmptyInputParameters } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { reservesTransport } from '../transport/reserves' + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: PoRProviderResponse + Settings: typeof config.settings +} + +export const endpoint = new PoRProviderEndpoint({ + name: 'reserves', + transport: reservesTransport, +}) diff --git a/packages/sources/copper/src/index.ts b/packages/sources/copper/src/index.ts new file mode 100644 index 00000000000..0f5745e3990 --- /dev/null +++ b/packages/sources/copper/src/index.ts @@ -0,0 +1,21 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { PoRAdapter } from '@chainlink/external-adapter-framework/adapter/por' +import { config } from './config' +import { reserves } from './endpoint' + +export const adapter = new PoRAdapter({ + defaultEndpoint: reserves.name, + name: 'COPPER', + config, + endpoints: [reserves], + rateLimiting: { + tiers: { + default: { + rateLimit1m: 6, + note: 'Copper API rate limit', + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/copper/src/transport/priceFeed.ts b/packages/sources/copper/src/transport/priceFeed.ts new file mode 100644 index 00000000000..0c96ec1d34c --- /dev/null +++ b/packages/sources/copper/src/transport/priceFeed.ts @@ -0,0 +1,187 @@ +import axios from 'axios' +import crypto from 'crypto' +import { Decimal } from 'decimal.js' +import { ethers } from 'ethers' +import { config } from '../config' + +const AGGREGATOR_ABI = [ + { + inputs: [], + name: 'decimals', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'latestAnswer', + outputs: [{ internalType: 'int256', name: '', type: 'int256' }], + stateMutability: 'view', + type: 'function', + }, +] + +export const SUPPORTED_ASSETS = [ + 'BTC', + 'ETH', + 'SOL', + 'USDC', + 'USDT', + 'USTB', + 'USYC', + 'OUSG', + 'JTRSY', +] as const +export type SupportedAsset = (typeof SUPPORTED_ASSETS)[number] + +export const isSupportedAsset = (currency: string): currency is SupportedAsset => { + return SUPPORTED_ASSETS.includes(currency.toUpperCase() as SupportedAsset) +} + +export interface PriceData { + price: Decimal + decimals: number +} + +export interface Wallet { + walletId: string + portfolioId: string + portfolioType: string + currency: string + mainCurrency: string + balance: string + stakeBalance: string + totalBalance: string + available: string + locked: string + reserve: string + updatedAt: string + createdAt: string +} + +interface WalletsResponseSchema { + wallets: Wallet[] +} + +export const getChainlinkPrice = async ( + feedAddress: string, + provider: ethers.JsonRpcProvider, +): Promise => { + const contract = new ethers.Contract(feedAddress, AGGREGATOR_ABI, provider) + const [decimals, latestAnswer]: [bigint, bigint] = await Promise.all([ + contract.decimals(), + contract.latestAnswer(), + ]) + + return { + price: new Decimal(latestAnswer.toString()), + decimals: Number(decimals), + } +} + +interface NavDataEntry { + net_asset_value: string + date: string +} + +export const getSuperstateNav = async (apiEndpoint: string, fundId: number): Promise => { + const response = await axios.get(`${apiEndpoint}/funds/${fundId}/nav-daily`, { + params: { + start_date: getDateString(-7), + end_date: getDateString(0), + }, + }) + + if (!response.data?.length) { + throw new Error(`No NAV data received from Superstate for fund ${fundId}`) + } + + const sortedData = [...response.data].sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime() + }) + + return new Decimal(sortedData[0].net_asset_value) +} + +const getDateString = (daysOffset: number): string => { + const date = new Date() + date.setDate(date.getDate() + daysOffset) + return date.toISOString().split('T')[0] +} + +export const getFeedAddress = ( + currency: string, + settings: typeof config.settings, +): string | null => { + const upperCurrency = currency.toUpperCase() + switch (upperCurrency) { + case 'BTC': + return settings.BTC_USD_FEED_ADDRESS + case 'ETH': + return settings.ETH_USD_FEED_ADDRESS + case 'SOL': + return settings.SOL_USD_FEED_ADDRESS + case 'USDC': + return settings.USDC_USD_FEED_ADDRESS + case 'USDT': + return settings.USDT_USD_FEED_ADDRESS + case 'USYC': + return settings.USYC_USD_FEED_ADDRESS + case 'OUSG': + return settings.OUSG_USD_FEED_ADDRESS + case 'JTRSY': + return settings.JTRSY_USD_FEED_ADDRESS + default: + return null + } +} + +export const convertToUsd = (balance: Decimal, priceData: PriceData): Decimal => { + const divisor = new Decimal(10).pow(priceData.decimals) + return balance.mul(priceData.price).div(divisor) +} + +const generateHmacSignature = ( + secret: string, + timestamp: string, + method: string, + path: string, + body: string, +): string => { + const payload = `${timestamp}${method}${path}${body}` + return crypto.createHmac('sha256', secret).update(payload).digest('hex') +} + +export const fetchWalletsFromCopper = async ( + apiEndpoint: string, + apiKey: string, + apiSecret: string, +): Promise => { + const timestamp = Date.now().toString() + const method = 'GET' + const path = '/wallets' + const body = '' + + const signature = generateHmacSignature(apiSecret, timestamp, method, path, body) + + let response + try { + response = await axios.get(`${apiEndpoint}${path}`, { + headers: { + accept: 'application/json', + 'X-COPPER-API-KEY': apiKey, + 'X-COPPER-SIGNATURE': signature, + 'X-COPPER-TIMESTAMP': timestamp, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + throw new Error(`Copper API request failed: ${message}`) + } + + if (!response.data || !response.data.wallets) { + throw new Error('Copper API returned invalid response: missing wallets data') + } + + return response.data.wallets +} diff --git a/packages/sources/copper/src/transport/reserves.ts b/packages/sources/copper/src/transport/reserves.ts new file mode 100644 index 00000000000..8df3143b16f --- /dev/null +++ b/packages/sources/copper/src/transport/reserves.ts @@ -0,0 +1,218 @@ +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 { Decimal } from 'decimal.js' +import { ethers } from 'ethers' +import { BaseEndpointTypes } from '../endpoint/reserves' +import { + convertToUsd, + fetchWalletsFromCopper, + getChainlinkPrice, + getFeedAddress, + getSuperstateNav, + isSupportedAsset, + PriceData, + Wallet, +} from './priceFeed' + +const logger = makeLogger('CopperReservesTransport') + +interface AssetData { + balance: Decimal + stakeBalance: Decimal + custodyUsd: Decimal + stakingUsd: Decimal +} + +export type TransportTypes = BaseEndpointTypes + +export class ReservesTransport extends SubscriptionTransport { + provider!: ethers.JsonRpcProvider + settings!: TransportTypes['Settings'] + + async initialize( + dependencies: TransportDependencies, + adapterSettings: TransportTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.settings = adapterSettings + this.provider = new ethers.JsonRpcProvider( + adapterSettings.ETHEREUM_RPC_URL, + adapterSettings.ETHEREUM_CHAIN_ID, + ) + } + + async backgroundHandler( + context: EndpointContext, + entries: object[], + ): Promise { + if (entries.length === 0) { + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + return + } + + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(param: object): Promise { + let response: AdapterResponse + try { + response = await this._handleRequest() + } catch (e) { + logger.error(e) + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + response = { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest(): Promise> { + const providerDataRequestedUnixMs = Date.now() + + const wallets = await fetchWalletsFromCopper( + this.settings.API_ENDPOINT, + this.settings.COPPER_API_KEY, + this.settings.COPPER_API_SECRET, + ) + const providerDataReceivedUnixMs = Date.now() + + if (!wallets || !Array.isArray(wallets)) { + return { + statusCode: 502, + errorMessage: 'No wallets data received from Copper API', + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + Decimal.set({ precision: 30 }) + + const assets: Record = {} + + for (const wallet of wallets) { + const currency = wallet.currency.toUpperCase() + if (!isSupportedAsset(currency)) { + continue + } + + const balance = new Decimal(wallet.balance || '0') + const stakeBalance = new Decimal(wallet.stakeBalance || '0') + + if (!assets[currency]) { + assets[currency] = { + balance: new Decimal(0), + stakeBalance: new Decimal(0), + custodyUsd: new Decimal(0), + stakingUsd: new Decimal(0), + } + } + + assets[currency].balance = assets[currency].balance.plus(balance) + assets[currency].stakeBalance = assets[currency].stakeBalance.plus(stakeBalance) + } + + const priceCache: Record = {} + let ustbNav: Decimal | null = null + + for (const currency of Object.keys(assets)) { + if (currency === 'USTB') { + if (ustbNav === null) { + ustbNav = await getSuperstateNav( + this.settings.SUPERSTATE_API_ENDPOINT, + this.settings.USTB_FUND_ID, + ) + } + } else { + const feedAddress = getFeedAddress(currency, this.settings) + if (!feedAddress) { + logger.warn(`No price feed address configured for supported asset: ${currency}`) + continue + } + if (!priceCache[currency]) { + priceCache[currency] = await getChainlinkPrice(feedAddress, this.provider) + } + } + } + + let totalCustodyUsd = new Decimal(0) + let totalStakingUsd = new Decimal(0) + + const STAKING_SUPPORTED_ASSETS = ['ETH', 'SOL'] + + for (const [currency, data] of Object.entries(assets)) { + let custodyUsd = new Decimal(0) + let stakingUsd = new Decimal(0) + + if (!data.stakeBalance.isZero() && !STAKING_SUPPORTED_ASSETS.includes(currency)) { + logger.warn( + `Unexpected staking balance for ${currency}: ${data.stakeBalance.toString()}. Only ETH and SOL should have staking.`, + ) + } + + if (currency === 'USTB' && ustbNav !== null) { + custodyUsd = data.balance.mul(ustbNav) + stakingUsd = data.stakeBalance.mul(ustbNav) + } else if (priceCache[currency]) { + custodyUsd = convertToUsd(data.balance, priceCache[currency]) + stakingUsd = convertToUsd(data.stakeBalance, priceCache[currency]) + } + + assets[currency].custodyUsd = custodyUsd + assets[currency].stakingUsd = stakingUsd + + totalCustodyUsd = totalCustodyUsd.plus(custodyUsd) + totalStakingUsd = totalStakingUsd.plus(stakingUsd) + } + + const totalUsd = totalCustodyUsd.plus(totalStakingUsd) + + const latestUpdatedAt = wallets.reduce((max: number, w: Wallet) => { + const updatedAt = Number(w.updatedAt) + return !isNaN(updatedAt) && updatedAt > max ? updatedAt : max + }, 0) + + const resultString = totalUsd.toFixed() + const result = Number(resultString) + + if (totalUsd.greaterThan(Number.MAX_SAFE_INTEGER)) { + logger.warn( + `Total USD value ${resultString} exceeds Number.MAX_SAFE_INTEGER. Precision loss may occur.`, + ) + } + + return { + data: { + result, + ripcord: false, + }, + result, + statusCode: 200, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs, + providerIndicatedTimeUnixMs: latestUpdatedAt > 0 ? latestUpdatedAt : undefined, + }, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: TransportTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const reservesTransport = new ReservesTransport() diff --git a/packages/sources/copper/test-payload.json b/packages/sources/copper/test-payload.json new file mode 100644 index 00000000000..4d15e19e705 --- /dev/null +++ b/packages/sources/copper/test-payload.json @@ -0,0 +1,7 @@ +{ + "requests": [ + { + "endpoint": "reserves" + } + ] +} diff --git a/packages/sources/copper/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/copper/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 00000000000..9e4360d6103 --- /dev/null +++ b/packages/sources/copper/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,140 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute reserves endpoint happy path should return success with USTB wallet using Superstate NAV 1`] = ` +{ + "data": { + "result": 151170, + "ripcord": false, + }, + "result": 151170, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + "providerIndicatedTimeUnixMs": 1704067200000, + }, +} +`; + +exports[`execute reserves endpoint happy path should return success with all supported assets 1`] = ` +{ + "data": { + "result": 200353.6, + "ripcord": false, + }, + "result": 200353.6, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + "providerIndicatedTimeUnixMs": 1704067200000, + }, +} +`; + +exports[`execute reserves endpoint happy path should return success with basic wallets 1`] = ` +{ + "data": { + "result": 1720000, + "ripcord": false, + }, + "result": 1720000, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + "providerIndicatedTimeUnixMs": 1704153600000, + }, +} +`; + +exports[`execute reserves endpoint happy path should return zero result when all assets are unsupported 1`] = ` +{ + "data": { + "result": 0, + "ripcord": false, + }, + "result": 0, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + "providerIndicatedTimeUnixMs": 1704067200000, + }, +} +`; + +exports[`execute reserves endpoint happy path should return zero result with empty wallets 1`] = ` +{ + "data": { + "result": 0, + "ripcord": false, + }, + "result": 0, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`execute reserves endpoint happy path should work without explicit endpoint parameter (default endpoint) 1`] = ` +{ + "data": { + "result": 1720000, + "ripcord": false, + }, + "result": 1720000, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + "providerIndicatedTimeUnixMs": 1704153600000, + }, +} +`; + +exports[`execute reserves endpoint upstream failures should handle Copper API error 1`] = ` +{ + "errorMessage": "Copper API request failed: Request failed with status code 500", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`execute reserves endpoint upstream failures should handle Copper API invalid response 1`] = ` +{ + "errorMessage": "Copper API returned invalid response: missing wallets data", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`execute reserves endpoint upstream failures should handle Copper API null response 1`] = ` +{ + "errorMessage": "Copper API returned invalid response: missing wallets data", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; + +exports[`execute reserves endpoint upstream failures should handle Superstate NAV empty response for USTB 1`] = ` +{ + "errorMessage": "No NAV data received from Superstate for fund 1", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 1704110400000, + "providerDataRequestedUnixMs": 1704110400000, + }, +} +`; diff --git a/packages/sources/copper/test/integration/adapter.test.ts b/packages/sources/copper/test/integration/adapter.test.ts new file mode 100644 index 00000000000..c99338e0f44 --- /dev/null +++ b/packages/sources/copper/test/integration/adapter.test.ts @@ -0,0 +1,273 @@ +import { sleep } from '@chainlink/external-adapter-framework/util' +import { + setEnvVariables, + TestAdapter, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { + JTRSY_USD_FEED_ADDRESS, + mockCopperWalletsAllAssets, + mockCopperWalletsApiError, + mockCopperWalletsEmpty, + mockCopperWalletsInvalidResponse, + mockCopperWalletsNullResponse, + mockCopperWalletsSuccess, + mockCopperWalletsUnsupportedAssets, + mockCopperWalletsWithUstb, + mockEthereumRpc, + mockSuperstateNavEmpty, + mockSuperstateNavSuccess, + OUSG_USD_FEED_ADDRESS, + USYC_USD_FEED_ADDRESS, +} 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.COPPER_API_KEY = process.env.COPPER_API_KEY ?? 'fake-api-key' + process.env.COPPER_API_SECRET = process.env.COPPER_API_SECRET ?? 'fake-api-secret' + process.env.ETHEREUM_RPC_URL = process.env.ETHEREUM_RPC_URL ?? 'http://localhost:8545' + process.env.ETHEREUM_CHAIN_ID = '1' + process.env.API_ENDPOINT = 'https://api.copper.co/platform' + process.env.SUPERSTATE_API_ENDPOINT = 'https://api.superstate.co' + process.env.USTB_FUND_ID = '1' + process.env.BACKGROUND_EXECUTE_MS = '100' + + // Required feed addresses without defaults + process.env.USYC_USD_FEED_ADDRESS = USYC_USD_FEED_ADDRESS + process.env.OUSG_USD_FEED_ADDRESS = OUSG_USD_FEED_ADDRESS + process.env.JTRSY_USD_FEED_ADDRESS = JTRSY_USD_FEED_ADDRESS + + const mockDate = new Date('2024-01-01T12:00:00.000Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + // Set up default mocks before adapter starts + mockCopperWalletsSuccess() + mockEthereumRpc() + mockSuperstateNavSuccess() + + 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('reserves endpoint', () => { + describe('happy path', () => { + it('should return success with basic wallets', async () => { + // Clear cache and set up mocks atomically + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsSuccess() + mockEthereumRpc() + + // First call triggers background execution + await testAdapter.request({ endpoint: 'reserves' }) + // Wait for background execution to complete + await sleep(200) + // Second call retrieves cached result + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success with USTB wallet using Superstate NAV', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsWithUstb() + mockEthereumRpc() + mockSuperstateNavSuccess() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success with all supported assets', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsAllAssets() + mockEthereumRpc() + mockSuperstateNavSuccess() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return zero result with empty wallets', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsEmpty() + mockEthereumRpc() + mockSuperstateNavSuccess() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return zero result when all assets are unsupported', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsUnsupportedAssets() + mockEthereumRpc() + mockSuperstateNavSuccess() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should work without explicit endpoint parameter (default endpoint)', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsSuccess() + mockEthereumRpc() + + await testAdapter.request({}) + await sleep(200) + const response = await testAdapter.request({}) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) + + describe('upstream failures', () => { + it('should handle Copper API error', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsApiError() + mockEthereumRpc() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should handle Copper API invalid response', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsInvalidResponse() + mockEthereumRpc() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should handle Copper API null response', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsNullResponse() + mockEthereumRpc() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should handle Superstate NAV empty response for USTB', async () => { + nock.cleanAll() + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + mockCopperWalletsWithUstb() + mockEthereumRpc() + mockSuperstateNavEmpty() + + await testAdapter.request({ endpoint: 'reserves' }) + await sleep(200) + const response = await testAdapter.request({ endpoint: 'reserves' }) + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + }) + }) +}) diff --git a/packages/sources/copper/test/integration/fixtures.ts b/packages/sources/copper/test/integration/fixtures.ts new file mode 100644 index 00000000000..cb34314bf95 --- /dev/null +++ b/packages/sources/copper/test/integration/fixtures.ts @@ -0,0 +1,472 @@ +import nock from 'nock' + +type JsonRpcPayload = { + id: number + method: string + params: Array<{ to: string; data: string }> + jsonrpc: '2.0' +} + +// Chainlink price feed function signatures +const DECIMALS_SIG_HASH = '0x313ce567' +const LATEST_ANSWER_SIG_HASH = '0x50d25bcd' + +// Price feed addresses (from config defaults) +export const BTC_USD_FEED_ADDRESS = '0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c' +export const ETH_USD_FEED_ADDRESS = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419' +export const SOL_USD_FEED_ADDRESS = '0x4ffC43a60e009B551865A93d232E33Fce9f01507' +export const USDC_USD_FEED_ADDRESS = '0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6' +export const USDT_USD_FEED_ADDRESS = '0x3E7d1eAB13ad0104d2750B8863b489D65364e32D' +export const USYC_USD_FEED_ADDRESS = '0x1111111111111111111111111111111111111111' +export const OUSG_USD_FEED_ADDRESS = '0x2222222222222222222222222222222222222222' +export const JTRSY_USD_FEED_ADDRESS = '0x3333333333333333333333333333333333333333' + +// Mock price data: { address: { decimals: number, latestAnswer: bigint } } +const PRICE_FEEDS: Record = { + [BTC_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 10000000000000n }, // $100,000 + [ETH_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 350000000000n }, // $3,500 + [SOL_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 15000000000n }, // $150 + [USDC_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 100000000n }, // $1 + [USDT_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 100000000n }, // $1 + [USYC_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 110000000n }, // $1.10 + [OUSG_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 105000000n }, // $1.05 + [JTRSY_USD_FEED_ADDRESS.toLowerCase()]: { decimals: 8, latestAnswer: 102000000n }, // $1.02 +} + +const bigintToEthRpcResult = (value: bigint): string => { + return '0x' + value.toString(16).padStart(64, '0') +} + +export const mockEthereumRpc = (): nock.Scope => + nock('http://localhost:8545', {}) + .post('/', (body: any) => Array.isArray(body)) + .reply( + 200, + (_uri, requestBody: JsonRpcPayload[]) => { + return requestBody.map((request: JsonRpcPayload) => { + if (request.method === 'eth_chainId') { + return { + jsonrpc: '2.0', + id: request.id, + result: bigintToEthRpcResult(1n), + } + } else if (request.method === 'eth_call') { + const [{ to, data }] = request.params + const feedAddress = to.toLowerCase() + const feed = PRICE_FEEDS[feedAddress] + + if (data === DECIMALS_SIG_HASH && feed) { + return { + jsonrpc: '2.0', + id: request.id, + result: bigintToEthRpcResult(BigInt(feed.decimals)), + } + } else if (data === LATEST_ANSWER_SIG_HASH && feed) { + return { + jsonrpc: '2.0', + id: request.id, + result: bigintToEthRpcResult(feed.latestAnswer), + } + } + } + console.log('Unmocked Ethereum RPC request:', JSON.stringify(request, null, 2)) + return { + jsonrpc: '2.0', + id: request.id, + result: '', + } + }) + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + .persist() + +// Mock Copper API responses +export const mockCopperWalletsSuccess = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply( + 200, + { + wallets: [ + { + walletId: 'wallet-1', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'BTC', + mainCurrency: 'BTC', + balance: '10.5', + stakeBalance: '0', + totalBalance: '10.5', + available: '10.5', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-2', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'ETH', + mainCurrency: 'ETH', + balance: '100', + stakeBalance: '50', + totalBalance: '150', + available: '100', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-3', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USDC', + mainCurrency: 'USDC', + balance: '100000', + stakeBalance: '0', + totalBalance: '100000', + available: '100000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-4', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'SOL', + mainCurrency: 'SOL', + balance: '200', + stakeBalance: '100', + totalBalance: '300', + available: '200', + locked: '0', + reserve: '0', + updatedAt: '1704153600000', + createdAt: '1704067200000', + }, + ], + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockCopperWalletsWithUstb = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply( + 200, + { + wallets: [ + { + walletId: 'wallet-1', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'BTC', + mainCurrency: 'BTC', + balance: '1', + stakeBalance: '0', + totalBalance: '1', + available: '1', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-2', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USTB', + mainCurrency: 'USTB', + balance: '50000', + stakeBalance: '0', + totalBalance: '50000', + available: '50000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + ], + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockCopperWalletsAllAssets = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply( + 200, + { + wallets: [ + { + walletId: 'wallet-btc', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'BTC', + mainCurrency: 'BTC', + balance: '1', + stakeBalance: '0', + totalBalance: '1', + available: '1', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-eth', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'ETH', + mainCurrency: 'ETH', + balance: '10', + stakeBalance: '5', + totalBalance: '15', + available: '10', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-sol', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'SOL', + mainCurrency: 'SOL', + balance: '100', + stakeBalance: '50', + totalBalance: '150', + available: '100', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-usdc', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USDC', + mainCurrency: 'USDC', + balance: '10000', + stakeBalance: '0', + totalBalance: '10000', + available: '10000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-usdt', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USDT', + mainCurrency: 'USDT', + balance: '5000', + stakeBalance: '0', + totalBalance: '5000', + available: '5000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-usyc', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USYC', + mainCurrency: 'USYC', + balance: '1000', + stakeBalance: '0', + totalBalance: '1000', + available: '1000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-ousg', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'OUSG', + mainCurrency: 'OUSG', + balance: '2000', + stakeBalance: '0', + totalBalance: '2000', + available: '2000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-jtrsy', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'JTRSY', + mainCurrency: 'JTRSY', + balance: '3000', + stakeBalance: '0', + totalBalance: '3000', + available: '3000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-ustb', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'USTB', + mainCurrency: 'USTB', + balance: '4000', + stakeBalance: '0', + totalBalance: '4000', + available: '4000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + ], + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockCopperWalletsEmpty = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply( + 200, + { + wallets: [], + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockCopperWalletsUnsupportedAssets = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply( + 200, + { + wallets: [ + { + walletId: 'wallet-1', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'DOGE', + mainCurrency: 'DOGE', + balance: '1000000', + stakeBalance: '0', + totalBalance: '1000000', + available: '1000000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + { + walletId: 'wallet-2', + portfolioId: 'portfolio-1', + portfolioType: 'custody', + currency: 'XRP', + mainCurrency: 'XRP', + balance: '500000', + stakeBalance: '0', + totalBalance: '500000', + available: '500000', + locked: '0', + reserve: '0', + updatedAt: '1704067200000', + createdAt: '1704067200000', + }, + ], + }, + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockCopperWalletsApiError = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply(500, { error: 'Internal Server Error' }) + +export const mockCopperWalletsInvalidResponse = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply(200, { data: 'invalid' }) + +export const mockCopperWalletsNullResponse = (): nock.Scope => + nock('https://api.copper.co/platform', { + encodedQueryParams: true, + }) + .persist() + .get('/wallets') + .reply(200, null) + +// Mock Superstate API responses +export const mockSuperstateNavSuccess = (): nock.Scope => + nock('https://api.superstate.co', { + encodedQueryParams: true, + }) + .persist() + .get('/funds/1/nav-daily') + .query(true) + .reply( + 200, + [ + { net_asset_value: '1.0234', date: '2024-01-01' }, + { net_asset_value: '1.0230', date: '2023-12-31' }, + { net_asset_value: '1.0225', date: '2023-12-30' }, + ], + ['Content-Type', 'application/json', 'Connection', 'close'], + ) + +export const mockSuperstateNavEmpty = (): nock.Scope => + nock('https://api.superstate.co', { + encodedQueryParams: true, + }) + .persist() + .get('/funds/1/nav-daily') + .query(true) + .reply(200, [], ['Content-Type', 'application/json', 'Connection', 'close']) + +export const mockSuperstateNavError = (): nock.Scope => + nock('https://api.superstate.co', { + encodedQueryParams: true, + }) + .persist() + .get('/funds/1/nav-daily') + .query(true) + .reply(500, { error: 'Internal Server Error' }) diff --git a/packages/sources/copper/test/unit/priceFeed.test.ts b/packages/sources/copper/test/unit/priceFeed.test.ts new file mode 100644 index 00000000000..11234f8fb0a --- /dev/null +++ b/packages/sources/copper/test/unit/priceFeed.test.ts @@ -0,0 +1,189 @@ +import { Decimal } from 'decimal.js' +import { + convertToUsd, + getFeedAddress, + isSupportedAsset, + PriceData, +} from '../../src/transport/priceFeed' + +describe('isSupportedAsset', () => { + it('returns true for BTC', () => { + expect(isSupportedAsset('BTC')).toBe(true) + }) + + it('returns true for lowercase btc', () => { + expect(isSupportedAsset('btc')).toBe(true) + }) + + it('returns true for mixed case Btc', () => { + expect(isSupportedAsset('Btc')).toBe(true) + }) + + it('returns true for ETH', () => { + expect(isSupportedAsset('ETH')).toBe(true) + }) + + it('returns true for SOL', () => { + expect(isSupportedAsset('SOL')).toBe(true) + }) + + it('returns true for USDC', () => { + expect(isSupportedAsset('USDC')).toBe(true) + }) + + it('returns true for USDT', () => { + expect(isSupportedAsset('USDT')).toBe(true) + }) + + it('returns true for USTB', () => { + expect(isSupportedAsset('USTB')).toBe(true) + }) + + it('returns true for USYC', () => { + expect(isSupportedAsset('USYC')).toBe(true) + }) + + it('returns true for OUSG', () => { + expect(isSupportedAsset('OUSG')).toBe(true) + }) + + it('returns true for JTRSY', () => { + expect(isSupportedAsset('JTRSY')).toBe(true) + }) + + it('returns false for unsupported DOGE', () => { + expect(isSupportedAsset('DOGE')).toBe(false) + }) + + it('returns false for unsupported XRP', () => { + expect(isSupportedAsset('XRP')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isSupportedAsset('')).toBe(false) + }) +}) + +describe('getFeedAddress', () => { + const mockSettings = { + BTC_USD_FEED_ADDRESS: '0xBTC', + ETH_USD_FEED_ADDRESS: '0xETH', + SOL_USD_FEED_ADDRESS: '0xSOL', + USDC_USD_FEED_ADDRESS: '0xUSDC', + USDT_USD_FEED_ADDRESS: '0xUSDT', + USYC_USD_FEED_ADDRESS: '0xUSYC', + OUSG_USD_FEED_ADDRESS: '0xOUSG', + JTRSY_USD_FEED_ADDRESS: '0xJTRSY', + } as any + + it('returns BTC feed address for BTC', () => { + expect(getFeedAddress('BTC', mockSettings)).toBe('0xBTC') + }) + + it('returns BTC feed address for lowercase btc', () => { + expect(getFeedAddress('btc', mockSettings)).toBe('0xBTC') + }) + + it('returns ETH feed address for ETH', () => { + expect(getFeedAddress('ETH', mockSettings)).toBe('0xETH') + }) + + it('returns SOL feed address for SOL', () => { + expect(getFeedAddress('SOL', mockSettings)).toBe('0xSOL') + }) + + it('returns USDC feed address for USDC', () => { + expect(getFeedAddress('USDC', mockSettings)).toBe('0xUSDC') + }) + + it('returns USDT feed address for USDT', () => { + expect(getFeedAddress('USDT', mockSettings)).toBe('0xUSDT') + }) + + it('returns USYC feed address for USYC', () => { + expect(getFeedAddress('USYC', mockSettings)).toBe('0xUSYC') + }) + + it('returns OUSG feed address for OUSG', () => { + expect(getFeedAddress('OUSG', mockSettings)).toBe('0xOUSG') + }) + + it('returns JTRSY feed address for JTRSY', () => { + expect(getFeedAddress('JTRSY', mockSettings)).toBe('0xJTRSY') + }) + + it('returns null for USTB (uses Superstate API instead)', () => { + expect(getFeedAddress('USTB', mockSettings)).toBeNull() + }) + + it('returns null for unsupported currency', () => { + expect(getFeedAddress('DOGE', mockSettings)).toBeNull() + }) + + it('returns null for empty string', () => { + expect(getFeedAddress('', mockSettings)).toBeNull() + }) +}) + +describe('convertToUsd', () => { + it('converts balance with 8 decimals correctly', () => { + const balance = new Decimal('10') + const priceData: PriceData = { + price: new Decimal('10000000000000'), // $100,000 with 8 decimals + decimals: 8, + } + const result = convertToUsd(balance, priceData) + expect(result.toNumber()).toBe(1000000) // 10 * $100,000 = $1,000,000 + }) + + it('converts balance with 18 decimals correctly', () => { + const balance = new Decimal('5') + const priceData: PriceData = { + price: new Decimal('2000000000000000000000'), // $2000 with 18 decimals + decimals: 18, + } + const result = convertToUsd(balance, priceData) + expect(result.toNumber()).toBe(10000) // 5 * $2000 = $10,000 + }) + + it('handles zero balance', () => { + const balance = new Decimal('0') + const priceData: PriceData = { + price: new Decimal('350000000000'), // $3500 with 8 decimals + decimals: 8, + } + const result = convertToUsd(balance, priceData) + expect(result.toNumber()).toBe(0) + }) + + it('handles fractional balance', () => { + const balance = new Decimal('0.5') + const priceData: PriceData = { + price: new Decimal('100000000'), // $1 with 8 decimals + decimals: 8, + } + const result = convertToUsd(balance, priceData) + expect(result.toNumber()).toBe(0.5) // 0.5 * $1 = $0.50 + }) + + it('handles large balance values', () => { + const balance = new Decimal('1000000') + const priceData: PriceData = { + price: new Decimal('100000000'), // $1 with 8 decimals + decimals: 8, + } + const result = convertToUsd(balance, priceData) + expect(result.toNumber()).toBe(1000000) // 1,000,000 * $1 = $1,000,000 + }) + + it('preserves precision with decimal.js', () => { + const balance = new Decimal('123.456789') + const priceData: PriceData = { + price: new Decimal('350000000000'), // $3500 with 8 decimals + decimals: 8, + } + const result = convertToUsd(balance, priceData) + // 123.456789 * 3500 = 432098.7615 + expect(result.toFixed(4)).toBe('432098.7615') + }) +}) diff --git a/packages/sources/copper/tsconfig.json b/packages/sources/copper/tsconfig.json new file mode 100644 index 00000000000..f59363fd76c --- /dev/null +++ b/packages/sources/copper/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/copper/tsconfig.test.json b/packages/sources/copper/tsconfig.test.json new file mode 100755 index 00000000000..e3de28cb5c0 --- /dev/null +++ b/packages/sources/copper/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 fdfd8590b06..37955d058a8 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -218,6 +218,9 @@ { "path": "./sources/coinranking" }, + { + "path": "./sources/copper" + }, { "path": "./sources/covid-tracker" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 34b47d0d3a5..2a53e72ea38 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -218,6 +218,9 @@ { "path": "./sources/coinranking/tsconfig.test.json" }, + { + "path": "./sources/copper/tsconfig.test.json" + }, { "path": "./sources/covid-tracker/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 91d0f297f83..811f266d395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2915,6 +2915,24 @@ __metadata: languageName: node linkType: hard +"@chainlink/copper-adapter@workspace:packages/sources/copper": + version: 0.0.0-use.local + resolution: "@chainlink/copper-adapter@workspace:packages/sources/copper" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.11.4" + "@sinonjs/fake-timers": "npm:9.1.2" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + "@types/sinonjs__fake-timers": "npm:8.1.5" + axios: "npm:1.13.4" + decimal.js: "npm:10.5.0" + ethers: "npm:^6.13.2" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/covid-tracker-adapter@workspace:packages/sources/covid-tracker": version: 0.0.0-use.local resolution: "@chainlink/covid-tracker-adapter@workspace:packages/sources/covid-tracker" @@ -3613,6 +3631,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