From 8c179a7cf2fabc2b652a731abae19e8b3fa0e3b7 Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Fri, 10 May 2024 16:58:43 +0200 Subject: [PATCH 1/4] feat(InventoryClient): Support 1:many HubPool mappings (#1465) This update to the InventoryClient permits multiple tokens to map to a single HubPool token. This will be used with USDC/CCTP, where the existing USDC deployment will map to both native USDC and bridged USDC on the various non-mainnet chains supported by Across. --- src/clients/InventoryClient.ts | 257 ++++++++++++++------- src/interfaces/InventoryManagement.ts | 75 ++++-- src/relayer/RelayerConfig.ts | 90 +++++--- test/InventoryClient.InventoryRebalance.ts | 243 +++++++++++++++++-- test/InventoryClient.RefundChain.ts | 77 +++++- test/mocks/MockInventoryClient.ts | 6 +- 6 files changed, 590 insertions(+), 158 deletions(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 174bacad4..92496aabc 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -25,7 +25,8 @@ import { } from "../utils"; import { HubPoolClient, TokenClient, BundleDataClient } from "."; import { AdapterManager, CrossChainTransferClient } from "./bridges"; -import { InventoryConfig, V3Deposit } from "../interfaces"; +import { V3Deposit } from "../interfaces"; +import { InventoryConfig, isAliasConfig, TokenBalanceConfig } from "../interfaces/InventoryManagement"; import lodash from "lodash"; import { CONTRACT_ADDRESSES, SLOW_WITHDRAWAL_CHAINS } from "../common"; import { CombinedRefunds } from "../dataworker/DataworkerUtils"; @@ -71,32 +72,72 @@ export class InventoryClient { this.formatWei = createFormatFunction(2, 4, false, 18); } - // Get the total balance across all chains, considering any outstanding cross chain transfers as a virtual balance on that chain. + /** + * Resolve the token balance configuration for `l1Token` on `chainId`. If `l1Token` maps to multiple tokens on + * `chainId` then `l2Token` must be supplied. + * @param l1Token L1 token address to query. + * @param chainId Chain ID to query on + * @param l2Token Optional L2 token address when l1Token maps to multiple l2Token addresses. + */ + getTokenConfig(l1Token: string, chainId: number, l2Token?: string): TokenBalanceConfig | undefined { + const tokenConfig = this.inventoryConfig.tokenConfig[l1Token]; + assert(isDefined(tokenConfig), `getTokenConfig: No token config found for ${l1Token}.`); + + if (isAliasConfig(tokenConfig)) { + assert(isDefined(l2Token), `Cannot resolve ambiguous ${getNetworkName(chainId)} token config for ${l1Token}`); + return tokenConfig[l2Token]?.[chainId]; + } else { + return tokenConfig[chainId]; + } + } + + /* + * Get the total balance for an L1 token across all chains, considering any outstanding cross chain transfers as a + * virtual balance on that chain. + * @param l1Token L1 token address to query. + * returns Cumulative balance of l1Token across all inventory-managed chains. + */ getCumulativeBalance(l1Token: string): BigNumber { return this.getEnabledChains() - .map((chainId) => this.getBalanceOnChainForL1Token(chainId, l1Token)) + .map((chainId) => this.getBalanceOnChain(chainId, l1Token)) .reduce((acc, curr) => acc.add(curr), bnZero); } - // Get the balance of a given l1 token on a target chain, considering any outstanding cross chain transfers as a virtual balance on that chain. - getBalanceOnChainForL1Token(chainId: number | string, l1Token: string): BigNumber { - // We want to skip any l2 token that is not present in the inventory config. - chainId = Number(chainId); - if (chainId !== this.hubPoolClient.chainId && !this._l1TokenEnabledForChain(l1Token, chainId)) { - return bnZero; + /** + * Determine the effective/virtual balance of an l1 token that has been deployed to another chain. + * Includes both the actual balance on the chain and any pending inbound transfers to the target chain. + * If l2Token is supplied, return its balance on the specified chain. Otherwise, return the total allocation + * of l1Token on the specified chain. + * @param chainId Chain to query token balance on. + * @param l1Token L1 token to query on chainId (after mapping). + * @param l2Token Optional l2 token address to narrow the balance reporting. + * @returns Balance of l1Token on chainId. + */ + getBalanceOnChain(chainId: number, l1Token: string, l2Token?: string): BigNumber { + const { crossChainTransferClient, relayer, tokenClient } = this; + let balance: BigNumber; + + // Return the balance for a specific l2 token on the remote chain. + if (isDefined(l2Token)) { + balance = tokenClient.getBalance(chainId, l2Token); + return balance.add( + crossChainTransferClient.getOutstandingCrossChainTransferAmount(relayer, chainId, l1Token, l2Token) + ); } - // If the chain does not have this token (EG BOBA on Optimism) then 0. - const balance = - this.tokenClient.getBalance(chainId, this.getDestinationTokenForL1Token(l1Token, chainId)) || bnZero; + const l2Tokens = this.getRemoteTokensForL1Token(l1Token, chainId); + balance = l2Tokens + .map((l2Token) => tokenClient.getBalance(chainId, l2Token)) + .reduce((acc, curr) => acc.add(curr), bnZero); - // Consider any L1->L2 transfers that are currently pending in the canonical bridge. - return balance.add( - this.crossChainTransferClient.getOutstandingCrossChainTransferAmount(this.relayer, chainId, l1Token) - ); + return balance.add(crossChainTransferClient.getOutstandingCrossChainTransferAmount(this.relayer, chainId, l1Token)); } - // Get the fraction of funds allocated on each chain. + /** + * Determine the allocation of an l1 token across all configured remote chain IDs. + * @param l1Token L1 token to query. + * @returns Distribution of l1Token by chain ID and l2Token. + */ getChainDistribution(l1Token: string): { [chainId: number]: TokenDistribution } { const cumulativeBalance = this.getCumulativeBalance(l1Token); const distribution: { [chainId: number]: TokenDistribution } = {}; @@ -105,19 +146,27 @@ export class InventoryClient { // If token doesn't have entry on chain, skip creating an entry for it since we'll likely run into an error // later trying to grab the chain equivalent of the L1 token via the HubPoolClient. if (chainId === this.hubPoolClient.chainId || this._l1TokenEnabledForChain(l1Token, chainId)) { - const l2Token = this.getDestinationTokenForL1Token(l1Token, chainId); - if (cumulativeBalance.gt(bnZero)) { - distribution[chainId] ??= {}; - distribution[chainId][l2Token] = this.getBalanceOnChainForL1Token(chainId, l1Token) - .mul(this.scalar) - .div(cumulativeBalance); + if (cumulativeBalance.eq(bnZero)) { + return; } + + distribution[chainId] ??= {}; + const l2Tokens = this.getRemoteTokensForL1Token(l1Token, chainId); + l2Tokens.forEach((l2Token) => { + // The effective balance is the current balance + inbound bridge transfers. + const effectiveBalance = this.getBalanceOnChain(chainId, l1Token, l2Token); + distribution[chainId][l2Token] = effectiveBalance.mul(this.scalar).div(cumulativeBalance); + }); } }); return distribution; } - // Get the distribution of all tokens, spread over all chains. + /** + * Determine the allocation of an l1 token across all configured remote chain IDs. + * @param l1Token L1 token to query. + * @returns Distribution of l1Token by chain ID and l2Token. + */ getTokenDistributionPerL1Token(): TokenDistributionPerL1Token { const distributionPerL1Token: TokenDistributionPerL1Token = {}; this.getL1Tokens().forEach((l1Token) => (distributionPerL1Token[l1Token] = this.getChainDistribution(l1Token))); @@ -125,33 +174,61 @@ export class InventoryClient { } // Get the balance of a given token on a given chain, including shortfalls and any pending cross chain transfers. - getCurrentAllocationPct(l1Token: string, chainId: number): BigNumber { + getCurrentAllocationPct(l1Token: string, chainId: number, l2Token: string): BigNumber { // If there is nothing over all chains, return early. const cumulativeBalance = this.getCumulativeBalance(l1Token); if (cumulativeBalance.eq(bnZero)) { return bnZero; } - const shortfall = this.getTokenShortFall(l1Token, chainId); - const currentBalance = this.getBalanceOnChainForL1Token(chainId, l1Token).sub(shortfall); + const shortfall = this.tokenClient.getShortfallTotalRequirement(chainId, l2Token); + const currentBalance = this.getBalanceOnChain(chainId, l1Token, l2Token).sub(shortfall); + // Multiply by scalar to avoid rounding errors. return currentBalance.mul(this.scalar).div(cumulativeBalance); } // Find how short a given chain is for a desired L1Token. getTokenShortFall(l1Token: string, chainId: number): BigNumber { - return this.tokenClient.getShortfallTotalRequirement(chainId, this.getDestinationTokenForL1Token(l1Token, chainId)); + return this.getRemoteTokensForL1Token(l1Token, chainId) + .map((token) => this.tokenClient.getShortfallTotalRequirement(chainId, token)) + .reduce((acc, curr) => acc.add(curr), bnZero); + } + + getRepaymentTokenForL1Token(l1Token: string, chainId: number | string): string | undefined { + // @todo: Update HubPoolClient.getL2TokenForL1TokenAtBlock() such that it returns `undefined` instead of throwing. + try { + return this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); + } catch { + return undefined; + } } - getDestinationTokenForL1Token(l1Token: string, chainId: number | string): string { - // TODO: Need to replace calling into the HubPoolClient with calling into TOKEN_SYMBOLS_MAP. For example, - // imagine there is a utility function getL2TokenInfo(l1Token: string, chainId: number): L1Token that - // looks into TOKEN_SYMBOLS_MAP and returns an L1Token object using a token entry that contains the l1Token address. - // We'd need to be able to tie-break between tokens that map to the same L1Token (USDC.e, USDC), so maybe - // this function would either return multiple L1Token objects or we'd need to pass in a symbol/l2TokenAddress. + /** + * From an L1Token and remote chain ID, resolve all supported corresponding tokens. + * This should include at least the relevant repayment token on the relevant chain, but may also include other + * "equivalent" tokens (i.e. as with Bridged & Native USDC). + * @param l1Token Mainnet token to query. + * @param chainId Remove chain to query. + * @returns An array of supported tokens on chainId that map back to l1Token on mainnet. + */ + getRemoteTokensForL1Token(l1Token: string, chainId: number | string): string[] { + if (chainId === this.hubPoolClient.chainId) { + return [l1Token]; + } + + const tokenConfig = this.inventoryConfig.tokenConfig[l1Token]; + + if (isAliasConfig(tokenConfig)) { + return Object.keys(tokenConfig).filter((k) => isDefined(tokenConfig[k][chainId])); + } - // return getL2TokenInfo(l1Token, chainId).address - return this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); + const destinationToken = this.getRepaymentTokenForL1Token(l1Token, chainId); + if (!isDefined(destinationToken)) { + return []; + } + + return [destinationToken]; } getEnabledChains(): number[] { @@ -222,7 +299,7 @@ export class InventoryClient { // Increase virtual balance by pending relayer refunds from the latest valid bundle and the // upcoming bundle. We can assume that all refunds from the second latest valid bundle have already // been executed. - let startTimer; + let startTimer: number; if (!isDefined(this.bundleRefundsPromise)) { startTimer = performance.now(); // @dev Save this as a promise so that other parallel calls to this function don't make the same call. @@ -232,9 +309,9 @@ export class InventoryClient { const totalRefundsPerChain = this.getEnabledChains().reduce( (refunds: { [chainId: string]: BigNumber }, chainId) => { if (!this.hubPoolClient.l2TokenEnabledForL1Token(l1Token, chainId)) { - refunds[chainId] = toBN(0); + refunds[chainId] = bnZero; } else { - const destinationToken = this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); + const destinationToken = this.getRepaymentTokenForL1Token(l1Token, chainId); refunds[chainId] = this.bundleDataClient.getTotalRefund( refundsToConsider, this.relayer, @@ -342,8 +419,8 @@ export class InventoryClient { ` (${inputToken} != ${outputToken})` ); } + l1Token ??= this.hubPoolClient.getL1TokenForL2TokenAtBlock(inputToken, originChainId); - const tokenConfig = this.inventoryConfig?.tokenConfig?.[l1Token]; // Consider any refunds from executed and to-be executed bundles. If bundle data client doesn't return in // time, return an object with zero refunds for all chains. @@ -399,8 +476,9 @@ export class InventoryClient { for (const _chain of chainsToEvaluate) { assert(this._l1TokenEnabledForChain(l1Token, _chain), `Token ${l1Token} not enabled for chain ${_chain}`); // Destination chain: - const chainShortfall = this.getTokenShortFall(l1Token, _chain); - const chainVirtualBalance = this.getBalanceOnChainForL1Token(_chain, l1Token); + const repaymentToken = this.getRepaymentTokenForL1Token(l1Token, _chain); + const chainShortfall = this.tokenClient.getShortfallTotalRequirement(_chain, repaymentToken); + const chainVirtualBalance = this.getBalanceOnChain(_chain, l1Token, repaymentToken); const chainVirtualBalanceWithShortfall = chainVirtualBalance.sub(chainShortfall); let cumulativeVirtualBalanceWithShortfall = cumulativeVirtualBalance.sub(chainShortfall); // @dev No need to factor in outputAmount when computing origin chain balance since funds only leave relayer @@ -413,6 +491,7 @@ export class InventoryClient { this.hubPoolClient.areTokensEquivalent(inputToken, originChainId, outputToken, destinationChainId) ? chainVirtualBalanceWithShortfall.sub(outputAmount) : chainVirtualBalanceWithShortfall; + // Add upcoming refunds: chainVirtualBalanceWithShortfallPostRelay = chainVirtualBalanceWithShortfallPostRelay.add( totalRefundsPerChain[_chain] @@ -429,8 +508,10 @@ export class InventoryClient { .div(cumulativeVirtualBalanceWithShortfallPostRelay); // Consider configured buffer for target to allow relayer to support slight overages. - const thresholdPct = toBN(this.inventoryConfig.tokenConfig[l1Token][_chain].targetPct) - .mul(tokenConfig[_chain].targetOverageBuffer ?? toBNWei("1")) + const tokenConfig = this.getTokenConfig(l1Token, _chain, repaymentToken); + assert(isDefined(tokenConfig), `No ${outputToken} tokenConfig for ${l1Token} on ${_chain}.`); + const thresholdPct = toBN(tokenConfig.targetPct) + .mul(tokenConfig.targetOverageBuffer ?? toBNWei("1")) .div(fixedPointAdjustment); this.log( `Evaluated taking repayment on ${ @@ -501,10 +582,12 @@ export class InventoryClient { runningBalanceForToken = leaf.runningBalances[l1TokenIndex]; } const l2Token = this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); + // Approximate latest running balance as last known proposed running balance... // - minus total deposit amount on chain since the latest end block proposed // - plus total refund amount on chain since the latest end block proposed const upcomingDeposits = this.bundleDataClient.getUpcomingDepositAmount(chainId, l2Token, blockRange[1]); + // Grab refunds that are not included in any bundle proposed on-chain. These are refunds that have not // been accounted for in the latest running balance set in `runningBalanceForToken`. const allBundleRefunds = lodash.cloneDeep(await this.bundleRefundsPromise); @@ -618,29 +701,34 @@ export class InventoryClient { } getPossibleRebalances(): Rebalance[] { + const chainIds = this.getEnabledChains(); const rebalancesRequired: Rebalance[] = []; - // First, compute the rebalances that we would do assuming we have sufficient tokens on L1. for (const l1Token of this.getL1Tokens()) { const cumulativeBalance = this.getCumulativeBalance(l1Token); if (cumulativeBalance.eq(bnZero)) { continue; } - for (const chainId of this.getEnabledL2Chains()) { - // Skip if there's no configuration for l1Token on chainId. This is the case for BOBA and BADGER - // as they're not present on all L2s. + chainIds.forEach((chainId) => { + // Skip if there's no configuration for l1Token on chainId. if (!this._l1TokenEnabledForChain(l1Token, chainId)) { - continue; + return; } - const currentAllocPct = this.getCurrentAllocationPct(l1Token, chainId); - const { thresholdPct, targetPct } = this.inventoryConfig.tokenConfig[l1Token][chainId]; - if (currentAllocPct.lt(thresholdPct)) { + const l2Tokens = this.getRemoteTokensForL1Token(l1Token, chainId); + l2Tokens.forEach((l2Token) => { + const currentAllocPct = this.getCurrentAllocationPct(l1Token, chainId, l2Token); + const tokenConfig = this.getTokenConfig(l1Token, chainId, l2Token); + const { thresholdPct, targetPct } = tokenConfig; + + if (currentAllocPct.gte(thresholdPct)) { + return; + } + const deltaPct = targetPct.sub(currentAllocPct); const amount = deltaPct.mul(cumulativeBalance).div(this.scalar); const balance = this.tokenClient.getBalance(this.hubPoolClient.chainId, l1Token); - const l2Token = this.getDestinationTokenForL1Token(l1Token, chainId); rebalancesRequired.push({ chainId, l1Token, @@ -652,9 +740,10 @@ export class InventoryClient { cumulativeBalance, amount, }); - } - } + }); + }); } + return rebalancesRequired; } @@ -681,7 +770,6 @@ export class InventoryClient { } // Next, evaluate if we have enough tokens on L1 to actually do these rebalances. - for (const rebalance of rebalancesRequired) { const { balance, amount, l1Token, l2Token, chainId } = rebalance; @@ -697,24 +785,14 @@ export class InventoryClient { // RPC's returning slowly, leading to concurrent/overlapping instances of the bot running. const tokenContract = new Contract(l1Token, ERC20.abi, this.hubPoolClient.hubPool.signer); const currentBalance = await tokenContract.balanceOf(this.relayer); - if (!balance.eq(currentBalance)) { - this.logger.warn({ - at: "InventoryClient", - message: "🚧 Token balance on Ethereum changed before sending transaction, skipping rebalance", - l1Token, - l2ChainId: chainId, - balance, - currentBalance, - }); - continue; - } else { - this.logger.debug({ - at: "InventoryClient", - message: "Token balance in relayer on Ethereum is as expected, sending cross chain transfer", - l1Token, - l2ChainId: chainId, - balance, - }); + + const balanceChanged = !balance.eq(currentBalance); + const [message, log] = balanceChanged + ? ["🚧 Token balance on mainnet changed, skipping rebalance", this.logger.warn] + : ["Token balance in relayer on mainnet is as expected, sending cross chain transfer", this.logger.debug]; + log({ at: "InventoryClient", message, l1Token, l2Token, l2ChainId: chainId, balance, currentBalance }); + + if (!balanceChanged) { possibleRebalances.push(rebalance); // Decrement token balance in client for this chain and increment cross chain counter. this.trackCrossChainTransfer(l1Token, l2Token, amount, chainId); @@ -782,7 +860,7 @@ export class InventoryClient { `- ${symbol} transfer blocked. Required to send ` + `${formatter(amount.toString())} but relayer has ` + `${formatter(balance.toString())} on L1. There is currently ` + - `${formatter(this.getBalanceOnChainForL1Token(chainId, l1Token).toString())} ${symbol} on ` + + `${formatter(this.getBalanceOnChain(chainId, l1Token, l2Token).toString())} ${symbol} on ` + `${getNetworkName(chainId)} which is ` + `${this.formatWei(distributionPct.toString())}% of the total ` + `${formatter(cumulativeBalance.toString())} ${symbol}.` + @@ -808,6 +886,10 @@ export class InventoryClient { } async unwrapWeth(): Promise { + if (!this.isInventoryManagementEnabled()) { + return; + } + // Note: these types are just used inside this method, so they are declared in-line. type ChainInfo = { chainId: number; @@ -824,16 +906,14 @@ export class InventoryClient { const executedTransactions: ExecutedUnwrap[] = []; try { - if (!this.isInventoryManagementEnabled()) { - return; - } const l1Weth = TOKEN_SYMBOLS_MAP.WETH.addresses[this.hubPoolClient.chainId]; const chains = await Promise.all( this.getEnabledChains() .map((chainId) => { - const unwrapWethThreshold = - this.inventoryConfig.tokenConfig?.[l1Weth]?.[chainId.toString()]?.unwrapWethThreshold; - const unwrapWethTarget = this.inventoryConfig.tokenConfig?.[l1Weth]?.[chainId.toString()]?.unwrapWethTarget; + const tokenConfig = this.getTokenConfig(l1Weth, chainId); + assert(isDefined(tokenConfig)); + + const { unwrapWethThreshold, unwrapWethTarget } = tokenConfig; // Ignore chains where ETH isn't the native gas token. Returning null will result in these being filtered. if (chainId === CHAIN_IDs.POLYGON || unwrapWethThreshold === undefined || unwrapWethTarget === undefined) { @@ -959,7 +1039,7 @@ export class InventoryClient { const chainId = Number(_chainId); Object.entries(distributionForToken[chainId]).forEach(([l2Token, amount]) => { - const balanceOnChain = this.getBalanceOnChainForL1Token(chainId, l1Token); + const balanceOnChain = this.getBalanceOnChain(chainId, l1Token, l2Token); const transfers = this.crossChainTransferClient.getOutstandingCrossChainTransferAmount( this.relayer, chainId, @@ -1042,7 +1122,18 @@ export class InventoryClient { } _l1TokenEnabledForChain(l1Token: string, chainId: number): boolean { - return this.inventoryConfig.tokenConfig?.[l1Token]?.[String(chainId)] !== undefined; + const tokenConfig = this.inventoryConfig?.tokenConfig?.[l1Token]; + if (!isDefined(tokenConfig)) { + return false; + } + + // If tokenConfig directly references chainId, token is enabled. + if (!isAliasConfig(tokenConfig) && isDefined(tokenConfig[chainId])) { + return true; + } + + // If any of the mapped symbols reference chainId, token is enabled. + return Object.keys(tokenConfig).some((symbol) => isDefined(tokenConfig[symbol][chainId])); } /** diff --git a/src/interfaces/InventoryManagement.ts b/src/interfaces/InventoryManagement.ts index 9cd9303fb..4f2e76c11 100644 --- a/src/interfaces/InventoryManagement.ts +++ b/src/interfaces/InventoryManagement.ts @@ -1,20 +1,59 @@ -import { BigNumber } from "ethers"; +import { BigNumber, utils as ethersUtils } from "ethers"; +import { TOKEN_SYMBOLS_MAP } from "../utils"; +export type TokenBalanceConfig = { + targetOverageBuffer: BigNumber; // Max multiplier for targetPct, to give flexibility in repayment chain selection. + targetPct: BigNumber; // The desired amount of the given token on the L2 chainId. + thresholdPct: BigNumber; // Threshold, below which, we will execute a rebalance. + unwrapWethThreshold?: BigNumber; // Threshold for ETH to trigger WETH unwrapping to maintain ETH balance. + unwrapWethTarget?: BigNumber; // Amount of WETH to unwrap to refill ETH. Unused if unwrapWethThreshold is undefined. +}; + +export type ChainTokenConfig = { + [chainId: string]: TokenBalanceConfig; +}; + +// AliasConfig permits a single HubPool token to map onto multiple tokens on a remote chain. +export type ChainTokenInventory = { + [symbol: string]: ChainTokenConfig; +}; + +/** + * Example configuration: + * - DAI on chains 10 & 42161. + * - Bridged USDC (USDC.e, USDbC) on chains 10, 137, 324, 8453, 42161 & 59144. + * - Native USDC on Polygon. + * + * All token allocations are "global", so Polygon will be allocated a total of 8% of all USDC: + * - 4% of global USDC as Native USDC, and + * - 4% as Bridged USDC. + * + * "tokenConfig": { + * "DAI": { + * "10": { "targetPct": 8, "thresholdPct": 4 }, + * "42161": { "targetPct": 8, "thresholdPct": 4 }, + * }, + * "USDC": { + * "USDC.e": { + * "10": { "targetPct": 8, "thresholdPct": 4 }, + * "137": { "targetPct": 4, "thresholdPct": 2 }, + * "324": { "targetPct": 8, "thresholdPct": 4 }, + * "42161": { "targetPct": 8, "thresholdPct": 4 }, + * "59144": { "targetPct": 5, "thresholdPct": 2 } + * }, + * "USDbC": { + * "8453": { "targetPct": 5, "thresholdPct": 2 } + * }, + * "USDC": { + * "137": { "targetPct": 4, "thresholdPct": 2 } + * } + * } + * } + */ export interface InventoryConfig { - tokenConfig: { - [l1Token: string]: { - [chainId: string]: { - targetOverageBuffer: BigNumber; // The relayer will be allowed to hold this multiple times the targetPct - // of the full token balance on this chain. - targetPct: BigNumber; // The desired amount of the given token on the L2 chainId. - thresholdPct: BigNumber; // Threshold, below which, we will execute a rebalance. - unwrapWethThreshold?: BigNumber; // Threshold for ETH on this chain to trigger WETH unwrapping to maintain - // ETH balance - unwrapWethTarget?: BigNumber; // Amount of WETH to unwrap to refill ETH balance. Unused if unwrapWethThreshold - // is undefined. - }; - }; - }; + // tokenConfig can map to a single token allocation, or a set of allocations that all map to the same HubPool token. + tokenConfig: { [l1Token: string]: ChainTokenConfig } | { [l1Token: string]: ChainTokenInventory }; + // If ETH balance on chain is above threshold, wrap the excess over the target to WETH. wrapEtherTargetPerChain: { [chainId: number]: BigNumber; @@ -25,3 +64,9 @@ export interface InventoryConfig { }; wrapEtherThreshold: BigNumber; } + +export function isAliasConfig(config: ChainTokenConfig | ChainTokenInventory): config is ChainTokenInventory { + return ( + Object.keys(config).every((k) => ethersUtils.isAddress(k)) || Object.keys(config).every((k) => TOKEN_SYMBOLS_MAP[k]) + ); +} diff --git a/src/relayer/RelayerConfig.ts b/src/relayer/RelayerConfig.ts index 4f90a1be6..a0c7de3ec 100644 --- a/src/relayer/RelayerConfig.ts +++ b/src/relayer/RelayerConfig.ts @@ -15,7 +15,7 @@ import { } from "../utils"; import { CommonConfig, ProcessEnv } from "../common"; import * as Constants from "../common/Constants"; -import { InventoryConfig } from "../interfaces"; +import { InventoryConfig, TokenBalanceConfig, isAliasConfig } from "../interfaces/InventoryManagement"; type DepositConfirmationConfig = { usdThreshold: BigNumber; @@ -153,47 +153,73 @@ export class RelayerConfig extends CommonConfig { } }); + const parseTokenConfig = ( + l1Token: string, + chainId: string, + rawTokenConfig: TokenBalanceConfig + ): TokenBalanceConfig => { + const { targetPct, thresholdPct, unwrapWethThreshold, unwrapWethTarget, targetOverageBuffer } = rawTokenConfig; + const tokenConfig: TokenBalanceConfig = { targetPct, thresholdPct, targetOverageBuffer }; + + assert( + targetPct !== undefined && thresholdPct !== undefined, + `Bad config. Must specify targetPct, thresholdPct for ${l1Token} on ${chainId}` + ); + assert( + toBN(thresholdPct).lte(toBN(targetPct)), + `Bad config. thresholdPct<=targetPct for ${l1Token} on ${chainId}` + ); + tokenConfig.targetPct = toBNWei(targetPct).div(100); + tokenConfig.thresholdPct = toBNWei(thresholdPct).div(100); + + // Default to 150% the targetPct. targetOverageBuffer does not have to be defined so that no existing configs + // are broken. This is a reasonable default because it allows the relayer to be a bit more flexible in + // holding more tokens than the targetPct, but perhaps a better default is 100% + tokenConfig.targetOverageBuffer = toBNWei(targetOverageBuffer ?? "1.5"); + + // For WETH, also consider any unwrap target/threshold. + if (l1Token === TOKEN_SYMBOLS_MAP.WETH.addresses[this.hubPoolChainId]) { + if (unwrapWethThreshold !== undefined) { + tokenConfig.unwrapWethThreshold = toBNWei(unwrapWethThreshold); + } + tokenConfig.unwrapWethTarget = toBNWei(unwrapWethTarget ?? 2); + } + + return tokenConfig; + }; + const rawTokenConfigs = inventoryConfig?.tokenConfig ?? {}; const tokenConfigs = (inventoryConfig.tokenConfig = {}); Object.keys(rawTokenConfigs).forEach((l1Token) => { // If the l1Token is a symbol, resolve the correct address. const effectiveL1Token = ethersUtils.isAddress(l1Token) ? l1Token - : TOKEN_SYMBOLS_MAP[l1Token]?.addresses[this.hubPoolChainId]; + : TOKEN_SYMBOLS_MAP[l1Token].addresses[this.hubPoolChainId]; assert(effectiveL1Token !== undefined, `No token identified for ${l1Token}`); - Object.keys(rawTokenConfigs[l1Token]).forEach((chainId) => { - const { targetPct, thresholdPct, unwrapWethThreshold, unwrapWethTarget, targetOverageBuffer } = - rawTokenConfigs[l1Token][chainId]; + tokenConfigs[effectiveL1Token] ??= {}; + const hubTokenConfig = rawTokenConfigs[l1Token]; - tokenConfigs[effectiveL1Token] ??= {}; - tokenConfigs[effectiveL1Token][chainId] ??= { targetPct, thresholdPct, targetOverageBuffer }; - const tokenConfig = tokenConfigs[effectiveL1Token][chainId]; + if (isAliasConfig(hubTokenConfig)) { + Object.keys(hubTokenConfig).forEach((symbol) => { + Object.keys(hubTokenConfig[symbol]).forEach((chainId) => { + const rawTokenConfig = hubTokenConfig[symbol][chainId]; + const effectiveSpokeToken = TOKEN_SYMBOLS_MAP[symbol].addresses[chainId]; - assert( - targetPct !== undefined && thresholdPct !== undefined, - `Bad config. Must specify targetPct, thresholdPct for ${l1Token} on ${chainId}` - ); - assert( - toBN(thresholdPct).lte(toBN(targetPct)), - `Bad config. thresholdPct<=targetPct for ${l1Token} on ${chainId}` - ); - tokenConfig.targetPct = toBNWei(targetPct).div(100); - tokenConfig.thresholdPct = toBNWei(thresholdPct).div(100); - - // Default to 150% the targetPct. targetOverageBuffer does not have to be defined so that no existing configs - // are broken. This is a reasonable default because it allows the relayer to be a bit more flexible in - // holding more tokens than the targetPct, but perhaps a better default is 100% - tokenConfig.targetOverageBuffer = toBNWei(targetOverageBuffer ?? "1.5"); - - // For WETH, also consider any unwrap target/threshold. - if (effectiveL1Token === TOKEN_SYMBOLS_MAP.WETH.addresses[this.hubPoolChainId]) { - if (unwrapWethThreshold !== undefined) { - tokenConfig.unwrapWethThreshold = toBNWei(unwrapWethThreshold); - } - tokenConfig.unwrapWethTarget = toBNWei(unwrapWethTarget ?? 2); - } - }); + tokenConfigs[effectiveL1Token][effectiveSpokeToken] ??= {}; + tokenConfigs[effectiveL1Token][effectiveSpokeToken][chainId] = parseTokenConfig( + l1Token, + chainId, + rawTokenConfig + ); + }); + }); + } else { + Object.keys(hubTokenConfig).forEach((chainId) => { + const rawTokenConfig = hubTokenConfig[chainId]; + tokenConfigs[effectiveL1Token][chainId] = parseTokenConfig(l1Token, chainId, rawTokenConfig); + }); + } }); } diff --git a/test/InventoryClient.InventoryRebalance.ts b/test/InventoryClient.InventoryRebalance.ts index fc6a262d0..4eef3a668 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -20,7 +20,15 @@ import { ConfigStoreClient, InventoryClient } from "../src/clients"; // Tested import { CrossChainTransferClient } from "../src/clients/bridges"; import { InventoryConfig } from "../src/interfaces"; import { MockAdapterManager, MockBundleDataClient, MockHubPoolClient, MockTokenClient } from "./mocks/"; -import { bnZero, CHAIN_IDs, ERC20, TOKEN_SYMBOLS_MAP } from "../src/utils"; +import { + bnZero, + CHAIN_IDs, + createFormatFunction, + ERC20, + fixedPointAdjustment as fixedPoint, + getNetworkName, + TOKEN_SYMBOLS_MAP, +} from "../src/utils"; const toMegaWei = (num: string | number | BigNumber) => ethers.utils.parseUnits(num.toString(), 6); @@ -30,8 +38,8 @@ let owner: SignerWithAddress, spy: sinon.SinonSpy, spyLogger: winston.Logger; let inventoryClient: InventoryClient; // tested let crossChainTransferClient: CrossChainTransferClient; -const { MAINNET, OPTIMISM, POLYGON, ARBITRUM } = CHAIN_IDs; -const enabledChainIds = [MAINNET, OPTIMISM, POLYGON, ARBITRUM]; +const { MAINNET, OPTIMISM, POLYGON, BASE, ARBITRUM } = CHAIN_IDs; +const enabledChainIds = [MAINNET, OPTIMISM, POLYGON, BASE, ARBITRUM]; const mainnetWeth = TOKEN_SYMBOLS_MAP.WETH.addresses[MAINNET]; const mainnetUsdc = TOKEN_SYMBOLS_MAP.USDC.addresses[MAINNET]; @@ -59,11 +67,13 @@ const inventoryConfig: InventoryConfig = { [mainnetWeth]: { [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + [BASE]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, }, [mainnetUsdc]: { [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + [BASE]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, }, }, @@ -74,11 +84,12 @@ const initialAllocation = { [MAINNET]: { [mainnetWeth]: toWei(100), [mainnetUsdc]: toMegaWei(10000) }, // seed 100 WETH and 10000 USDC [OPTIMISM]: { [mainnetWeth]: toWei(20), [mainnetUsdc]: toMegaWei(2000) }, // seed 20 WETH and 2000 USDC [POLYGON]: { [mainnetWeth]: toWei(10), [mainnetUsdc]: toMegaWei(1000) }, // seed 10 WETH and 1000 USDC + [BASE]: { [mainnetWeth]: toWei(10), [mainnetUsdc]: toMegaWei(1000) }, // seed 10 WETH and 1000 USDC [ARBITRUM]: { [mainnetWeth]: toWei(10), [mainnetUsdc]: toMegaWei(1000) }, // seed 10 WETH and 1000 USDC }; -const initialWethTotal = toWei(140); // Sum over all 4 chains is 140 -const initialUsdcTotal = toMegaWei(14000); // Sum over all 4 chains is 14000 +const initialWethTotal = toWei(150); // Sum over all 5 chains is 150 +const initialUsdcTotal = toMegaWei(15000); // Sum over all 5 chains is 15000 const initialTotals = { [mainnetWeth]: initialWethTotal, [mainnetUsdc]: initialUsdcTotal }; describe("InventoryClient: Rebalancing inventory", async function () { @@ -125,7 +136,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { it("Accessors work as expected", async function () { expect(inventoryClient.getEnabledChains()).to.deep.equal(enabledChainIds); expect(inventoryClient.getL1Tokens()).to.deep.equal(Object.keys(inventoryConfig.tokenConfig)); - expect(inventoryClient.getEnabledL2Chains()).to.deep.equal([OPTIMISM, POLYGON, ARBITRUM]); + expect(inventoryClient.getEnabledL2Chains()).to.deep.equal([OPTIMISM, POLYGON, BASE, ARBITRUM]); expect(inventoryClient.getCumulativeBalance(mainnetWeth).eq(initialWethTotal)).to.be.true; expect(inventoryClient.getCumulativeBalance(mainnetUsdc).eq(initialUsdcTotal)).to.be.true; @@ -134,9 +145,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { const tokenDistribution = inventoryClient.getTokenDistributionPerL1Token(); for (const chainId of enabledChainIds) { for (const l1Token of inventoryClient.getL1Tokens()) { - expect(inventoryClient.getBalanceOnChainForL1Token(chainId, l1Token)).to.equal( - initialAllocation[chainId][l1Token] - ); + expect(inventoryClient.getBalanceOnChain(chainId, l1Token)).to.equal(initialAllocation[chainId][l1Token]); expect( inventoryClient.crossChainTransferClient .getOutstandingCrossChainTransferAmount(owner.address, chainId, l1Token) @@ -158,7 +167,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; // Now, simulate the re-allocation of funds. Say that the USDC on arbitrum is half used up. This will leave arbitrum - // with 500 USDC, giving a percentage of 500/14000 = 0.035. This is below the threshold of 0.5 so we should see + // with 500 USDC, giving a percentage of 500/15000 = 0.035. This is below the threshold of 0.5 so we should see // a re-balance executed in size of the target allocation + overshoot percentage. const initialBalance = initialAllocation[ARBITRUM][mainnetUsdc]; expect(tokenClient.getBalance(ARBITRUM, l2TokensForUsdc[ARBITRUM]).eq(initialBalance)).to.be.true; @@ -172,14 +181,14 @@ describe("InventoryClient: Rebalancing inventory", async function () { // Execute rebalance. Check logs and enqueued transaction in Adapter manager. Given the total amount over all chains // and the amount still on arbitrum we would expect the module to instruct the relayer to send over: - // (0.05 + 0.02) * (14000 - 500) - 500 = 445. Note the -500 component is there as arbitrum already has 500. our left + // (0.05 + 0.02) * (15000 - 500) - 500 = 515. Note the -500 component is there as arbitrum already has 500 remaining // post previous relay. - const expectedBridgedAmount = toMegaWei(445); + const expectedBridgedAmount = toMegaWei(515); await inventoryClient.update(); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "Executed Inventory rebalances")).to.be.true; expect(lastSpyLogIncludes(spy, "Rebalances sent to Arbitrum")).to.be.true; - expect(lastSpyLogIncludes(spy, "445.00 USDC rebalanced")).to.be.true; // cast to formatting expected by client. + expect(lastSpyLogIncludes(spy, "515.00 USDC rebalanced")).to.be.true; // cast to formatting expected by client. expect(lastSpyLogIncludes(spy, "This meets target allocation of 7.00%")).to.be.true; // config from client. // The mock adapter manager should have been called with the expected transaction. @@ -192,7 +201,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.update(); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; - expect(spyLogIncludes(spy, -2, '"outstandingTransfers":"445.00"')).to.be.true; + expect(spyLogIncludes(spy, -2, '"outstandingTransfers":"515.00"')).to.be.true; // Now mock that funds have finished coming over the bridge and check behavior is as expected. adapterManager.setMockedOutstandingCrossChainTransfers(ARBITRUM, owner.address, mainnetUsdc, bnZero); // zero the transfer. mock conclusion. @@ -203,9 +212,9 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.update(); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; - // We should see a log for chain ARBITRUM that shows the actual balance after the relay concluded and the share. - // actual balance should be listed above at 945. share should be 945/(13500) =0.7 (initial total - withdrawAmount). - expect(spyLogIncludes(spy, -2, `"${ARBITRUM}":{"actualBalanceOnChain":"945.00"`)).to.be.true; + // We should see a log for Arbitrum that shows the actual balance after the relay concluded and the share. The + // actual balance should be listed above at 1015. share should be 1015/(14500) = 0.7 (initial total - withdrawAmount). + expect(spyLogIncludes(spy, -2, `"${ARBITRUM}":{"actualBalanceOnChain":"1,015.00"`)).to.be.true; expect(spyLogIncludes(spy, -2, '"proRataShare":"7.00%"')).to.be.true; }); @@ -222,13 +231,13 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.update(); // If we now consider how much should be sent over the bridge. The spoke pool, considering the shortfall, has an - // allocation of -5.7%. The target is, however, 5% of the total supply. factoring in the overshoot parameter we - // should see a transfer of 5 + 2 - (-5.7)=12.714% of total inventory. This should be an amount of 0.127*140=17.79. - const expectedBridgedAmount = toBN("17799999999999999880"); + // allocation of -5.3%. The target is, however, 5% of the total supply. factoring in the overshoot parameter we + // should see a transfer of 5 + 2 - (-5.3)=12.3% of total inventory. This should be an amount of 0.1233*150=18.49. + const expectedBridgedAmount = toBN("18499999999999999950"); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "Executed Inventory rebalances")).to.be.true; expect(lastSpyLogIncludes(spy, "Rebalances sent to Polygon")).to.be.true; - expect(lastSpyLogIncludes(spy, "17.79 WETH rebalanced")).to.be.true; // expected bridge amount rounded for logs. + expect(lastSpyLogIncludes(spy, "18.49 WETH rebalanced")).to.be.true; // expected bridge amount rounded for logs. expect(lastSpyLogIncludes(spy, "This meets target allocation of 7.00%")).to.be.true; // config from client. // Note that there should be some additional state updates that we should check. In particular the token balance @@ -254,9 +263,9 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.update(); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; - expect(spyLogIncludes(spy, -2, '"outstandingTransfers":"17.79"')).to.be.true; + expect(spyLogIncludes(spy, -2, '"outstandingTransfers":"18.49"')).to.be.true; expect(spyLogIncludes(spy, -2, '"actualBalanceOnChain":"10.00"')).to.be.true; - expect(spyLogIncludes(spy, -2, '"virtualBalanceOnChain":"27.79"')).to.be.true; + expect(spyLogIncludes(spy, -2, '"virtualBalanceOnChain":"28.49"')).to.be.true; // Now mock that funds have finished coming over the bridge and check behavior is as expected. // Zero the transfer. mock conclusion. @@ -299,13 +308,199 @@ describe("InventoryClient: Rebalancing inventory", async function () { .whenCalledWith(owner.address) .returns(initialAllocation[MAINNET][mainnetUsdc].sub(toMegaWei(1))); await inventoryClient.rebalanceInventoryIfNeeded(); - expect(spyLogIncludes(spy, -2, "Token balance on Ethereum changed")).to.be.true; + expect(spyLogIncludes(spy, -2, "Token balance on mainnet changed")).to.be.true; // Reset and check again. mainnetUsdcContract.balanceOf.whenCalledWith(owner.address).returns(initialAllocation[MAINNET][mainnetUsdc]); await inventoryClient.rebalanceInventoryIfNeeded(); expect(lastSpyLogIncludes(spy, "Executed Inventory rebalances")).to.be.true; }); + + describe("Remote chain token mappings", async function () { + const nativeUSDC = TOKEN_SYMBOLS_MAP._USDC.addresses; + const bridgedUSDC = { ...TOKEN_SYMBOLS_MAP["USDC.e"].addresses, ...TOKEN_SYMBOLS_MAP["USDbC"].addresses }; + const usdcConfig = { + [nativeUSDC[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [nativeUSDC[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [nativeUSDC[BASE]]: { + [BASE]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [nativeUSDC[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [bridgedUSDC[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [bridgedUSDC[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [bridgedUSDC[BASE]]: { + [BASE]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [bridgedUSDC[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + }; + + beforeEach(async function () { + // Sub in a nested USDC config for the existing USDC single-token config. + inventoryConfig.tokenConfig[mainnetUsdc] = usdcConfig; + }); + + it("Correctly resolves 1:many token mappings", async function () { + // Caller must specify l2Token for 1:many mappings. + expect(() => inventoryClient.getTokenConfig(mainnetUsdc, BASE)).to.throw; + + enabledChainIds + .filter((chainId) => chainId !== MAINNET) + .forEach((chainId) => { + const config = inventoryClient.getTokenConfig(mainnetUsdc, chainId, bridgedUSDC[chainId]); + expect(config).to.exist; + + const expectedConfig = inventoryConfig.tokenConfig[mainnetUsdc][bridgedUSDC[chainId]][chainId]; + expect(expectedConfig).to.exist; + expect(expectedConfig).to.deep.equal(expectedConfig); + }); + }); + + it("Correctly isolates 1:many token balances", async function () { + enabledChainIds + .filter((chainId) => chainId !== MAINNET) + .forEach((chainId) => { + const bridgedBalance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, bridgedUSDC[chainId]); + expect(bridgedBalance.gt(bnZero)).to.be.true; + + // Non-zero bridged USDC balance, zero native balance. + let nativeBalance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, nativeUSDC[chainId]); + expect(nativeBalance.eq(bnZero)).to.be.true; + + // Add native balance. + tokenClient.setTokenData(chainId, nativeUSDC[chainId], bridgedBalance); + + // Native balance should now match bridged balance. + nativeBalance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, nativeUSDC[chainId]); + expect(nativeBalance.eq(bridgedBalance)).to.be.true; + }); + }); + + it("Correctly sums 1:many token balances", async function () { + enabledChainIds + .filter((chainId) => chainId !== MAINNET) + .forEach((chainId) => { + const bridgedBalance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, bridgedUSDC[chainId]); + expect(bridgedBalance.gt(bnZero)).to.be.true; + + const nativeBalance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, nativeUSDC[chainId]); + expect(nativeBalance.eq(bnZero)).to.be.true; + + const cumulativeBalance = inventoryClient.getCumulativeBalance(mainnetUsdc); + expect(cumulativeBalance.eq(initialUsdcTotal)).to.be.true; + + tokenClient.setTokenData(chainId, nativeUSDC[chainId], bridgedBalance); + + const newBalance = inventoryClient.getCumulativeBalance(mainnetUsdc); + expect(newBalance.eq(initialUsdcTotal.add(bridgedBalance))).to.be.true; + + // Revert to 0 balance for native USDC. + tokenClient.setTokenData(chainId, nativeUSDC[chainId], bnZero); + }); + }); + + it("Correctly tracks 1:many token distributions", async function () { + enabledChainIds + .filter((chainId) => chainId !== MAINNET) + .forEach((chainId) => { + // Total USDC across all chains. + let cumulativeBalance = inventoryClient.getCumulativeBalance(mainnetUsdc); + expect(cumulativeBalance.gt(bnZero)).to.be.true; + expect(cumulativeBalance.eq(initialUsdcTotal)).to.be.true; + + // The initial allocation is all bridged USDC, 0 native. + const bridgedAllocation = inventoryClient.getCurrentAllocationPct(mainnetUsdc, chainId, bridgedUSDC[chainId]); + expect(bridgedAllocation.gt(bnZero)).to.be.true; + let balance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, bridgedUSDC[chainId]); + expect(bridgedAllocation.eq(balance.mul(fixedPoint).div(cumulativeBalance))).to.be.true; + + let nativeAllocation = inventoryClient.getCurrentAllocationPct(mainnetUsdc, chainId, nativeUSDC[chainId]); + expect(nativeAllocation.eq(bnZero)).to.be.true; + + balance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, nativeUSDC[chainId]); + expect(nativeAllocation.eq(bnZero)).to.be.true; + + // Add native USDC, same amount as bridged USDC. + balance = inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, bridgedUSDC[chainId]); + tokenClient.setTokenData(chainId, nativeUSDC[chainId], balance); + expect(inventoryClient.getBalanceOnChain(chainId, mainnetUsdc, nativeUSDC[chainId]).eq(balance)).to.be.true; + expect(nativeAllocation.eq(bnZero)).to.be.true; + + // Native USDC allocation should now be non-zero. + nativeAllocation = inventoryClient.getCurrentAllocationPct(mainnetUsdc, chainId, nativeUSDC[chainId]); + expect(nativeAllocation.gt(bnZero)).to.be.true; + + expect(inventoryClient.getCumulativeBalance(mainnetUsdc).gt(cumulativeBalance)).to.be.true; + cumulativeBalance = inventoryClient.getCumulativeBalance(mainnetUsdc); + expect(cumulativeBalance.gt(initialUsdcTotal)).to.be.true; + + // Return native USDC balance to 0 for next loop. + tokenClient.setTokenData(chainId, nativeUSDC[chainId], bnZero); + }); + }); + + it("Correctly rebalances mainnet USDC into non-repayment USDC", async function () { + // Unset all native USDC allocations. + for (const chainId of [OPTIMISM, POLYGON, BASE, ARBITRUM]) { + const l2Token = nativeUSDC[chainId]; + delete inventoryConfig.tokenConfig[mainnetUsdc][l2Token]; + } + + await inventoryClient.update(); + await inventoryClient.rebalanceInventoryIfNeeded(); + expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; + + const cumulativeUSDC = inventoryClient.getCumulativeBalance(mainnetUsdc); + const targetPct = toWei(0.1); + const thresholdPct = toWei(0.05); + const expectedRebalance = cumulativeUSDC.mul(targetPct).div(fixedPoint); + const { decimals } = TOKEN_SYMBOLS_MAP.USDC; + const formatter = createFormatFunction(2, 4, false, decimals); + const formattedAmount = formatter(expectedRebalance.toString()); + + let virtualMainnetBalance = initialAllocation[MAINNET][mainnetUsdc]; + + for (const chainId of [OPTIMISM, POLYGON, BASE, ARBITRUM]) { + const chain = getNetworkName(chainId); + await inventoryClient.update(); + const l2Token = nativeUSDC[chainId]; + + // Apply a new target balance for native USDC. + inventoryConfig.tokenConfig[mainnetUsdc][l2Token] = { + [chainId]: { targetPct, thresholdPct, targetOverageBuffer }, + }; + + await inventoryClient.update(); + await inventoryClient.rebalanceInventoryIfNeeded(); + expect(lastSpyLogIncludes(spy, `Rebalances sent to ${chain}`)).to.be.true; + expect(lastSpyLogIncludes(spy, `${formattedAmount} USDC rebalanced`)).to.be.true; + expect(lastSpyLogIncludes(spy, "This meets target allocation of 10.00%")).to.be.true; // config from client. + + // Decrement the mainnet USDC balance to simulate the rebalance. + virtualMainnetBalance = virtualMainnetBalance.sub(expectedRebalance); + mainnetUsdcContract.balanceOf.whenCalledWith(owner.address).returns(virtualMainnetBalance); + + // The mock adapter manager should have been called with the expected transaction. + expect(adapterManager.tokensSentCrossChain[chainId][mainnetUsdc].amount.eq(expectedRebalance)).to.be.true; + + await inventoryClient.update(); + await inventoryClient.rebalanceInventoryIfNeeded(); + expect(lastSpyLogIncludes(spy, "No rebalances required")).to.be.true; + expect(spyLogIncludes(spy, -2, `"outstandingTransfers":"${formattedAmount}"`)).to.be.true; + } + }); + }); }); function seedMocks(seedBalances: { [chainId: string]: { [token: string]: BigNumber } }) { diff --git a/test/InventoryClient.RefundChain.ts b/test/InventoryClient.RefundChain.ts index c569b1278..cf6b3000a 100644 --- a/test/InventoryClient.RefundChain.ts +++ b/test/InventoryClient.RefundChain.ts @@ -64,7 +64,6 @@ describe("InventoryClient: Refund chain selection", async function () { [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, }, - [mainnetUsdc]: { [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, @@ -539,4 +538,80 @@ describe("InventoryClient: Refund chain selection", async function () { expect(possibleRepaymentChains.length).to.equal(4); }); }); + + describe("In-protocol swap", async function () { + const nativeUSDC = TOKEN_SYMBOLS_MAP._USDC.addresses; + const bridgedUSDC = { ...TOKEN_SYMBOLS_MAP["USDC.e"].addresses, ...TOKEN_SYMBOLS_MAP["USDbC"].addresses }; + + beforeEach(async function () { + // Sub in a nested USDC config for the existing USDC config. + const usdcConfig = { + [nativeUSDC[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [nativeUSDC[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [nativeUSDC[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [bridgedUSDC[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [bridgedUSDC[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [bridgedUSDC[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + }; + inventoryConfig.tokenConfig[mainnetUsdc] = usdcConfig; + + const inputAmount = toMegaWei(100); + sampleDepositData = { + depositId: 0, + originChainId: ARBITRUM, + destinationChainId: OPTIMISM, + depositor: owner.address, + recipient: owner.address, + inputToken: nativeUSDC[ARBITRUM], + inputAmount, + outputToken: bridgedUSDC[OPTIMISM], + outputAmount: inputAmount, + message: "0x", + quoteTimestamp: hubPoolClient.currentTime!, + fillDeadline: 0, + exclusivityDeadline: 0, + exclusiveRelayer: ZERO_ADDRESS, + }; + }); + + it("outputToken is not supported as a repaymentToken", async function () { + // Verify that there is no native USDC anywhere. The relayer is responsible for ensuring that it can make the fill. + enabledChainIds + .filter((chainId) => chainId !== MAINNET) + .forEach((chainId) => expect(tokenClient.getBalance(chainId, nativeUSDC[chainId]).eq(bnZero)).to.be.true); + + // All chains are at target balance; cumulative balance will go down but repaymentToken balances on all chains are unaffected. + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(MAINNET); + expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"71942446043165467"')).to.be.true; // (10000-0)/(14000-100)=0.71942 + + // Even when the output amount is equal to the destination's entire balance, take repayment on mainnet. + sampleDepositData.outputAmount = inventoryClient.getBalanceOnChain(OPTIMISM, mainnetUsdc); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(MAINNET); + expect(lastSpyLogIncludes(spy, 'expectedPostRelayAllocation":"83333333333333333"')).to.be.true; // (10000-0)/(14000-2000)=0.8333 + + // Drop the relayer's repaymentToken balance on Optimism. Repayment chain should now be Optimism. + let balance = tokenClient.getBalance(OPTIMISM, bridgedUSDC[OPTIMISM]); + tokenClient.setTokenData(OPTIMISM, bridgedUSDC[OPTIMISM], bnZero); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(OPTIMISM); + + // Restore the Optimism balance and drop the Arbitrum balance. Repayment chain should now be Arbitrum. + tokenClient.setTokenData(OPTIMISM, bridgedUSDC[OPTIMISM], balance); + + balance = tokenClient.getBalance(ARBITRUM, bridgedUSDC[ARBITRUM]); + tokenClient.setTokenData(ARBITRUM, bridgedUSDC[ARBITRUM], bnZero); + expect(await inventoryClient.determineRefundChainId(sampleDepositData, mainnetUsdc)).to.equal(ARBITRUM); + }); + }); }); diff --git a/test/mocks/MockInventoryClient.ts b/test/mocks/MockInventoryClient.ts index 0916da06c..74da30567 100644 --- a/test/mocks/MockInventoryClient.ts +++ b/test/mocks/MockInventoryClient.ts @@ -33,7 +33,7 @@ export class MockInventoryClient extends InventoryClient { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async determineRefundChainId(_deposit: Deposit): Promise { + override async determineRefundChainId(_deposit: Deposit): Promise { return this.inventoryConfig === null ? 1 : super.determineRefundChainId(_deposit); } @@ -53,7 +53,7 @@ export class MockInventoryClient extends InventoryClient { this.possibleRebalances = []; } - getPossibleRebalances(): Rebalance[] { + override getPossibleRebalances(): Rebalance[] { return this.possibleRebalances; } @@ -61,7 +61,7 @@ export class MockInventoryClient extends InventoryClient { this.balanceOnChain = newBalance; } - getBalanceOnChainForL1Token(): BigNumber { + override getBalanceOnChain(): BigNumber { return this.balanceOnChain; } } From 2737cd6f8703586e3bd6306fad4667ebe04d965e Mon Sep 17 00:00:00 2001 From: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Date: Fri, 10 May 2024 11:42:16 -0400 Subject: [PATCH 2/4] improve: Remove getTokenShortfall (#1505) Replace with `tokenClient.getShortfallTotalRequirement` because `getTokenShortFall` doesn't correctly get shortfall for a specific L2 token in the case where multiple L2 tokens map to the same L1 token --- src/clients/InventoryClient.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 92496aabc..13e195a7b 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -188,13 +188,6 @@ export class InventoryClient { return currentBalance.mul(this.scalar).div(cumulativeBalance); } - // Find how short a given chain is for a desired L1Token. - getTokenShortFall(l1Token: string, chainId: number): BigNumber { - return this.getRemoteTokensForL1Token(l1Token, chainId) - .map((token) => this.tokenClient.getShortfallTotalRequirement(chainId, token)) - .reduce((acc, curr) => acc.add(curr), bnZero); - } - getRepaymentTokenForL1Token(l1Token: string, chainId: number | string): string | undefined { // @todo: Update HubPoolClient.getL2TokenForL1TokenAtBlock() such that it returns `undefined` instead of throwing. try { @@ -825,7 +818,7 @@ export class InventoryClient { for (const [_chainId, rebalances] of Object.entries(groupedRebalances)) { const chainId = Number(_chainId); mrkdwn += `*Rebalances sent to ${getNetworkName(chainId)}:*\n`; - for (const { l1Token, amount, targetPct, thresholdPct, cumulativeBalance, hash } of rebalances) { + for (const { l1Token, l2Token, amount, targetPct, thresholdPct, cumulativeBalance, hash } of rebalances) { const tokenInfo = this.hubPoolClient.getTokenInfoForL1Token(l1Token); if (!tokenInfo) { throw new Error(`InventoryClient::rebalanceInventoryIfNeeded no L1 token info for token ${l1Token}`); @@ -839,7 +832,7 @@ export class InventoryClient { `${formatter( cumulativeBalance.toString() )} ${symbol} over all chains (ignoring hubpool repayments). This chain has a shortfall of ` + - `${formatter(this.getTokenShortFall(l1Token, chainId).toString())} ${symbol} ` + + `${formatter(this.tokenClient.getShortfallTotalRequirement(chainId, l2Token).toString())} ${symbol} ` + `tx: ${blockExplorerLink(hash, this.hubPoolClient.chainId)}\n`; } } @@ -1052,7 +1045,7 @@ export class InventoryClient { actualBalanceOnChain: formatter(actualBalanceOnChain.toString()), virtualBalanceOnChain: formatter(balanceOnChain.toString()), outstandingTransfers: formatter(transfers.toString()), - tokenShortFalls: formatter(this.getTokenShortFall(l1Token, chainId).toString()), + tokenShortFalls: formatter(this.tokenClient.getShortfallTotalRequirement(chainId, l2Token).toString()), proRataShare: this.formatWei(amount.mul(100).toString()) + "%", }; }); From 52f2c3510d59bd40d6feb7eaa7b811f4c449497e Mon Sep 17 00:00:00 2001 From: Paul <108695806+pxrl@users.noreply.github.com> Date: Fri, 10 May 2024 17:54:35 +0200 Subject: [PATCH 3/4] fix(InventoryClient): Ignore mainnet in rebalance evaluation (#1506) The set of rebalance chainIds was incorrectly changed from getEnabledL2Chains() to getEnabledChains() in the previous commit. --- src/clients/InventoryClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 13e195a7b..b2c482f65 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -694,7 +694,7 @@ export class InventoryClient { } getPossibleRebalances(): Rebalance[] { - const chainIds = this.getEnabledChains(); + const chainIds = this.getEnabledL2Chains(); const rebalancesRequired: Rebalance[] = []; for (const l1Token of this.getL1Tokens()) { From 4675913179626f0aa98875cb3cf287e612c3f2ee Mon Sep 17 00:00:00 2001 From: nicholaspai <9457025+nicholaspai@users.noreply.github.com> Date: Fri, 10 May 2024 13:30:37 -0400 Subject: [PATCH 4/4] improve(validateRunningBalances): Add expected excesses map (#1500) Useful to keep this script scalable is to keep track of known excesses produced by anyone sending tokens directly to the Spokes. These excesses can't be easily removed without admin intervention so we shouldn't have the script error out because of them. --- src/scripts/validateRunningBalances.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/scripts/validateRunningBalances.ts b/src/scripts/validateRunningBalances.ts index e4a92bed5..72dd95185 100644 --- a/src/scripts/validateRunningBalances.ts +++ b/src/scripts/validateRunningBalances.ts @@ -51,6 +51,10 @@ let logger: winston.Logger; const slowRootCache = {}; +const expectedExcesses: { [chainId: number]: { [token: string]: number } } = { + [10]: { ["USDC"]: 15.336508 }, // On May 4th, USDC was sent to the SpokePool here: https://optimistic.etherscan.io/tx/0x5f53293fe6a27ff9897d4dde445fd6aab46f841ca641befea48beef62014a549 +}; + export async function runScript(_logger: winston.Logger, baseSigner: Signer): Promise { logger = _logger; @@ -367,14 +371,17 @@ export async function runScript(_logger: winston.Logger, baseSigner: Signer): Pr logger.debug({ at: "validateRunningBalances#index", message: "Historical excesses", + expectedExcesses, excesses, }); - const unexpectedExcess = Object.entries(excesses).some(([, tokenExcesses]) => { - return Object.entries(tokenExcesses).some(([, excesses]) => { + const unexpectedExcess = Object.entries(excesses).some(([chainId, tokenExcesses]) => { + return Object.entries(tokenExcesses).some(([l1Token, excesses]) => { // We only care about the latest excess, because sometimes excesses can appear in historical bundles // due to ordering of executing leaves. As long as the excess resets back to 0 eventually it is fine. const excess = Number(excesses[0]); - return excess > 0.05 || excess < -0.05; + // Subtract any expected excesses + const excessForChain = excess - (expectedExcesses[chainId]?.[l1Token] ?? 0); + return excessForChain > 0.05 || excessForChain < -0.05; }); }); if (unexpectedExcess) {