Skip to content

Conversation

@nicholaspai
Copy link
Member

@nicholaspai nicholaspai commented Dec 11, 2025

Nomenclature

In this description, when I refer to Rebalancer I am referring to the new module that I am introducing in this PR. Contrast the Rebalancer with the InventoryClient which currently bridges tokens between chains to "rebalance" inventory.

Eventually, the plan is for the rebalancing logic in the InventoryClient to be taken over by the logic in the Rebalancer.

To start, the Rebalancer will only be responsible for rebalancing inventory across chains and swapping that inventory between currencies (i.e. USDC on a chain to USDT on another chain; could be the same chain). The InventoryClient will remain responsible for bridging tokens across chains without swapping those tokens between currencies.

Overview

This PR introduces a RebalancerClient that can be used to swap inventory between USDC and USDT across any chains. The list of eligible chains are those that either have (i) CCTP support, (ii) OFT support, or (iii) support for depositing into Binance.

The RebalancerClient allows the user to pass in a distribution of token balances across all chains alongside a "target" distribution. For any chains that are under target, the client will attempt to draw balance from those chains that are over target and swap these excess distributions to fulfill deficits.

For example, if the current distribution of balances appears to be underweight USDC balance on Arbitrum and overweight USDT balance on Optimism, then the rebalancer will attempt to swap that USDC into USDT and send USDT to Optimism.

The rebalancer will find the cheapest path to connect these excesses to deficits amongst two options provided in this initial PR: swapping on Hyperliquid and swapping on Binance.

The user of the RebalancerClient can track all outstanding rebalances and get a list of virtual balances across all chains representing in-flight rebalances. These virtual balances are designed to then be applied to the user's current assumption about its inventory distribution in order to produce a more accurate view of their distribution.

Intended usage

The RebalancerClient is currently designed to be run in the same manner that the InventoryClient does today. I envision the inventoryClient keeping its view of current balances accurate by using the returned virtual balances from RebalancerClient.getPendingRebalances(). Moreover, I expect that the inventoryClient.getTokenDistributionPerL1Token() function is used as input into the RebalancerClient.initializeRebalance() function.

By using the RebalancerClient in this PR alongside the InventoryClient, we can not only continue to rebalance inventory across chains by bridging equivalent tokens across chains, but we can also define per-chain target balances and use excess balances to fill any under target chain balances.

RebalancerClient

Constructing

Config

The client requires a configuration containing a targetBalances object of type: { [chainId: number]: { [tokenSymbol: string]: { targetBalance: BigNumber; priorityTier: number } }. This will be compared against the currentBalances object of an identical name when evaluating new rebalance opportunities.

The priorityTier for each targetBalance is a way to break ties between different chains that each are undertarget. In some cases, it's not appropriate to simply rank target deficits by simply comparing (targetBalance - currentBalance). For example, we might want to treat a chain like Ethereum Mainnet as a "universal sink" whose targetBalance = MAX_INTEGER because in most cases, we want to send excess L2 inventory back to L1. However, setting the targetBalance to this high of a number means that its ""deficit" amount will always outrank any other chain's deficit if we simply compared (target - current). Therefore, I believe it will be helpful for us to have a second dimension for ranking deficits/excesses with the priorityTier scalar.

interface ChainConfig {
  // This should be possible to set to 0 (to indicate that a chain should hold zero funds) or
  // positive infinity (to indicate that a chain should be the universal sink for the given token).
  targetBalance: BigNumber;
  // Set this higher to prioritize returning this balance (if below target) back to target or deprioritize
  // sending this balance when above target.
  priorityTier: number;
}

Adapters

We pass in a list of adapters into the client that implement ways to swap between tokens represented in the targetBalances. Each adapter must implement an interface:

export interface RebalancerAdapter {
  // Must be called before calling any other functions. Used to evaluate construction inputs, set allowances, pre-load data, etc
  initialize(availableRoutes: RebalanceRoute[]): Promise<void>;
  // Execute a new rebalance described by the route and the amount
  initializeRebalance(rebalanceRoute: RebalanceRoute, amountToTransfer: BigNumber): Promise<void>;
  // Checks pending orders and progresses them throughout their lifecycle as appropriate (e.g. if a "pending swap" gets filled, proceed to withdraw it from the exchange)
  updateRebalanceStatuses(): Promise<void>;
  // Get all currently unfinalized rebalance amounts. Positive values are virtual balance credits that should be counted as part of that chain's true balance, while negative values are virtual balance deficits that should be subtracted from that chain's true balance
  getPendingRebalances(): Promise<{ [chainId: number]: { [token: string]: BigNumber } }>;
  // Returns the fee amount in the currency of the `rebalanceRoute.sourceChain` and `rebalanceRoute.sourceToken`. Fee can be negative or positive. This function can be used to modify the `amountToTransfer` passed in as input to `initializeRebalance()` so that the received amount on the `destinationChain` is the expected amount post-fees. This function is also used by the `RebalancerClient` to rank rebalance routes.
  getEstimatedCost(rebalanceRoute: RebalanceRoute, amountToTransfer: BigNumber, debugLog: boolean): Promise<BigNumber>;
}

Rebalance Routes

We pass a list of rebalance routes into the client's constructor which are the client's available options to redistribute its currentBalances towards its desired targetBalances distribution. Two routes can have identical sourceChain/Token and destinationChain/Token but differ in the adapter property, and then the client should pick the "cheapest" option as per the user's constraints. The maxAmountToTransfer property is probably not strictly required but I found it useful to make sure I didn't accidentally send too many funds into any exchange during my local testing. I essentially treat maxAmountToTransfer as a way to throttle the amount of funds I'm sending per test.

export interface RebalanceRoute {
  sourceChain: number;
  destinationChain: number;
  sourceToken: string;
  destinationToken: string;
  maxAmountToTransfer: BigNumber; // Assumed to be in units of the source chain currency.
  adapter: string; // Name of adapter to use for this rebalance. e.g. "hyperliquid" or "binance"
}

Initializing

The initializer, unlike the constructor, can be an async function so i've found it useful to do one-time set up things here like checking allowances and evaluating rebalance routes for each adapter.

The RebalancerClient simply calls adapter.initialize() for each adapter its constructed with.

Public interface

There is only a single function I've implemented so far:

  /**
   *
   * @param currentBalances Allow caller to pass in current allocation of balances. This is designed so that this
   * rebalancer can be seamessly used by the existing inventory manager client which has its own way of determining
   * allocations.
   * @dev A current balance entry must be set for each source chain + source token and destination chain + destination token
   * combination that has a rebalance route. A current balance entry must also be set for each target balance in the
   * client configuration.
   */
  async rebalanceInventory(
    currentBalances: { [chainId: number]: { [token: string]: BigNumber } },
    maxFeePct: BigNumber
  ): Promise<void> {

I ideally would like to see currentBalances is close to the value returned by inventoryClient.getTokenDistribution() plus any virtual balance credits and deficits returned by each adapter.getPendingRebalances() function. See the following code snippet that I've copied from src/rebalancer/index.ts:

for (const adapter of adaptersToUpdate) {
    // Modify all current balances with the pending rebalances:
    timerStart = performance.now();
    const pendingRebalances = await adapter.getPendingRebalances();
    logger.debug({
      at: "index.ts:runRebalancer",
      message: `Completed getting pending rebalances for adapter ${adapter.constructor.name}`,
      duration: performance.now() - timerStart,
    });
    if (Object.keys(pendingRebalances).length > 0) {
      logger.debug({
        at: "index.ts:runRebalancer",
        message: `Pending rebalances for adapter ${adapter.constructor.name}`,
        pendingRebalances: Object.entries(pendingRebalances).map(([chainId, tokens]) => ({
          [chainId]: Object.fromEntries(Object.entries(tokens).map(([token, amount]) => [token, amount.toString()])),
        })),
      });
    }
    for (const [chainId, tokens] of Object.entries(currentBalances)) {
      for (const token of Object.keys(tokens)) {
        const pendingRebalanceAmount = pendingRebalances[chainId]?.[token] ?? bnZero;
        currentBalances[chainId][token] = currentBalances[chainId][token].add(pendingRebalanceAmount);
        if (!pendingRebalanceAmount.eq(bnZero)) {
          logger.debug({
            at: "index.ts:runRebalancer",
            message: `${pendingRebalanceAmount.gt(bnZero) ? "Added" : "Subtracted"} pending rebalance amount from ${
              adapter.constructor.name
            } of ${pendingRebalanceAmount.toString()} to current balance for ${token} on ${chainId}`,
            pendingRebalanceAmount: pendingRebalanceAmount.toString(),
            newCurrentBalance: currentBalances[chainId][token].toString(),
          });
        }
      }
    }
  }

Implementation Details

Code Structure

│   ├── rebalancer
│   │   ├── adapters
│   │   │   ├── baseAdapter.ts
│   │   │   ├── binance.ts
│   │   │   ├── cctpAdapter.ts
│   │   │   ├── hyperliquid.ts
│   │   │   └── oftAdapter.ts
│   │   ├── index.ts
│   │   ├── rebalancer.ts
│   │   └── RebalancerConfig.ts

Modules

Adapters

Each adapter provides logic to

  1. deposit source into a liquidity pool venue
  2. swap source tokens for destination tokens on that venue
  3. withdraw destination tokens from that venue
  4. track pending orders

The following adapters are built with the following features:

  • Binance: fully featured, including bridging USDC/USDT to a network that can deposit into Binance if the sourceChain or destinationChain is not a Binance supported network, like HyperEVM or Unichain for example. Arbitrum is hardcoded as the default network to deposit into Binance if the sourceChain or destinationChain doesn't have a Binance connection.
  • Hyperliquid: also fully featured only differs from Binance in that swaps occur on Hypercore. If the sourceChain or destinationChain is not HyperEVM, then tokens are bridged to/from HyperEVM via CCTP/OFT.
  • OFT/CCTP: Only implements getPendingRebalances which is used to track any bridges arising from the Binance or HL adapter rebalances.
Controller

The RebalancerClient.rebalanceInventory(currentBalances, maxFeePct) method performs the following heuristic, which can be improved in the future:

  1. Determine which sourceChain+sourceToken sources have "excess" balance by comparing current and target balances. (Ignore chains where currentBalance <= targetBalance that don't have an excess.)
  2. Determine which destinationChain+destinationToken sinks have "deficit" balance using similar logic to (1). (Ignore chains where currentBalance >= targetBalance that don't have an deficit.)
  3. Rank the deficits in descending order from highest priorityTier to lowest, using (targetBalance - currentBalance) as the tie breaker. This essentially orders chains by the highest priority deficits we want to fill.
  4. Rank the excesses in ascending order from lowest priorityTier to highest, using (currentBalance - targetBalance) as the tie breaker. The simple intuition of priorityTier is a scalar to represent the willingness that we want to keep balance on that chain, so lower priority tier chains are the first to remove excess balance from and the last to fill deficits for. In practice, I envision setting the priorityTier for Ethereum Mainnet to be 0 and set its targetBalance = MAX_INTEGER so that we send excess balance from every chain back to Ethereum only after evaluating all other L2 target deficits.
  5. Iterate through all sorted deficits and find the first excess that matches with it. To match, there must be at least one rebalanceRoute configured in the RebalancerClient where route.sourceChain = excess.chain && route.sourceToken = excess.token && route.destinationChain = deficit.chain && route.destinationToken = deficit.token for we are always rebalancing from excess to deficit.
  6. For each route that matches a deficit and excess, calculate the expected cost to rebalance from deficit to excess over that route by calling route.adapter.getEstimatedCost(route) and choose the lowest cost less than maxFeePct. An snippet of this logic is:
    // Now, go through each deficit and attempt to fill it with an excess balance, using the lowest priority excesses first.
    for (const deficit of sortedDeficits) {
      const { chainId: destinationChainId, token: destinationToken, deficitAmount } = deficit;

      // Find the first excess that can be used to fill the deficit. We must make sure that the source chain and destination chain
      // are associated with a rebalance route.
      let rebalanceRouteToUse: RebalanceRoute | undefined;
      let cheapestExpectedCost = bnUint256Max;
      let matchingExcess: { chainId: number; token: string; excessAmount: BigNumber } | undefined;
      await forEachAsync(sortedExcesses, async (excess, excessIndex) => {
        const { chainId: sourceChainId, token: sourceToken, excessAmount } = excess;
        // Convert excess to deficit token decimals. Also, we assume here that the tokens are worth the same price,
        // as we'd need to normalize to USD terms to determine if an excess can fill a deficit otherwise.
        const amountConverter = ConvertDecimals(
          TOKEN_SYMBOLS_MAP[destinationToken].decimals,
          TOKEN_SYMBOLS_MAP[sourceToken].decimals
        );
        // @todo: Prioritize rebalance routes based on estimated cost and also be aware of user's fee cap.
        // We need this function to take in a fee cap to handle this logic.
        await forEachAsync(this.rebalanceRoutes, async (r) => {
          // For this rebalance route, cap the deficit amount at the maxAmountToTransfer for this route.
          const deficitAmountCapped = r.maxAmountToTransfer.gt(deficitAmount) ? deficitAmount : r.maxAmountToTransfer;
          if (
            r.sourceChain === sourceChainId &&
            r.sourceToken === sourceToken &&
            r.destinationChain === destinationChainId &&
            r.destinationToken === destinationToken &&
            amountConverter(excessAmount).gte(deficitAmountCapped)
          ) {
            // Check the estimated cost for this route and replace the best route if this one is cheaper.
            const expectedCostForRebalance = await this.adapters[r.adapter].getEstimatedCost(
              r,
              deficitAmountCapped,
              true
            );
            if (expectedCostForRebalance.lt(cheapestExpectedCost)) {
              cheapestExpectedCost = expectedCostForRebalance;
              rebalanceRouteToUse = r;
            }
          }
        });
        if (!isDefined(rebalanceRouteToUse)) {
          return;
        }
        matchingExcess = excess;

        const deficitAmountCapped = rebalanceRouteToUse.maxAmountToTransfer.gt(deficitAmount)
          ? deficitAmount
          : rebalanceRouteToUse.maxAmountToTransfer;
        const maxFee = deficitAmountCapped.mul(maxFeePct).div(toBNWei(100));
        if (cheapestExpectedCost.gt(maxFee)) {
          return;
        }
      });
    }
  1. On the cheapest route, execute a rebalance.

Integration with existing InventoryClient

I envision parts of the RebalancerClient being upstream as well as downstream of the InventoryClient's logic as executed in src/relayer/index.ts.

It is upstream in that the RebalancerClient should ultimately expose a method to query each of its adapter's getPendingRebalances() functions, aggregate their results, and provide these virtual balance modifications to the InventoryClient. This way the InventoryClient's getCurrentBalance() function can be more accurate.

Something like the following can be used by the InventoryClient:

client RebalancerClient {
  ...
  getPendingRebalances(): { [chainId: number]: { [token: string]: BigNumber } } {
    const pendingRebalances: { [chainId: number]: { [token: string]: BigNumber } }
    await forEachAsync(this.adapters, async (adapter) => {
        const pending = await adapter.getPendingRebalances();
        Object.entries(pending).forEach(([chainId, tokenBalance]) => {
            Object.entries(tokenBalance).forEach(([token, amount]) => {
                pending[chainId] ??= {};
                pending[chainId][token] = (pending[chainId]?.[token] ?? bnZero).add(amount);
            });
        });
    });
    return pendingRebalances;
  }
}

The RebalancerClient is also downstream in that rebalanceInventory(currentBalances) takes in a currentBalances struct that should be the result of the aforementioned InventoryClient.getTokenDistribution() method. This way new RebalanceClient-originated rebalances are issued using the latest balance calculations.

Preventing desync problems

The RebalancerClient keeps track of all pending orders in the Redis cache. Orders are updated only when an adapter's updateRebalanceStatuses() method is called. Each adapter's getPendingRebalances() should always return the most up to date state of balances.

If there is a use case in which we want to fetch historical pending rebalances (i.e. pending rebalances at a snapshot in time in the past), then we can implement this by passing in timestamp boundaries into getPendingRebalances but I haven't implemented this yet as I don't really see a need for this currently... please correct me if I'm mistaken.

Binance race conditions

The binanceFinalizer currently queries all deposits into Binance and all withdrawals out of Binance when determining how much more should be withdrawn from Binance. All deposits are currently factored into this accounting, which means that any deposits sent by the Rebalancer might get withdrawn prematurely before they can be swapped by the binanceFinalizer.

This PR introduces helper methods in src/utils/BinanceUtils that can be used to "tag" Binance deposits and withdrawals with a "SWAP" type that the binanceFinalizer can subsequently filter for and remove from the deposits that it counts when calculating how much to withdraw from Binance.

Testing

I'm currently testing using the rebalancer/index.ts file with the command ts-node ./index.ts --rebalancer2 --wallet mnemonic. I don't necessarily envision this index.ts file being part of our production stack but its a good example implementation of how we could use the RebalancerClient in practice.

… 2.0

This PR is designed to open the discussion on designing an improved inventory manager system that supports the following features:
- Allows arbitrary any to any rebalances
- Allows user to specify target balances instead of percentages, which are easier to reason about

The current system is limited to L1->L2 and L2->L1 flows and it handles them separately with different clients, this makes the code harder to maintain and also difficult to implement L2 to L2 rebalancing.
next up is to place an order in initializeRebalances
@nicholaspai nicholaspai changed the title WIP: Add RebalancerClient scaffolding for Inventory Management System 2.0 feat: Add RebalancerClient module enabling inventory rebalancing via token swapping (e.g. USDC any chain to USDT any chain) Jan 9, 2026
@nicholaspai nicholaspai marked this pull request as ready for review January 10, 2026 00:14
@nicholaspai nicholaspai added the do not merge Don't merge until label is removed label Jan 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do not merge Don't merge until label is removed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants