Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/rebalancer-mode-adapter-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ flowchart TD
modeLogic["RebalancingModes\ncumulative.rebalanceInventory + readOnly.pendingState"]
routeSelection["RouteSelection\nselect source/destination + cost checks"]
adapterInterface["RebalancerAdapterInterface\ninitializeRebalance/getEstimatedCost/etc"]
adapterImpls["AdapterImplementations\nbinance.ts, hyperliquid.ts, ..."]
adapterImpls["AdapterImplementations\nbinance.ts, hyperliquid.ts, matchaAdapter.ts, ..."]
pendingState["PendingState\ngetPendingRebalances + getPendingOrders"]

modeLogic --> routeSelection
Expand Down
16 changes: 13 additions & 3 deletions src/rebalancer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,17 @@ export interface RebalancerAdapter {

Implemented production swap adapters:

- Binance
- Hyperliquid
- Binance — centralized exchange swap (USDT↔USDC) via Binance API
- Hyperliquid — centralized exchange swap (USDT↔USDC) via Hyperliquid spot market
- Matcha — on-chain DEX-aggregated swap (USDT↔USDC) via 0x Swap API on Ethereum, BSC, Arbitrum, Base

All three swap adapters extend `SwapAdapterBase`, which provides shared bridge-routing logic (bridging to/from an intermediate "swap chain" via CCTP/OFT adapters).

Swap adapters are conditionally registered based on environment variables:

- Binance requires `BINANCE_API_KEY`
- Matcha requires `ZERO_X_API_KEY`
- Hyperliquid is always registered

`BaseAdapter` persists pending state in Redis so in-flight multi-stage swaps can be resumed and tracked deterministically across runs.

Expand Down Expand Up @@ -104,7 +113,8 @@ The active config shape is cumulative-target based:
},
"maxPendingOrders": {
"binance": 3,
"hyperliquid": 3
"hyperliquid": 3,
"matcha": 3
}
}
```
Expand Down
33 changes: 22 additions & 11 deletions src/rebalancer/RebalancerClientHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CHAIN_IDs, Signer, winston } from "../utils";
import { BinanceStablecoinSwapAdapter } from "./adapters/binance";
import { CctpAdapter } from "./adapters/cctpAdapter";
import { HyperliquidStablecoinSwapAdapter } from "./adapters/hyperliquid";
import { MatchaSwapAdapter } from "./adapters/matchaAdapter";
import { OftAdapter } from "./adapters/oftAdapter";
import { CumulativeBalanceRebalancerClient } from "./clients/CumulativeBalanceRebalancerClient";
import { ReadOnlyRebalancerClient } from "./clients/ReadOnlyRebalancerClient";
Expand Down Expand Up @@ -29,14 +30,23 @@ function constructRebalancerDependencies(
cctpAdapter,
oftAdapter
);
const binanceAdapter = new BinanceStablecoinSwapAdapter(
logger,
rebalancerConfig,
baseSigner,
cctpAdapter,
oftAdapter
);
const adapterMap = { hyperliquid: hyperliquidAdapter, binance: binanceAdapter, cctp: cctpAdapter, oft: oftAdapter };
const adapterMap: { [name: string]: RebalancerAdapter } = {
hyperliquid: hyperliquidAdapter,
cctp: cctpAdapter,
oft: oftAdapter,
};
if (process.env.BINANCE_API_KEY) {
adapterMap.binance = new BinanceStablecoinSwapAdapter(
logger,
rebalancerConfig,
baseSigner,
cctpAdapter,
oftAdapter
);
}
if (process.env.ZERO_X_API_KEY) {
adapterMap.matcha = new MatchaSwapAdapter(logger, rebalancerConfig, baseSigner, cctpAdapter, oftAdapter);
}

// Following two variables are hardcoded to aid testing:
const usdtChains = [
Expand Down Expand Up @@ -64,9 +74,10 @@ function constructRebalancerDependencies(
if (!rebalancerConfig.chainIds.includes(usdtChain) || !rebalancerConfig.chainIds.includes(usdcChain)) {
continue;
}
for (const adapter of ["binance", "hyperliquid"]) {
// Handle exceptions:
if (adapter !== "binance" && (usdtChain === CHAIN_IDs.BSC || usdcChain === CHAIN_IDs.BSC)) {
const swapAdapters = ["hyperliquid", "binance", "matcha"].filter((name) => adapterMap[name]);
for (const adapter of swapAdapters) {
// Handle exceptions: Only Hyperliquid cannot handle BSC routes.
if (adapter === "hyperliquid" && (usdtChain === CHAIN_IDs.BSC || usdcChain === CHAIN_IDs.BSC)) {
continue;
}

Expand Down
33 changes: 33 additions & 0 deletions src/rebalancer/adapters/baseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
forEachAsync,
getBlockForTimestamp,
getCurrentTime,
getGasPrice,
getNativeTokenInfoForChain,
getProvider,
getRedisCache,
getTokenInfo,
Expand All @@ -28,6 +30,7 @@ import {
PriceClient,
Signer,
submitTransaction,
toBNWei,
winston,
} from "../../utils";
import { RebalancerAdapter, RebalanceRoute } from "../utils/interfaces";
Expand Down Expand Up @@ -404,6 +407,36 @@ export abstract class BaseAdapter implements RebalancerAdapter {
}
}

/**
* Estimates the gas cost of a transaction in source token units (assumes stablecoins ~$1).
* @param chainId - The chain where gas will be consumed.
* @param estimatedGasUnits - Estimated gas units for the transaction.
* @param sourceToken - The source token symbol (for decimal conversion).
* @param sourceChain - The chain of the source token (for decimal info).
*/
protected async _estimateGasCostInSourceToken(
chainId: number,
estimatedGasUnits: number,
sourceToken: string,
sourceChain: number
): Promise<BigNumber> {
const provider = await getProvider(chainId);
const { maxFeePerGas } = await getGasPrice(provider);
// maxFeePerGas already includes the priority fee (scaledBaseFee + scaledPriorityFee),
// so use it directly to avoid double-counting.
const gasPrice = maxFeePerGas;
const gasCostNative = gasPrice.mul(estimatedGasUnits);

// Convert native token cost to USD.
const nativeTokenInfo = getNativeTokenInfoForChain(chainId, CHAIN_IDs.MAINNET);
const price = (await this.priceClient.getPriceByAddress(nativeTokenInfo.address)).price;
const gasCostUsd = toBNWei(price).mul(gasCostNative).div(toBNWei(1, nativeTokenInfo.decimals));

// Convert USD to source token decimals (assumes stablecoins ~$1).
const sourceTokenInfo = this._getTokenInfo(sourceToken, sourceChain);
return ConvertDecimals(18, sourceTokenInfo.decimals)(gasCostUsd);
}

// @todo: Add retry logic here! Or replace with the multicaller client. However, we can't easily swap in the MulticallerClient
// because of the interplay between tracking order statuses in the RedisCache and confirming on chain transactions. Often times
// we can only update an order status once its corresponding transaction has confirmed, which is different from how we use
Expand Down
Loading
Loading