From c7d85415a5a307092cf8811c6f599fad7dc83100 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 25 Mar 2026 11:36:19 -0400 Subject: [PATCH 1/4] improve: Add utility functions for inventory equivalence remapping, Binance deposit matching, and floatToBN fix - TokenUtils: getInventoryEquivalentL1TokenAddress, getInventoryBalanceContributorTokens, isL2OnlyEquivalentToken - RunningBalanceUtils: getLatestRunningBalances extracted for shared use - BinanceUtils: getOutstandingBinanceDeposits for cross-L2 deposit matching, export BinanceDeposit type - BNUtils: Fix floatToBN MAX_SAFE_INTEGER overflow via string manipulation Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/utils/BNUtils.ts | 18 ++++-- src/utils/BinanceUtils.ts | 75 ++++++++++++++++++++++- src/utils/RunningBalanceUtils.ts | 101 +++++++++++++++++++++++++++++++ src/utils/TokenUtils.ts | 70 ++++++++++++++++++++- src/utils/index.ts | 1 + test/BNUtils.ts | 53 ++++++++++++++++ test/BinanceUtils.ts | 101 +++++++++++++++++++++++++++++++ test/TokenUtils.ts | 44 ++++++++++++++ yarn.lock | 5 ++ 10 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 src/utils/RunningBalanceUtils.ts create mode 100644 test/BNUtils.ts create mode 100644 test/BinanceUtils.ts create mode 100644 test/TokenUtils.ts diff --git a/package.json b/package.json index 1ea9b19dd5..ffd098ecc0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "node": ">=22.18.0" }, "dependencies": { - "@across-protocol/constants": "^3.1.100", + "@across-protocol/constants": "^3.1.102", "@across-protocol/contracts": "5.0.0", "@across-protocol/sdk": "4.3.135", "@arbitrum/sdk": "^4.0.2", diff --git a/src/utils/BNUtils.ts b/src/utils/BNUtils.ts index 7fc6cf072c..4b339cf168 100644 --- a/src/utils/BNUtils.ts +++ b/src/utils/BNUtils.ts @@ -26,9 +26,19 @@ export function bnComparatorAscending(a: BigNumber, b: BigNumber): -1 | 0 | 1 { } export function floatToBN(float: string | number, precision: number): BigNumber { - const strFloat = String(float); - const adjustment = strFloat.length - strFloat.indexOf(".") - 1; - const scaledAmount = Number(float) * 10 ** adjustment; - const bnAmount = BigNumber.from(Math.round(scaledAmount)); + // Convert to a fixed-point decimal string to avoid scientific notation (e.g. "1.234e-9") + // that JavaScript produces for very small or very large numbers. + const strFloat = typeof float === "string" ? float : float.toFixed(20).replace(/0+$/, "").replace(/\.$/, ""); + const dotIndex = strFloat.indexOf("."); + if (dotIndex === -1) { + // No decimal point — treat as integer with 0 decimal places. + return ConvertDecimals(0, precision)(BigNumber.from(strFloat)); + } + // Remove the decimal point to get the scaled integer as a string, avoiding floating-point overflow + // for numbers with many decimal digits (e.g. 5654.8610313399695 * 10^13 > Number.MAX_SAFE_INTEGER). + const integerPart = strFloat.slice(0, dotIndex); + const fractionalPart = strFloat.slice(dotIndex + 1); + const adjustment = fractionalPart.length; + const bnAmount = BigNumber.from(integerPart + fractionalPart); return ConvertDecimals(adjustment, precision)(bnAmount); } diff --git a/src/utils/BinanceUtils.ts b/src/utils/BinanceUtils.ts index 2958a08e63..93f56017b7 100644 --- a/src/utils/BinanceUtils.ts +++ b/src/utils/BinanceUtils.ts @@ -5,7 +5,7 @@ import Binance, { type Binance as BinanceApi, } from "binance-api-node"; import minimist from "minimist"; -import { getGckmsConfig, retrieveGckmsKeys, isDefined, assert, delay, CHAIN_IDs, getRedisCache } from "./"; +import { getGckmsConfig, retrieveGckmsKeys, isDefined, assert, delay, CHAIN_IDs, getRedisCache, truncate } from "./"; // Store global promises on Gckms key retrieval actions so that we don't retrieve the same key multiple times. let binanceSecretKeyPromise = undefined; @@ -51,7 +51,7 @@ type Network = { }; // A BinanceDeposit is either a simplified element of the return type of the Binance API's `depositHistory`. -type BinanceDeposit = { +export type BinanceDeposit = { // The amount of `coin` transferred in this interaction. amount: number; // The coin used in this interaction (i.e. the token symbol). @@ -60,12 +60,14 @@ type BinanceDeposit = { network: string; // The transaction hash of the deposit. txId: string; + // The timestamp that Binance assigns the deposit. + insertTime: number; // The status of the deposit/withdrawal. status?: number; }; // A BinanceWithdrawal is a simplified element of the return type of the Binance API's `withdrawHistory`. -export type BinanceWithdrawal = BinanceDeposit & { +export type BinanceWithdrawal = Omit & { // The recipient of `coin` on the destination network. recipient: string; // The unique withdrawal ID. @@ -256,6 +258,7 @@ export async function getBinanceDeposits( network: deposit.network, txId: deposit.txId, status: deposit.status, + insertTime: deposit.insertTime, } satisfies BinanceDeposit; }); } @@ -323,3 +326,69 @@ export async function getAccountCoins(binanceApi: BinanceApi): Promise withdrawal.network === BINANCE_NETWORKS[CHAIN_IDs.MAINNET]), + "Withdrawals must be for the Mainnet network" + ); + if (deposits.length === 0) { + return []; + } + assert( + deposits.every((deposit) => deposit.coin === deposits[0].coin), + "Deposits must be for the same coin" + ); + // Determining which deposits are outstanding is tricky for two reasons: + // - Binance withdrawals on Ethereum can batch together deposited amounts from different L2s. + // - It is not possible to determine which deposit is getting "finalized" by any withdrawal on Etheruem because + // there is no metadata associated with the withdrawal that indicates the L2 it originated from. + // - Binance withdrawals can be greater than or less than the deposited amount for individual deposits. + + // First, find the net outstanding deposited amount. + // @dev amount + txnFee can often exceed deposited amount due to existing dust on the Binance account + // that also gets included in the batch withdrawal. + const totalWithdrawalAmount = withdrawals.reduce((acc, w) => acc + Number(w.amount) + Number(w.transactionFee), 0); + const totalDepositedAmount = deposits.reduce((acc, d) => acc + Number(d.amount), 0); + let remainingOutstanding = totalDepositedAmount - totalWithdrawalAmount; + if (remainingOutstanding <= 0) { + return []; + } + + // There is outstanding deposited amount, so iterate through deposits from newest to oldest + // (newest deposits are most likely to be the ones not yet finalized) until we've accounted for + // all outstanding volume. + const sortedDepositsNewestFirst = deposits.slice().sort((a, b) => b.insertTime - a.insertTime); + const outstandingDeposits: BinanceDeposit[] = []; + for (const deposit of sortedDepositsNewestFirst) { + if (remainingOutstanding <= 0) { + break; + } + + if (deposit.amount <= remainingOutstanding) { + // Entire deposit is outstanding. + outstandingDeposits.push({ ...deposit }); + remainingOutstanding -= deposit.amount; + } else { + // Only part of this deposit is outstanding. The rest was covered by withdrawals. + // 8 decimal places is the precision of the Binance API and we truncate for simplicity sake and avoiding BN to float conversion issues. + outstandingDeposits.push({ ...deposit, amount: truncate(remainingOutstanding, 8) }); + remainingOutstanding = 0; + } + } + + // Filter for the deposits on the specific network and return them. + return outstandingDeposits.filter((deposit) => deposit.network === depositNetwork); +} diff --git a/src/utils/RunningBalanceUtils.ts b/src/utils/RunningBalanceUtils.ts new file mode 100644 index 0000000000..cf983267e3 --- /dev/null +++ b/src/utils/RunningBalanceUtils.ts @@ -0,0 +1,101 @@ +import { utils as sdkUtils } from "@across-protocol/sdk"; +import { BigNumber, isDefined, toBN, EvmAddress } from "."; +import { ProposedRootBundle } from "../interfaces"; +import { BundleDataApproxClient } from "../clients/BundleDataApproxClient"; +import { HubPoolClient } from "../clients"; + +type RunningBalanceResult = { + absLatestRunningBalance: BigNumber; + lastValidatedRunningBalance: BigNumber; + upcomingDeposits: BigNumber; + upcomingRefunds: BigNumber; + bundleEndBlock: number; + proposedRootBundle: string | undefined; +}; + +/** + * Returns running balances for an l1Token on the specified chains. + * @param l1Token L1 token address to query. + * @param chainsToEvaluate Chain IDs to compute running balances for. + * @param hubPoolClient HubPoolClient instance for querying validated bundles. + * @param bundleDataApproxClient BundleDataApproxClient for upcoming deposits/refunds. + * @returns Dictionary keyed by chain ID of running balance results. + */ +export async function getLatestRunningBalances( + l1Token: EvmAddress, + chainsToEvaluate: number[], + hubPoolClient: HubPoolClient, + bundleDataApproxClient: BundleDataApproxClient +): Promise<{ [chainId: number]: RunningBalanceResult }> { + const chainIds = hubPoolClient.configStoreClient.getChainIdIndicesForBlock(); + + const entries = await sdkUtils.mapAsync(chainsToEvaluate, async (chainId) => { + const chainIdIndex = chainIds.indexOf(chainId); + + // We need to find the latest validated running balance for this chain and token. + const lastValidatedRunningBalance = hubPoolClient.getRunningBalanceBeforeBlockForChain( + hubPoolClient.latestHeightSearched, + chainId, + l1Token + ).runningBalance; + + // Approximate latest running balance for a chain as last known validated running balance... + // - minus total deposit amount on chain since the latest validated end block + // - plus total refund amount on chain since the latest validated end block + const latestValidatedBundle = hubPoolClient.getLatestExecutedRootBundleContainingL1Token( + hubPoolClient.latestHeightSearched, + chainId, + l1Token + ); + const l2Token = hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); + if (!isDefined(l2Token)) { + return undefined; + } + + // If there is no ExecutedRootBundle event in the hub pool client's lookback for the token and chain, then + // default the bundle end block to 0. This will force getUpcomingDepositAmount to count any deposit + // seen in the spoke pool client's lookback. It would be very odd however for there to be deposits or refunds + // for a token and chain without there being a validated root bundle containing the token, so really the + // following check will be hit if the chain's running balance is very stale. The best way to check + // its running balance at that point is to query the token balance directly but this is still potentially + // inaccurate if someone sent tokens directly to the contract, and it incurs an extra RPC call so we avoid + // it for now. The default running balance will be 0, and this function is primarily designed to choose + // which chains have too many running balances and therefore should be selected for repayment, so returning + // 0 here means this chain will never be selected for repayment as a "slow withdrawal" chain. + let lastValidatedBundleEndBlock = 0; + let proposedRootBundle: ProposedRootBundle | undefined; + if (latestValidatedBundle) { + // The ProposeRootBundle event must precede the ExecutedRootBundle event we grabbed above. However, it + // might not exist if the ExecutedRootBundle event is old enough that the preceding ProposeRootBundle is + // older than the lookback. In this case, leave the last validated bundle end block as 0, since it must be + // before the earliest lookback block. + proposedRootBundle = hubPoolClient.getLatestFullyExecutedRootBundle(latestValidatedBundle.blockNumber); + if (proposedRootBundle) { + lastValidatedBundleEndBlock = proposedRootBundle.bundleEvaluationBlockNumbers[chainIdIndex].toNumber(); + } + } + + const upcomingDeposits = bundleDataApproxClient.getUpcomingDeposits(chainId, l1Token); + const upcomingRefunds = bundleDataApproxClient.getUpcomingRefunds(chainId, l1Token); + + // Updated running balance is last known running balance minus deposits plus upcoming refunds. + const latestRunningBalance = lastValidatedRunningBalance.sub(upcomingDeposits).add(upcomingRefunds); + // A negative running balance means that the spoke has a balance. If the running balance is positive, then the + // hub owes it funds and its below target so we don't want to take additional repayment. + const absLatestRunningBalance = latestRunningBalance.lt(0) ? latestRunningBalance.abs() : toBN(0); + + return [ + chainId, + { + absLatestRunningBalance, + lastValidatedRunningBalance, + upcomingDeposits, + upcomingRefunds, + bundleEndBlock: lastValidatedBundleEndBlock, + proposedRootBundle: proposedRootBundle?.txnRef, + }, + ] as [number, RunningBalanceResult]; + }); + + return Object.fromEntries(entries.filter(isDefined)); +} diff --git a/src/utils/TokenUtils.ts b/src/utils/TokenUtils.ts index 46c01bbae3..4f88b31dc6 100644 --- a/src/utils/TokenUtils.ts +++ b/src/utils/TokenUtils.ts @@ -2,7 +2,7 @@ import { CHAIN_IDs, TOKEN_EQUIVALENCE_REMAPPING, TOKEN_SYMBOLS_MAP } from "@acro import { constants, utils, arch } from "@across-protocol/sdk"; import { CONTRACT_ADDRESSES } from "../common"; import { BigNumberish, BigNumber } from "./BNUtils"; -import { formatUnits, getTokenInfo } from "./SDKUtils"; +import { formatUnits, getL1TokenAddress as resolveL1TokenAddress, getTokenInfo } from "./SDKUtils"; import { isDefined } from "./TypeGuards"; import { Address, toAddressType, EvmAddress, SvmAddress, SVMProvider, toBN } from "./"; import { TokenInfo } from "../interfaces"; @@ -11,6 +11,10 @@ const { ZERO_ADDRESS } = constants; export const { fetchTokenInfo, getL2TokenAddresses } = utils; +// Returns the canonical token for the given L1 token on the given remote chain, assuming that the L1 token +// exists in only a single mapping in TOKEN_SYMBOLS_MAP. This is the case currently for all tokens except for +// USDC.e, but that's why we use the TOKEN_EQUIVALENCE_REMAPPING to remap the token back to its inventory +// equivalent L1 token. export function getRemoteTokenForL1Token( _l1Token: EvmAddress, remoteChainId: number | string, @@ -30,6 +34,70 @@ export function getRemoteTokenForL1Token( ); } +// Returns the L1 token that is equivalent to the `l2Token` within the context of the inventory. +// This is used to link tokens that are not linked via pool rebalance routes, for example. +export function getInventoryEquivalentL1TokenAddress( + l2Token: Address, + chainId: number, + hubChainId = CHAIN_IDs.MAINNET +): EvmAddress { + try { + return resolveL1TokenAddress(l2Token, chainId); + } catch { + const { symbol } = getTokenInfo(l2Token, chainId); + const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[symbol] ?? symbol; + const l1TokenAddress = TOKEN_SYMBOLS_MAP[remappedSymbol]?.addresses[hubChainId]; + if (!isDefined(l1TokenAddress)) { + throw new Error(`Unable to resolve inventory-equivalent L1 token for ${l2Token.toNative()} on chain ${chainId}`); + } + return EvmAddress.from(l1TokenAddress); + } +} + +// Returns the L2 tokens that are equivalent for a given `l1Token` within the context of the inventory. +// Equivalency is defined by tokens that share the same L1 token within TOKEN_SYMBOLS_MAP or are +// mapped to each other in TOKEN_EQUIVALENCE_REMAPPING. +export function getInventoryBalanceContributorTokens( + l1Token: EvmAddress, + chainId: number, + hubChainId = CHAIN_IDs.MAINNET +): Address[] { + if (chainId === hubChainId) { + return [l1Token]; + } + + const hubTokenSymbol = getTokenInfo(l1Token, hubChainId).symbol; + const balanceContributorTokens: Address[] = []; + const canonicalToken = getRemoteTokenForL1Token(l1Token, chainId, hubChainId); + if (isDefined(canonicalToken)) { + balanceContributorTokens.push(canonicalToken); + } + + Object.keys(TOKEN_SYMBOLS_MAP).forEach((tokenSymbol) => { + const token = TOKEN_SYMBOLS_MAP[tokenSymbol]; + const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[token.symbol] ?? token.symbol; + if (remappedSymbol === hubTokenSymbol && isDefined(token.addresses[chainId])) { + balanceContributorTokens.push(toAddressType(token.addresses[chainId], chainId)); + } + }); + + return balanceContributorTokens.filter( + (token, index, allTokens) => allTokens.findIndex((candidate) => candidate.eq(token)) === index + ); +} + +// Returns true if the token symbol is an L2-only token that maps to a parent L1 token via +// TOKEN_EQUIVALENCE_REMAPPING (e.g. pathUSD -> USDC, USDH -> USDC). These tokens have no +// hub chain address and exist only on specific L2 chains. +export function isL2OnlyEquivalentToken(symbol: string, hubChainId = CHAIN_IDs.MAINNET): boolean { + const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[symbol]; + if (!isDefined(remappedSymbol)) { + return false; + } + const tokenInfo = TOKEN_SYMBOLS_MAP[symbol]; + return isDefined(tokenInfo) && !isDefined(tokenInfo.addresses[hubChainId]); +} + export function getNativeTokenAddressForChain(chainId: number): Address { return toAddressType(CONTRACT_ADDRESSES[chainId]?.nativeToken?.address ?? ZERO_ADDRESS, chainId); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 4ad97ffbfd..85dc41c13f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -83,3 +83,4 @@ export * from "./HyperliquidUtils"; export * from "./Tasks"; export * from "./TimeUtils"; export * from "./DepositAddressUtils"; +export * from "./RunningBalanceUtils"; diff --git a/test/BNUtils.ts b/test/BNUtils.ts new file mode 100644 index 0000000000..2916af52f8 --- /dev/null +++ b/test/BNUtils.ts @@ -0,0 +1,53 @@ +import { expect } from "./utils"; +import { floatToBN } from "../src/utils/BNUtils"; + +describe("floatToBN", function () { + it("Integer input", function () { + expect(floatToBN(100, 6).toString()).to.equal("100000000"); + expect(floatToBN(100, 18).toString()).to.equal("100000000000000000000"); + expect(floatToBN(0, 18).toString()).to.equal("0"); + expect(floatToBN(1, 0).toString()).to.equal("1"); + }); + + it("Simple decimal input", function () { + expect(floatToBN(0.5, 18).toString()).to.equal("500000000000000000"); + expect(floatToBN(1.5, 6).toString()).to.equal("1500000"); + // Number(99.99) is not exactly representable in IEEE 754; use string for exact values. + expect(floatToBN("99.99", 6).toString()).to.equal("99990000"); + }); + + it("Many decimal digits without exceeding MAX_SAFE_INTEGER", function () { + // This was the original failing case: 13 fractional digits would cause + // Number(float) * 10^13 to exceed Number.MAX_SAFE_INTEGER. + const result = floatToBN(5654.8610313399695, 18); + // The integer part scaled to 18 decimals: 5654 * 10^18 = 5654000000000000000000 + // Should be in the right ballpark. + expect(result.gt(0)).to.be.true; + expect(result.toString().startsWith("5654")).to.be.true; + }); + + it("Very small numbers (scientific notation)", function () { + // JavaScript renders 0.000000001234 as "1.234e-9" via String(), so the + // function must handle that without throwing. + const result = floatToBN(0.000000001234, 18); + expect(result.toString()).to.equal("1234000000"); + + const result2 = floatToBN(1e-10, 18); + expect(result2.toString()).to.equal("100000000"); + }); + + it("String input bypasses toFixed", function () { + expect(floatToBN("0.001", 18).toString()).to.equal("1000000000000000"); + expect(floatToBN("100", 6).toString()).to.equal("100000000"); + expect(floatToBN("5654.8610313399695", 18).toString()).to.equal("5654861031339969500000"); + }); + + it("Precision smaller than fractional digits truncates via ConvertDecimals", function () { + // 1.123456789 has 9 fractional digits, precision=6 → ConvertDecimals(9, 6) divides by 10^3. + expect(floatToBN("1.123456789", 6).toString()).to.equal("1123456"); + }); + + it("Large integer input", function () { + expect(floatToBN(1000000, 6).toString()).to.equal("1000000000000"); + }); +}); diff --git a/test/BinanceUtils.ts b/test/BinanceUtils.ts new file mode 100644 index 0000000000..6461defa98 --- /dev/null +++ b/test/BinanceUtils.ts @@ -0,0 +1,101 @@ +import { expect } from "./utils"; +import { BinanceDeposit, BinanceWithdrawal, getOutstandingBinanceDeposits } from "../src/utils"; + +function makeDeposit(network: string, amount: number, insertTime: number): BinanceDeposit { + return { network, amount, coin: "USDT", txId: `0x${insertTime}`, insertTime }; +} + +function makeWithdrawal(amount: number, timestamp: number, transactionFee = 0): BinanceWithdrawal { + return { + network: "ETH", + amount, + coin: "USDT", + txId: `0x${timestamp}`, + recipient: "0xRelayer", + id: `w${timestamp}`, + transactionFee, + applyTime: new Date(timestamp).toISOString(), + }; +} + +describe("BinanceUtils: getOutstandingBinanceDeposits", function () { + it("Multiple deposits, no withdrawals: all deposits are outstanding", function () { + const deposits = [makeDeposit("BSC", 50_000, 1), makeDeposit("OPTIMISM", 40_000, 2), makeDeposit("BSC", 10_000, 3)]; + + const outstanding = getOutstandingBinanceDeposits(deposits, [], "BSC"); + expect(outstanding.length).to.equal(2); + expect(outstanding[0].amount).to.equal(10_000); // newest first (t=3) + expect(outstanding[1].amount).to.equal(50_000); // oldest (t=1) + + const outstandingOP = getOutstandingBinanceDeposits(deposits, [], "OPTIMISM"); + expect(outstandingOP.length).to.equal(1); + expect(outstandingOP[0].amount).to.equal(40_000); + }); + + it("Total deposits <= total withdrawals: no deposits are outstanding", function () { + const deposits = [makeDeposit("BSC", 50_000, 1), makeDeposit("OPTIMISM", 40_000, 2)]; + + // Exact match. + expect(getOutstandingBinanceDeposits(deposits, [makeWithdrawal(90_000, 3)], "BSC")).to.deep.equal([]); + + // Withdrawals exceed deposits (dust/fees included). + expect(getOutstandingBinanceDeposits(deposits, [makeWithdrawal(85_000, 3, 10_000)], "BSC")).to.deep.equal([]); + }); + + it("Newest deposits are outstanding first; oldest outstanding deposit gets partial amount", function () { + // Four deposits totalling 100k. Withdrawals cover 65k. Outstanding = 35k. + const deposits = [ + makeDeposit("BSC", 10_000, 1), // oldest + makeDeposit("BSC", 30_000, 2), + makeDeposit("BSC", 40_000, 3), + makeDeposit("BSC", 20_000, 4), // newest + ]; + const withdrawals = [makeWithdrawal(65_000, 5)]; + + // Outstanding = 100k - 65k = 35k. + // Newest-first: 20k (t=4) fully outstanding (remaining=15k), 40k (t=3) partially outstanding (amount=15k). + const outstanding = getOutstandingBinanceDeposits(deposits, withdrawals, "BSC"); + expect(outstanding.length).to.equal(2); + expect(outstanding[0].amount).to.equal(20_000); + expect(outstanding[0].insertTime).to.equal(4); + expect(outstanding[1].amount).to.equal(15_000); // partial: 35k - 20k = 15k remaining + expect(outstanding[1].insertTime).to.equal(3); + }); + + it("Only outstanding deposits on the requested network are returned", function () { + // 50k BSC + 40k OP = 90k total. Withdrawal covers 40k. Outstanding = 50k. + // Newest deposit is BSC (t=3), then OP (t=2), then BSC (t=1). + const deposits = [makeDeposit("BSC", 20_000, 1), makeDeposit("OPTIMISM", 40_000, 2), makeDeposit("BSC", 30_000, 3)]; + const withdrawals = [makeWithdrawal(40_000, 4)]; + + // Outstanding = 90k - 40k = 50k. + // Newest-first: BSC 30k (t=3, fully outstanding, remaining=20k), OP 40k (t=2, partially 20k). + const outstandingBSC = getOutstandingBinanceDeposits(deposits, withdrawals, "BSC"); + expect(outstandingBSC.length).to.equal(1); + expect(outstandingBSC[0].amount).to.equal(30_000); + + const outstandingOP = getOutstandingBinanceDeposits(deposits, withdrawals, "OPTIMISM"); + expect(outstandingOP.length).to.equal(1); + expect(outstandingOP[0].amount).to.equal(20_000); // partial + }); + + it("Partial outstanding when withdrawal covers most of a deposit", function () { + const deposits = [makeDeposit("BSC", 100, 1)]; + const withdrawals = [makeWithdrawal(95, 2)]; + + const outstanding = getOutstandingBinanceDeposits(deposits, withdrawals, "BSC"); + expect(outstanding.length).to.equal(1); + expect(outstanding[0].amount).to.equal(5); + }); + + it("Does not mutate input arrays or deposit objects", function () { + const deposits = [makeDeposit("BSC", 100, 1)]; + const withdrawals = [makeWithdrawal(60, 2)]; + const originalAmount = deposits[0].amount; + + getOutstandingBinanceDeposits(deposits, withdrawals, "BSC"); + + expect(deposits[0].amount).to.equal(originalAmount); + expect(deposits.length).to.equal(1); + }); +}); diff --git a/test/TokenUtils.ts b/test/TokenUtils.ts new file mode 100644 index 0000000000..045b86775a --- /dev/null +++ b/test/TokenUtils.ts @@ -0,0 +1,44 @@ +import { expect } from "./utils"; +import { + CHAIN_IDs, + EvmAddress, + getInventoryBalanceContributorTokens, + getInventoryEquivalentL1TokenAddress, + TOKEN_SYMBOLS_MAP, + toAddressType, +} from "../src/utils"; + +describe("TokenUtils", function () { + it("resolves inventory-equivalent L1 tokens for L2-only remapped tokens", function () { + const l1Token = getInventoryEquivalentL1TokenAddress( + toAddressType(TOKEN_SYMBOLS_MAP.pathUSD.addresses[CHAIN_IDs.TEMPO], CHAIN_IDs.TEMPO), + CHAIN_IDs.TEMPO, + CHAIN_IDs.MAINNET + ); + + expect(l1Token.eq(EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]))).to.be.true; + }); + + it("derives all Tempo USDC balance contributors from TOKEN_EQUIVALENCE_REMAPPING", function () { + const contributorTokens = getInventoryBalanceContributorTokens( + EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]), + CHAIN_IDs.TEMPO, + CHAIN_IDs.MAINNET + ).map((token) => token.toNative()); + + expect(contributorTokens).to.deep.equal([ + TOKEN_SYMBOLS_MAP["USDC.e"].addresses[CHAIN_IDs.TEMPO], + TOKEN_SYMBOLS_MAP.pathUSD.addresses[CHAIN_IDs.TEMPO], + ]); + }); + + it("derives non-pathUSD contributors generically from existing remappings", function () { + const contributorTokens = getInventoryBalanceContributorTokens( + EvmAddress.from(TOKEN_SYMBOLS_MAP.DAI.addresses[CHAIN_IDs.MAINNET]), + CHAIN_IDs.BLAST, + CHAIN_IDs.MAINNET + ).map((token) => token.toNative()); + + expect(contributorTokens).to.deep.equal([TOKEN_SYMBOLS_MAP.USDB.addresses[CHAIN_IDs.BLAST]]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 13f56f7bc0..5a41d89970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,6 +16,11 @@ resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.100.tgz#ac7f7910340e62abb66dceea451e96f590742211" integrity sha512-HWALdgQHY7Q0IQFMCThi/bcUp1CTgb/sdVmRvj5sLkjz2tFZfCcPA0nKzKK+5pXD3hdd2W44uqQA58Z19XhSSw== +"@across-protocol/constants@^3.1.102": + version "3.1.103" + resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.103.tgz#cce4f1518b07f7f0006489fdf5d4bc26e4e9972a" + integrity sha512-ghMBQLm1PpCAwMQ/VVR3hXc1eHnhvHecIAAM+JYxre/lfQnt9LriPTHRDHdnXt18GRfKsLPKnQdfoZUfNWXJ8A== + "@across-protocol/contracts@5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-5.0.0.tgz#b3e2b5b6e5938c16f7c0002fbd8c0f6398c336ef" From 67fccda3258cdb682d03e5625db59e313d64e850 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 25 Mar 2026 11:40:27 -0400 Subject: [PATCH 2/4] improve: Use inventory equivalence remapping in BundleDataApproxClient, InventoryClient, TokenClient - BundleDataApproxClient: returns values in L1 token decimals, groups deposits/refunds across inventory-equivalent L2 tokens, restores requireExecution param, fixes refund decimal conversion - InventoryClient: uses getInventoryEquivalentL1TokenAddress, getInventoryBalanceContributorTokens, getLatestRunningBalances; adds protected getTokenInfo for test mocking - TokenClient: uses getInventoryBalanceContributorTokens for L2 token discovery - Test updates: alias config for USDC, L1-decimal refunds, MockInventoryClient getTokenInfo override Co-Authored-By: Claude Opus 4.6 (1M context) --- src/clients/BundleDataApproxClient.ts | 148 ++++++++++------- src/clients/InventoryClient.ts | 179 ++++++--------------- src/clients/TokenClient.ts | 23 +-- test/BundleDataApproxClient.ts | 129 ++++++++++++++- test/InventoryClient.InventoryRebalance.ts | 64 ++++++-- test/InventoryClient.RefundChain.ts | 29 +++- test/mocks/MockBundleDataApproxClient.ts | 15 +- test/mocks/MockInventoryClient.ts | 14 +- 8 files changed, 369 insertions(+), 232 deletions(-) diff --git a/src/clients/BundleDataApproxClient.ts b/src/clients/BundleDataApproxClient.ts index 4c16f9a498..6813c76a05 100644 --- a/src/clients/BundleDataApproxClient.ts +++ b/src/clients/BundleDataApproxClient.ts @@ -5,8 +5,17 @@ import { SpokePoolManager } from "."; import { SpokePoolClientsByChain } from "../interfaces"; -import { assert, BigNumber, isDefined, winston, ConvertDecimals, getTokenInfo } from "../utils"; -import { Address, bnZero, getL1TokenAddress } from "../utils/SDKUtils"; +import { + assert, + BigNumber, + isDefined, + winston, + ConvertDecimals, + getTokenInfo, + getInventoryEquivalentL1TokenAddress, + getInventoryBalanceContributorTokens, +} from "../utils"; +import { Address, bnZero } from "../utils/SDKUtils"; import { HubPoolClient } from "./HubPoolClient"; export type BundleDataState = { @@ -14,11 +23,16 @@ export type BundleDataState = { upcomingRefunds: { [l1Token: string]: { [chainId: number]: { [relayer: string]: BigNumber } } }; }; +// This client is used to approximate running balances and the refunds and deposits for a given L1 token. Running balances +// can easily be estimated by taking the last validated running balance for a chain and subtracting the total deposit amount +// on that chain since the last validated end block and adding the total refund amount on that chain since the last validated +// end block. export class BundleDataApproxClient { private upcomingRefunds: { [l1Token: string]: { [chainId: number]: { [relayer: string]: BigNumber } } } = undefined; private upcomingDeposits: { [l1Token: string]: { [chainId: number]: BigNumber } } = undefined; private readonly spokePoolManager: SpokePoolManager; + private readonly protocolChainIdIndices: number[]; constructor( spokePoolClients: SpokePoolClientsByChain, private readonly hubPoolClient: HubPoolClient, @@ -27,6 +41,7 @@ export class BundleDataApproxClient { private readonly logger: winston.Logger ) { this.spokePoolManager = new SpokePoolManager(logger, spokePoolClients); + this.protocolChainIdIndices = this.hubPoolClient.configStoreClient.getChainIdIndicesForBlock(); } /** @@ -49,10 +64,14 @@ export class BundleDataApproxClient { this.upcomingRefunds = upcomingRefunds; } - // Return sum of refunds for all fills sent after the fromBlocks. - // Makes a simple assumption that all fills that were sent after the last executed bundle - // are valid and will be refunded on the repayment chain selected. Assume additionally that the repayment chain - // set is a valid one for the deposit. + /** + * Return sum of refunds for all fills sent after the fromBlocks. + * Makes a simple assumption that all fills that were sent by this relayer after the last executed bundle + * are valid and will be refunded on the repayment chain selected. + * @param l1Token L1 token to get refunds for all inventory-equivalent L2 tokens on each chain. + * @param fromBlocks Blocks to start counting refunds from. + * @returns Refunds grouped by relayer for each chain. Refunds are denominated in L1 token decimals. + */ protected getApproximateRefundsForToken( l1Token: Address, fromBlocks: { [chainId: number]: number } @@ -64,51 +83,32 @@ export class BundleDataApproxClient { if (!isDefined(spokePoolClient)) { continue; } - spokePoolClient - .getFills() - .filter((fill) => { - if (fill.blockNumber < fromBlocks[chainId]) { - return false; - } - const expectedL1Token = this.getL1TokenAddress(fill.inputToken, fill.originChainId); - if (!isDefined(expectedL1Token) || !expectedL1Token.eq(l1Token)) { - return false; - } - // A simple check that this fill is *probably* valid is to check that the output and input token - // map to the same L1 token. This means that this method will ignore refunds for swaps, but these - // are currently very rare in practice. This prevents invalid fills with very large input amounts - // from skewing the numbers. - const outputMappedL1Token = this.getL1TokenAddress(fill.outputToken, fill.destinationChainId); - if (!isDefined(outputMappedL1Token) || !outputMappedL1Token.eq(expectedL1Token)) { - return false; - } + spokePoolClient.getFills().forEach((fill) => { + const { inputAmount: _refundAmount, originChainId, repaymentChainId, relayer, inputToken, blockNumber } = fill; + if (blockNumber < fromBlocks[chainId]) { + return; + } - return true; - }) - .forEach((fill) => { - const { inputAmount: _refundAmount, originChainId, repaymentChainId, relayer, inputToken } = fill; - // This call to `getTokenInfo` should not throw since we just filtered out all input tokens for - // which there is no output token. - const { decimals: inputTokenDecimals } = getTokenInfo(inputToken, originChainId); - const inputL1Token = this.getL1TokenAddress(inputToken, originChainId); + // Fills get refunded in the input token currency so need to check that the input token + // and the l1Token parameter are the same. If the input token is equivalent from an inventory management + // perspective to the l1Token then we can count it here because in this case the refund for the fill + // will essentially be in an equivalent l1Token currency on the repayment chain (i.e. getting repaid + // in this currency is just as good as getting repaid in the l1Token currency). + const expectedL1Token = this.getL1TokenAddress(fill.inputToken, fill.originChainId); + if (!isDefined(expectedL1Token) || !expectedL1Token.eq(l1Token)) { + return; + } - assert(inputL1Token.isEVM()); - const inputTokenOnRepaymentChain = this.hubPoolClient.getL2TokenForL1TokenAtBlock( - inputL1Token, - repaymentChainId - ); - if (!isDefined(inputTokenOnRepaymentChain)) { - return; - } - // If the repayment token is defined, then that means an entry exists in our token symbols mapping, - // so this is also a "safe" call to `getTokenInfo.` - const { decimals: repaymentTokenDecimals } = getTokenInfo(inputTokenOnRepaymentChain, repaymentChainId); - const refundAmount = ConvertDecimals(inputTokenDecimals, repaymentTokenDecimals)(_refundAmount); - refundsForChain[repaymentChainId] ??= {}; - refundsForChain[repaymentChainId][relayer.toNative()] ??= bnZero; - refundsForChain[repaymentChainId][relayer.toNative()] = - refundsForChain[repaymentChainId][relayer.toNative()].add(refundAmount); - }); + const { decimals: inputTokenDecimals } = getTokenInfo(inputToken, originChainId); + const refundAmount = ConvertDecimals( + inputTokenDecimals, + getTokenInfo(l1Token, this.hubPoolClient.chainId).decimals + )(_refundAmount); + refundsForChain[repaymentChainId] ??= {}; + refundsForChain[repaymentChainId][relayer.toNative()] ??= bnZero; + refundsForChain[repaymentChainId][relayer.toNative()] = + refundsForChain[repaymentChainId][relayer.toNative()].add(refundAmount); + }); } return refundsForChain; } @@ -134,11 +134,7 @@ export class BundleDataApproxClient { return true; } - // Make sure the leaf for this specific L1 token on the chain from the root bundle relay has been executed. - const l2Token = this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, chainId); - if (!isDefined(l2Token)) { - return false; - } + const l2Tokens = getInventoryBalanceContributorTokens(l1Token, chainId, this.hubPoolClient.chainId); return isDefined( spokePoolClient.getRelayerRefundExecutions().findLast((execution) => { if (!isDefined(execution)) { @@ -149,7 +145,10 @@ export class BundleDataApproxClient { // likelihood, all leaves were executed in the same transaction. If they were not, then this client // will underestimate the upcoming refunds until that leaf is executed. Since this client is ultimately // an approximation, this is acceptable. - return execution.rootBundleId === relay.rootBundleId && execution.l2TokenAddress.eq(l2Token); + return ( + execution.rootBundleId === relay.rootBundleId && + l2Tokens.some((l2Token) => l2Token.eq(execution.l2TokenAddress)) + ); }) ); }); @@ -172,7 +171,7 @@ export class BundleDataApproxClient { const bundleEndBlock = this.hubPoolClient.getBundleEndBlockForChain( correspondingProposedRootBundle, chainId, - this.chainIdList + this.protocolChainIdIndices ); return [chainId, bundleEndBlock > 0 ? bundleEndBlock + 1 : 0]; }) @@ -185,6 +184,12 @@ export class BundleDataApproxClient { return refundsForChain; } + /** + * Return sum of deposits for all deposits sent after the fromBlocks. + * @param l1Token L1 token to get deposits for all inventory-equivalent L2 tokens on each chain. + * @param fromBlocks Blocks to start counting deposits from. + * @returns Deposits grouped by chain. Deposits are denominated in L1 token decimals. + */ private getApproximateDepositsForToken( l1Token: Address, fromBlocks: { [chainId: number]: number } @@ -199,14 +204,24 @@ export class BundleDataApproxClient { spokePoolClient .getDeposits() .filter((deposit) => { + if (deposit.blockNumber < fromBlocks[chainId]) { + return false; + } + // We are ok to group together deposits for inventory-equivalent tokens because these approximate + // deposits and refunds are usually computed and summed together to approximate running balances. So we should + // use the same methodology for equating input and l1 tokens as we do in the getApproximateRefundsForToken method. const expectedL1Token = this.getL1TokenAddress(deposit.inputToken, deposit.originChainId); if (!isDefined(expectedL1Token)) { return false; } - return l1Token.eq(expectedL1Token) && deposit.blockNumber >= fromBlocks[chainId]; + return l1Token.eq(expectedL1Token); }) .forEach((deposit) => { - depositsForChain[chainId] = depositsForChain[chainId].add(deposit.inputAmount); + const depositAmount = ConvertDecimals( + getTokenInfo(deposit.inputToken, deposit.originChainId).decimals, + getTokenInfo(l1Token, this.hubPoolClient.chainId).decimals + )(deposit.inputAmount); + depositsForChain[chainId] = depositsForChain[chainId].add(depositAmount); }); } return depositsForChain; @@ -223,7 +238,7 @@ export class BundleDataApproxClient { protected getL1TokenAddress(l2Token: Address, chainId: number): Address | undefined { try { - return getL1TokenAddress(l2Token, chainId); + return getInventoryEquivalentL1TokenAddress(l2Token, chainId, this.hubPoolClient.chainId); } catch { return undefined; } @@ -245,6 +260,14 @@ export class BundleDataApproxClient { }); } + /** + * Return refunds for a given L1 token on a given chain for all inventory-equivalent L2 tokens on that chain. + * Refunds are denominated in L1 token decimals. + * @param chainId Chain ID to get refunds for. + * @param l1Token L1 token to get refunds for. + * @param relayer Optional relayer to get refunds for. If not provided, returns the sum of refunds for all relayers. + * @returns Refunds for the given L1 token on the given chain for all inventory-equivalent L2 tokens on that chain. Refunds are denominated in L1 token decimals. + */ getUpcomingRefunds(chainId: number, l1Token: Address, relayer?: Address): BigNumber { assert( isDefined(this.upcomingRefunds), @@ -263,6 +286,13 @@ export class BundleDataApproxClient { ); } + /** + * Return deposits for a given L1 token on a given chain for all inventory-equivalent L2 tokens on that chain. + * Deposits are denominated in L1 token decimals. + * @param chainId Chain ID to get deposits for. + * @param l1Token L1 token to get deposits for. + * @returns Deposits for the given L1 token on the given chain for all inventory-equivalent L2 tokens on that chain. Deposits are denominated in L1 token decimals. + */ getUpcomingDeposits(chainId: number, l1Token: Address): BigNumber { assert( isDefined(this.upcomingDeposits), diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 64f8f99a5d..afea436fa9 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -26,7 +26,7 @@ import { assert, Profiler, getNativeTokenSymbol, - getL1TokenAddress, + getInventoryEquivalentL1TokenAddress, depositForcesOriginChainRepayment, getRemoteTokenForL1Token, getTokenInfo, @@ -37,10 +37,12 @@ import { repaymentChainCanBeQuicklyRebalanced, forEachAsync, max, + getLatestRunningBalances, + getInventoryBalanceContributorTokens, } from "../utils"; import { BundleDataApproxClient, BundleDataState } from "./BundleDataApproxClient"; import { HubPoolClient, TokenClient, TransactionClient } from "."; -import { Deposit, ProposedRootBundle } from "../interfaces"; +import { Deposit, TokenInfo } from "../interfaces"; import { InventoryConfig, isAliasConfig, TokenBalanceConfig } from "../interfaces/InventoryManagement"; import lodash from "lodash"; import { SLOW_WITHDRAWAL_CHAINS } from "../common"; @@ -162,6 +164,10 @@ export class InventoryClient { getInventoryCacheKey(inventoryTopic: string): string { return `${inventoryTopic}-${this.relayer}`; } + protected getTokenInfo(token: Address, chainId: number): TokenInfo { + return getTokenInfo(token, chainId); + } + /** * Resolve the token balance configuration for `l1Token` on `chainId`. If `l1Token` maps to multiple tokens on * `chainId` then `l2Token` must be supplied. @@ -221,16 +227,9 @@ export class InventoryClient { */ getCumulativeBalanceWithApproximateUpcomingRefunds(l1Token: EvmAddress): BigNumber { const totalRefundsPerChain: { [chainId: number]: BigNumber } = {}; - const { decimals: l1TokenDecimals } = getTokenInfo(l1Token, this.hubPoolClient.chainId); for (const chainId of this.chainIdList) { - const repaymentToken = this.getRemoteTokenForL1Token(l1Token, chainId); - if (!repaymentToken) { - continue; - } - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(repaymentToken, chainId); const refundAmount = this.getUpcomingRefunds(chainId, l1Token, this.relayer); - const convertedRefundAmount = sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(refundAmount); - totalRefundsPerChain[chainId] = convertedRefundAmount; + totalRefundsPerChain[chainId] = refundAmount; } const cumulativeRefunds = Object.values(totalRefundsPerChain).reduce((acc, curr) => acc.add(curr), bnZero); const cumulativeVirtualBalance = this.getCumulativeBalance(l1Token); @@ -267,7 +266,7 @@ export class InventoryClient { const { crossChainTransferClient, relayer, tokenClient } = this; let balance = bnZero; - const { decimals: l1TokenDecimals, symbol: l1TokenSymbol } = getTokenInfo(l1Token, this.hubPoolClient.chainId); + const { decimals: l1TokenDecimals, symbol: l1TokenSymbol } = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); // If chain is L1, add all pending L2->L1 withdrawals. if (chainId === this.hubPoolClient.chainId) { @@ -284,9 +283,13 @@ export class InventoryClient { // Add in any pending swap rebalances. Pending Rebalances are currently only supported for the canonical L2 tokens // mapped to each L1 token (i.e. the L2 token for an L1 token returned by getRemoteTokenForL1Token()) const pendingRebalancesForChain = this.pendingRebalances[chainId]; - if (isDefined(pendingRebalancesForChain)) { - const _l2Token = l2Token ?? this.getRemoteTokenForL1Token(l1Token, chainId); - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(_l2Token, chainId); + const canonicalL2Token = getRemoteTokenForL1Token(l1Token, chainId, this.hubPoolClient.chainId); + if ( + isDefined(pendingRebalancesForChain) && + isDefined(canonicalL2Token) && + (!isDefined(l2Token) || l2Token.eq(canonicalL2Token)) + ) { + const { decimals: l2TokenDecimals } = this.getTokenInfo(canonicalL2Token, chainId); const pendingRebalancesForToken = pendingRebalancesForChain[l1TokenSymbol]; if (isDefined(pendingRebalancesForToken)) { balance = balance.add(sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(pendingRebalancesForToken)); @@ -295,7 +298,7 @@ export class InventoryClient { // Return the balance for a specific l2 token on the remote chain. if (isDefined(l2Token)) { - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); balance = balance.add( sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(tokenClient.getBalance(chainId, l2Token)) ); @@ -310,7 +313,7 @@ export class InventoryClient { balance = balance.add( l2Tokens .map((l2Token) => { - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); return sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(tokenClient.getBalance(chainId, l2Token)); }) .reduce((acc, curr) => acc.add(curr), bnZero) @@ -379,8 +382,8 @@ export class InventoryClient { return bnZero; } - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); - const { decimals: l1TokenDecimals } = getTokenInfo(l1Token, this.hubPoolClient.chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); + const { decimals: l1TokenDecimals } = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); const shortfall = sdkUtils.ConvertDecimals( l2TokenDecimals, l1TokenDecimals @@ -423,12 +426,16 @@ export class InventoryClient { .map((token) => toAddressType(token, chainId)); } - const destinationToken = this.getRemoteTokenForL1Token(l1Token, chainId); - if (!isDefined(destinationToken)) { + const inventoryEquivalentTokens = getInventoryBalanceContributorTokens( + l1Token, + chainId, + this.hubPoolClient.chainId + ); + if (inventoryEquivalentTokens.length === 0) { return []; } - return [destinationToken]; + return inventoryEquivalentTokens; } getEnabledChains(): number[] { @@ -468,10 +475,6 @@ export class InventoryClient { return this.bundleDataApproxClient.getUpcomingRefunds(chainId, l1Token, relayer); } - getUpcomingDeposits(chainId: number, l1Token: EvmAddress): BigNumber { - return this.bundleDataApproxClient.getUpcomingDeposits(chainId, l1Token); - } - /** * Returns possible repayment chain options for a deposit. This is designed to be called by the relayer * so that it can batch compute LP fees for all possible repayment chains. By locating this function @@ -557,13 +560,7 @@ export class InventoryClient { */ getL1TokenAddress(l2Token: Address, chainId: number): EvmAddress | undefined { try { - // Add exception for USDC-like tokens which only exist on L2. - // @todo Add support for equivalence mappings. - const l2TokenInfo = getTokenInfo(l2Token, chainId); - if (["pathUSD"].includes(l2TokenInfo.symbol)) { - return EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[this.hubPoolClient.chainId]); - } - return getL1TokenAddress(l2Token, chainId); + return getInventoryEquivalentL1TokenAddress(l2Token, chainId, this.hubPoolClient.chainId); } catch { return undefined; } @@ -700,21 +697,15 @@ export class InventoryClient { } } - const { decimals: l1TokenDecimals } = getTokenInfo(l1Token, this.hubPoolClient.chainId); - const { decimals: inputTokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(inputToken, originChainId); + const { decimals: l1TokenDecimals } = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); + const { decimals: inputTokenDecimals } = this.getTokenInfo(inputToken, originChainId); const inputAmountInL1TokenDecimals = sdkUtils.ConvertDecimals(inputTokenDecimals, l1TokenDecimals)(inputAmount); - // Consider any upcoming refunds. Convert all refunds to same precision as L1 token. + // Consider any upcoming refunds. const totalRefundsPerChain: { [chainId: number]: BigNumber } = {}; for (const chainId of this.chainIdList) { - const repaymentToken = chainId === originChainId ? inputToken : this.getRemoteTokenForL1Token(l1Token, chainId); - if (!repaymentToken) { - continue; - } - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(repaymentToken, chainId); const refundAmount = this.getUpcomingRefunds(chainId, l1Token, this.relayer); - const convertedRefundAmount = sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(refundAmount); - totalRefundsPerChain[chainId] = convertedRefundAmount; + totalRefundsPerChain[chainId] = refundAmount; } // @dev: The following async call to `getExcessRunningBalancePcts` should be very fast compared to the above @@ -797,7 +788,7 @@ export class InventoryClient { } else { repaymentToken = inputToken; } - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(repaymentToken, chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(repaymentToken, chainId); const chainShortfall = sdkUtils.ConvertDecimals( l2TokenDecimals, l1TokenDecimals @@ -914,77 +905,11 @@ export class InventoryClient { chainsToEvaluate: number[] ): Promise<{ [chainId: number]: BigNumber }> { const mark = this.profiler.start("getLatestRunningBalances"); - const chainIds = this.hubPoolClient.configStoreClient.getChainIdIndicesForBlock(); - const l1TokenDecimals = getTokenInfo(l1Token, this.hubPoolClient.chainId).decimals; - const runningBalances = Object.fromEntries( - await sdkUtils.mapAsync(chainsToEvaluate, async (chainId) => { - const chainIdIndex = chainIds.indexOf(chainId); - - // We need to find the latest validated running balance for this chain and token. - const lastValidatedRunningBalance = this.hubPoolClient.getRunningBalanceBeforeBlockForChain( - this.hubPoolClient.latestHeightSearched, - chainId, - l1Token - ).runningBalance; - - // Approximate latest running balance for a chain as last known validated running balance... - // - minus total deposit amount on chain since the latest validated end block - // - plus total refund amount on chain since the latest validated end block - const latestValidatedBundle = this.hubPoolClient.getLatestExecutedRootBundleContainingL1Token( - this.hubPoolClient.latestHeightSearched, - chainId, - l1Token - ); - const l2Token = this.hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId)); - const l2TokenDecimals = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId).decimals; - const l2AmountToL1Amount = sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals); - - // If there is no ExecutedRootBundle event in the hub pool client's lookback for the token and chain, then - // default the bundle end block to 0. This will force getUpcomingDepositAmount to count any deposit - // seen in the spoke pool client's lookback. It would be very odd however for there to be deposits or refunds - // for a token and chain without there being a validated root bundle containing the token, so really the - // following check will be hit if the chain's running balance is very stale. The best way to check - // its running balance at that point is to query the token balance directly but this is still potentially - // inaccurate if someone sent tokens directly to the contract, and it incurs an extra RPC call so we avoid - // it for now. The default running balance will be 0, and this function is primarily designed to choose - // which chains have too many running balances and therefore should be selected for repayment, so returning - // 0 here means this chain will never be selected for repayment as a "slow withdrawal" chain. - let lastValidatedBundleEndBlock = 0; - let proposedRootBundle: ProposedRootBundle | undefined; - if (latestValidatedBundle) { - proposedRootBundle = this.hubPoolClient.getLatestFullyExecutedRootBundle( - latestValidatedBundle.blockNumber // The ProposeRootBundle event must precede the ExecutedRootBundle - // event we grabbed above. However, it might not exist if the ExecutedRootBundle event is old enough - // that the preceding ProposeRootBundle is older than the lookback. In this case, leave the - // last validated bundle end block as 0, since it must be before the earliest lookback block since it was - // before the ProposeRootBundle event and we can't even find that. - ); - if (proposedRootBundle) { - lastValidatedBundleEndBlock = proposedRootBundle.bundleEvaluationBlockNumbers[chainIdIndex].toNumber(); - } - } - const upcomingDepositsAfterLastValidatedBundle = l2AmountToL1Amount(this.getUpcomingDeposits(chainId, l1Token)); - const upcomingRefundsAfterLastValidatedBundle = l2AmountToL1Amount(this.getUpcomingRefunds(chainId, l1Token)); - - // Updated running balance is last known running balance minus deposits plus upcoming refunds. - const latestRunningBalance = lastValidatedRunningBalance - .sub(upcomingDepositsAfterLastValidatedBundle) - .add(upcomingRefundsAfterLastValidatedBundle); - // A negative running balance means that the spoke has a balance. If the running balance is positive, then the hub - // owes it funds and its below target so we don't want to take additional repayment. - const absLatestRunningBalance = latestRunningBalance.lt(0) ? latestRunningBalance.abs() : toBN(0); - return [ - chainId, - { - absLatestRunningBalance, - lastValidatedRunningBalance, - upcomingDeposits: upcomingDepositsAfterLastValidatedBundle, - upcomingRefunds: upcomingRefundsAfterLastValidatedBundle, - bundleEndBlock: lastValidatedBundleEndBlock, - proposedRootBundle: proposedRootBundle?.txnRef, - }, - ]; - }) + const runningBalances = await getLatestRunningBalances( + l1Token, + chainsToEvaluate, + this.hubPoolClient, + this.bundleDataApproxClient ); mark.stop({ message: `Time to get running balances for ${l1Token}`, @@ -1139,8 +1064,8 @@ export class InventoryClient { } _getPossibleShortfallRebalances(l1Token: EvmAddress, chainId: number, l2Token: Address): Rebalance[] { - const { decimals: l1TokenDecimals } = getTokenInfo(l1Token, this.hubPoolClient.chainId); - const { decimals: l2TokenDecimals } = getTokenInfo(l2Token, chainId); + const { decimals: l1TokenDecimals } = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); // Order unfilled amounts from largest to smallest to prioritize larger shortfalls. const unfilledDepositAmounts = this.tokenClient .getUnfilledDepositAmounts(chainId, l2Token) @@ -1283,13 +1208,13 @@ export class InventoryClient { const chainId = Number(_chainId); mrkdwn += `*Rebalances sent to ${getNetworkName(chainId)}:*\n`; for (const { l1Token, l2Token, amount, hash, chainId, isShortfallRebalance } of rebalances) { - const tokenInfo = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const tokenInfo = this.getTokenInfo(l2Token, chainId); if (!tokenInfo) { `InventoryClient::rebalanceInventoryIfNeeded no token info for L2 token ${l2Token} on chain ${chainId}`; } const { symbol, decimals } = tokenInfo; const l2TokenFormatter = createFormatFunction(2, 4, false, decimals); - const l1TokenInfo = getTokenInfo(l1Token, this.hubPoolClient.chainId); + const l1TokenInfo = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); const l1Formatter = createFormatFunction(2, 4, false, l1TokenInfo.decimals); const cumulativeBalance = this.getCumulativeBalance(l1Token); @@ -1316,13 +1241,13 @@ export class InventoryClient { const chainId = Number(_chainId); mrkdwn += `*Insufficient amount to rebalance to ${getNetworkName(chainId)}:*\n`; for (const { l1Token, l2Token, balance, amount } of rebalances) { - const tokenInfo = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const tokenInfo = this.getTokenInfo(l2Token, chainId); if (!tokenInfo) { throw new Error( `InventoryClient::rebalanceInventoryIfNeeded no token info for L2 token ${l2Token} on chain ${chainId}` ); } - const l1TokenInfo = getTokenInfo(l1Token, this.hubPoolClient.chainId); + const l1TokenInfo = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); const l1Formatter = createFormatFunction(2, 4, false, l1TokenInfo.decimals); const { symbol, decimals } = tokenInfo; @@ -1502,7 +1427,7 @@ export class InventoryClient { const chainMrkdwns: { [chainId: number]: string } = {}; await sdkUtils.forEachAsync(this.getL1Tokens(), async (l1Token) => { - const l1TokenInfo = getTokenInfo(l1Token, this.hubPoolClient.chainId); + const l1TokenInfo = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); const formatter = createFormatFunction(2, 4, false, l1TokenInfo.decimals); // We do not currently count any outstanding L2->L1 pending withdrawal balance in the cumulative balance @@ -1521,7 +1446,7 @@ export class InventoryClient { const l2Tokens = this.getRemoteTokensForL1Token(l1Token, chainId); await sdkUtils.forEachAsync(l2Tokens, async (l2Token) => { - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); const l2TokenFormatter = createFormatFunction(2, 4, false, l2TokenDecimals); const l2BalanceFromL1Decimals = sdkUtils.ConvertDecimals(l1TokenInfo.decimals, l2TokenDecimals); const tokenConfig = this.getTokenConfig(l1Token, chainId, l2Token); @@ -1684,7 +1609,7 @@ export class InventoryClient { message: `L2->L1 withdrawals on ${getNetworkName(chainId)} submitted`, chainId, withdrawalsRequired: withdrawalsRequired[chainId].map((withdrawal: L2Withdrawal) => { - const l2TokenInfo = this.hubPoolClient.getTokenInfoForAddress(withdrawal.l2Token, Number(chainId)); + const l2TokenInfo = this.getTokenInfo(withdrawal.l2Token, Number(chainId)); const formatter = createFormatFunction(2, 4, false, l2TokenInfo.decimals); return { @@ -1714,7 +1639,7 @@ export class InventoryClient { } = {}; const cumulativeBalances: { [symbol: string]: string } = {}; Object.entries(distribution).forEach(([l1Token, distributionForToken]) => { - const tokenInfo = getTokenInfo(EvmAddress.from(l1Token), this.hubPoolClient.chainId); + const tokenInfo = this.getTokenInfo(EvmAddress.from(l1Token), this.hubPoolClient.chainId); if (tokenInfo === undefined) { throw new Error( `InventoryClient::constructConsideringRebalanceDebugLog info not found for L1 token ${l1Token}` @@ -1731,7 +1656,7 @@ export class InventoryClient { Object.entries(distributionForToken[chainId]).forEach(([_l2Token, amount]) => { const l2Token = toAddressType(_l2Token, chainId); - const { decimals: l2TokenDecimals } = this.hubPoolClient.getTokenInfoForAddress(l2Token, chainId); + const { decimals: l2TokenDecimals } = this.getTokenInfo(l2Token, chainId); const l2Formatter = createFormatFunction(2, 4, false, l2TokenDecimals); const l1TokenAddr = EvmAddress.from(l1Token); const balanceOnChain = this.getBalanceOnChain(chainId, l1TokenAddr, l2Token); @@ -1833,7 +1758,9 @@ export class InventoryClient { l1Token ); Object.keys(pendingWithdrawalBalances).forEach((chainId) => { - this.pendingL2Withdrawals[l1Token.toNative()][Number(chainId)] = pendingWithdrawalBalances[Number(chainId)]; + if (pendingWithdrawalBalances[Number(chainId)].gt(bnZero)) { + this.pendingL2Withdrawals[l1Token.toNative()][Number(chainId)] = pendingWithdrawalBalances[Number(chainId)]; + } }); }); this.logger.debug({ diff --git a/src/clients/TokenClient.ts b/src/clients/TokenClient.ts index a56b7a8f3e..c8c302177d 100644 --- a/src/clients/TokenClient.ts +++ b/src/clients/TokenClient.ts @@ -19,7 +19,7 @@ import { toBN, winston, getRedisCache, - TOKEN_SYMBOLS_MAP, + getInventoryBalanceContributorTokens, getRemoteTokenForL1Token, getTokenInfo, isEVMSpokePoolClient, @@ -249,26 +249,19 @@ export class TokenClient { } const tokens = hubPoolTokens - .map(({ symbol, address }) => { + .map(({ address }) => { let tokenAddrs: string[] = []; try { - const spokePoolToken = getRemoteTokenForL1Token(address, chainId, this.hubPoolClient.chainId); - tokenAddrs.push(spokePoolToken.toEvmAddress()); + tokenAddrs = dedupArray( + getInventoryBalanceContributorTokens(address, chainId, this.hubPoolClient.chainId).map((token) => + token.toEvmAddress() + ) + ); } catch { // No known deployment for this token on the SpokePool. // note: To be overhauled subject to https://github.com/across-protocol/sdk/pull/643 } - - // If the HubPool token is USDC then it might map to multiple tokens on the destination chain. - if (symbol === "USDC") { - ["USDC.e", "USDbC", "USDzC", "pathUSD"] - .map((symbol) => TOKEN_SYMBOLS_MAP[symbol]?.addresses[chainId]) - .filter(isDefined) - .forEach((address) => tokenAddrs.push(address)); - tokenAddrs = dedupArray(tokenAddrs); - } - - return tokenAddrs.filter(isDefined).map((address) => erc20.attach(address)); + return tokenAddrs.filter(isDefined).map((tokenAddress) => erc20.attach(tokenAddress)); }) .flat(); diff --git a/test/BundleDataApproxClient.ts b/test/BundleDataApproxClient.ts index fe3a26a575..610eef3715 100644 --- a/test/BundleDataApproxClient.ts +++ b/test/BundleDataApproxClient.ts @@ -292,6 +292,111 @@ describe("BundleDataApproxClient: Accounting for unexecuted, upcoming relayer re }); }); + describe("Aggregates balances across multiple inventory contributor tokens", function () { + // Test that when two different L2 tokens on the same chain both map to the same L1 token + // (e.g. USDC.e and native USDC both mapping to L1 USDC on Optimism), getUpcomingRefunds and + // getUpcomingDeposits correctly aggregate balances from both contributor tokens. + const nativeUsdcOnOptimism = TOKEN_SYMBOLS_MAP.USDC.addresses[OPTIMISM]; + + beforeEach(function () { + // Update the token mapping so both USDC.e and native USDC on Optimism map to L1 USDC. + (bundleDataClient as MockBundleDataApproxClient).setTokenMapping({ + [mainnetWeth]: { + [MAINNET]: mainnetWeth, + [OPTIMISM]: l2TokensForWeth[OPTIMISM], + [BSC]: l2TokensForWeth[BSC], + }, + [mainnetUsdc]: { + [MAINNET]: mainnetUsdc, + [OPTIMISM]: [l2TokensForUsdc[OPTIMISM], nativeUsdcOnOptimism], + [BSC]: l2TokensForUsdc[BSC], + }, + }); + }); + + it("getUpcomingRefunds aggregates fills from both contributor tokens", async function () { + const fillAmount1 = toBNWei(100, 6); + const fillAmount2 = toBNWei(50, 6); + + // Fill with the primary USDC.e token on Optimism, repaid on Optimism. + await generateFill("USDC", OPTIMISM, OPTIMISM, owner.address, fillAmount1); + + // Fill with the second contributor token on Optimism, repaid on Optimism. + // Use the raw MockSpokePoolClient.fillRelay to specify a custom inputToken. + const spokePoolClient = spokePoolClients[OPTIMISM]; + (spokePoolClient as unknown as MockSpokePoolClient).fillRelay({ + message: "0x", + messageHash: ZERO_BYTES, + inputToken: toAddressType(nativeUsdcOnOptimism, OPTIMISM), + outputToken: toAddressType(l2TokensForUsdc[OPTIMISM], OPTIMISM), + destinationChainId: OPTIMISM, + depositor: toAddressType(owner.address, OPTIMISM), + recipient: toAddressType(owner.address, OPTIMISM), + exclusivityDeadline: getCurrentTime() + 14400, + depositId: bnZero, + exclusiveRelayer: toAddressType(owner.address, OPTIMISM), + inputAmount: fillAmount2, + outputAmount: fillAmount2, + fillDeadline: getCurrentTime() + 14400, + blockNumber: spokePoolClient.latestHeightSearched, + txnIndex: 0, + logIndex: 1, + txnRef: "0x", + relayer: toAddressType(owner.address, MAINNET), + originChainId: OPTIMISM, + repaymentChainId: OPTIMISM, + relayExecutionInfo: { + updatedRecipient: toAddressType(owner.address, OPTIMISM), + updatedMessage: "0x", + updatedMessageHash: ZERO_BYTES, + updatedOutputAmount: fillAmount2, + fillType: interfaces.FillType.FastFill, + }, + } as interfaces.FillWithBlock); + await spokePoolClient.update(["FilledRelay"]); + + bundleDataClient.initialize(); + + // Refunds should aggregate both fills. + const totalRefunds = bundleDataClient.getUpcomingRefunds(OPTIMISM, l1Usdc); + expect(totalRefunds).to.equal(fillAmount1.add(fillAmount2)); + }); + + it("getUpcomingDeposits aggregates deposits from both contributor tokens", async function () { + const depositAmount1 = toBNWei(200, 6); + const depositAmount2 = toBNWei(75, 6); + + // Deposit with the primary USDC.e token on Optimism. + await generateDeposit("USDC", OPTIMISM, depositAmount1); + + // Deposit with the second contributor token on Optimism. + const spokePoolClient = spokePoolClients[OPTIMISM]; + (spokePoolClient as unknown as MockSpokePoolClient).deposit({ + inputToken: toAddressType(nativeUsdcOnOptimism, OPTIMISM), + inputAmount: depositAmount2, + depositor: toAddressType(owner.address, OPTIMISM), + recipient: toAddressType(owner.address, OPTIMISM), + outputToken: toAddressType(randomAddress(), OPTIMISM), + outputAmount: depositAmount2, + quoteTimestamp: getCurrentTime(), + fillDeadline: getCurrentTime() + 14400, + exclusivityDeadline: getCurrentTime() + 14400, + exclusiveRelayer: toAddressType(owner.address, OPTIMISM), + originChainId: OPTIMISM, + blockNumber: spokePoolClient.latestHeightSearched, + txnIndex: 0, + logIndex: 1, + } as interfaces.DepositWithBlock); + await spokePoolClient.update(["FundsDeposited"]); + + bundleDataClient.initialize(); + + // Deposits should aggregate both deposit amounts. + const totalDeposits = bundleDataClient.getUpcomingDeposits(OPTIMISM, l1Usdc); + expect(totalDeposits).to.equal(depositAmount1.add(depositAmount2)); + }); + }); + describe("getUnexecutedBundleStartBlocks", function () { it("Returns endBlocks+1 from last relayed bundle for chain", async function () { configStoreClient.setAvailableChains([MAINNET, OPTIMISM, BSC]); @@ -299,7 +404,7 @@ describe("BundleDataApproxClient: Accounting for unexecuted, upcoming relayer re // When there are no RootBundleRelay/ProposedRootBundle events, returns 0 for all chains. const defaultFromBlocks = (bundleDataClient as MockBundleDataApproxClient).getUnexecutedBundleStartBlocks( l1Weth, - false + true ); expect(defaultFromBlocks[MAINNET]).to.equal(0); expect(defaultFromBlocks[OPTIMISM]).to.equal(0); @@ -307,6 +412,7 @@ describe("BundleDataApproxClient: Accounting for unexecuted, upcoming relayer re const rootBundleRelays = [ { + rootBundleId: 0, relayerRefundRoot: "0x1234", bundleEvaluationBlockNumbers: [toBN(3), toBN(4), toBN(5)], }, @@ -316,10 +422,23 @@ describe("BundleDataApproxClient: Accounting for unexecuted, upcoming relayer re ); hubPoolClient.setValidatedRootBundles(rootBundleRelays as unknown as ProposedRootBundle[]); - const fromBlocks1 = (bundleDataClient as MockBundleDataApproxClient).getUnexecutedBundleStartBlocks( - l1Weth, - false - ); + // Add a matching execution so getUnexecutedBundleStartBlocks can verify the leaf was executed. + // Directly push to the internal array to avoid event parsing issues in the mock. + (spokePoolClients[MAINNET] as any).relayerRefundExecutions.push({ + rootBundleId: 0, + leafId: 0, + chainId: MAINNET, + amountToReturn: bnZero, + l2TokenAddress: toAddressType(mainnetWeth, MAINNET), + refundAddresses: [], + refundAmounts: [], + blockNumber: 0, + txnIndex: 0, + logIndex: 0, + txnRef: "0x", + }); + + const fromBlocks1 = (bundleDataClient as MockBundleDataApproxClient).getUnexecutedBundleStartBlocks(l1Weth, true); // Only the spoke pool clients that saw the RootBundleRelay events should have non-zero fromBlocks. expect(fromBlocks1[MAINNET]).to.equal(4); diff --git a/test/InventoryClient.InventoryRebalance.ts b/test/InventoryClient.InventoryRebalance.ts index 7b64282e09..4125493814 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -18,7 +18,7 @@ import { import { ConfigStoreClient, InventoryClient } from "../src/clients"; // Tested import { CrossChainTransferClient } from "../src/clients/bridges"; import { InventoryConfig } from "../src/interfaces"; -import { MockAdapterManager, MockHubPoolClient, MockTokenClient } from "./mocks/"; +import { MockAdapterManager, MockHubPoolClient, MockInventoryClient, MockTokenClient } from "./mocks/"; import { bnZero, CHAIN_IDs, @@ -76,10 +76,18 @@ const inventoryConfig: InventoryConfig = { [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 }, + [l2TokensForUsdc[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [l2TokensForUsdc[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [l2TokensForUsdc[BASE]]: { + [BASE]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [l2TokensForUsdc[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, }, }, }; @@ -121,7 +129,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { crossChainTransferClient = new CrossChainTransferClient(spyLogger, enabledChainIds, adapterManager); mockRebalancerClient = new MockRebalancerClient(spyLogger); - inventoryClient = new InventoryClient( + inventoryClient = new MockInventoryClient( EvmAddress.from(owner.address), spyLogger, inventoryConfig, @@ -433,8 +441,9 @@ describe("InventoryClient: Rebalancing inventory", async function () { const testL2Token = toAddressType(l2TokensForUsdc[testChain], testChain); const targetOverageBuffer = toWei("2"); beforeEach(function () { - inventoryConfig.tokenConfig[testL1Token][testChain].withdrawExcessPeriod = 7200; - inventoryConfig.tokenConfig[testL1Token][testChain].targetOverageBuffer = targetOverageBuffer; + inventoryConfig.tokenConfig[testL1Token][testL2Token.toNative()][testChain].withdrawExcessPeriod = 7200; + inventoryConfig.tokenConfig[testL1Token][testL2Token.toNative()][testChain].targetOverageBuffer = + targetOverageBuffer; const mockAdapter = new MockBaseChainAdapter(); adapterManager.setAdapters(testChain, mockAdapter); }); @@ -443,7 +452,9 @@ describe("InventoryClient: Rebalancing inventory", async function () { // The threshold to trigger an excess withdrawal is when the currentAllocPct is greater than the // targetPct multiplied by the "targetPctMultiplier" const targetPctMultiplier = targetOverageBuffer.mul(toWei("0.95")).div(toWei("1")); - const excessWithdrawThresholdPct = inventoryConfig.tokenConfig[testL1Token][testChain].targetPct + const excessWithdrawThresholdPct = inventoryConfig.tokenConfig[testL1Token][testL2Token.toNative()][ + testChain + ].targetPct .mul(targetPctMultiplier) .div(toWei("1")); @@ -459,7 +470,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.withdrawExcessBalances(); const expectedWithdrawalPct = currentAllocationPct.sub( - inventoryConfig.tokenConfig[testL1Token][testChain].targetPct + inventoryConfig.tokenConfig[testL1Token][testL2Token.toNative()][testChain].targetPct ); const expectedWithdrawalAmount = expectedWithdrawalPct.mul(currentCumulativeBalance).div(toWei(1)); expect(adapterManager.withdrawalsRequired[0].amountToWithdraw).eq(expectedWithdrawalAmount); @@ -487,7 +498,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.withdrawExcessBalances(); const expectedWithdrawalPct = currentAllocationPct.sub( - BigNumber.from(inventoryConfig.tokenConfig[testL1Token][testChain].targetPct) + BigNumber.from(inventoryConfig.tokenConfig[testL1Token][testL2Token.toNative()][testChain].targetPct) ); // Expected withdrawal amount is in correct decimals: @@ -664,7 +675,7 @@ describe("InventoryClient: Rebalancing inventory", async function () { it("Correct normalizes pending rebalances to L1 token decimals", async function () { const testChain = CHAIN_IDs.OPTIMISM; - hubPoolClient.mapTokenInfo(toAddressType(bridgedUSDC[testChain], testChain), "USDC", 18); + hubPoolClient.mapTokenInfo(toAddressType(nativeUSDC[testChain], testChain), "USDC", 18); // Pending rebalance should be in chain decimals: const pendingRebalanceAmount = toWei("1"); @@ -675,11 +686,38 @@ describe("InventoryClient: Rebalancing inventory", async function () { const expectedBalance = toMegaWei("1"); expect( inventoryClient - .getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc), toAddressType(bridgedUSDC[testChain], testChain)) + .getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc), toAddressType(nativeUSDC[testChain], testChain)) .eq(expectedBalance) ).to.be.true; }); + it("Includes pending rebalances once in aggregate chain balances", async function () { + const testChain = CHAIN_IDs.OPTIMISM; + const canonicalL2Token = toAddressType(nativeUSDC[testChain], testChain); + const nonCanonicalL2Token = toAddressType(bridgedUSDC[testChain], testChain); + hubPoolClient.mapTokenInfo(canonicalL2Token, "USDC", 18); + tokenClient.setTokenData(testChain, canonicalL2Token, toWei("2000")); + + const bridgedBalance = toMegaWei("2"); + tokenClient.setTokenData(testChain, nonCanonicalL2Token, bridgedBalance); + + const pendingRebalanceAmount = toWei("1"); + mockRebalancerClient.setPendingRebalance(testChain, "USDC", pendingRebalanceAmount); + await inventoryClient.update(); + + const aggregateBalance = inventoryClient.getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc)); + const canonicalBalance = inventoryClient.getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc), canonicalL2Token); + const nonCanonicalBalance = inventoryClient.getBalanceOnChain( + testChain, + EvmAddress.from(mainnetUsdc), + nonCanonicalL2Token + ); + + expect(aggregateBalance).to.eq(toMegaWei("2003")); + expect(canonicalBalance).to.eq(toMegaWei("2001")); + expect(nonCanonicalBalance).to.eq(bridgedBalance); + }); + it("Correctly sums 1:many token balances", async function () { enabledChainIds .filter((chainId) => chainId !== MAINNET) diff --git a/test/InventoryClient.RefundChain.ts b/test/InventoryClient.RefundChain.ts index 5e4ab8603d..4b805bd105 100644 --- a/test/InventoryClient.RefundChain.ts +++ b/test/InventoryClient.RefundChain.ts @@ -82,10 +82,18 @@ describe("InventoryClient: Refund chain selection", async function () { [BSC]: { 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 }, - [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, - [BSC]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + [l2TokensForUsdc[OPTIMISM]]: { + [OPTIMISM]: { targetPct: toWei(0.12), thresholdPct: toWei(0.1), targetOverageBuffer }, + }, + [l2TokensForUsdc[POLYGON]]: { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [l2TokensForUsdc[ARBITRUM]]: { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, + [l2TokensForUsdc[BSC]]: { + [BSC]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }, }, }, }; @@ -259,9 +267,10 @@ describe("InventoryClient: Refund chain selection", async function () { sampleDepositData.inputAmount = toWei(5); sampleDepositData.outputAmount = sdkUtils.ConvertDecimals(18, 6)(await computeOutputAmount(sampleDepositData)); + // BundleDataApproxClient now returns upcoming refunds in L1 token decimals, so mock them in L1 decimals too. (inventoryClient as MockInventoryClient).setUpcomingRefunds(mainnetWeth, { [MAINNET]: toWei(5), - [OPTIMISM]: toMegaWei(10), + [OPTIMISM]: toWei(10), }); expect(await inventoryClient.determineRefundChainId(sampleDepositData)).to.deep.equal([MAINNET]); @@ -865,12 +874,22 @@ describe("InventoryClient: Refund chain selection", async function () { // Modify mocks to be aware of native USDC, which is a "fast" rebalance token for certain routes hubPoolClient.setTokenMapping(mainnetUsdc, POLYGON, TOKEN_SYMBOLS_MAP.USDC.addresses[POLYGON]); hubPoolClient.setTokenMapping(mainnetUsdc, ARBITRUM, TOKEN_SYMBOLS_MAP.USDC.addresses[ARBITRUM]); + hubPoolClient.mapTokenInfo(toAddressType(TOKEN_SYMBOLS_MAP.USDC.addresses[POLYGON], POLYGON), "USDC", 6); + hubPoolClient.mapTokenInfo(toAddressType(TOKEN_SYMBOLS_MAP.USDC.addresses[ARBITRUM], ARBITRUM), "USDC", 6); (inventoryClient as unknown as MockInventoryClient).setTokenMapping({ [mainnetUsdc]: { [POLYGON]: TOKEN_SYMBOLS_MAP.USDC.addresses[POLYGON], [ARBITRUM]: TOKEN_SYMBOLS_MAP.USDC.addresses[ARBITRUM], }, }); + // Add native USDC entries to the alias config so that getTokenConfig finds them. + const usdcConfig = inventoryConfig.tokenConfig[mainnetUsdc]; + usdcConfig[TOKEN_SYMBOLS_MAP.USDC.addresses[POLYGON]] = { + [POLYGON]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }; + usdcConfig[TOKEN_SYMBOLS_MAP.USDC.addresses[ARBITRUM]] = { + [ARBITRUM]: { targetPct: toWei(0.07), thresholdPct: toWei(0.05), targetOverageBuffer }, + }; sampleDepositData = { depositId: bnZero, fromLiteChain: false, diff --git a/test/mocks/MockBundleDataApproxClient.ts b/test/mocks/MockBundleDataApproxClient.ts index 315524f5ae..b0dc513133 100644 --- a/test/mocks/MockBundleDataApproxClient.ts +++ b/test/mocks/MockBundleDataApproxClient.ts @@ -1,7 +1,7 @@ import { BundleDataApproxClient } from "../../src/clients"; import { Address, BigNumber, EvmAddress, toAddressType } from "../../src/utils"; -type TokenMapping = { [l1Token: string]: { [chainId: number]: string } }; +type TokenMapping = { [l1Token: string]: { [chainId: number]: string | string[] } }; export class MockBundleDataApproxClient extends BundleDataApproxClient { tokenMappings: TokenMapping | undefined = undefined; @@ -11,9 +11,13 @@ export class MockBundleDataApproxClient extends BundleDataApproxClient { override getL1TokenAddress(l2Token: Address, chainId: number): EvmAddress { if (this.tokenMappings) { - const tokenMapping = Object.entries(this.tokenMappings).find( - ([, mapping]) => mapping[chainId] === l2Token.toEvmAddress() - ); + const tokenMapping = Object.entries(this.tokenMappings).find(([, mapping]) => { + const mapped = mapping[chainId]; + if (Array.isArray(mapped)) { + return mapped.includes(l2Token.toEvmAddress()); + } + return mapped === l2Token.toEvmAddress(); + }); if (tokenMapping) { return toAddressType(tokenMapping[0], chainId); } @@ -28,8 +32,7 @@ export class MockBundleDataApproxClient extends BundleDataApproxClient { return super.getApproximateRefundsForToken(l1Token, fromBlocks); } - // Return the next starting block for each chain following the bundle end block of the last executed bundle that - // was relayed to that chain. + // Expose for unit testing override getUnexecutedBundleStartBlocks(l1Token: Address, requireExecution: boolean): { [chainId: number]: number } { return super.getUnexecutedBundleStartBlocks(l1Token, requireExecution); } diff --git a/test/mocks/MockInventoryClient.ts b/test/mocks/MockInventoryClient.ts index cff6e133ad..01669526ad 100644 --- a/test/mocks/MockInventoryClient.ts +++ b/test/mocks/MockInventoryClient.ts @@ -1,7 +1,7 @@ -import { Deposit, InventoryConfig } from "../../src/interfaces"; +import { Deposit, InventoryConfig, TokenInfo } from "../../src/interfaces"; import { HubPoolClient, InventoryClient, Rebalance, TokenClient } from "../../src/clients"; import { AdapterManager, CrossChainTransferClient } from "../../src/clients/bridges"; -import { BigNumber, bnZero, EvmAddress, toAddressType } from "../../src/utils"; +import { Address, BigNumber, bnZero, EvmAddress, getTokenInfo, toAddressType } from "../../src/utils"; import winston from "winston"; import { RebalancerClient } from "../../src/rebalancer/utils/interfaces"; @@ -42,6 +42,14 @@ export class MockInventoryClient extends InventoryClient { ); } + protected override getTokenInfo(token: Address, chainId: number): TokenInfo { + try { + return this.hubPoolClient.getTokenInfoForAddress(token, chainId); + } catch { + return getTokenInfo(token, chainId); + } + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars override async determineRefundChainId(_deposit: Deposit): Promise { return this.inventoryConfig === null ? [1] : super.determineRefundChainId(_deposit); @@ -72,7 +80,7 @@ export class MockInventoryClient extends InventoryClient { } override getPossibleRebalances(): Rebalance[] { - return this.possibleRebalances; + return this.possibleRebalances.length > 0 ? this.possibleRebalances : super.getPossibleRebalances(); } setBalanceOnChainForL1Token(newBalance: BigNumber | undefined): void { From 1fecd57fd4c20eab878f05d190ceb8dc86fd1309 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 25 Mar 2026 11:48:11 -0400 Subject: [PATCH 3/4] Update InventoryClient.InventoryRebalance.ts --- test/InventoryClient.InventoryRebalance.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/InventoryClient.InventoryRebalance.ts b/test/InventoryClient.InventoryRebalance.ts index 4125493814..bbff238919 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -706,7 +706,11 @@ describe("InventoryClient: Rebalancing inventory", async function () { await inventoryClient.update(); const aggregateBalance = inventoryClient.getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc)); - const canonicalBalance = inventoryClient.getBalanceOnChain(testChain, EvmAddress.from(mainnetUsdc), canonicalL2Token); + const canonicalBalance = inventoryClient.getBalanceOnChain( + testChain, + EvmAddress.from(mainnetUsdc), + canonicalL2Token + ); const nonCanonicalBalance = inventoryClient.getBalanceOnChain( testChain, EvmAddress.from(mainnetUsdc), From 7dc0dc0bff000e1607a082442d1be6943427e982 Mon Sep 17 00:00:00 2001 From: nicholaspai Date: Wed, 25 Mar 2026 17:22:45 -0400 Subject: [PATCH 4/4] ts --- src/clients/InventoryClient.ts | 7 ++++--- test/BundleDataApproxClient.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index afea436fa9..628b5a5ce1 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -1209,9 +1209,10 @@ export class InventoryClient { mrkdwn += `*Rebalances sent to ${getNetworkName(chainId)}:*\n`; for (const { l1Token, l2Token, amount, hash, chainId, isShortfallRebalance } of rebalances) { const tokenInfo = this.getTokenInfo(l2Token, chainId); - if (!tokenInfo) { - `InventoryClient::rebalanceInventoryIfNeeded no token info for L2 token ${l2Token} on chain ${chainId}`; - } + assert( + isDefined(tokenInfo), + `InventoryClient::rebalanceInventoryIfNeeded no token info for L2 token ${l2Token} on chain ${chainId}` + ); const { symbol, decimals } = tokenInfo; const l2TokenFormatter = createFormatFunction(2, 4, false, decimals); const l1TokenInfo = this.getTokenInfo(l1Token, this.hubPoolClient.chainId); diff --git a/test/BundleDataApproxClient.ts b/test/BundleDataApproxClient.ts index 35556134e6..e8c59cebfe 100644 --- a/test/BundleDataApproxClient.ts +++ b/test/BundleDataApproxClient.ts @@ -422,6 +422,7 @@ describe("BundleDataApproxClient: Accounting for unexecuted, upcoming relayer re // Add a matching execution so getUnexecutedBundleStartBlocks can verify the leaf was executed. // Directly push to the internal array to avoid event parsing issues in the mock. + // eslint-disable-next-line @typescript-eslint/no-explicit-any (spokePoolClients[MAINNET] as any).relayerRefundExecutions.push({ rootBundleId: 0, leafId: 0,