From 6fbbb327b79dcca17780287f04d4ae1a1520f01b Mon Sep 17 00:00:00 2001 From: droplet-rl <284132418+droplet-rl@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:09:51 +0000 Subject: [PATCH 1/2] fix(InventoryClient): exclude pendingRebalances when ignoreL1ToL2PendingAmount is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The July-2025 guardrail in `getBalanceOnChain` excludes canonical L1→L2 pending transfers from the virtual chain balance when the caller asks for a liquidity-faithful view (notably `withdrawExcessBalances` via `getCurrentAllocationPct`). The Feb-2026 `pendingRebalances` integration introduced a second source of virtual inbound — rebalancer-service pending bridges to a chain — which the same guardrail did not cover. As the swap rebalancer became active in production, that second channel began inflating `currentAllocPct` for chains receiving in-flight CCTP/OFT inbound, triggering `withdrawExcessBalances` to size a withdraw sized off the virtual balance against a smaller liquid balance, reverting the resulting `depositForBurn` with `ERC20: transfer amount exceeds balance`. Extend the same `ignoreL1ToL2PendingAmount` flag to also skip the `pendingRebalances[chainId][token]` contribution so both virtual-inbound sources are filtered together. Callers that intentionally want to count inbound (e.g. `_getPossibleInventoryRebalances` to avoid duplicate L1→L2 refills) keep the flag at its default `false` and see the same behavior as before. Co-Authored-By: Claude Opus 4.7 --- src/clients/InventoryClient.ts | 12 +++++++++++- test/InventoryClient.InventoryRebalance.ts | 11 +++++++++++ test/mocks/MockInventoryClient.ts | 9 +++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index 570219edbb..297337cf8e 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -276,6 +276,11 @@ 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 are excluded together so callers that want a liquidity-faithful view see the same picture for + * either source of in-flight inbound, regardless of which subsystem initiated it. * @returns Balance of l1Token on chainId. */ protected getBalanceOnChain( @@ -302,10 +307,15 @@ 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, skip this for the same reason we skip + // `crossChainTransferClient` pending transfers below: both 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. const pendingRebalancesForChain = this.pendingRebalances[chainId]; const canonicalL2Token = getRemoteTokenForL1Token(l1Token, chainId, this.hubPoolClient.chainId); if ( + !ignoreL1ToL2PendingAmount && isDefined(pendingRebalancesForChain) && isDefined(canonicalL2Token) && (!isDefined(l2Token) || l2Token.eq(canonicalL2Token)) diff --git a/test/InventoryClient.InventoryRebalance.ts b/test/InventoryClient.InventoryRebalance.ts index 06ad2f3e2b..d5b1c68d1e 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -393,6 +393,17 @@ describe("InventoryClient: Rebalancing inventory", function () { ); expect(arbitrumUsdcBalance.eq(tokenClient.getBalance(ARBITRUM, arbitrumUsdcL2Token).add(pendingUsdcSwapRebalance))) .to.be.true; + + // Case 4: ignoreL1ToL2PendingAmount=true excludes 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; }); 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 { From ada8e075f6c590eedd98810d75b2cb31890b0b68 Mon Sep 17 00:00:00 2001 From: droplet-rl <284132418+droplet-rl@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:22:48 +0000 Subject: [PATCH 2/2] fix(InventoryClient): preserve negative pendingRebalances when ignoreL1ToL2PendingAmount is set Codex review on #3492 flagged that suppressing every pendingRebalances entry under `ignoreL1ToL2PendingAmount` would also drop the negative reservations that adapters like `HyperliquidStablecoinSwapAdapter` push to the destination chain when an intermediate withdrawal finalizes but the onward bridge step is still pending. Treating that reserved balance as excess would let `withdrawExcessBalances` size a withdraw against funds earmarked for the in-flight rebalance. Only suppress positive pendingRebalances entries; keep negative entries in the virtual balance regardless of the flag. Document the new semantics in the JSDoc, the clients README, and the virtual-balance model deep dive, and add a regression test asserting that a negative pending rebalance is preserved with `ignoreL1ToL2PendingAmount=true`. Co-Authored-By: Claude Opus 4.7 --- docs/inventory-virtual-balance-model.md | 4 +++- src/clients/InventoryClient.ts | 22 ++++++++++++------ src/clients/README.md | 2 ++ test/InventoryClient.InventoryRebalance.ts | 26 +++++++++++++++++++--- 4 files changed, 43 insertions(+), 11 deletions(-) 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 297337cf8e..cf4483be64 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -279,8 +279,12 @@ export class InventoryClient { * @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 are excluded together so callers that want a liquidity-faithful view see the same picture for - * either source of in-flight inbound, regardless of which subsystem initiated it. + * 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( @@ -308,14 +312,16 @@ 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()). - // When `ignoreL1ToL2PendingAmount` is set, skip this for the same reason we skip - // `crossChainTransferClient` pending transfers below: both inflate the chain's perceived balance with + // 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. + // 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 ( - !ignoreL1ToL2PendingAmount && isDefined(pendingRebalancesForChain) && isDefined(canonicalL2Token) && (!isDefined(l2Token) || l2Token.eq(canonicalL2Token)) @@ -323,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 d5b1c68d1e..7f371933d8 100644 --- a/test/InventoryClient.InventoryRebalance.ts +++ b/test/InventoryClient.InventoryRebalance.ts @@ -394,9 +394,10 @@ describe("InventoryClient: Rebalancing inventory", function () { expect(arbitrumUsdcBalance.eq(tokenClient.getBalance(ARBITRUM, arbitrumUsdcL2Token).add(pendingUsdcSwapRebalance))) .to.be.true; - // Case 4: ignoreL1ToL2PendingAmount=true excludes 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. + // 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), @@ -404,6 +405,25 @@ describe("InventoryClient: Rebalancing inventory", function () { 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 () {