diff --git a/docs/inventory-virtual-balance-model.md b/docs/inventory-virtual-balance-model.md index 21a8e1b735..8fe9b72a92 100644 --- a/docs/inventory-virtual-balance-model.md +++ b/docs/inventory-virtual-balance-model.md @@ -34,7 +34,7 @@ This gives a "virtual" balance intended to better reflect near-future executable ## Core primitives -### `getBalanceOnChain(chainId, l1Token, l2Token?, ignorePending?)` +### `getBalanceOnChain(chainId, l1Token, l2Token?, ignoreL1ToL2PendingAmount?)` This is the base virtual-balance primitive. It: @@ -44,6 +44,8 @@ This is the base virtual-balance primitive. It: - includes outstanding L1->L2 transfer amounts via CrossChainTransferClient - returns either per-token (`l2Token`) or aggregate-per-chain balance +When `ignoreL1ToL2PendingAmount=true`, both positive virtual-inbound sources are suppressed: outstanding `CrossChainTransferClient` L1->L2 transfers and positive `pendingRebalances` entries. Callers like `withdrawExcessBalances` use this to avoid sizing a withdraw off inbound that hasn't physically landed. Negative `pendingRebalances` entries are preserved because adapters use them to reserve already-arrived intermediate funds for an onward bridge step (e.g. `HyperliquidStablecoinSwapAdapter` subtracts finalized Hypercore withdrawals from HyperEVM); dropping those would let a withdraw race the in-flight rebalance. + ### `getCumulativeBalance(l1Token)` Sums virtual balances over all enabled chains. Used as denominator for allocation percentages. diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 570219edbb..cf4483be64 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -276,6 +276,15 @@ export class InventoryClient { * @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. + * @param ignoreL1ToL2PendingAmount If true, exclude virtual inbound balance that hasn't physically landed + * on the target chain yet. Currently two sources contribute virtual inbound: canonical L1→L2 transfers + * from `crossChainTransferClient`, and rebalancer-service pending rebalances from `pendingRebalances`. + * Both inbound sources are excluded together so callers that want a liquidity-faithful view see the + * same picture regardless of which subsystem initiated the in-flight inbound. Negative + * `pendingRebalances` entries are preserved: adapters such as the Hyperliquid stablecoin swap subtract + * already-finalized intermediate withdrawals from the destination chain to reserve liquid funds for the + * onward bridge step, and treating those reservations as excess would let `withdrawExcessBalances` pull + * them away from the pending rebalance. * @returns Balance of l1Token on chainId. */ protected getBalanceOnChain( @@ -302,7 +311,14 @@ 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()) + // mapped to each L1 token (i.e. the L2 token for an L1 token returned by getRemoteTokenForL1Token()). + // When `ignoreL1ToL2PendingAmount` is set, drop positive entries for the same reason we skip + // `crossChainTransferClient` pending transfers below: they inflate the chain's perceived balance with + // in-flight inbound that hasn't physically arrived, which can cause `withdrawExcessBalances` to size a + // withdraw that the on-chain ERC20 balance cannot cover. Negative entries are preserved because adapters + // use them to reserve already-arrived intermediate funds for a still-pending onward bridge step (e.g. + // `HyperliquidStablecoinSwapAdapter` subtracts finalized Hypercore withdrawals from HyperEVM); ignoring + // those would let `withdrawExcessBalances` count the reserved balance as excess. const pendingRebalancesForChain = this.pendingRebalances[chainId]; const canonicalL2Token = getRemoteTokenForL1Token(l1Token, chainId, this.hubPoolClient.chainId); if ( @@ -313,7 +329,9 @@ export class InventoryClient { const { decimals: l2TokenDecimals } = this.getTokenInfo(canonicalL2Token, chainId); const pendingRebalancesForToken = pendingRebalancesForChain[l1TokenSymbol]; if (isDefined(pendingRebalancesForToken)) { - balance = balance.add(sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(pendingRebalancesForToken)); + const includedAmount = + ignoreL1ToL2PendingAmount && pendingRebalancesForToken.gt(bnZero) ? bnZero : pendingRebalancesForToken; + balance = balance.add(sdkUtils.ConvertDecimals(l2TokenDecimals, l1TokenDecimals)(includedAmount)); } } diff --git a/src/clients/README.md b/src/clients/README.md index d2be5c016d..8af894a400 100644 --- a/src/clients/README.md +++ b/src/clients/README.md @@ -27,6 +27,8 @@ The full inventory config is defined in /src/interfaces/ and its read from the u The InventoryClient is designed to track inventory across chains, which are actual on-chain token balances plus any virtual balance modifications stemming from incomplete transfers from the `CrossChainTransferClient` and incomplete rebalances from rebalancer clients. The InventoryClient can also add in virtual modifications for upcoming relayer refunds from the `BundleDataApproxClient`. +`getBalanceOnChain` exposes an `ignoreL1ToL2PendingAmount` flag for callers that need a liquidity-faithful view (notably `withdrawExcessBalances` via `getCurrentAllocationPct`): when set, it suppresses both sources of positive virtual inbound — outstanding `CrossChainTransferClient` L1→L2 transfers and positive `pendingRebalances` entries — so the size of a withdraw isn't keyed off in-flight inbound that hasn't physically landed. Negative `pendingRebalances` entries are intentionally preserved: rebalancer adapters use them to reserve already-arrived intermediate balance for an onward bridge step (for example, `HyperliquidStablecoinSwapAdapter` subtracts finalized Hypercore withdrawals from HyperEVM), and counting those as excess would let a withdraw race the in-flight rebalance. + The InventoryClient exposes functions that let other bots like the `Relayer` and rebalancer clients know its latest calculation of virtual chain balances for a particular token. For pending rebalance adjustments specifically, it depends on the read-only `ReadOnlyRebalancerClient` interface so it does not need to choose a rebalancing mode. In addition to chain-level virtual balances, InventoryClient exposes cumulative token-level balance context via `getCumulativeBalanceWithApproximateUpcomingRefunds()`. The Rebalancer uses this to evaluate cumulative deficits and excesses when running cumulative inventory rebalancing. diff --git a/test/InventoryClient.InventoryRebalance.ts b/test/InventoryClient.InventoryRebalance.ts index 06ad2f3e2b..7f371933d8 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -393,6 +393,37 @@ describe("InventoryClient: Rebalancing inventory", function () { ); expect(arbitrumUsdcBalance.eq(tokenClient.getBalance(ARBITRUM, arbitrumUsdcL2Token).add(pendingUsdcSwapRebalance))) .to.be.true; + + // Case 4: ignoreL1ToL2PendingAmount=true excludes positive pendingRebalances from the virtual balance, + // so callers requesting a liquidity-faithful view (e.g. `withdrawExcessBalances` via + // `getCurrentAllocationPct`) don't size against in-flight inbound that hasn't physically landed on the + // chain. + const arbitrumUsdcBalanceIgnoringPending = inventoryClient.getBalanceOnChain( + ARBITRUM, + EvmAddress.from(mainnetUsdc), + arbitrumUsdcL2Token, + true + ); + expect(arbitrumUsdcBalanceIgnoringPending.eq(tokenClient.getBalance(ARBITRUM, arbitrumUsdcL2Token))).to.be.true; + + // Case 5: ignoreL1ToL2PendingAmount=true still applies negative pendingRebalances entries. Adapters + // (e.g. `HyperliquidStablecoinSwapAdapter`) subtract already-arrived intermediate funds from the + // destination chain to reserve them for a still-pending onward bridge step; treating those reservations + // as excess would let `withdrawExcessBalances` pull liquid balance away from the pending rebalance. + const reservedUsdcOnArbitrum = toMegaWei(50).mul(-1); + mockRebalancerClient.setPendingRebalance(ARBITRUM, "USDC", reservedUsdcOnArbitrum); + await inventoryClient.update(); + const arbitrumUsdcBalanceWithReservation = inventoryClient.getBalanceOnChain( + ARBITRUM, + EvmAddress.from(mainnetUsdc), + arbitrumUsdcL2Token, + true + ); + expect( + arbitrumUsdcBalanceWithReservation.eq( + tokenClient.getBalance(ARBITRUM, arbitrumUsdcL2Token).add(reservedUsdcOnArbitrum) + ) + ).to.be.true; }); it("Refuses to send rebalance when ERC20 balance changes", async function () { diff --git a/test/mocks/MockInventoryClient.ts b/test/mocks/MockInventoryClient.ts index 9829e32390..d9909425d5 100644 --- a/test/mocks/MockInventoryClient.ts +++ b/test/mocks/MockInventoryClient.ts @@ -95,8 +95,13 @@ export class MockInventoryClient extends InventoryClient { this.balanceOnChain = newBalance; } - override getBalanceOnChain(chainId: number, l1Token: string, l2Token?: string): BigNumber { - return this.balanceOnChain ?? super.getBalanceOnChain(chainId, l1Token, l2Token); + override getBalanceOnChain( + chainId: number, + l1Token: string, + l2Token?: string, + ignoreL1ToL2PendingAmount = false + ): BigNumber { + return this.balanceOnChain ?? super.getBalanceOnChain(chainId, l1Token, l2Token, ignoreL1ToL2PendingAmount); } setTokenMapping(tokenMapping: TokenMapping): void {