From b386051bd276b31563f0a3b420aa6e3025b812d8 Mon Sep 17 00:00:00 2001 From: Abhishek Rajput Date: Mon, 4 Dec 2023 19:37:15 +0530 Subject: [PATCH] Feat: Pull balances /w block-height | fetch tokenPrice for native token (#119) * Pull balances|Add opt block height /w fetching native balance * Add commitment in solana getBlockHeight * Refactor appending token balances in usd * Fix minor issue | fix type issue * Bump with beta version * Make sure to compute tokenBalance in bigint * Refactor | Add usd balance for native tokens * Update code for all the chains * Fix type issue * Use number instead of bigint to avoid conversion issues * Fix cache invalidation of token prices * Accept blockHeightByChain as option in pullBalances method * Sync client wallet manager with wallet-manager changes * Fix Injecting native tokens logic * Lift priceFeedOptions as walletOptions * Optimize price feed instance instantiation * Bump version * Add comment for wallet balance config * Add chainName in balances schema * Bump version * minor update * publish beta version for integration * Revert mandatory blockHeight * Expose blockHeightPerChain method|remove chainName from balance * Bump package to latest version w.r.t main branch * Resolve PR review comment * Add coingeckoIds for cosm-wasm chains * Bump version --- examples/wallet-manager.ts | 63 ++++- package-lock.json | 4 +- package.json | 2 +- src/balances/evm/index.ts | 5 +- src/balances/solana.ts | 6 +- src/balances/sui.ts | 12 +- src/chain-wallet-manager.ts | 57 ++-- src/grpc/client.ts | 246 +++++++++++++---- src/i-wallet-manager.ts | 8 +- src/index.ts | 2 +- src/price-assistant/helper.ts | 59 +++- src/price-assistant/ondemand-price-feed.ts | 115 ++++---- src/price-assistant/price-feed.ts | 15 +- src/price-assistant/scheduled-price-feed.ts | 50 ++-- .../supported-tokens.config.ts | 251 +++++++++++++++++- src/prometheus-exporter.ts | 6 +- src/wallet-manager.ts | 183 +++++++++++-- src/wallets/base-wallet.ts | 12 +- src/wallets/cosmos/index.ts | 1 + src/wallets/evm/index.ts | 40 +-- src/wallets/index.ts | 10 +- src/wallets/solana/index.ts | 102 +++---- src/wallets/solana/solana.config.ts | 1 + src/wallets/sui/index.ts | 87 +++--- test/wallets/sui/sui.test.ts | 1 - 25 files changed, 999 insertions(+), 339 deletions(-) diff --git a/examples/wallet-manager.ts b/examples/wallet-manager.ts index 77ec29b..24e7918 100644 --- a/examples/wallet-manager.ts +++ b/examples/wallet-manager.ts @@ -18,29 +18,42 @@ const allChainWallets: WalletManagerFullConfig['config'] = { tokens: ["WBTC"] } ], - priceFeedConfig: { + walletBalanceConfig: { + enabled: true, scheduled: { - enabled: true - }, + enabled: false, + } + }, + priceFeedConfig: { supportedTokens: [{ chainId: 2, + chainName: "ethereum", tokenContract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", coingeckoId: "usd-coin", symbol: "USDC" }, { chainId: 2, + chainName: "ethereum", tokenContract: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", coingeckoId: "wrapped-bitcoin", symbol: "WBTC" }], - enabled: true } }, solana: { wallets: [ { address: "6VnfVsLdLwNuuCmooLTziQ99PFXZ5vc3yyqyb9tMDhhw", tokens: ['usdc'] }, ], + walletBalanceConfig: { + enabled: true, + scheduled: { + enabled: false, + } + }, + priceFeedConfig: { + supportedTokens: [] + } }, sui: { rebalance: { @@ -62,14 +75,20 @@ const allChainWallets: WalletManagerFullConfig['config'] = { tokens: ['USDC', 'USDT'] }, ], + walletBalanceConfig: { + enabled: true, + scheduled: { + enabled: false, + } + }, priceFeedConfig: { supportedTokens: [{ chainId: 21, + chainName: "sui", tokenContract: "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf", coingeckoId: "usd-coin", symbol: "USDC" }], - enabled: false, } }, klatyn: { @@ -88,7 +107,16 @@ const allChainWallets: WalletManagerFullConfig['config'] = { address: "0x8d0d970225597085A59ADCcd7032113226C0419d", tokens: [] } - ] + ], + walletBalanceConfig: { + enabled: true, + scheduled: { + enabled: false, + } + }, + priceFeedConfig: { + supportedTokens: [] + } } } @@ -103,15 +131,22 @@ export const manager = buildWalletManager({ enabled: true, serve: true, port: 9091, + }, + priceFeedOptions: { + enabled: true, + scheduled: { + enabled: false, + } } } }); - -// Note: Below code needs wallet's private key to be set in config abopve for aquiring wallet -// manager.withWallet('ethereum', async (wallet) => { -// console.log('Address', wallet.address); -// console.log('Block height', wallet.walletToolbox.getBlockHeight()); -// console.log('Native balances', await wallet.walletToolbox.pullNativeBalance(wallet.address)); -// console.log('Token balances', await wallet.walletToolbox.pullTokenBalances(wallet.address, ['0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'])); -// }) +(async () => { + try { + console.time('balances') + const balances = await manager.pullBalancesAtBlockHeight(); + console.timeLog('balances', JSON.stringify(balances)) + } catch (err) { + console.error('Failed to pullBalancesAtBlockHeight', err); + } +})(); diff --git a/package-lock.json b/package-lock.json index 5fc590b..cdf3558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@xlabs-xyz/wallet-monitor", - "version": "0.2.25", + "version": "0.2.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@xlabs-xyz/wallet-monitor", - "version": "0.2.25", + "version": "0.2.26", "license": "MIT", "dependencies": { "@cosmjs/proto-signing": "^0.31.1", diff --git a/package.json b/package.json index d86e70d..edcbe39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xlabs-xyz/wallet-monitor", - "version": "0.2.25", + "version": "0.2.26", "description": "A set of utilities to monitor blockchain wallets and react to them", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/balances/evm/index.ts b/src/balances/evm/index.ts index 0feabb5..c83ad4d 100644 --- a/src/balances/evm/index.ts +++ b/src/balances/evm/index.ts @@ -42,9 +42,10 @@ export async function pullEvmTokenBalance( export async function pullEvmNativeBalance( provider: ethers.providers.JsonRpcProvider, address: string, + blockHeight?: number, ): Promise{ - const weiAmount = await provider.getBalance(address); - + const weiAmount = await provider.getBalance(address, blockHeight); + return { isNative: true, rawBalance: weiAmount.toString(), diff --git a/src/balances/solana.ts b/src/balances/solana.ts index f7ec395..29deeb0 100644 --- a/src/balances/solana.ts +++ b/src/balances/solana.ts @@ -1,12 +1,14 @@ import bs58 from 'bs58'; import { Connection, PublicKey, Keypair } from "@solana/web3.js"; import { Balance } from "./index"; +import { SOLANA_DEFAULT_COMMITMENT } from '../wallets/solana/solana.config'; export async function pullSolanaNativeBalance( connection: Connection, - address: string + address: string, ): Promise { - const lamports = await connection.getBalance(new PublicKey(address)) + // solana web3.js doesn't support passing exact slot(block number) only minSlot, while fetching balance + const lamports = await connection.getBalance(new PublicKey(address), SOLANA_DEFAULT_COMMITMENT) return { isNative: true, diff --git a/src/balances/sui.ts b/src/balances/sui.ts index e6b89d1..d62a5b1 100644 --- a/src/balances/sui.ts +++ b/src/balances/sui.ts @@ -18,6 +18,8 @@ export interface SuiTransactionDetails { export async function pullSuiNativeBalance(conn: Connection, address: string): Promise { const provider = new JsonRpcProvider(conn); + // mysten SDK doesn't support passing checkpoint (block number) to getBalance + // https://github.com/MystenLabs/sui/issues/14137 const rawBalance = await provider.getBalance({ owner: address }); return { @@ -54,7 +56,15 @@ export async function pullSuiTokenData( export async function pullSuiTokenBalances( conn: Connection, address: string -): Promise { +): Promise<{ + coinType: string; + coinObjectCount: number; + totalBalance: string; + lockedBalance: { + number?: number | undefined; + epochId?: number | undefined; + }; +}[]> { const provider = new JsonRpcProvider(conn); return provider.getAllBalances({ owner: address }); diff --git a/src/chain-wallet-manager.ts b/src/chain-wallet-manager.ts index ceb9bf9..3309aec 100644 --- a/src/chain-wallet-manager.ts +++ b/src/chain-wallet-manager.ts @@ -17,13 +17,7 @@ import { CosmosProvider, CosmosWallet } from "./wallets/cosmos"; import { EVMProvider, EVMWallet } from "./wallets/evm"; import { SolanaProvider, SolanaWallet } from "./wallets/solana"; import { SuiProvider, SuiWallet } from "./wallets/sui"; -import { - PriceFeed, - WalletPriceFeedConfig, - WalletRebalancingConfig, -} from "./wallet-manager"; -import { ScheduledPriceFeed } from "./price-assistant/scheduled-price-feed"; -import { OnDemandPriceFeed } from "./price-assistant/ondemand-price-feed"; +import { PriceFeed, WalletBalanceConfig, WalletPriceFeedConfig, WalletRebalancingConfig } from "./wallet-manager"; const DEFAULT_POLL_INTERVAL = 60 * 1000; const DEFAULT_REBALANCE_INTERVAL = 60 * 1000; @@ -42,6 +36,7 @@ export type ChainWalletManagerOptions = { maxGasPrice?: number; gasLimit?: number; }; + walletBalanceConfig: WalletBalanceConfig; priceFeedConfig: WalletPriceFeedConfig; balancePollInterval?: number; walletOptions?: WalletOptions; @@ -85,7 +80,11 @@ export class ChainWalletManager { public walletToolbox: Wallet; protected priceFeed?: PriceFeed; - constructor(options: any, private wallets: WalletConfig[]) { + constructor( + options: any, + private wallets: WalletConfig[], + priceFeedInstance?: PriceFeed, + ) { this.validateOptions(options); this.options = this.parseOptions(options); @@ -94,15 +93,7 @@ export class ChainWalletManager { } this.logger = createLogger(this.options.logger); - const { priceFeedConfig } = this.options; - - if (priceFeedConfig?.enabled) { - if (priceFeedConfig?.scheduled?.enabled) { - this.priceFeed = new ScheduledPriceFeed(priceFeedConfig, this.logger); - } else { - this.priceFeed = new OnDemandPriceFeed(priceFeedConfig, this.logger); - } - } + this.priceFeed = priceFeedInstance this.walletToolbox = createWalletToolbox( options.network, @@ -274,9 +265,20 @@ export class ChainWalletManager { ); this.priceFeed?.start(); } - this.interval = setInterval(async () => { - await this.refreshBalances(); - }, this.options.balancePollInterval); + + if (this.options.walletBalanceConfig?.enabled) { + if (this.options.walletBalanceConfig?.scheduled?.enabled) { + this.interval = setInterval(async () => { + await this.refreshBalances(); + }, this.options.walletBalanceConfig?.scheduled?.interval ?? this.options.balancePollInterval); + } else { + // no op: Don't poll balances, fetch on demand instead + } + } else { + this.interval = setInterval(async () => { + await this.refreshBalances(); + }, this.options.balancePollInterval); + } if (this.options.rebalance.enabled) { this.logger.info( @@ -419,4 +421,19 @@ export class ChainWalletManager { this.availableWalletsByChainName[chainName], ); } + + public getBlockHeight () { + return this.walletToolbox.getBlockHeight(); + } + /** Pull balances on demand */ + public async pullBalances () { + const balances = await this.walletToolbox.pullBalances(); + return this.mapBalances(balances); + } + + /** Pull balances on demand with block height */ + public async pullBalancesAtBlockHeight(blockHeight: number) { + const balances = await this.walletToolbox.pullBalancesAtBlockHeight(blockHeight); + return this.mapBalances(balances); + } } diff --git a/src/grpc/client.ts b/src/grpc/client.ts index bc4fdff..f3eccb2 100644 --- a/src/grpc/client.ts +++ b/src/grpc/client.ts @@ -1,70 +1,200 @@ -import {ChainWalletManager, WalletExecuteOptions, WithWalletExecutor} from "../chain-wallet-manager"; -import {ChainName, isChain} from "../wallets"; -import {getDefaultNetwork, WalletManagerConfig, WalletManagerOptions} from "../wallet-manager"; -import winston from "winston"; -import {createLogger} from "../utils"; -import {IClientWalletManager} from "../i-wallet-manager"; -import {createChannel, createClient} from "nice-grpc"; import { - WalletManagerGRPCServiceDefinition -} from "./out/wallet-manager-grpc-service"; + ChainWalletManager, + WalletBalancesByAddress, + WalletExecuteOptions, + WithWalletExecutor, +} from "../chain-wallet-manager"; +import { ChainName, isChain } from "../wallets"; +import { + getDefaultNetwork, + WalletManagerConfig, + WalletManagerOptions, +} from "../wallet-manager"; +import winston from "winston"; +import { createLogger, mapConcurrent } from "../utils"; +import { IClientWalletManager } from "../i-wallet-manager"; +import { createChannel, createClient } from "nice-grpc"; +import { WalletManagerGRPCServiceDefinition } from "./out/wallet-manager-grpc-service"; export class ClientWalletManager implements IClientWalletManager { - private walletManagerGRPCChannel; - private walletManagerGRPCClient; - private managers; - - protected logger: winston.Logger; - - constructor(private host: string, private port: number, config: WalletManagerConfig, options?: WalletManagerOptions) { - this.logger = createLogger(options?.logger, options?.logLevel, { label: 'WalletManager' }); - this.managers = {} as Record; - - this.walletManagerGRPCChannel = createChannel(`${host}:${port}`) - this.walletManagerGRPCClient = createClient(WalletManagerGRPCServiceDefinition, this.walletManagerGRPCChannel) - - // Constructing a record of manager for the only purpose of extracting the appropriate provider and private key - // to bundle together with the lock acquired from the grpc service. - for (const [chainName, chainConfig] of Object.entries(config)) { - if (!isChain(chainName)) throw new Error(`Invalid chain name: ${chainName}`); - const network = chainConfig.network || getDefaultNetwork(chainName); - - const chainManagerConfig = { - network, - chainName, - logger: this.logger, - rebalance: {...chainConfig.rebalance, enabled: false}, - walletOptions: chainConfig.chainConfig, - }; - - this.managers[chainName] = new ChainWalletManager(chainManagerConfig, chainConfig.wallets); - } + private walletManagerGRPCChannel; + private walletManagerGRPCClient; + private managers; + + protected logger: winston.Logger; + + constructor( + private host: string, + private port: number, + config: WalletManagerConfig, + options?: WalletManagerOptions, + ) { + this.logger = createLogger(options?.logger, options?.logLevel, { + label: "WalletManager", + }); + this.managers = {} as Record; + + this.walletManagerGRPCChannel = createChannel(`${host}:${port}`); + this.walletManagerGRPCClient = createClient( + WalletManagerGRPCServiceDefinition, + this.walletManagerGRPCChannel, + ); + + // Constructing a record of manager for the only purpose of extracting the appropriate provider and private key + // to bundle together with the lock acquired from the grpc service. + for (const [chainName, chainConfig] of Object.entries(config)) { + if (!isChain(chainName)) + throw new Error(`Invalid chain name: ${chainName}`); + const network = chainConfig.network || getDefaultNetwork(chainName); + + const chainManagerConfig = { + network, + chainName, + logger: this.logger, + rebalance: { ...chainConfig.rebalance, enabled: false }, + walletOptions: chainConfig.chainConfig, + }; + + this.managers[chainName] = new ChainWalletManager( + chainManagerConfig, + chainConfig.wallets, + ); + } + } + + public async withWallet( + chainName: ChainName, + fn: WithWalletExecutor, + opts?: WalletExecuteOptions, + ): Promise { + const chainManager = this.managers[chainName]; + if (!chainManager) + throw new Error(`No wallets configured for chain: ${chainName}`); + + const { address: acquiredAddress } = + await this.walletManagerGRPCClient.acquireLock({ + chainName, + address: opts?.address, + leaseTimeout: opts?.leaseTimeout, + acquireTimeout: opts?.waitToAcquireTimeout, + }); + + // FIXME + // Dirty solution. We are doing as little work as possible to get the same expected WalletInterface after + // locking. + // Unfortunately this is not only inefficient (we lock 2 times) but also nonsense because, if we successfully + // locked a particular address in the wallet manager service, it's impossible that we have it locked here. + // Nevertheless, this should allow us to just make it work right now. + const acquiredWallet = await this.managers[chainName].acquireLock({ + ...opts, + address: acquiredAddress, + }); + + try { + return await fn(acquiredWallet); + } catch (error) { + this.logger.error( + "The workflow function failed to run within the context of the acquired wallet.", + error, + ); + throw error; + } finally { + await Promise.all([ + this.walletManagerGRPCClient.releaseLock({ + chainName, + address: acquiredAddress, + }), + this.managers[chainName].releaseLock(acquiredAddress), + ]); } + } + + public getBlockHeight(chainName: ChainName): Promise { + const manager = this.managers[chainName]; + if (!manager) + throw new Error(`No wallets configured for chain: ${chainName}`); + + return manager.getBlockHeight(); + } + + private async balanceHandlerMapper(method: "getBalances" | "pullBalances") { + const balances: Record = {}; - public async withWallet(chainName: ChainName, fn: WithWalletExecutor, opts?: WalletExecuteOptions): Promise { - const chainManager = this.managers[chainName]; - if (!chainManager) throw new Error(`No wallets configured for chain: ${chainName}`); + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { + const balancesByChain = await manager[method](); + balances[chainName] = balancesByChain; + }, + ); - const { address: acquiredAddress } = await this.walletManagerGRPCClient.acquireLock({chainName, address: opts?.address, leaseTimeout: opts?.leaseTimeout, acquireTimeout: opts?.waitToAcquireTimeout }) + return balances; + } - // FIXME - // Dirty solution. We are doing as little work as possible to get the same expected WalletInterface after - // locking. - // Unfortunately this is not only inefficient (we lock 2 times) but also nonsense because, if we successfully - // locked a particular address in the wallet manager service, it's impossible that we have it locked here. - // Nevertheless, this should allow us to just make it work right now. - const acquiredWallet = await this.managers[chainName].acquireLock({...opts, address: acquiredAddress}); + // PullBalances doesn't need balances to be refreshed in the background + public async pullBalances(): Promise< + Record + > { + return await this.balanceHandlerMapper("pullBalances"); + } + private validateBlockHeightByChain( + blockHeightByChain: Record, + ) { + for (const chain in blockHeightByChain) { + const manager = this.managers[chain as ChainName]; + if (!manager) + throw new Error(`No wallets configured for chain: ${chain}`); + } + } + + public async getBlockHeightForAllSupportedChains(): Promise< + Record + > { + // Required concurrency is the number of chains as we want to fetch the block height for all chains in parallel + // to be precise about the block height at the time of fetching balances + let blockHeightPerChain = {} as Record; + const requiredConcurrency = Object.keys(this.managers).length; + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { try { - return await fn(acquiredWallet); - } catch (error) { - this.logger.error('The workflow function failed to run within the context of the acquired wallet.', error); - throw error; - } finally { - await Promise.all([ - this.walletManagerGRPCClient.releaseLock({chainName, address: acquiredAddress}), - this.managers[chainName].releaseLock(acquiredAddress) - ]); + const blockHeight = await manager.getBlockHeight(); + blockHeightPerChain = { + ...blockHeightPerChain, + [chainName]: blockHeight, + } as Record; + } catch (err) { + throw new Error(`No block height found for chain: ${chainName}, error: ${err}`); } + }, + requiredConcurrency, + ); + return blockHeightPerChain; + } + + // pullBalancesAtBlockHeight doesn't need balances to be refreshed in the background + public async pullBalancesAtBlockHeight( + blockHeightByChain?: Record, + ): Promise> { + const balances: Record = {}; + if (blockHeightByChain) { + this.validateBlockHeightByChain(blockHeightByChain); } + + const blockHeightPerChain = blockHeightByChain ?? await this.getBlockHeightForAllSupportedChains(); + + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { + const blockHeight = blockHeightPerChain[chainName as ChainName]; + const balancesByChain = await manager.pullBalancesAtBlockHeight( + blockHeight, + ); + balances[chainName] = balancesByChain; + }, + ); + + return balances; + } } diff --git a/src/i-wallet-manager.ts b/src/i-wallet-manager.ts index 55b1d6f..09e26db 100644 --- a/src/i-wallet-manager.ts +++ b/src/i-wallet-manager.ts @@ -1,4 +1,4 @@ -import {Providers, WalletExecuteOptions, WalletInterface, Wallets, WithWalletExecutor} from "./chain-wallet-manager"; +import {Providers, WalletBalancesByAddress, WalletExecuteOptions, WalletInterface, Wallets, WithWalletExecutor} from "./chain-wallet-manager"; import { ChainName } from "./wallets"; /* @@ -16,7 +16,11 @@ import { ChainName } from "./wallets"; */ interface IWMContextManagedLocks { - withWallet

(chainName: ChainName, fn: WithWalletExecutor, opts?: WalletExecuteOptions): Promise + withWallet

(chainName: ChainName, fn: WithWalletExecutor, opts?: WalletExecuteOptions): Promise; + pullBalances: () => Promise>; + pullBalancesAtBlockHeight: (blockHeightByChain?: Record) => Promise>; + getBlockHeight: (chainName: ChainName) => Promise; + getBlockHeightForAllSupportedChains: () => Promise>; } interface IWMBareLocks { acquireLock(chainName: ChainName, opts?: WalletExecuteOptions): Promise diff --git a/src/index.ts b/src/index.ts index 1a08a3f..9e03a0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { export type WalletBalancesByAddress = WBBA; export type WalletInterface = WI; -export type {ChainName} from "./wallets"; +export type {ChainName, Environment} from "./wallets"; export {isEvmChain, isSolanaChain, isSuiChain} from './wallets'; diff --git a/src/price-assistant/helper.ts b/src/price-assistant/helper.ts index 2089191..7011668 100644 --- a/src/price-assistant/helper.ts +++ b/src/price-assistant/helper.ts @@ -1,29 +1,60 @@ import axios from "axios"; import { inspect } from "util"; import { Logger } from "winston"; -import { CoinGeckoIds } from "./supported-tokens.config"; +import { CoinGeckoIds, supportedNativeTokensByEnv } from "./supported-tokens.config"; +import { Environment } from "../wallets"; +import { TokenInfo, WalletManagerConfig } from "../wallet-manager"; export type CoinGeckoPriceDict = Partial<{ - [k in CoinGeckoIds]: { - usd: number; - }; + [k in CoinGeckoIds]: { + usd: number; + }; }>; /** * @param tokens - array of coingecko ids for tokens */ -export async function getCoingeckoPrices(tokens: string[] | string, logger: Logger): Promise { - tokens = typeof tokens === "string" ? tokens : tokens.join(","); - const response = await axios.get(`https://api.coingecko.com/api/v3/simple/price?ids=${tokens}&vs_currencies=usd`, { +export async function getCoingeckoPrices( + tokens: string[] | string, + logger: Logger, +): Promise { + const tokensToProcess = + typeof tokens === "string" ? tokens : tokens.join(","); + const response = await axios.get( + `https://api.coingecko.com/api/v3/simple/price?ids=${tokensToProcess}&vs_currencies=usd`, + { headers: { Accept: "application/json", }, - }); + }, + ); + + if (response.status != 200) { + logger.warn( + `Failed to get CoinGecko Prices. Response: ${inspect(response)}`, + ); + throw new Error(`HTTP status != 200. Original Status: ${response.status}`); + } + + return response.data; +} + +export function preparePriceFeedConfig (walletConfig: WalletManagerConfig, network?: string) { + const priceFeedSupportedTokens = Object.values(walletConfig).reduce((acc, chainConfig) => { + if (!chainConfig.priceFeedConfig?.supportedTokens) return acc; + return [...acc, ...chainConfig.priceFeedConfig.supportedTokens]; + }, [] as TokenInfo[]); - if (response.status != 200) { - logger.warn(`Failed to get CoinGecko Prices. Response: ${inspect(response)}`); - throw new Error(`HTTP status != 200. Original Status: ${response.status}`); + // Inject native token into price feed config, if enabled + // Note: It is safe to use "mainnet" fallback here, because we are only using native token's coingeckoId + const environment: Environment = network ? network as Environment : Environment.MAINNET; + const nativeTokens = supportedNativeTokensByEnv[environment]; + + for (const nativeToken of nativeTokens) { + const isNativeTokenDefined = priceFeedSupportedTokens.find(token => token.coingeckoId === nativeToken.coingeckoId); + if (!isNativeTokenDefined) { + priceFeedSupportedTokens.push(nativeToken); } - - return response.data; - } \ No newline at end of file + } + return priceFeedSupportedTokens; +} \ No newline at end of file diff --git a/src/price-assistant/ondemand-price-feed.ts b/src/price-assistant/ondemand-price-feed.ts index 4ac95bf..9f85f47 100644 --- a/src/price-assistant/ondemand-price-feed.ts +++ b/src/price-assistant/ondemand-price-feed.ts @@ -6,29 +6,32 @@ import { getCoingeckoPrices } from "./helper"; import { CoinGeckoIds } from "./supported-tokens.config"; import { PriceFeed, TokenPriceData } from "./price-feed"; -const DEFAULT_PRICE_PRECISION = 8; const DEFAULT_TOKEN_PRICE_RETENSION_TIME = 5 * 1000; // 5 seconds /** * OnDemandPriceFeed is a price feed that fetches token prices from coingecko on-demand */ -export class OnDemandPriceFeed extends PriceFeed{ +export class OnDemandPriceFeed extends PriceFeed { // here cache key is tokenContractAddress - private cache = new TimeLimitedCache(); + private cache = new TimeLimitedCache(); supportedTokens: TokenInfo[]; tokenPriceGauge?: Gauge; - protected pricePrecision: number; - protected logger: Logger; + private tokenContractToCoingeckoId: Record = {}; constructor( priceAssistantConfig: WalletPriceFeedConfig, logger: Logger, registry?: Registry, ) { - super("ONDEMAND_TOKEN_PRICE", logger, registry, undefined, priceAssistantConfig.pricePrecision) - const { supportedTokens, pricePrecision } = priceAssistantConfig; - this.supportedTokens = supportedTokens; - this.pricePrecision = pricePrecision || DEFAULT_PRICE_PRECISION; - this.logger = logger; + super("ONDEMAND_TOKEN_PRICE", logger, registry, undefined); + this.supportedTokens = priceAssistantConfig.supportedTokens; + + this.tokenContractToCoingeckoId = this.supportedTokens.reduce( + (acc, token) => { + acc[token.tokenContract] = token.coingeckoId as CoinGeckoIds; + return acc; + }, + {} as Record, + ); if (registry) { this.tokenPriceGauge = new Gauge({ @@ -40,68 +43,72 @@ export class OnDemandPriceFeed extends PriceFeed{ } } - start () { + start() { // no op } - stop () { + stop() { // no op } - async update () { - // no op + public getCoinGeckoId(tokenContract: string): CoinGeckoIds | undefined { + return this.tokenContractToCoingeckoId[tokenContract]; } - protected async get (tokenContract: string): Promise { - const cachedPrice = this.cache.get(tokenContract); - if (cachedPrice) { - return cachedPrice; - } - const tokenPrices = await this.pullTokenPrices([tokenContract]); - return tokenPrices[tokenContract]; + protected get(coingeckoId: string): number | undefined { + return this.cache.get(coingeckoId as CoinGeckoIds); } - public async pullTokenPrices(tokens: string[]): Promise { + async pullTokenPrices() { const coingekoTokens = []; - const priceDict = {} as TokenPriceData; - for (const token of tokens) { - const supportedToken = this.supportedTokens.find((supportedToken) => supportedToken.tokenContract === token); - if (!supportedToken) { - this.logger.error(`Token ${token} not supported`); - throw new Error(`Token ${token} not supported`); - } + const priceDict: TokenPriceData = {}; + for (const token of this.supportedTokens) { + const { coingeckoId } = token; - // Check if we already have the price for this token - const cachedPrice = this.cache.get(token); - if (cachedPrice) { - priceDict[token] = cachedPrice - continue; - } - coingekoTokens.push(supportedToken); + // Check if we already have the price for this token + const cachedPrice = this.cache.get(coingeckoId); + if (cachedPrice) { + priceDict[coingeckoId] = cachedPrice; + continue; + } + coingekoTokens.push(token); } - const coingekoTokenIds = coingekoTokens.map(token => token.coingeckoId) - // If we don't have the price, fetch it from an external API - const coingeckoData = await getCoingeckoPrices(coingekoTokenIds, this.logger); + if (coingekoTokens.length === 0) { + // All the cached tokens price are already available and valid + return priceDict; + } - for (const token of coingekoTokens) { - const {symbol, coingeckoId, tokenContract} = token; + const coingekoTokenIds = coingekoTokens.map(token => token.coingeckoId); + const coingeckoData = await getCoingeckoPrices( + coingekoTokenIds, + this.logger, + ); + for (const token of this.supportedTokens) { + const { coingeckoId, symbol } = token; - if (!(coingeckoId in coingeckoData)) { - this.logger.warn(`coingecko: ${coingeckoId} not found in coingecko response data`); - continue; - } + if (!(coingeckoId in coingeckoData)) { + this.logger.warn( + `Token ${symbol} (coingecko: ${coingeckoId}) not found in coingecko response data`, + ); + continue; + } - const tokenPrice = coingeckoData?.[coingeckoId as CoinGeckoIds]?.usd; - if (tokenPrice) { - const preciseTokenPrice = BigInt(Math.round(tokenPrice * 10 ** this.pricePrecision)); - // Token Price is stored by token contract address - this.cache.set(tokenContract, preciseTokenPrice, DEFAULT_TOKEN_PRICE_RETENSION_TIME); - priceDict[tokenContract] = preciseTokenPrice; - this.tokenPriceGauge?.labels({ symbol }).set(Number(tokenPrice)); - } + const tokenPrice = coingeckoData?.[coingeckoId]?.usd; + if (tokenPrice) { + this.cache.set( + coingeckoId, + tokenPrice, + DEFAULT_TOKEN_PRICE_RETENSION_TIME, + ); + priceDict[coingeckoId] = tokenPrice; + this.tokenPriceGauge?.labels({ symbol }).set(tokenPrice); + } } - return priceDict; } + + async update() { + // no op + } } diff --git a/src/price-assistant/price-feed.ts b/src/price-assistant/price-feed.ts index ec3d943..34f3db2 100644 --- a/src/price-assistant/price-feed.ts +++ b/src/price-assistant/price-feed.ts @@ -3,9 +3,8 @@ import { Logger } from "winston"; import { printError } from "../utils"; const DEFAULT_FEED_INTERVAL = 10_000; -const DEFAULT_PRICE_PRECISION = 8; -export type TokenPriceData = Partial>; +export type TokenPriceData = Partial>; export abstract class PriceFeed { private name: string; @@ -13,23 +12,21 @@ export abstract class PriceFeed { private locked: boolean; private runIntervalMs: number; private metrics?: BasePriceFeedMetrics; - protected pricePrecision: number; protected logger: Logger; - constructor(name: string, logger: Logger, registry?: Registry, runIntervalMs?: number, pricePrecision?: number) { + constructor(name: string, logger: Logger, registry?: Registry, runIntervalMs?: number) { this.name = name; this.logger = logger; this.locked = false; this.runIntervalMs = runIntervalMs || DEFAULT_FEED_INTERVAL; if (registry) this.metrics = this.initMetrics(registry); - this.pricePrecision = pricePrecision || DEFAULT_PRICE_PRECISION; } protected abstract update(): Promise; - protected abstract get(key: K): Promise; + protected abstract get(key: K): V; - public abstract pullTokenPrices (tokens: string[]): Promise; + public abstract pullTokenPrices (): Promise; public start(): void { this.interval = setInterval(() => this.run(), this.runIntervalMs); @@ -40,9 +37,9 @@ export abstract class PriceFeed { clearInterval(this.interval); } - public async getKey(key: K): Promise { + public getKey(key: K): V { const result = this.get(key); - if (result === undefined || result === null) throw new Error(`Key Not Found: ${key}`); + if (result === undefined || result === null) this.logger.error(`PriceFeed Key Not Found: ${key}`); return result; } diff --git a/src/price-assistant/scheduled-price-feed.ts b/src/price-assistant/scheduled-price-feed.ts index db72b8e..ea8c195 100644 --- a/src/price-assistant/scheduled-price-feed.ts +++ b/src/price-assistant/scheduled-price-feed.ts @@ -1,21 +1,30 @@ import { Gauge, Registry } from "prom-client"; import { Logger } from "winston"; import { PriceFeed, TokenPriceData } from "./price-feed"; -import { TokenInfo, WalletPriceFeedConfig } from "../wallet-manager"; +import { TokenInfo, WalletPriceFeedConfig, WalletPriceFeedOptions } from "../wallet-manager"; import { getCoingeckoPrices } from "./helper"; +import { inspect } from "util"; +import { CoinGeckoIds } from "./supported-tokens.config"; /** * ScheduledPriceFeed is a price feed that periodically fetches token prices from coingecko */ -export class ScheduledPriceFeed extends PriceFeed { +export class ScheduledPriceFeed extends PriceFeed { private data = {} as TokenPriceData; supportedTokens: TokenInfo[]; tokenPriceGauge?: Gauge; + private tokenContractToCoingeckoId: Record = {}; - constructor(priceFeedConfig: WalletPriceFeedConfig, logger: Logger, registry?: Registry) { - const {scheduled, supportedTokens, pricePrecision} = priceFeedConfig; - super("SCHEDULED_TOKEN_PRICE", logger, registry, scheduled?.interval, pricePrecision); + constructor(priceFeedConfig: WalletPriceFeedConfig & WalletPriceFeedOptions, logger: Logger, registry?: Registry) { + const {scheduled, supportedTokens} = priceFeedConfig; + super("SCHEDULED_TOKEN_PRICE", logger, registry, scheduled?.interval); this.supportedTokens = supportedTokens; + + this.tokenContractToCoingeckoId = supportedTokens.reduce((acc, token) => { + acc[token.tokenContract] = token.coingeckoId as CoinGeckoIds; + return acc; + }, {} as Record); + if (registry) { this.tokenPriceGauge = new Gauge({ name: "token_usd_price", @@ -26,19 +35,27 @@ export class ScheduledPriceFeed extends PriceFeed { } } - public async pullTokenPrices(tokens: string[]): Promise { - const tokenPrices = {} as TokenPriceData; - for await (const token of tokens) { - tokenPrices[token] = await this.get(token); + public getCoinGeckoId (tokenContract: string): CoinGeckoIds | undefined { + return this.tokenContractToCoingeckoId[tokenContract]; + } + + async pullTokenPrices (): Promise { + const priceDict: TokenPriceData = {}; + for (const token of this.supportedTokens) { + const { coingeckoId } = token; + const tokenPrice = this.get(coingeckoId); + if (tokenPrice) { + priceDict[coingeckoId] = tokenPrice; + } } - return tokenPrices; + return priceDict } async update() { const coingekoTokenIds = this.supportedTokens.map((token) => token.coingeckoId); const coingeckoData = await getCoingeckoPrices(coingekoTokenIds, this.logger); for (const token of this.supportedTokens) { - const { coingeckoId, symbol, tokenContract } = token; + const { coingeckoId, symbol } = token; if (!(coingeckoId in coingeckoData)) { this.logger.warn(`Token ${symbol} (coingecko: ${coingeckoId}) not found in coingecko response data`); @@ -47,16 +64,15 @@ export class ScheduledPriceFeed extends PriceFeed { const tokenPrice = coingeckoData?.[coingeckoId]?.usd; if (tokenPrice) { - // Token Price is stored by token contract address - this.data[tokenContract] = BigInt(Math.round(tokenPrice * 10 ** this.pricePrecision)); - this.tokenPriceGauge?.labels({ symbol }).set(Number(tokenPrice)); + this.data[coingeckoId] = tokenPrice; + this.tokenPriceGauge?.labels({ symbol }).set(tokenPrice); } } - // this.logger.debug(`Updated price feed token prices: ${inspect(this.data)}`); + this.logger.debug(`Updated price feed token prices: ${inspect(this.data)}`); } - protected async get(tokenContract: string): Promise { - return this.data[tokenContract]; + protected get(coingeckoId: string): number | undefined { + return this.data[coingeckoId]; } } diff --git a/src/price-assistant/supported-tokens.config.ts b/src/price-assistant/supported-tokens.config.ts index df2e308..298c0f5 100644 --- a/src/price-assistant/supported-tokens.config.ts +++ b/src/price-assistant/supported-tokens.config.ts @@ -1,11 +1,8 @@ import { z } from "zod"; +import { ChainName, Environment } from "../wallets"; +import { TokenInfo } from "../wallet-manager"; -export declare enum Environment { - MAINNET = "mainnet", - TESTNET = "testnet", - DEVNET = "devnet", -} - +// get all coingeckoIds from here: https://api.coingecko.com/api/v3/coins/list export const CoinGeckoIdsSchema = z .union([ z.literal("solana"), @@ -21,6 +18,248 @@ export const CoinGeckoIdsSchema = z z.literal("tether"), z.literal("wrapped-bitcoin"), z.literal("sui"), + z.literal("arbitrum"), + z.literal("optimism"), + z.literal("klay-token"), + z.literal("base"), + z.literal("pyth-network"), + z.literal("sepolia"), + z.literal("osmosis"), + z.literal("cosmos"), + z.literal("evmos"), + z.literal("kujira"), + z.literal("gateway"), ]); export type CoinGeckoIds = z.infer; + +export const coinGeckoIdByChainName = { + "solana": "solana", + "ethereum": "ethereum", + "bsc": "binancecoin", + "polygon": "matic-network", + "avalanche": "avalanche-2", + "fantom": "fantom", + "celo": "celo", + "moonbeam": "moonbeam", + "sui": "sui", + "arbitrum": "arbitrum", + "optimism": "optimism", + "base": "base", + "klaytn": "klay-token", + "pythnet": "pyth-network", + "sepolia": "ethereum", + "osmosis": "osmosis", + "cosmoshub": "cosmos", + "evmos": "evmos", + "kujira": "kujira", + "gateway": "gateway" +} as const satisfies Record; + +const mainnetNativeTokens = [ + { + chainId: 1, + chainName: "solana", + coingeckoId: "solana", + symbol: "WSOL", + tokenContract: "069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", + }, + { + chainId: 2, + chainName: "ethereum", + coingeckoId: "ethereum", + symbol: "WETH", + tokenContract: "000000000000000000000000C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + }, + { + chainId: 4, + chainName: "bsc", + coingeckoId: "binancecoin", + symbol: "WBNB", + tokenContract: "000000000000000000000000bb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + }, + { + chainId: 5, + chainName: "polygon", + coingeckoId: "matic-network", + symbol: "WMATIC", + tokenContract: "0000000000000000000000000d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + }, + { + chainId: 6, + chainName: "avalanche", + coingeckoId: "avalanche-2", + symbol: "WAVAX", + tokenContract: "000000000000000000000000B31f66AA3C1e785363F0875A1B74E27b85FD66c7", + }, + { + chainId: 10, + chainName: "fantom", + coingeckoId: "fantom", + symbol: "WFTM", + tokenContract: "00000000000000000000000021be370D5312f44cB42ce377BC9b8a0cEF1A4C83", + }, + { + chainId: 13, + chainName: "klaytn", + coingeckoId: "klay-token", + symbol: "WKLAY", + tokenContract: "", + }, + { + chainId: 14, + chainName: "celo", + coingeckoId: "celo", + symbol: "WCELO", + tokenContract: "000000000000000000000000471EcE3750Da237f93B8E339c536989b8978a438", + }, + { + chainId: 16, + chainName: "moonbeam", + coingeckoId: "moonbeam", + symbol: "WGLMR", + tokenContract: "000000000000000000000000Acc15dC74880C9944775448304B263D191c6077F", + }, + { + chainId: 21, + chainName: "sui", + coingeckoId: "sui", + symbol: "WSUI", + tokenContract: "9258181f5ceac8dbffb7030890243caed69a9599d2886d957a9cb7656af3bdb3", + }, + { + chainId: 23, + chainName: "arbitrum", + coingeckoId: "arbitrum", + symbol: "WARB", + tokenContract: "0x912CE59144191C1204E64559FE8253a0e49E6548", + }, + { + chainId: 24, + chainName: "optimism", + coingeckoId: "optimism", + symbol: "WOP", + tokenContract: "0x4200000000000000000000000000000000000042", + }, + { + chainId: 26, + chainName: "pythnet", + coingeckoId: "pyth-network", + symbol: "WPYTH", + tokenContract: "", + }, + { + chainId: 30, + chainName: "base", + coingeckoId: "base", + symbol: "WBASE", + tokenContract: "0x07150e919B4De5fD6a63DE1F9384828396f25fDC", + }, +] satisfies TokenInfo[]; + +const testnetNativeTokens = [ + { + chainId: 1, + chainName: "solana", + coingeckoId: "solana", + symbol: "SOL", + tokenContract: "", + }, + { + chainId: 2, + chainName: "ethereum", + coingeckoId: "ethereum", + symbol: "ETH", + tokenContract: "000000000000000000000000B4FBF271143F4FBf7B91A5ded31805e42b2208d6", + }, + { + chainId: 4, + chainName: "bsc", + coingeckoId: "binancecoin", + symbol: "BNB", + tokenContract: "000000000000000000000000ae13d989daC2f0dEbFf460aC112a837C89BAa7cd", + }, + { + chainId: 5, + chainName: "polygon", + coingeckoId: "matic-network", + symbol: "MATIC", + tokenContract: "0000000000000000000000009c3C9283D3e44854697Cd22D3Faa240Cfb032889", + }, + { + chainId: 6, + chainName: "avalanche", + coingeckoId: "avalanche-2", + symbol: "AVAX", + tokenContract: "000000000000000000000000d00ae08403B9bbb9124bB305C09058E32C39A48c", + }, + { + chainId: 10, + chainName: "fantom", + coingeckoId: "fantom", + symbol: "FTM", + tokenContract: "000000000000000000000000f1277d1Ed8AD466beddF92ef448A132661956621", + }, + { + chainId: 13, + chainName: "klaytn", + coingeckoId: "klay-token", + symbol: "KLAY", + tokenContract: "", + }, + { + chainId: 14, + chainName: "celo", + coingeckoId: "celo", + symbol: "CELO", + tokenContract: "000000000000000000000000F194afDf50B03e69Bd7D057c1Aa9e10c9954E4C9", + }, + { + chainId: 16, + chainName: "moonbeam", + coingeckoId: "moonbeam", + symbol: "GLMR", + tokenContract: "000000000000000000000000D909178CC99d318e4D46e7E66a972955859670E1", + }, + { + chainId: 21, + chainName: "sui", + coingeckoId: "sui", + symbol: "SUI", + tokenContract: "587c29de216efd4219573e08a1f6964d4fa7cb714518c2c8a0f29abfa264327d", + }, + { + chainId: 23, + chainName: "arbitrum", + coingeckoId: "arbitrum", + symbol: "ARB", + tokenContract: "0xF861378B543525ae0C47d33C90C954Dc774Ac1F9", + }, + { + chainId: 24, + chainName: "optimism", + coingeckoId: "optimism", + symbol: "OP", + tokenContract: "0x4200000000000000000000000000000000000042", + }, + { + chainId: 26, + chainName: "pythnet", + coingeckoId: "pyth-network", + symbol: "PYTH", + tokenContract: "", + }, + { + chainId: 30, + chainName: "base", + coingeckoId: "base", + symbol: "BASE", + tokenContract: "", + }, +] satisfies TokenInfo[]; + +export const supportedNativeTokensByEnv: Record = { + [Environment.MAINNET]: mainnetNativeTokens, + [Environment.TESTNET]: testnetNativeTokens, + [Environment.DEVNET]: [], +}; \ No newline at end of file diff --git a/src/prometheus-exporter.ts b/src/prometheus-exporter.ts index 7042943..a69e4e1 100644 --- a/src/prometheus-exporter.ts +++ b/src/prometheus-exporter.ts @@ -14,14 +14,14 @@ function updateBalancesGauge(gauge: Gauge, chainName: string, network: string, b } function updateBalancesInUsdGauge(gauge: Gauge, chainName: string, network: string, balance: WalletBalance | TokenBalance) { - const { symbol, address, isNative, usd } = balance; + const { symbol, address, isNative, balanceUsd } = balance; - if (!usd) return; + if (!balanceUsd) return; const tokenAddress = (balance as TokenBalance).tokenAddress || ''; gauge .labels(chainName, network, symbol, isNative.toString(), tokenAddress, address) - .set(Number(usd.toString())); + .set(Number(balanceUsd.toString())); } function updateAvailableWalletsGauge(gauge: Gauge, chainName: string, network: string, count: number) { diff --git a/src/wallet-manager.ts b/src/wallet-manager.ts index a733693..e290bc0 100644 --- a/src/wallet-manager.ts +++ b/src/wallet-manager.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "stream"; import { z } from "zod"; import winston from "winston"; -import { createLogger } from "./utils"; +import { createLogger, mapConcurrent } from "./utils"; import { PrometheusExporter } from "./prometheus-exporter"; import { ChainWalletManager, @@ -23,6 +23,7 @@ import { RebalanceInstruction } from "./rebalance-strategies"; import { CoinGeckoIdsSchema } from "./price-assistant/supported-tokens.config"; import { ScheduledPriceFeed } from "./price-assistant/scheduled-price-feed"; import { OnDemandPriceFeed } from "./price-assistant/ondemand-price-feed"; +import { preparePriceFeedConfig } from "./price-assistant/helper"; export const WalletRebalancingConfigSchema = z.object({ enabled: z.boolean(), @@ -36,26 +37,45 @@ export const WalletRebalancingConfigSchema = z.object({ const TokenInfoSchema = z.object({ tokenContract: z.string(), chainId: z.number(), + chainName: z.string(), coingeckoId: CoinGeckoIdsSchema, symbol: z.string().optional(), -}) +}); -export type TokenInfo = z.infer< - typeof TokenInfoSchema -> +export type TokenInfo = z.infer; export const WalletPriceFeedConfigSchema = z.object({ - enabled: z.boolean(), supportedTokens: z.array(TokenInfoSchema), - pricePrecision: z.number().optional(), - scheduled: z.object({ +}); + +export const WalletPriceFeedOptionsSchema = z.object({ + enabled: z.boolean(), + scheduled: z + .object({ + enabled: z.boolean().default(false), + interval: z.number().optional(), + }) + .optional(), +}); + +export const WalletBalanceConfigSchema = z + .object({ enabled: z.boolean().default(false), - interval: z.number().optional(), - }).optional(), -}) + scheduled: z + .object({ + enabled: z.boolean().default(false), + interval: z.number().optional(), + }) + .optional(), + }) + .optional(); + +export type WalletBalanceConfig = z.infer; + +export type WalletPriceFeedConfig = z.infer; -export type WalletPriceFeedConfig = z.infer< - typeof WalletPriceFeedConfigSchema +export type WalletPriceFeedOptions = z.infer< + typeof WalletPriceFeedOptionsSchema >; export type WalletRebalancingConfig = z.infer< @@ -67,8 +87,10 @@ export const WalletManagerChainConfigSchema = z.object({ // FIXME: This should be a zod schema chainConfig: z.any().optional(), rebalance: WalletRebalancingConfigSchema.optional(), + // This config can be used to control refresh balances behaviour + walletBalanceConfig: WalletBalanceConfigSchema.optional(), wallets: z.array(WalletConfigSchema), - priceFeedConfig: WalletPriceFeedConfigSchema.optional() + priceFeedConfig: WalletPriceFeedConfigSchema.optional(), }); export const WalletManagerConfigSchema = z.record( @@ -101,6 +123,7 @@ export const WalletManagerOptionsSchema = z.object({ .optional(), failOnInvalidChain: z.boolean().default(true), failOnInvalidTokens: z.boolean().default(true).optional(), + priceFeedOptions: WalletPriceFeedOptionsSchema.optional(), }); export type WalletManagerOptions = z.infer; @@ -148,6 +171,24 @@ export class WalletManager { } } + const isPriceFeedEnabled = options?.priceFeedOptions?.enabled; + // Create PriceFeed instance once for all chains + let priceFeedInstance; + if (isPriceFeedEnabled) { + const allSupportedTokens = preparePriceFeedConfig(config); + if (options?.priceFeedOptions?.scheduled?.enabled) { + priceFeedInstance = new ScheduledPriceFeed( + { supportedTokens: allSupportedTokens, ...options.priceFeedOptions }, + this.logger, + ); + } else { + priceFeedInstance = new OnDemandPriceFeed( + { supportedTokens: allSupportedTokens }, + this.logger, + ); + } + } + for (const [chainName, chainConfig] of Object.entries(config)) { if (!isChain(chainName)) { if (options?.failOnInvalidChain) { @@ -157,6 +198,7 @@ export class WalletManager { continue; } } + const network = chainConfig.network || getDefaultNetwork(chainName); const chainManagerConfig = { @@ -165,14 +207,15 @@ export class WalletManager { logger: this.logger, rebalance: chainConfig.rebalance, walletOptions: chainConfig.chainConfig, - priceFeedConfig: chainConfig.priceFeedConfig, + walletBalanceConfig: chainConfig.walletBalanceConfig, balancePollInterval: options?.balancePollInterval, failOnInvalidTokens: options?.failOnInvalidTokens ?? true, }; const chainManager = new ChainWalletManager( chainManagerConfig, - chainConfig.wallets + chainConfig.wallets, + priceFeedInstance, ); chainManager.on("error", error => { @@ -183,7 +226,6 @@ export class WalletManager { chainManager.on( "balances", (balances: WalletBalance[], previousBalances: WalletBalance[]) => { - this.logger.verbose(`Balances updated for ${chainName} (${network})`); this.exporter?.updateBalances(chainName, network, balances); @@ -223,11 +265,19 @@ export class WalletManager { chainManager.on("active-wallets-count", (chainName, network, count) => { this.exporter?.updateActiveWallets(chainName, network, count); - }) + }); - chainManager.on("wallets-lock-period", (chainName, network, walletAddress, lockTime) => { - this.exporter?.updateWalletsLockPeriod(chainName, network, walletAddress, lockTime); - }) + chainManager.on( + "wallets-lock-period", + (chainName, network, walletAddress, lockTime) => { + this.exporter?.updateWalletsLockPeriod( + chainName, + network, + walletAddress, + lockTime, + ); + }, + ); this.managers[chainName] = chainManager; @@ -301,13 +351,98 @@ export class WalletManager { } } - public getAllBalances(): Record { + private async balanceHandlerMapper(method: "getBalances" | "pullBalances") { const balances: Record = {}; - for (const [chainName, manager] of Object.entries(this.managers)) { - balances[chainName] = manager.getBalances(); + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { + const balancesByChain = await manager[method](); + balances[chainName] = balancesByChain; + }, + ); + + return balances; + } + + public async getAllBalances(): Promise< + Record + > { + return await this.balanceHandlerMapper("getBalances"); + } + + public getBlockHeight(chainName: ChainName): Promise { + const manager = this.managers[chainName]; + if (!manager) + throw new Error(`No wallets configured for chain: ${chainName}`); + + return manager.getBlockHeight(); + } + + // PullBalances doesn't need balances to be refreshed in the background + public async pullBalances(): Promise< + Record + > { + return await this.balanceHandlerMapper("pullBalances"); + } + + private validateBlockHeightByChain( + blockHeightByChain: Record, + ) { + for (const chain in blockHeightByChain) { + const manager = this.managers[chain as ChainName]; + if (!manager) + throw new Error(`No wallets configured for chain: ${chain}`); + } + } + + public async getBlockHeightForAllSupportedChains(): Promise< + Record + > { + // Required concurrency is the number of chains as we want to fetch the block height for all chains in parallel + // to be precise about the block height at the time of fetching balances + let blockHeightPerChain = {} as Record; + const requiredConcurrency = Object.keys(this.managers).length; + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { + try { + const blockHeight = await manager.getBlockHeight(); + blockHeightPerChain = { + ...blockHeightPerChain, + [chainName]: blockHeight, + } as Record; + } catch (err) { + throw new Error(`No block height found for chain: ${chainName}, error: ${err}`); + } + }, + requiredConcurrency, + ); + return blockHeightPerChain; + } + + // pullBalancesAtBlockHeight doesn't need balances to be refreshed in the background + public async pullBalancesAtBlockHeight( + blockHeightByChain?: Record, + ): Promise> { + const balances: Record = {}; + if (blockHeightByChain) { + this.validateBlockHeightByChain(blockHeightByChain); } + const blockHeightPerChain = blockHeightByChain ?? await this.getBlockHeightForAllSupportedChains(); + + await mapConcurrent( + Object.entries(this.managers), + async ([chainName, manager]) => { + const blockHeight = blockHeightPerChain[chainName as ChainName]; + const balancesByChain = await manager.pullBalancesAtBlockHeight( + blockHeight, + ); + balances[chainName] = balancesByChain; + }, + ); + return balances; } diff --git a/src/wallets/base-wallet.ts b/src/wallets/base-wallet.ts index c3cc4da..d0f21e6 100644 --- a/src/wallets/base-wallet.ts +++ b/src/wallets/base-wallet.ts @@ -57,7 +57,10 @@ export abstract class WalletToolbox { ): string; // Should return balances for a native address in the chain - abstract pullNativeBalance(address: string): Promise; + abstract pullNativeBalance( + address: string, + blockHeight?: number, + ): Promise; // Should return balances for tokens in the list for the address specified abstract pullTokenBalances( @@ -107,6 +110,7 @@ export abstract class WalletToolbox { public async pullBalances( isRebalancingEnabled = false, minBalanceThreshold?: number, + blockHeight?: number, ): Promise { if (!this.warm) { this.logger.debug( @@ -133,7 +137,7 @@ export abstract class WalletToolbox { let nativeBalance: WalletBalance; try { - nativeBalance = await this.pullNativeBalance(address); + nativeBalance = await this.pullNativeBalance(address, blockHeight); this.addOrDiscardWalletIfRequired( isRebalancingEnabled, @@ -183,6 +187,10 @@ export abstract class WalletToolbox { return balances; } + public async pullBalancesAtBlockHeight(blockHeight: number) { + return this.pullBalances(false, undefined, blockHeight); + } + public async acquire(address?: string, acquireTimeout?: number) { const timeout = acquireTimeout || DEFAULT_WALLET_ACQUIRE_TIMEOUT; // this.grpcClient.acquireWallet(address); diff --git a/src/wallets/cosmos/index.ts b/src/wallets/cosmos/index.ts index 353bc36..9a492c7 100644 --- a/src/wallets/cosmos/index.ts +++ b/src/wallets/cosmos/index.ts @@ -170,6 +170,7 @@ export class CosmosWalletToolbox extends WalletToolbox { this.provider = await StargateClient.connect(this.options.nodeUrl!); } + // TODO: Implement getNativeBalance by blockHeight if possible public async pullNativeBalance(address: string): Promise { const { nativeDenom, defaultDecimals } = this.chainConfig.defaultConfigs[this.network]; diff --git a/src/wallets/evm/index.ts b/src/wallets/evm/index.ts index b6be6d4..d289c2a 100644 --- a/src/wallets/evm/index.ts +++ b/src/wallets/evm/index.ts @@ -53,6 +53,7 @@ import { import { KlaytnNetwork, KLAYTN, KLAYTN_CHAIN_CONFIG } from "./klaytn.config"; import { BaseNetwork, BASE, BASE_CHAIN_CONFIG } from "./base.config"; import { PriceFeed } from "../../wallet-manager"; +import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; const EVM_HEX_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; @@ -239,9 +240,14 @@ export class EvmWalletToolbox extends WalletToolbox { this.logger.debug(`EVM token data: ${JSON.stringify(this.tokenData)}`); } - public async pullNativeBalance(address: string): Promise { - const balance = await pullEvmNativeBalance(this.provider, address); + public async pullNativeBalance(address: string, blockHeight?: number): Promise { + const balance = await pullEvmNativeBalance(this.provider, address, blockHeight); const formattedBalance = ethers.utils.formatEther(balance.rawBalance); + + // Pull prices in USD for all the native tokens in single network call + await this.priceFeed?.pullTokenPrices(); + const coingeckoId = coinGeckoIdByChainName[this.chainName]; + const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); return { ...balance, @@ -249,6 +255,11 @@ export class EvmWalletToolbox extends WalletToolbox { formattedBalance, tokens: [], symbol: this.chainConfig.nativeCurrencySymbol, + blockHeight, + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) }; } @@ -256,7 +267,9 @@ export class EvmWalletToolbox extends WalletToolbox { address: string, tokens: string[], ): Promise { - const tokenBalances = await mapConcurrent( + // Pull prices in USD for all the tokens in single network call + const tokenPrices = await this.priceFeed?.pullTokenPrices(); + return mapConcurrent( tokens, async tokenAddress => { const tokenData = this.tokenData[tokenAddress]; @@ -270,29 +283,24 @@ export class EvmWalletToolbox extends WalletToolbox { tokenData.decimals, ); + const coinGeckoId = this.priceFeed?.getCoinGeckoId(tokenAddress); + const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; + return { ...balance, address, tokenAddress, formattedBalance, + symbol: tokenData.symbol, + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) }; }, this.options.tokenPollConcurrency, ); - - // Pull prices in USD for all the tokens in single network call - await this.priceFeed?.pullTokenPrices(tokens); - - // Add USD price to each token balance - return mapConcurrent(tokenBalances, async balance => { - const tokenPrice = await this.priceFeed?.getKey(balance.tokenAddress); - const tokenBalanceInUsd = tokenPrice ? Number(balance.formattedBalance) * Number(tokenPrice) : undefined; - return { - ...balance, - usd: tokenBalanceInUsd, - }; - }, this.options.tokenPollConcurrency); } protected async transferNativeBalance( diff --git a/src/wallets/index.ts b/src/wallets/index.ts index 5da8ed1..1c824cd 100644 --- a/src/wallets/index.ts +++ b/src/wallets/index.ts @@ -2,6 +2,12 @@ import { z } from "zod"; export const DEVNET = "devnet"; +export const enum Environment { + MAINNET = "mainnet", + TESTNET = "testnet", + DEVNET = "devnet", +} + import { EvmWalletOptions, EvmWalletToolbox, @@ -80,7 +86,9 @@ export type Balance = { isNative: boolean; rawBalance: string; formattedBalance: string; - usd?: bigint; + blockHeight?: number; + tokenUsdPrice?: number; + balanceUsd?: number; }; export type TokenBalance = Balance & { diff --git a/src/wallets/solana/index.ts b/src/wallets/solana/index.ts index 05d3b1b..bc83312 100644 --- a/src/wallets/solana/index.ts +++ b/src/wallets/solana/index.ts @@ -6,7 +6,12 @@ import { RecentPrioritizationFees, } from "@solana/web3.js"; import { decode } from "bs58"; -import { SOLANA, SOLANA_CHAIN_CONFIG, SolanaNetworks } from "./solana.config"; +import { + SOLANA, + SOLANA_CHAIN_CONFIG, + SOLANA_DEFAULT_COMMITMENT, + SolanaNetworks, +} from "./solana.config"; import { PYTHNET, PYTHNET_CHAIN_CONFIG } from "./pythnet.config"; import { BaseWalletOptions, @@ -26,6 +31,7 @@ import { import { getMint, Mint, TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { findMedian, mapConcurrent } from "../../utils"; import { PriceFeed } from "../../wallet-manager"; +import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; export type SolanaChainConfig = { chainName: string; @@ -109,18 +115,33 @@ export class SolanaWalletToolbox extends WalletToolbox { return validTokens; } - public async pullNativeBalance(address: string): Promise { + public async pullNativeBalance(address: string, blockHeight?: number): Promise { const balance = await pullSolanaNativeBalance(this.connection, address); const formattedBalance = ( Number(balance.rawBalance) / LAMPORTS_PER_SOL ).toString(); + + if (blockHeight) { + this.logger.warn(`Solana does not support pulling balances by block height, ignoring blockHeight: ${blockHeight}`); + } + + // Pull prices in USD for all the native tokens in single network call + await this.priceFeed?.pullTokenPrices(); + const coingeckoId = coinGeckoIdByChainName[this.chainName]; + const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); + return { ...balance, address, formattedBalance, tokens: [], symbol: this.chainConfig.nativeCurrencySymbol, + blockHeight, + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) }; } @@ -147,53 +168,36 @@ export class SolanaWalletToolbox extends WalletToolbox { ); }); - // Assuming that tokens[] is actually an array of mint account addresses. - const balances = await mapConcurrent( - tokens, - async token => { - const tokenData = this.tokenData[token]; - const tokenKnownInfo = Object.entries( - this.chainConfig.knownTokens[this.network], - ).find(([_, value]) => value === token); - const tokenKnownSymbol = tokenKnownInfo ? tokenKnownInfo[0] : undefined; - - // We are choosing to show a balance of 0 for a token that is not owned by the address. - const tokenBalance = tokenBalancesDistinct.get(token) ?? 0; - const formattedBalance = ( - tokenBalance / - 10 ** tokenData.decimals - ).toString(); - - return { - isNative: false, - rawBalance: tokenBalance.toString(), - address, - formattedBalance, - symbol: tokenKnownSymbol ?? "unknown", - }; - }, - this.options.tokenPollConcurrency, - ) as TokenBalance[]; - // Pull prices in USD for all the tokens in single network call - await this.priceFeed?.pullTokenPrices(tokens); - - // Add USD price to each token balance - return mapConcurrent( - balances, - async balance => { - const tokenPrice = await this.priceFeed?.getKey(balance.tokenAddress!); - const tokenBalanceInUsd = tokenPrice - ? BigInt(balance.formattedBalance) * tokenPrice - : undefined; - - return { - ...balance, - usd: tokenBalanceInUsd, - }; - }, - this.options.tokenPollConcurrency, - ); + const tokenPrices = await this.priceFeed?.pullTokenPrices(); + + // Assuming that tokens[] is actually an array of mint account addresses. + return tokens.map(token => { + const tokenData = this.tokenData[token]; + const tokenKnownInfo = Object.entries( + this.chainConfig.knownTokens[this.network], + ).find(([_, value]) => value === token); + const tokenKnownSymbol = tokenKnownInfo ? tokenKnownInfo[0] : undefined; + + // We are choosing to show a balance of 0 for a token that is not owned by the address. + const tokenBalance = tokenBalancesDistinct.get(token) ?? 0; + const formattedBalance = tokenBalance / 10 ** tokenData.decimals; + + const coinGeckoId = this.priceFeed?.getCoinGeckoId(token); + const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; + + return { + isNative: false, + rawBalance: tokenBalance.toString(), + address, + formattedBalance: formattedBalance.toString(), + symbol: tokenKnownSymbol ?? "unknown", + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) + }; + }); } protected validateChainName(chainName: string): chainName is SolanaChainName { @@ -304,6 +308,6 @@ export class SolanaWalletToolbox extends WalletToolbox { } public async getBlockHeight(): Promise { - return this.connection.getBlockHeight(); + return this.connection.getBlockHeight(SOLANA_DEFAULT_COMMITMENT); } } diff --git a/src/wallets/solana/solana.config.ts b/src/wallets/solana/solana.config.ts index 0b626f3..c52349c 100644 --- a/src/wallets/solana/solana.config.ts +++ b/src/wallets/solana/solana.config.ts @@ -4,6 +4,7 @@ const SOLANA_TESTNET = 'solana-devnet'; const SOLANA_CURRENCY_SYMBOL = 'SOL'; export const SOLANA = 'solana'; +export const SOLANA_DEFAULT_COMMITMENT = 'finalized'; export const SOLANA_NETWORKS = { [SOLANA_MAINNET]: 1, diff --git a/src/wallets/sui/index.ts b/src/wallets/sui/index.ts index 0ec94dc..d21af83 100644 --- a/src/wallets/sui/index.ts +++ b/src/wallets/sui/index.ts @@ -23,6 +23,7 @@ import { getSuiAddressFromPrivateKey } from "../../balances/sui"; import {mapConcurrent} from "../../utils"; import { formatFixed } from "@ethersproject/bignumber"; import { PriceFeed } from "../../wallet-manager"; +import { coinGeckoIdByChainName } from "../../price-assistant/supported-tokens.config"; export const SUI_CHAINS = { [SUI]: 1, @@ -162,15 +163,30 @@ export class SuiWalletToolbox extends WalletToolbox { }, [] as string[]); } - public async pullNativeBalance(address: string): Promise { + public async pullNativeBalance(address: string, blockHeight?: number): Promise { const balance = await pullSuiNativeBalance(this.connection, address); const formattedBalance = String(+balance.rawBalance / 10 ** 9); + + if (blockHeight) { + this.logger.warn(`Sui does not support pulling balances by block height, ignoring blockHeight: ${blockHeight}`); + } + + // Pull prices in USD for all the native tokens in single network call + await this.priceFeed?.pullTokenPrices(); + const coingeckoId = coinGeckoIdByChainName[this.chainName]; + const tokenUsdPrice = this.priceFeed?.getKey(coingeckoId); + return { ...balance, address, formattedBalance, tokens: [], symbol: this.chainConfig.nativeCurrencySymbol, + blockHeight, + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) }; } @@ -180,57 +196,48 @@ export class SuiWalletToolbox extends WalletToolbox { ): Promise { const uniqueTokens = [...new Set(tokens)]; const allBalances = await pullSuiTokenBalances(this.connection, address); + // Pull prices in USD for all the tokens in single network call + const tokenPrices = await this.priceFeed?.pullTokenPrices(); - const tokenBalances = await mapConcurrent(uniqueTokens, async (tokenAddress: string) => { + return uniqueTokens.map(tokenAddress => { const tokenData = this.tokenData[tokenAddress]; const symbol: string = tokenData?.symbol ? tokenData.symbol : ""; - for (const balance of allBalances) { - if (balance.coinType === tokenData.address) { - - const formattedBalance = formatFixed( - balance.totalBalance, - tokenData?.decimals ? tokenData.decimals : 9 - ); - - return { - tokenAddress, - address, - isNative: false, - rawBalance: balance.totalBalance, - formattedBalance, - symbol, - }; + const balance = allBalances.find(balance => balance.coinType === tokenData.address); + if (!balance) { + return { + tokenAddress, + address, + + isNative: false, + rawBalance: "0", + formattedBalance: "0", + symbol, } } + const tokenDecimals = tokenData?.decimals ?? 9; + const formattedBalance = formatFixed( + balance.totalBalance, + tokenDecimals + ); + + const coinGeckoId = this.priceFeed?.getCoinGeckoId(tokenAddress); + const tokenUsdPrice = coinGeckoId && tokenPrices?.[coinGeckoId]; + return { tokenAddress, address, isNative: false, - rawBalance: "0", - formattedBalance: "0", + rawBalance: balance.totalBalance, + formattedBalance, symbol, - } - }, this.options.tokenPollConcurrency) as TokenBalance[]; - - // Pull prices in USD for all the tokens in single network call - await this.priceFeed?.pullTokenPrices(tokens); - - // Add USD price to each token balance - return mapConcurrent( - tokenBalances, - async balance => { - const tokenPrice = await this.priceFeed?.getKey(balance.tokenAddress!); - const tokenBalanceInUsd = tokenPrice ? BigInt(balance.formattedBalance) * tokenPrice : undefined; - - return { - ...balance, - usd: tokenBalanceInUsd, - }; - }, - this.options.tokenPollConcurrency, - ); + ...(tokenUsdPrice && { + balanceUsd: Number(formattedBalance) * tokenUsdPrice, + tokenUsdPrice + }) + }; + }); } protected async transferNativeBalance( diff --git a/test/wallets/sui/sui.test.ts b/test/wallets/sui/sui.test.ts index 753f2e2..24bfccf 100644 --- a/test/wallets/sui/sui.test.ts +++ b/test/wallets/sui/sui.test.ts @@ -98,7 +98,6 @@ describe("sui wallet tests", () => { "rawBalance": "100000000", "formattedBalance": "100.0", "symbol": "USDC", - "usd": undefined } ]); });