Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/inventory-virtual-balance-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down
22 changes: 20 additions & 2 deletions src/clients/InventoryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines +279 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the inventory docs for the new ignore semantics

This changes the documented behavior of the core virtual-balance primitive, but only the inline JSDoc was updated. AGENTS.md requires relevant README/AGENTS docs to be updated when behavior changes, and src/clients/README.md:28-30 still tells operators that incomplete rebalancer-client rebalances are part of InventoryClient virtual balances without mentioning that callers can now suppress them via this flag; that leaves the module docs misleading for contributors debugging withdrawExcessBalances and allocation calculations.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated src/clients/README.md (the "Setting and Getting Virtual Balances" section) and docs/inventory-virtual-balance-model.md (the getBalanceOnChain primitive section) in ada8e07 to call out both that ignoreL1ToL2PendingAmount now suppresses positive pendingRebalances entries alongside outstanding CrossChainTransferClient transfers, and that negative pendingRebalances entries are kept by design so adapter-driven reservations (e.g. the Hyperliquid stablecoin swap subtraction on HyperEVM) survive the filter.

* 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(
Expand All @@ -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 (
Expand All @@ -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));
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/clients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions test/InventoryClient.InventoryRebalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
9 changes: 7 additions & 2 deletions test/mocks/MockInventoryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down