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; } }