diff --git a/.changeset/mighty-panthers-carry.md b/.changeset/mighty-panthers-carry.md new file mode 100644 index 0000000000..a40635d452 --- /dev/null +++ b/.changeset/mighty-panthers-carry.md @@ -0,0 +1,5 @@ +--- +'@chainlink/copper-adapter': major +--- + +Copper EA diff --git a/.pnp.cjs b/.pnp.cjs index 022bcf2331..78ae6703c1 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -398,6 +398,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"\ @@ -1055,6 +1059,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/coinpaprika-adapter", ["workspace:packages/sources/coinpaprika"]],\ ["@chainlink/coinranking-adapter", ["workspace:packages/sources/coinranking"]],\ ["@chainlink/conflux-adapter", ["workspace:packages/targets/conflux"]],\ + ["@chainlink/copper-adapter", ["workspace:packages/sources/copper"]],\ ["@chainlink/covid-tracker-adapter", ["workspace:packages/sources/covid-tracker"]],\ ["@chainlink/cryptex-adapter", ["workspace:packages/sources/cryptex"]],\ ["@chainlink/crypto-volatility-index-adapter", ["workspace:packages/composites/crypto-volatility-index"]],\ @@ -6154,6 +6159,25 @@ 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.7.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"],\ + ["decimal.js", "npm:10.4.3"],\ + ["ethers", "npm:5.4.7"],\ + ["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/",\ diff --git a/packages/sources/copper/CHANGELOG.md b/packages/sources/copper/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/copper/README.md b/packages/sources/copper/README.md new file mode 100644 index 0000000000..a322355afe --- /dev/null +++ b/packages/sources/copper/README.md @@ -0,0 +1,78 @@ +# COPPER + +![0.0.0](https://img.shields.io/github/package-json/v/smartcontractkit/external-adapters-js?filename=packages/sources/copper/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 | An API key for Data Provider - Copper | string | | | +| ✅ | API_SECRET | An API secret for Data Provider - Copper | string | | | +| | API_ENDPOINT | An API endpoint for Data Provider - Copper | string | | `https://api.copper.co` | +| | ETHEREUM_RPC_URL | RPC url of Ethereum node | string | | `` | +| | ETHEREUM_RPC_CHAIN_ID | Ethereum chain id | number | | `1` | +| | ARBITRUM_RPC_URL | RPC url of Arbitrum node | string | | `` | +| | ARBITRUM_RPC_CHAIN_ID | Arbitrum chain id | number | | `42161` | +| | BACKGROUND_EXECUTE_MS | Background execute time in milliseconds | number | | `1000` | +| | GROUP_SIZE | Number of requests to execute asynchronously before the adapter waits to execute the next group of requests. Setting this lower than the default may result in lower performance from the adapter. | number | | `25` | + +--- + +## 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 | [wallets](#wallets-endpoint) | | + +## Wallets Endpoint + +`wallets` is the only supported name for this endpoint. + +### Input Params + +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :--------------------------: | :-----: | :----------------------------------------------------------------------------------: | :------: | :-----: | :-----: | :--------: | :------------: | +| ✅ | priceOracles | | Configuration of the on-chain price oracle that provides real-time token valuations. | object[] | | | | | +| ✅ | priceOracles.token | | Symbol of the token to fetch price data for (e.g., ETH, SOL). | string | | | | | +| ✅ | priceOracles.contractAddress | | Contract address of the price oracle used to fetch token price data. | string | | | | | +| ✅ | priceOracles.chainId | | Blockchain network Id of the price oracle contract (e.g., ETHEREUM, ARBITRUM). | string | | | | | +| | portfolioId | | The portfolio ID to query. | string | | | | | +| | currencies | | The list of currency symbols to query. | string[] | | | | | + +### Example + +Request: + +```json +{ + "data": { + "endpoint": "wallets", + "priceOracles": [ + { + "token": "ETH", + "contractAddress": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", + "chainId": "1" + }, + { + "token": "SOL", + "contractAddress": "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612", + "chainId": "42161" + } + ], + "portfolioId": "cme14144g001y3b6k5x9gljwp", + "currencies": ["ETH", "SOL", "USDC", "USDT", "USTB", "ETH", "SOL"] + } +} +``` + +--- + +MIT License diff --git a/packages/sources/copper/package.json b/packages/sources/copper/package.json new file mode 100644 index 0000000000..72e4932298 --- /dev/null +++ b/packages/sources/copper/package.json @@ -0,0 +1,44 @@ +{ + "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": { + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.7.0", + "@types/crypto-js": "4.2.2", + "crypto-js": "4.2.0", + "decimal.js": "^10.3.1", + "ethers": "5.4.7", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/copper/src/config/EACAggregatorProxy.json b/packages/sources/copper/src/config/EACAggregatorProxy.json new file mode 100644 index 0000000000..fe49b55859 --- /dev/null +++ b/packages/sources/copper/src/config/EACAggregatorProxy.json @@ -0,0 +1,239 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "_aggregator", "type": "address" }, + { "internalType": "address", "name": "_accessController", "type": "address" } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "int256", "name": "current", "type": "int256" }, + { "indexed": true, "internalType": "uint256", "name": "roundId", "type": "uint256" }, + { "indexed": false, "internalType": "uint256", "name": "updatedAt", "type": "uint256" } + ], + "name": "AnswerUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "uint256", "name": "roundId", "type": "uint256" }, + { "indexed": true, "internalType": "address", "name": "startedBy", "type": "address" }, + { "indexed": false, "internalType": "uint256", "name": "startedAt", "type": "uint256" } + ], + "name": "NewRound", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "to", "type": "address" } + ], + "name": "OwnershipTransferRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "to", "type": "address" } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "accessController", + "outputs": [ + { "internalType": "contract AccessControllerInterface", "name": "", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "aggregator", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_aggregator", "type": "address" }], + "name": "confirmAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "description", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "_roundId", "type": "uint256" }], + "name": "getAnswer", + "outputs": [{ "internalType": "int256", "name": "", "type": "int256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint80", "name": "_roundId", "type": "uint80" }], + "name": "getRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint256", "name": "_roundId", "type": "uint256" }], + "name": "getTimestamp", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestAnswer", + "outputs": [{ "internalType": "int256", "name": "", "type": "int256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRound", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestTimestamp", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address payable", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint16", "name": "", "type": "uint16" }], + "name": "phaseAggregators", + "outputs": [ + { "internalType": "contract AggregatorV2V3Interface", "name": "", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "phaseId", + "outputs": [{ "internalType": "uint16", "name": "", "type": "uint16" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_aggregator", "type": "address" }], + "name": "proposeAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "proposedAggregator", + "outputs": [ + { "internalType": "contract AggregatorV2V3Interface", "name": "", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint80", "name": "_roundId", "type": "uint80" }], + "name": "proposedGetRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proposedLatestRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_accessController", "type": "address" }], + "name": "setController", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_to", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/packages/sources/copper/src/config/index.ts b/packages/sources/copper/src/config/index.ts new file mode 100644 index 0000000000..f309a2c730 --- /dev/null +++ b/packages/sources/copper/src/config/index.ts @@ -0,0 +1,53 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'An API key for Data Provider - Copper', + type: 'string', + required: true, + sensitive: true, + array: true, + }, + API_SECRET: { + description: 'An API secret for Data Provider - Copper', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Data Provider - Copper', + type: 'string', + default: 'https://api.copper.co', + }, + ETHEREUM_RPC_URL: { + description: 'RPC url of Ethereum node', + type: 'string', + default: '', + }, + ETHEREUM_RPC_CHAIN_ID: { + description: 'Ethereum chain id', + type: 'number', + default: 1, + }, + ARBITRUM_RPC_URL: { + description: 'RPC url of Arbitrum node', + type: 'string', + default: '', + }, + ARBITRUM_RPC_CHAIN_ID: { + description: 'Arbitrum chain id', + type: 'number', + default: 42161, + }, + BACKGROUND_EXECUTE_MS: { + description: 'Background execute time in milliseconds', + type: 'number', + default: 1000, + }, + GROUP_SIZE: { + description: + 'Number of requests to execute asynchronously before the adapter waits to execute the next group of requests. Setting this lower than the default may result in lower performance from the adapter.', + type: 'number', + default: 25, + }, +}) diff --git a/packages/sources/copper/src/endpoint/index.ts b/packages/sources/copper/src/endpoint/index.ts new file mode 100644 index 0000000000..fe2385110d --- /dev/null +++ b/packages/sources/copper/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as wallets } from './wallets' diff --git a/packages/sources/copper/src/endpoint/wallets.ts b/packages/sources/copper/src/endpoint/wallets.ts new file mode 100644 index 0000000000..6ae9c6c1d9 --- /dev/null +++ b/packages/sources/copper/src/endpoint/wallets.ts @@ -0,0 +1,82 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { walletsTransport } from '../transport/wallets' + +export const inputParameters = new InputParameters( + { + priceOracles: { + required: true, + description: + 'Configuration of the on-chain price oracle that provides real-time token valuations.', + type: { + token: { + required: true, + type: 'string', + description: 'Symbol of the token to fetch price data for (e.g., ETH, SOL).', + }, + contractAddress: { + required: true, + type: 'string', + description: 'Contract address of the price oracle used to fetch token price data.', + }, + chainId: { + required: true, + type: 'string', + description: + 'Blockchain network Id of the price oracle contract (e.g., ETHEREUM, ARBITRUM).', + }, + }, + array: true, + }, + portfolioId: { + description: 'The portfolio ID to query.', + type: 'string', + required: false, + }, + currencies: { + description: 'The list of currency symbols to query.', + type: 'string', + array: true, + required: false, + }, + }, + [ + { + priceOracles: [ + { + token: 'ETH', + contractAddress: '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419', + chainId: '1', + }, + { + token: 'SOL', + contractAddress: '0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612', + chainId: '42161', + }, + ], + portfolioId: 'cme14144g001y3b6k5x9gljwp', + currencies: ['ETH', 'SOL', 'USDC', 'USDT', 'USTB', 'ETH', 'SOL'], + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: string + Data: { + totalUsdValue: string + decimal: number + totalUsdValueInHex: string + balances: Record + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'wallets', + transport: walletsTransport, + inputParameters, +}) diff --git a/packages/sources/copper/src/index.ts b/packages/sources/copper/src/index.ts new file mode 100644 index 0000000000..18afa292af --- /dev/null +++ b/packages/sources/copper/src/index.ts @@ -0,0 +1,12 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { wallets } from './endpoint' + +export const adapter = new Adapter({ + name: 'COPPER', + config, + endpoints: [wallets], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/copper/src/transport/utils.ts b/packages/sources/copper/src/transport/utils.ts new file mode 100644 index 0000000000..3fa7234697 --- /dev/null +++ b/packages/sources/copper/src/transport/utils.ts @@ -0,0 +1,97 @@ +import { GroupRunner } from '@chainlink/external-adapter-framework/util/group-runner' +import crypto from 'crypto' +import { ethers } from 'ethers' +import EACAggregatorProxy from '../config/EACAggregatorProxy.json' + +export type OraclePriceType = { + value: bigint + decimal: number +} +export function signRequest( + method: string, + path: string, + body = '', + apiKey: string, + apiSecret: string, + params: Record = {}, +) { + const timestamp = Date.now().toString() + + const qs = buildQuery(params) + const pathWithQs = qs ? `${path}?${qs}` : path + + const preHash = `${timestamp}${method.toUpperCase()}${pathWithQs}${body}` + const signature = crypto.createHmac('sha256', apiSecret).update(preHash, 'utf8').digest('hex') + + return { + Authorization: `ApiKey ${apiKey}`, + 'X-Signature': signature, + 'X-Timestamp': timestamp, + } +} + +function buildQuery(params: Record): string { + return Object.entries(params) + .map( + ([key, val]) => `${encodeURIComponent(key)}=${val}`, // don’t encode commas in values + ) + .join('&') +} + +export function toBigIntBalance(balance: string, decimals: number): bigint { + const [whole, frac = ''] = balance.split('.') + const fracPadded = frac.padEnd(decimals, '0') + const normalized = whole + fracPadded.slice(0, decimals) + return BigInt(normalized || '0') +} + +export function toEvenHex(value: bigint): string { + let hex = value.toString(16) + if (hex.length % 2 !== 0) { + hex = '0' + hex + } + return '0x' + hex +} + +// Wraps a JsonRpcProvider to allow for grouping of requests to limit the +// number of concurrent requests. +// Its lifetime should not be longer than the lifetime of a request to the EA. +export class GroupedProvider { + private readonly runner: GroupRunner + + constructor(private readonly provider: ethers.providers.JsonRpcProvider, groupSize: number) { + this.runner = new GroupRunner(groupSize) + } + + createPriceOracleContract(address: string): GroupedPriceOracleContract { + return new GroupedPriceOracleContract(address, this.provider, this.runner) + } +} + +export class GroupedPriceOracleContract { + private readonly contract: ethers.Contract + + constructor( + address: string, + provider: ethers.providers.JsonRpcProvider, + private readonly runner: GroupRunner, + ) { + this.contract = new ethers.Contract(address, EACAggregatorProxy, provider) + } + + async decimals(): Promise { + return this.runner.run(() => this.contract.decimals()) + } + + async latestRoundData(): Promise { + return this.runner.run(() => this.contract.latestRoundData()) + } + + async getRateFromLatestRoundData(): Promise { + const [[_, answer], decimal] = await Promise.all([this.latestRoundData(), this.decimals()]) + return { + value: answer, + decimal: Number(decimal), + } + } +} diff --git a/packages/sources/copper/src/transport/wallets.ts b/packages/sources/copper/src/transport/wallets.ts new file mode 100644 index 0000000000..366801a0c3 --- /dev/null +++ b/packages/sources/copper/src/transport/wallets.ts @@ -0,0 +1,299 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { calculateHttpRequestKey } from '@chainlink/external-adapter-framework/cache' +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 { BaseEndpointTypes, inputParameters } from '../endpoint/wallets' + +import { ethers } from 'ethers' +import { GroupedProvider, OraclePriceType, signRequest, toBigIntBalance, toEvenHex } from './utils' + +const logger = makeLogger('Copper - Wallets') + +type RequestParams = typeof inputParameters.validated + +const RESULT_DECIMALS = 18 +const path = '/platform/wallets' + +type RequestContext = { + groupedProviders: { + [chainId: string]: GroupedProvider + } +} + +type PriceOraclesTypes = { + token: string + contractAddress: string + chainId: string +} + +export interface WalletResponseSchema { + walletId: string + portfolioId: string + currency: string + mainCurrency: string + balance: string + stakeBalance: string + totalBalance: string + [key: string]: any +} + +export class WalletsTransport extends SubscriptionTransport { + config!: BaseEndpointTypes['Settings'] + endpointName!: string + requester!: Requester + ethProvider!: ethers.providers.JsonRpcProvider + arbProvider!: ethers.providers.JsonRpcProvider + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.config = adapterSettings + this.endpointName = endpointName + this.requester = dependencies.requester + + if (!adapterSettings.ARBITRUM_RPC_URL) { + logger.error('ARBITRUM_RPC_URL is missing') + } else { + this.arbProvider = new ethers.providers.JsonRpcProvider( + adapterSettings.ARBITRUM_RPC_URL, + Number(adapterSettings.ARBITRUM_RPC_CHAIN_ID), + ) + } + + if (!adapterSettings.ETHEREUM_RPC_URL) { + logger.error('ETHEREUM_RPC_URL is missing') + } else { + this.ethProvider = new ethers.providers.JsonRpcProvider( + adapterSettings.ETHEREUM_RPC_URL, + Number(adapterSettings.ETHEREUM_RPC_CHAIN_ID), + ) + } + } + + 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 + const requestContext: RequestContext = { + groupedProviders: {}, + } + try { + response = await this._handleRequest(_context, param, requestContext) + } 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( + config: EndpointContext, + param: RequestParams, + requestContext: RequestContext, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + + const balances = await this.getAggregatedWalletBalance(config, param) + const priceData = await this.getPriceData(config, param, requestContext) + const usdValues = await this.computeUsdValue(balances, priceData) + + // Sum all USD values to get total portfolio value + const totalUsdValue = Object.values(usdValues).reduce((acc, val) => acc + val.value, 0n) + const totalUsdValueInHex = toEvenHex(totalUsdValue) + + const stringifiedBalances = Object.fromEntries( + Object.entries(balances).map(([token, { value, decimals }]) => [ + token, + { + value: value.toString(), + decimals, + }, + ]), + ) + + return { + data: { + totalUsdValue: totalUsdValue.toString(), + decimal: RESULT_DECIMALS, + totalUsdValueInHex: totalUsdValueInHex, + balances: stringifiedBalances, + }, + statusCode: 200, + result: totalUsdValueInHex, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + getGroupedProvider( + context: EndpointContext, + chainId: string, + requestContext: RequestContext, + ): GroupedProvider { + let provider!: ethers.providers.JsonRpcProvider + if (chainId === String(context.adapterSettings.ETHEREUM_RPC_CHAIN_ID)) { + provider = this.ethProvider + } else if (chainId === String(context.adapterSettings.ARBITRUM_RPC_CHAIN_ID)) { + provider = this.arbProvider + } else { + throw new AdapterInputError({ + statusCode: 400, + message: `ChainId ${chainId} not supported.`, + }) + } + + if (!requestContext.groupedProviders[chainId]) { + requestContext.groupedProviders[chainId] = new GroupedProvider( + provider, + context.adapterSettings.GROUP_SIZE, + ) + } + return requestContext.groupedProviders[chainId] + } + + async getWalletBalance( + config: EndpointContext, + param: RequestParams, + ): Promise { + const parameters: Record = {} + + if (param.portfolioId) { + parameters.portfolioId = param.portfolioId + } + if (param.currencies && param.currencies.length > 0) { + parameters.currencies = param.currencies.join(',') + } + + const requestConfig = { + baseURL: config.adapterSettings.API_ENDPOINT, + url: path, + method: 'GET', + data: '', + headers: signRequest( + 'GET', + path, + '', + config.adapterSettings.API_KEY, + config.adapterSettings.API_SECRET, + parameters, + ), + params: parameters, + } + + const result = await this.requester.request( + calculateHttpRequestKey({ + context: { + adapterSettings: this.config, + inputParameters, + endpointName: this.endpointName, + }, + data: {}, + transportName: this.name, + }), + requestConfig, + ) + + return result.response.data + } + + async getAggregatedWalletBalance( + config: EndpointContext, + param: RequestParams, + ): Promise> { + const data = await this.getWalletBalance(config, param) + + const aggregatedBalances: Record = {} + + for (const wallet of data.wallets) { + const curr = wallet.currency + const balanceBigInt = toBigIntBalance(wallet.totalBalance || '0', RESULT_DECIMALS) + + if (!aggregatedBalances[curr]) { + aggregatedBalances[curr] = { value: 0n, decimals: 18 } + } + + aggregatedBalances[curr].value += balanceBigInt + } + + return aggregatedBalances + } + + async getPriceData( + context: EndpointContext, + param: RequestParams, + requestContext: RequestContext, + ): Promise> { + const results: Record = {} + + await Promise.all( + param.priceOracles.map(async (oracle: PriceOraclesTypes) => { + const { token, contractAddress, chainId } = oracle + const groupedProvider = this.getGroupedProvider(context, chainId, requestContext) + const priceOracleContract = groupedProvider.createPriceOracleContract(contractAddress) + + const oraclePriceUSD: OraclePriceType = + await priceOracleContract.getRateFromLatestRoundData() + + results[token] = oraclePriceUSD + }), + ) + + return results + } + + async computeUsdValue( + balances: Record, + prices: Record, + ): Promise> { + const usdValues: Record = {} + + for (const [currency, { value }] of Object.entries(balances)) { + const price = prices[currency]?.value + if (!price) continue + + // normalize price to bigint(18) + const priceBigInt = BigInt(price.toString()) + const priceDecimals = prices[currency]?.decimal ?? 18 + + const normalizedPrice = + priceDecimals < 18 + ? priceBigInt * 10n ** BigInt(18 - priceDecimals) + : priceBigInt / 10n ** BigInt(priceDecimals - 18) + + usdValues[currency] = { + value: (value * normalizedPrice) / 10n ** 18n, + decimals: RESULT_DECIMALS, + } + } + + return usdValues + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const walletsTransport = new WalletsTransport() diff --git a/packages/sources/copper/test-payload.json b/packages/sources/copper/test-payload.json new file mode 100644 index 0000000000..68ea8fbd05 --- /dev/null +++ b/packages/sources/copper/test-payload.json @@ -0,0 +1,34 @@ +{ + "requests": [ + { + "endpoint": "wallets", + "priceOracles": [ + { + "token": "ETH", + "contractAddress": "0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612", + "chainId": "42161" + }, + { + "token": "SOL", + "contractAddress": "0x24ceA4b8ce57cdA5058b924B9B9987992450590c", + "chainId": "42161" + }, + { + "token": "USDC", + "contractAddress": "0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3", + "chainId": "42161" + }, + { + "token": "USDT", + "contractAddress": "0x3f3f5dF88dC9F13eac63DF89EC16ef6e7E25DdE7", + "chainId": "42161" + }, + { + "token": "USTB", + "contractAddress": "0x289B5036cd942e619E1Ee48670F98d214E745AAC", + "chainId": "1" + } + ] + } + ] +} 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 0000000000..f3ba88b730 --- /dev/null +++ b/packages/sources/copper/test/integration/adapter.test.ts @@ -0,0 +1,48 @@ +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.API_SECRET = process.env.API_SECRET ?? 'fake-api-secret' + + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('solstice endpoint', () => { + it('should return success', async () => { + const data = { + portfolioId: 'cme0yn5cu00743b6uvbqj9ysn', + currencies: ['BTC', 'ETH', 'SOL', 'LTC', 'NEAR', 'USDC', 'USDT'], + } + mockResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + 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 0000000000..3b7e58e409 --- /dev/null +++ b/packages/sources/copper/test/integration/fixtures.ts @@ -0,0 +1,22 @@ +import nock from 'nock' + +export const mockResponseSuccess = (): nock.Scope => + nock('https://dataproviderapi.com', { + encodedQueryParams: true, + }) + .get('/cryptocurrency/price') + .query({ + symbol: 'ETH', + convert: 'USD', + }) + .reply(200, () => ({ ETH: { price: 10000 } }), [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ]) + .persist() diff --git a/packages/sources/copper/test/unit/adapter.test.ts b/packages/sources/copper/test/unit/adapter.test.ts new file mode 100644 index 0000000000..c66e5381c9 --- /dev/null +++ b/packages/sources/copper/test/unit/adapter.test.ts @@ -0,0 +1,70 @@ +import { OraclePriceType, toBigIntBalance } from '../../src/transport/utils' +import { WalletsTransport } from '../../src/transport/wallets' + +const RESULT_DECIMALS = 18 + +describe('WalletsTransport.computeUsdValue', () => { + let transport: WalletsTransport + + beforeEach(() => { + transport = new WalletsTransport() + }) + + it('scales 6-decimal price correctly', async () => { + const balances = { + USDC: { value: 1000n * 10n ** 18n, decimals: RESULT_DECIMALS }, // 1000 USDC + } + const prices: Record = { + USDC: { value: BigInt(1_000_000), decimal: 6 }, // 1.0 + } + + const result = await transport.computeUsdValue(balances, prices) + expect(result.USDC.value).toBe(1000n * 10n ** 18n) + }) + + it('scales 8-decimal price correctly', async () => { + const balances = { + BTC: { value: 2n * 10n ** 18n, decimals: RESULT_DECIMALS }, // 2 BTC + } + const prices: Record = { + BTC: { value: BigInt(50_000_000_000_000), decimal: 8 }, // 500,000 + } + + const result = await transport.computeUsdValue(balances, prices) + expect(result.BTC.value).toBe(1_000_000n * 10n ** 18n) // 2 * 500k + }) + + it('handles 18-decimal price correctly', async () => { + const balances = { + ETH: { value: 1n * 10n ** 18n, decimals: RESULT_DECIMALS }, // 1 ETH + } + const prices: Record = { + ETH: { value: 2000n * 10n ** 18n, decimal: 18 }, // 2000 + } + + const result = await transport.computeUsdValue(balances, prices) + expect(result.ETH.value).toBe(2000n * 10n ** 18n) + }) +}) + +describe('WalletsTransport.getAggregatedWalletBalance', () => { + let transport: WalletsTransport + + beforeEach(() => { + transport = new WalletsTransport() + jest.spyOn(transport, 'getWalletBalance').mockResolvedValue({ + wallets: [ + { currency: 'ETH', totalBalance: '1' }, + { currency: 'ETH', totalBalance: '2' }, + { currency: 'USDC', totalBalance: '100' }, + ], + } as any) + }) + + it('aggregates balances by currency', async () => { + const balances = await transport.getAggregatedWalletBalance({} as any, {} as any) + + expect(balances.ETH.value).toBe(toBigIntBalance('3', RESULT_DECIMALS)) + expect(balances.USDC.value).toBe(toBigIntBalance('100', RESULT_DECIMALS)) + }) +}) diff --git a/packages/sources/copper/test/unit/utils.test.ts b/packages/sources/copper/test/unit/utils.test.ts new file mode 100644 index 0000000000..4f0b670e6b --- /dev/null +++ b/packages/sources/copper/test/unit/utils.test.ts @@ -0,0 +1,35 @@ +import { signRequest, toBigIntBalance, toEvenHex } from '../../src/transport/utils' + +describe('utils', () => { + describe('toBigIntBalance', () => { + it('converts decimal string to bigint', () => { + expect(toBigIntBalance('1', 18)).toBe(10n ** 18n) + expect(toBigIntBalance('0.5', 18)).toBe(5n * 10n ** 17n) + }) + + it('handles zero and small decimals', () => { + expect(toBigIntBalance('0', 18)).toBe(0n) + expect(toBigIntBalance('0.000001', 18)).toBe(1000000000000n) + }) + }) + + describe('toEvenHex', () => { + it('pads odd hex strings', () => { + expect(toEvenHex(15n)).toBe('0x0f') + }) + it('returns even hex unchanged', () => { + expect(toEvenHex(16n)).toBe('0x10') + }) + it('handles zero', () => { + expect(toEvenHex(0n)).toBe('0x00') + }) + }) + + describe('signRequest', () => { + it('creates deterministic signature', () => { + const sig1 = signRequest('GET', '/wallets', '', 'apiKey', 'secret', { foo: 'bar' }) + const sig2 = signRequest('GET', '/wallets', '', 'apiKey', 'secret', { foo: 'bar' }) + expect(sig1).toEqual(sig2) + }) + }) +}) diff --git a/packages/sources/copper/tsconfig.json b/packages/sources/copper/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /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 0000000000..e3de28cb5c --- /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 c1849fda3a..19b2796c15 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -278,6 +278,9 @@ { "path": "./sources/coinranking" }, + { + "path": "./sources/copper" + }, { "path": "./sources/covid-tracker" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 4024484497..9f09b7c4c6 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -278,6 +278,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 f15a109d94..6d2b356ac3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3274,6 +3274,23 @@ __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.7.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" + decimal.js: "npm:^10.3.1" + ethers: "npm:5.4.7" + 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"