diff --git a/docs/rebalancer-mode-adapter-architecture.md b/docs/rebalancer-mode-adapter-architecture.md index fc0e01fa3e..f8cf970476 100644 --- a/docs/rebalancer-mode-adapter-architecture.md +++ b/docs/rebalancer-mode-adapter-architecture.md @@ -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 diff --git a/src/rebalancer/README.md b/src/rebalancer/README.md index 65f3b005b8..3025cb1698 100644 --- a/src/rebalancer/README.md +++ b/src/rebalancer/README.md @@ -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. @@ -104,7 +113,8 @@ The active config shape is cumulative-target based: }, "maxPendingOrders": { "binance": 3, - "hyperliquid": 3 + "hyperliquid": 3, + "matcha": 3 } } ``` diff --git a/src/rebalancer/RebalancerClientHelper.ts b/src/rebalancer/RebalancerClientHelper.ts index d809833f1c..af37df519a 100644 --- a/src/rebalancer/RebalancerClientHelper.ts +++ b/src/rebalancer/RebalancerClientHelper.ts @@ -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"; @@ -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 = [ @@ -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; } diff --git a/src/rebalancer/adapters/baseAdapter.ts b/src/rebalancer/adapters/baseAdapter.ts index 099a9c92b1..0139b7cf75 100644 --- a/src/rebalancer/adapters/baseAdapter.ts +++ b/src/rebalancer/adapters/baseAdapter.ts @@ -19,6 +19,8 @@ import { forEachAsync, getBlockForTimestamp, getCurrentTime, + getGasPrice, + getNativeTokenInfoForChain, getProvider, getRedisCache, getTokenInfo, @@ -28,6 +30,7 @@ import { PriceClient, Signer, submitTransaction, + toBNWei, winston, } from "../../utils"; import { RebalancerAdapter, RebalanceRoute } from "../utils/interfaces"; @@ -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 { + 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 diff --git a/src/rebalancer/adapters/binance.ts b/src/rebalancer/adapters/binance.ts index 6263438826..2deff25c23 100644 --- a/src/rebalancer/adapters/binance.ts +++ b/src/rebalancer/adapters/binance.ts @@ -28,7 +28,8 @@ import { winston, } from "../../utils"; import { RebalanceRoute } from "../utils/interfaces"; -import { BaseAdapter, OrderDetails, STATUS } from "./baseAdapter"; +import { OrderDetails, STATUS } from "./baseAdapter"; +import { SwapAdapterBase } from "./swapAdapterBase"; import { AugmentedTransaction } from "../../clients"; import { RebalancerConfig } from "../RebalancerConfig"; import { CctpAdapter } from "./cctpAdapter"; @@ -43,7 +44,7 @@ interface SPOT_MARKET_META { isBuy: boolean; } -export class BinanceStablecoinSwapAdapter extends BaseAdapter { +export class BinanceStablecoinSwapAdapter extends SwapAdapterBase { private binanceApiClient: Binance; REDIS_PREFIX = "binance-stablecoin-swap:"; @@ -76,7 +77,11 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { readonly cctpAdapter: CctpAdapter, readonly oftAdapter: OftAdapter ) { - super(logger, config, baseSigner); + super(logger, config, baseSigner, cctpAdapter, oftAdapter); + } + + protected async _getSwapChain(chainId: number, token: string): Promise { + return this._getEntrypointNetwork(chainId, token); } // //////////////////////////////////////////////////////////// @@ -91,6 +96,10 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { this.binanceApiClient = await getBinanceApiClient(process.env.BINANCE_API_BASE); + // Validate intermediate CCTP/OFT bridge routes exist for non-Binance-network chains. + await this._validateIntermediateRoutes(this.availableRoutes, "Binance"); + + // Binance-specific: validate that source/destination tokens are supported on the entrypoint Binance networks. await forEachAsync(this.availableRoutes, async (route) => { const { sourceToken, destinationToken, sourceChain, destinationChain } = route; const [sourceCoin, destinationCoin] = await Promise.all([ @@ -103,41 +112,6 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { this._getEntrypointNetwork(sourceChain, sourceToken), this._getEntrypointNetwork(destinationChain, destinationToken), ]); - const getIntermediateAdapter = (token: string) => (token === "USDT" ? this.oftAdapter : this.cctpAdapter); - const getIntermediateAdapterName = (token: string) => (token === "USDT" ? "oft" : "cctp"); - // Validate that route can be supported using intermediate bridges to get to/from Arbitrum to access Binance. - if (destinationEntrypointNetwork !== destinationChain) { - const intermediateRoute = { - ...route, - sourceChain: destinationEntrypointNetwork, - sourceToken: destinationToken, - adapter: getIntermediateAdapterName(destinationToken), - }; - assert( - getIntermediateAdapter(destinationToken).supportsRoute(intermediateRoute), - `Destination chain ${getNetworkName( - destinationChain - )} is not a valid final destination chain for token ${destinationToken} because it doesn't have a ${getIntermediateAdapterName( - destinationToken - )} bridge route from the Binance entry point network ${destinationEntrypointNetwork}` - ); - } - if (sourceEntrypointNetwork !== sourceChain) { - const intermediateRoute = { - ...route, - destinationChain: sourceEntrypointNetwork, - destinationToken: sourceToken, - adapter: getIntermediateAdapterName(sourceToken), - }; - assert( - getIntermediateAdapter(sourceToken).supportsRoute(intermediateRoute), - `Source chain ${getNetworkName( - sourceChain - )} is not a valid source chain for token ${sourceToken} because it doesn't have a ${getIntermediateAdapterName( - sourceToken - )} bridge route to the Binance entrypoint network ${sourceEntrypointNetwork}` - ); - } assert( sourceCoin.networkList.find((network) => network.name === BINANCE_NETWORKS[sourceEntrypointNetwork]), `Source token ${sourceToken} network list does not contain Binance source entrypoint network "${ @@ -388,71 +362,11 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { async getPendingRebalances(): Promise<{ [chainId: number]: { [token: string]: BigNumber } }> { this._assertInitialized(); - const pendingRebalances: { [chainId: number]: { [token: string]: BigNumber } } = {}; - // If there are any rebalances that are currently in the state of being bridged to Binance - // (to subsequently be deposited into Binance), then the bridge adapter's getPendingRebalances() method will show - // a virtual balance credit for the source token on the bridge destination chain - // (i.e. Binance deposit entrypoint network in this case). - // This credit plus this adapter's final destination chain credit (given to all pending orders) means that the - // total virtual balance credit added for this one order will be too high (the order amount will be double counted). - // Therefore, to counteract this double counting, we subtract each order's amount from the bridge destination chain's - // virtual balance (i.e. Binance deposit entrypoint network in this case). - const pendingBridgeToBinanceNetwork = await this._redisGetPendingBridgesPreDeposit(); - for (const cloid of pendingBridgeToBinanceNetwork) { - const orderDetails = await this._redisGetOrderDetails(cloid); - const { sourceChain, sourceToken, amountToTransfer } = orderDetails; - const binanceDepositNetwork = await this._getEntrypointNetwork(sourceChain, sourceToken); - const amountConverter = this._getAmountConverter( - sourceChain, - this._getTokenInfo(sourceToken, sourceChain).address, - binanceDepositNetwork, - this._getTokenInfo(sourceToken, binanceDepositNetwork).address - ); - const convertedAmount = amountConverter(amountToTransfer); - pendingRebalances[binanceDepositNetwork] ??= {}; - pendingRebalances[binanceDepositNetwork][sourceToken] = ( - pendingRebalances[binanceDepositNetwork][sourceToken] ?? bnZero - ).sub(convertedAmount); - this.logger.debug({ - at: "BinanceStablecoinSwapAdapter.getPendingRebalances", - message: `Subtracting ${convertedAmount.toString()} ${sourceToken} from Binance deposit network ${binanceDepositNetwork} for intermediate bridge`, - }); - } - - // Add virtual destination chain credits for all pending orders, so that the user of this class is aware that - // we are in the process of sending tokens to the destination chain. - const pendingOrders = await this._redisGetPendingOrders(); - if (pendingOrders.length > 0) { - this.logger.debug({ - at: "BinanceStablecoinSwapAdapter.getPendingRebalances", - message: `Found ${pendingOrders.length} pending orders`, - pendingOrders: pendingOrders, - }); - } - for (const cloid of pendingOrders) { - const orderDetails = await this._redisGetOrderDetails(cloid); - const { destinationChain, destinationToken, sourceChain, sourceToken, amountToTransfer } = orderDetails; - // Convert amountToTransfer to destination chain precision: - const amountConverter = this._getAmountConverter( - sourceChain, - this._getTokenInfo(sourceToken, sourceChain).address, - destinationChain, - this._getTokenInfo(destinationToken, destinationChain).address - ); - const convertedAmount = amountConverter(amountToTransfer); - this.logger.debug({ - at: "BinanceStablecoinSwapAdapter.getPendingRebalances", - message: `Adding ${convertedAmount.toString()} for pending order cloid ${cloid} to destination chain ${destinationChain}`, - cloid: cloid, - }); - pendingRebalances[destinationChain] ??= {}; - pendingRebalances[destinationChain][destinationToken] = ( - pendingRebalances[destinationChain][destinationToken] ?? bnZero - ).add(convertedAmount); - } + // Get common bridge accounting (intermediate bridge subtraction + destination chain credits). + const pendingRebalances = await this._getPendingRebalancesWithBridgeAccounting(); - // Similar to how we treat orders that are in the state of being bridged to a Binance deposit network, we need to + // Binance-specific: Similar to how we treat orders that are in the state of being bridged to a Binance deposit network, we need to // also account for orders that are in the state of being bridged to a Binance withdrawal network (which may or may // not be subsequently bridged to a final destination chain). If the withdrawn amount has arrived at the withdrawal network, // then we should subtract the order's virtual balance from the withdrawal network. @@ -674,53 +588,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { } const spreadFee = toBNWei(spreadPct.toFixed(18), 18).mul(amountToTransfer).div(toBNWei(1, 18)); - // Bridge to Binance deposit network Fee: - let bridgeToBinanceFee = bnZero; - const binanceDepositNetwork = await this._getEntrypointNetwork(sourceChain, sourceToken); - if (binanceDepositNetwork !== sourceChain) { - const _rebalanceRoute = { ...rebalanceRoute, destinationChain: binanceDepositNetwork }; - if ( - sourceToken === "USDT" && - this.oftAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }) - ) { - bridgeToBinanceFee = await this.oftAdapter.getEstimatedCost( - { ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }, - amountToTransfer - ); - } else if ( - sourceToken === "USDC" && - this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }) - ) { - bridgeToBinanceFee = await this.cctpAdapter.getEstimatedCost( - { ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }, - amountToTransfer - ); - } - } - - // Bridge from Binance withdrawal network fee: - let bridgeFromBinanceFee = bnZero; - const binanceWithdrawNetwork = await this._getEntrypointNetwork(destinationChain, destinationToken); - if (binanceWithdrawNetwork !== destinationChain) { - const _rebalanceRoute = { ...rebalanceRoute, sourceChain: binanceWithdrawNetwork }; - if ( - destinationToken === "USDT" && - this.oftAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }) - ) { - bridgeFromBinanceFee = await this.oftAdapter.getEstimatedCost( - { ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }, - amountToTransfer - ); - } else if ( - destinationToken === "USDC" && - this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }) - ) { - bridgeFromBinanceFee = await this.cctpAdapter.getEstimatedCost( - { ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }, - amountToTransfer - ); - } - } + const { bridgeToFee, bridgeFromFee } = await this._estimateBridgeFees(rebalanceRoute, amountToTransfer); // The only time we add an opportunity cost of capital component is when we require rebalancing via OFT from HyperEVM // because this is the only route amongst all CCTP/OFT routes that takes longer than ~20 minutes to complete. It takes @@ -738,12 +606,22 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { .mul(amountToTransfer) .div(toBNWei(100, 18)); + // Gas cost of ERC20 transfer() to Binance deposit address (~65k gas) on the deposit network. + const binanceDepositNetwork = await this._getEntrypointNetwork(sourceChain, sourceToken); + const depositGasCost = await this._estimateGasCostInSourceToken( + binanceDepositNetwork, + 65_000, + sourceToken, + sourceChain + ); + const totalFee = tradeFee .add(withdrawFeeConvertedToSourceToken) .add(spreadFee) - .add(bridgeToBinanceFee) - .add(bridgeFromBinanceFee) - .add(opportunityCostOfCapitalFixed); + .add(bridgeToFee) + .add(bridgeFromFee) + .add(opportunityCostOfCapitalFixed) + .add(depositGasCost); if (debugLog) { this.logger.debug({ @@ -760,8 +638,9 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { spreadPct: spreadPct * 100, spreadFee: spreadFee.toString(), opportunityCostOfCapitalFixed: opportunityCostOfCapitalFixed.toString(), - bridgeToBinanceFee: bridgeToBinanceFee.toString(), - bridgeFromBinanceFee: bridgeFromBinanceFee.toString(), + bridgeToFee: bridgeToFee.toString(), + bridgeFromFee: bridgeFromFee.toString(), + depositGasCost: depositGasCost.toString(), totalFee: totalFee.toString(), }); } @@ -1043,40 +922,6 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { return initiatedWithdrawal; } - protected async _bridgeToChain( - token: string, - originChain: number, - destinationChain: number, - expectedAmountToTransfer: BigNumber - ): Promise { - switch (token) { - case "USDT": - return await this.oftAdapter.initializeRebalance( - { - sourceChain: originChain, - destinationChain, - sourceToken: "USDT", - destinationToken: "USDT", - adapter: "oft", - }, - expectedAmountToTransfer - ); - case "USDC": - return await this.cctpAdapter.initializeRebalance( - { - sourceChain: originChain, - destinationChain, - sourceToken: "USDC", - destinationToken: "USDC", - adapter: "cctp", - }, - expectedAmountToTransfer - ); - default: - throw new Error(`Should never happen: Unsupported bridge for token: ${token}`); - } - } - private async _withdraw( cloid: string, quantity: number, diff --git a/src/rebalancer/adapters/cctpAdapter.ts b/src/rebalancer/adapters/cctpAdapter.ts index 155cc516cf..15886731b1 100644 --- a/src/rebalancer/adapters/cctpAdapter.ts +++ b/src/rebalancer/adapters/cctpAdapter.ts @@ -188,9 +188,12 @@ export class CctpAdapter extends BaseAdapter { async getEstimatedCost(rebalanceRoute: RebalanceRoute, amountToTransfer: BigNumber): Promise { this._assertRouteIsSupported(rebalanceRoute); - const { sourceChain, destinationChain } = rebalanceRoute; + const { sourceChain, sourceToken, destinationChain } = rebalanceRoute; const { maxFee } = await this._getCctpV2MaxFee(sourceChain, destinationChain, amountToTransfer); - return maxFee; + // Gas cost of depositForBurn() on source chain. + // Includes burn + message emission overhead and is intentionally conservative. + const gasCost = await this._estimateGasCostInSourceToken(sourceChain, 140_000, sourceToken, sourceChain); + return maxFee.add(gasCost); } async getPendingOrders(): Promise { diff --git a/src/rebalancer/adapters/hyperliquid.ts b/src/rebalancer/adapters/hyperliquid.ts index 9dd8c12565..a75e973228 100644 --- a/src/rebalancer/adapters/hyperliquid.ts +++ b/src/rebalancer/adapters/hyperliquid.ts @@ -20,7 +20,6 @@ import { toBN, toBNWei, winston, - forEachAsync, ZERO_ADDRESS, truncate, getCurrentTime, @@ -35,7 +34,8 @@ import { } from "../../utils"; import { RebalanceRoute } from "../utils/interfaces"; import * as hl from "@nktkas/hyperliquid"; -import { BaseAdapter, OrderDetails } from "./baseAdapter"; +import { OrderDetails } from "./baseAdapter"; +import { SwapAdapterBase } from "./swapAdapterBase"; import { RebalancerConfig } from "../RebalancerConfig"; import { OftAdapter } from "./oftAdapter"; import { CctpAdapter } from "./cctpAdapter"; @@ -75,7 +75,7 @@ const USDC_CORE_DEPOSIT_WALLET_ADDRESS = "0x6B9E773128f453f5c2C60935Ee2DE2CBc539 // prior to bridging because most chains have high fees for stablecoin swaps on DEX's, whereas bridging from OFT/CCTP // into HyperEVM is free (or 0.01% for fast transfers) and then swapping on Hyperliquid is very cheap compared to DEX's. // We should continually re-evaluate whether hyperliquid stablecoin swaps are indeed the cheapest option. -export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { +export class HyperliquidStablecoinSwapAdapter extends SwapAdapterBase { REDIS_PREFIX = "hyperliquid-stablecoin-swap:"; // @dev Every market is saved in here twice, where the base and quote asset are reversed in the dictionary key @@ -134,7 +134,11 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { ) { // Will need to be able use Signer as Wallet to submit HL order assert(isSignerWallet(baseSigner), "Signer is not a Wallet"); - super(logger, config, baseSigner); + super(logger, config, baseSigner, cctpAdapter, oftAdapter); + } + + protected _getSwapChain(_chainId: number, _token: string): number { + return HYPEREVM; } // //////////////////////////////////////////////////////////// @@ -147,49 +151,16 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { } await super.initialize(_availableRoutes.filter((route) => route.adapter === "hyperliquid")); - await forEachAsync(this.availableRoutes, async (route) => { - const { sourceToken, destinationToken, sourceChain, destinationChain } = route; - const expectedName = `${sourceToken}-${destinationToken}`; + // Validate spot market metadata exists for all routes. + for (const route of this.availableRoutes) { + const expectedName = `${route.sourceToken}-${route.destinationToken}`; if (!this.spotMarketMeta[expectedName]) { throw new Error(`Missing spotMarketMeta data for ${expectedName}`); } + } - // Validate that route can be supported using intermediate bridges to get to/from HyperEVM to access Hyperliquid. - const getIntermediateAdapter = (token: string) => (token === "USDT" ? this.oftAdapter : this.cctpAdapter); - const getIntermediateAdapterName = (token: string) => (token === "USDT" ? "oft" : "cctp"); - if (destinationChain !== HYPEREVM) { - const intermediateRoute = { - ...route, - sourceChain: HYPEREVM, - sourceToken: destinationToken, - adapter: getIntermediateAdapterName(destinationToken), - }; - assert( - getIntermediateAdapter(destinationToken).supportsRoute(intermediateRoute), - `Destination chain ${getNetworkName( - destinationChain - )} is not a valid final destination chain for token ${destinationToken} because it doesn't have a ${getIntermediateAdapterName( - destinationToken - )} bridge route from HyperEVM` - ); - } - if (sourceChain !== HYPEREVM) { - const intermediateRoute = { - ...route, - destinationChain: HYPEREVM, - destinationToken: sourceToken, - adapter: getIntermediateAdapterName(sourceToken), - }; - assert( - getIntermediateAdapter(sourceToken).supportsRoute(intermediateRoute), - `Source chain ${getNetworkName( - sourceChain - )} is not a valid source chain for token ${sourceToken} because it doesn't have a ${getIntermediateAdapterName( - sourceToken - )} bridge route to HyperEVM` - ); - } - }); + // Validate that intermediate CCTP/OFT bridge routes exist for non-HyperEVM chains. + await this._validateIntermediateRoutes(this.availableRoutes, "HyperEVM"); } async setApprovals(): Promise { @@ -565,51 +536,7 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { const takerFeePct = await this._getUserTakerFeePct(); const takerFeeFixed = takerFeePct.mul(amountToTransfer).div(toBNWei(100, 18)); - // Bridge to HyperEVM Fee: - let bridgeToHyperEvmFee = bnZero; - if (rebalanceRoute.sourceChain !== HYPEREVM) { - const _rebalanceRoute = { ...rebalanceRoute, destinationChain: HYPEREVM }; - if ( - sourceToken === "USDT" && - this.oftAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }) - ) { - bridgeToHyperEvmFee = await this.oftAdapter.getEstimatedCost( - { ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }, - amountToTransfer - ); - } else if ( - sourceToken === "USDC" && - this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }) - ) { - bridgeToHyperEvmFee = await this.cctpAdapter.getEstimatedCost( - { ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }, - amountToTransfer - ); - } - } - - // Bridge from HyperEVMFee: - let bridgeFromHyperEvmFee = bnZero; - if (rebalanceRoute.destinationChain !== HYPEREVM) { - const _rebalanceRoute = { ...rebalanceRoute, sourceChain: HYPEREVM }; - if ( - destinationToken === "USDT" && - this.oftAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }) - ) { - bridgeFromHyperEvmFee = await this.oftAdapter.getEstimatedCost( - { ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }, - amountToTransfer - ); - } else if ( - destinationToken === "USDC" && - this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }) - ) { - bridgeFromHyperEvmFee = await this.cctpAdapter.getEstimatedCost( - { ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }, - amountToTransfer - ); - } - } + const { bridgeToFee, bridgeFromFee } = await this._estimateBridgeFees(rebalanceRoute, amountToTransfer); // The only time we add an opportunity cost of capital component is when we require rebalancing via OFT from HyperEVM // because this is the only route amongst all CCTP/OFT routes that takes longer than ~20 minutes to complete. It takes @@ -624,11 +551,20 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { .mul(amountToTransfer) .div(toBNWei(100, 18)); + // Gas costs on HyperEVM: deposit to Hypercore (~65k gas) + CoreWriter withdrawal (~47k gas). + // CoreWriter.sendRawAction burns ~25k gas before emitting a log, with total call cost ~47k. + const [depositGasCost, withdrawalGasCost] = await Promise.all([ + this._estimateGasCostInSourceToken(HYPEREVM, 65_000, sourceToken, sourceChain), + this._estimateGasCostInSourceToken(HYPEREVM, 47_000, sourceToken, sourceChain), + ]); + const totalFee = spreadFee .add(takerFeeFixed) - .add(bridgeToHyperEvmFee) - .add(bridgeFromHyperEvmFee) - .add(opportunityCostOfCapitalFixed); + .add(bridgeToFee) + .add(bridgeFromFee) + .add(opportunityCostOfCapitalFixed) + .add(depositGasCost) + .add(withdrawalGasCost); if (debugLog) { this.logger.debug({ @@ -644,9 +580,11 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { takerFeePct: fromWei(takerFeePct, 18), takerFee: takerFeeFixed.toString(), takerFeeFixed: takerFeeFixed.toString(), - bridgeToHyperEvmFee: bridgeToHyperEvmFee.toString(), - bridgeFromHyperEvmFee: bridgeFromHyperEvmFee.toString(), + bridgeToFee: bridgeToFee.toString(), + bridgeFromFee: bridgeFromFee.toString(), opportunityCostOfCapitalFixed: opportunityCostOfCapitalFixed.toString(), + depositGasCost: depositGasCost.toString(), + withdrawalGasCost: withdrawalGasCost.toString(), totalFee: totalFee.toString(), }); } @@ -656,37 +594,12 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { async getPendingRebalances(): Promise<{ [chainId: number]: { [token: string]: BigNumber } }> { this._assertInitialized(); - const pendingRebalances: { [chainId: number]: { [token: string]: BigNumber } } = {}; - - // If there are any rebalances that are currently in the state of being bridged to HyperEVM - // (to subsequently be deposited into Hypercore), then the bridge adapter's getPendingRebalances() method will show - // a virtual balance credit for the source token on the bridge destination chain (i.e. HyperEVM in this case). - // This credit plus this adapter's final destination chain credit (given to all pending orders) means that the - // total virtual balance credit added for this one order will be too high (the order amount will be double counted). - // Therefore, to counteract this double counting, we subtract each order's amount from the bridge destination - // chain's virtual balance (i.e. HyperEVM in this case). - const pendingBridgeToHyperevm = await this._redisGetPendingBridgesPreDeposit(); - for (const cloid of pendingBridgeToHyperevm) { - const orderDetails = await this._redisGetOrderDetails(cloid); - const { sourceChain, sourceToken, amountToTransfer } = orderDetails; - const amountConverter = this._getAmountConverter( - sourceChain, - this._getTokenInfo(sourceToken, sourceChain).address, - HYPEREVM, - this._getTokenInfo(sourceToken, HYPEREVM).address - ); - const convertedAmount = amountConverter(amountToTransfer); - pendingRebalances[HYPEREVM] ??= {}; - pendingRebalances[HYPEREVM][sourceToken] = (pendingRebalances[HYPEREVM][sourceToken] ?? bnZero).sub( - convertedAmount - ); - this.logger.debug({ - at: "HyperliquidStablecoinSwapAdapter.getPendingRebalances", - message: `Subtracting ${convertedAmount.toString()} ${sourceToken} from HyperEVM for intermediate bridge`, - }); - } - // For each pending withdrawal from Hypercore, check if it has finalized, and if it has, subtract its virtual balance from HyperEVM. + // Get common bridge accounting (intermediate bridge subtraction + destination chain credits). + const pendingRebalances = await this._getPendingRebalancesWithBridgeAccounting(); + + // Hyperliquid-specific: For each pending withdrawal from Hypercore, check if it has finalized, + // and if it has, subtract its virtual balance from HyperEVM. const pendingWithdrawalsFromHypercore = await this._redisGetPendingWithdrawals(); if (pendingWithdrawalsFromHypercore.length > 0) { this.logger.debug({ @@ -751,37 +664,6 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { ); } - // For any pending orders at all, we should add a virtual balance to the destination chain. This includes - // orders with statuses: { PENDING_BRIDGE_TO_HYPEREVM, PENDING_SWAP, PENDING_DEPOSIT_TO_HYPERCORE, PENDING_WITHDRAWAL_FROM_HYPERCORE }, - const pendingOrders = await this._redisGetPendingOrders(); - if (pendingOrders.length > 0) { - this.logger.debug({ - at: "HyperliquidStablecoinSwapAdapter.getPendingRebalances", - message: `Found ${pendingOrders.length} pending orders`, - pendingOrders: pendingOrders, - }); - } - for (const cloid of pendingOrders) { - // Filter this to match pending rebalance routes: - const orderDetails = await this._redisGetOrderDetails(cloid); - const { destinationChain, destinationToken, sourceChain, sourceToken, amountToTransfer } = orderDetails; - // Convert amountToTransfer to destination chain precision: - const amountConverter = this._getAmountConverter( - sourceChain, - this._getTokenInfo(sourceToken, sourceChain).address, - destinationChain, - this._getTokenInfo(destinationToken, destinationChain).address - ); - const convertedAmount = amountConverter(amountToTransfer); - this.logger.debug({ - at: "HyperliquidStablecoinSwapAdapter.getPendingRebalances", - message: `Adding ${convertedAmount.toString()} ${destinationToken} for pending order cloid ${cloid} to destination chain ${destinationChain}`, - }); - pendingRebalances[destinationChain] ??= {}; - pendingRebalances[destinationChain][destinationToken] = ( - pendingRebalances[destinationChain][destinationToken] ?? bnZero - ).add(convertedAmount); - } return pendingRebalances; } @@ -1232,40 +1114,6 @@ export class HyperliquidStablecoinSwapAdapter extends BaseAdapter { await this._submitTransaction(transaction); } - protected async _bridgeToChain( - token: string, - originChain: number, - destinationChain: number, - expectedAmountToTransfer: BigNumber - ): Promise { - switch (token) { - case "USDT": - return await this.oftAdapter.initializeRebalance( - { - sourceChain: originChain, - destinationChain, - sourceToken: "USDT", - destinationToken: "USDT", - adapter: "oft", - }, - expectedAmountToTransfer - ); - case "USDC": - return await this.cctpAdapter.initializeRebalance( - { - sourceChain: originChain, - destinationChain, - sourceToken: "USDC", - destinationToken: "USDC", - adapter: "cctp", - }, - expectedAmountToTransfer - ); - default: - throw new Error(`Should never happen: Unsupported bridge for token: ${token}`); - } - } - private async _getInitiatedWithdrawalsFromHypercore( destinationToken: string, withdrawalInitiatedEarliestTimestampMilliseconds: number diff --git a/src/rebalancer/adapters/matchaAdapter.ts b/src/rebalancer/adapters/matchaAdapter.ts new file mode 100644 index 0000000000..545926fd2f --- /dev/null +++ b/src/rebalancer/adapters/matchaAdapter.ts @@ -0,0 +1,506 @@ +import { MultiCallerClient } from "../../clients"; +import { + assert, + BigNumber, + bnZero, + CHAIN_IDs, + Contract, + ERC20, + ethers, + fromWei, + getMatchaQuote, + getNetworkName, + getProvider, + MAX_SAFE_ALLOWANCE, + Signer, + toBN, + toBNWei, + winston, + ZERO_X_ALLOWANCE_HOLDER, +} from "../../utils"; +import { RebalanceRoute } from "../utils/interfaces"; +import { STATUS } from "./baseAdapter"; +import { SwapAdapterBase } from "./swapAdapterBase"; +import { RebalancerConfig } from "../RebalancerConfig"; +import { CctpAdapter } from "./cctpAdapter"; +import { OftAdapter } from "./oftAdapter"; + +// Chains where Matcha/0x swaps can be executed directly on-chain. +const MATCHA_NATIVE_CHAINS = new Set([CHAIN_IDs.MAINNET, CHAIN_IDs.BSC, CHAIN_IDs.ARBITRUM, CHAIN_IDs.BASE]); + +// Default slippage tolerance in basis points to set when submitting matcha txn. Rebalances sent through +// this adapter must still obey any max fee allowed variables set by upstream client. +const DEFAULT_SLIPPAGE_BPS = 10; // 10 is lowest allowed setting on Match UI before you get a "low slippage" warning. + +// https://www.quicknode.com/docs/ethereum/eth_getTransactionReceipt status field is either 1 or 0: +const ETH_GET_TRANSACTION_RESPONSE_REVERTED_TXN = 0; + +export class MatchaSwapAdapter extends SwapAdapterBase { + REDIS_PREFIX = "matcha-swap:"; + + private slippageBps: number; + + constructor( + readonly logger: winston.Logger, + readonly config: RebalancerConfig, + readonly baseSigner: Signer, + readonly cctpAdapter: CctpAdapter, + readonly oftAdapter: OftAdapter + ) { + super(logger, config, baseSigner, cctpAdapter, oftAdapter); + this.slippageBps = Number(process.env.MATCHA_SLIPPAGE_BPS ?? DEFAULT_SLIPPAGE_BPS); + } + + // //////////////////////////////////////////////////////////// + // ABSTRACT METHOD IMPLEMENTATION + // //////////////////////////////////////////////////////////// + + protected _getSwapChain(chainId: number, _token: string): number { + if (MATCHA_NATIVE_CHAINS.has(chainId)) { + return chainId; + } + // Default to Arbitrum: supports both CCTP (USDC) and OFT (USDT) bridges, low gas fees. + return CHAIN_IDs.ARBITRUM; + } + + // //////////////////////////////////////////////////////////// + // PUBLIC METHODS + // //////////////////////////////////////////////////////////// + + async initialize(_availableRoutes: RebalanceRoute[]): Promise { + if (this.initialized) { + return; + } + await super.initialize(_availableRoutes.filter((route) => route.adapter === "matcha")); + + // Validate intermediate CCTP/OFT bridge routes exist for non-native chains. + await this._validateIntermediateRoutes(this.availableRoutes, "Matcha"); + } + + async setApprovals(): Promise { + await super.setApprovals(); + this.multicallerClient = new MultiCallerClient(this.logger, this.config.multiCallChunkSize, this.baseSigner); + + // For each natively supported chain used by routes, approve the AllowanceHolder contract for each token. + const chainsAndTokens = new Map>(); + for (const route of this.availableRoutes) { + const swapChain = this._getSwapChain(route.sourceChain, route.sourceToken); + if (!chainsAndTokens.has(swapChain)) { + chainsAndTokens.set(swapChain, new Set()); + } + chainsAndTokens.get(swapChain).add(route.sourceToken); + } + + for (const [chainId, tokens] of chainsAndTokens) { + const provider = await getProvider(chainId); + const connectedSigner = this.baseSigner.connect(provider); + for (const token of tokens) { + const tokenInfo = this._getTokenInfo(token, chainId); + const erc20 = new Contract(tokenInfo.address.toNative(), ERC20.abi, connectedSigner); + // @todo: Consider grabbing allowance target from 0x API at cost of additional API call. + const allowance = await erc20.allowance(this.baseSignerAddress.toNative(), ZERO_X_ALLOWANCE_HOLDER); + if (allowance.lt(toBN(MAX_SAFE_ALLOWANCE).div(2))) { + this.multicallerClient.enqueueTransaction({ + contract: erc20, + chainId, + method: "approve", + nonMulticall: true, + unpermissioned: false, + args: [ZERO_X_ALLOWANCE_HOLDER, MAX_SAFE_ALLOWANCE], + message: `Approved ${token} for 0x AllowanceHolder on ${getNetworkName(chainId)}`, + mrkdwn: `Approved ${token} for 0x AllowanceHolder on ${getNetworkName(chainId)}`, + }); + } + } + } + const simMode = !this.config.sendingTransactionsEnabled; + await this.multicallerClient.executeTxnQueues(simMode); + } + + async initializeRebalance(rebalanceRoute: RebalanceRoute, amountToTransfer: BigNumber): Promise { + this._assertInitialized(); + this._assertRouteIsSupported(rebalanceRoute); + + const { sourceToken, sourceChain, destinationToken, destinationChain } = rebalanceRoute; + const sourceTokenInfo = this._getTokenInfo(sourceToken, sourceChain); + + // Check minimum order size. + const minimumOrderSize = toBNWei(process.env.MATCHA_MINIMUM_SWAP_AMOUNT ?? 10, sourceTokenInfo.decimals); + if (amountToTransfer.lt(minimumOrderSize)) { + this.logger.debug({ + at: "MatchaSwapAdapter.initializeRebalance", + message: `Amount to transfer ${amountToTransfer.toString()} is less than minimum order size ${minimumOrderSize.toString()}`, + }); + return bnZero; + } + + const cloid = await this._redisGetNextCloid(); + const swapChain = this._getSwapChain(sourceChain, sourceToken); + + if (sourceChain !== swapChain) { + // Bridge source token to swap chain first. + this.logger.info({ + at: "MatchaSwapAdapter.initializeRebalance", + message: `🍻 Creating new order ${cloid} by first bridging ${sourceToken} to ${getNetworkName(swapChain)} from ${getNetworkName(sourceChain)}`, + destinationToken, + destinationChain: getNetworkName(destinationChain), + amountToTransfer: amountToTransfer.toString(), + }); + const amountReceivedFromBridge = await this._bridgeToChain(sourceToken, sourceChain, swapChain, amountToTransfer); + await this._redisCreateOrder(cloid, STATUS.PENDING_BRIDGE_PRE_DEPOSIT, rebalanceRoute, amountReceivedFromBridge); + return amountReceivedFromBridge; + } else { + // Source chain is the swap chain; execute swap immediately. + this.logger.info({ + at: "MatchaSwapAdapter.initializeRebalance", + message: `🍻 Creating new order ${cloid} by swapping ${sourceToken} to ${destinationToken} on ${getNetworkName(swapChain)}`, + destinationChain: getNetworkName(destinationChain), + amountToTransfer: amountToTransfer.toString(), + }); + const swapTxHash = await this._submitSwapWithValidation( + swapChain, + sourceToken, + destinationToken, + amountToTransfer + ); + await this._redisSaveSwapTxHash(cloid, swapTxHash); + await this._redisCreateOrder(cloid, STATUS.PENDING_SWAP, rebalanceRoute, amountToTransfer); + return amountToTransfer; + } + } + + async updateRebalanceStatuses(): Promise { + this._assertInitialized(); + + // PENDING_BRIDGE_PRE_DEPOSIT -> PENDING_SWAP: Bridge has landed on swap chain, execute swap. + const pendingBridges = await this._redisGetPendingBridgesPreDeposit(); + if (pendingBridges.length > 0) { + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: "Orders pending bridge to swap chain", + pendingBridges, + }); + } + for (const cloid of pendingBridges) { + const orderDetails = await this._redisGetOrderDetails(cloid); + const { sourceToken, destinationToken, amountToTransfer, sourceChain } = orderDetails; + const swapChain = this._getSwapChain(sourceChain, sourceToken); + const tokenInfo = this._getTokenInfo(sourceToken, swapChain); + const balance = await this._getERC20Balance(swapChain, tokenInfo.address.toNative()); + + const amountConverter = this._getAmountConverter( + sourceChain, + this._getTokenInfo(sourceToken, sourceChain).address, + swapChain, + tokenInfo.address + ); + const requiredAmount = amountConverter(amountToTransfer); + + if (balance.lt(requiredAmount)) { + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `Not enough ${sourceToken} balance on ${getNetworkName(swapChain)} to execute swap for order ${cloid}`, + balance: balance.toString(), + requiredAmount: requiredAmount.toString(), + }); + continue; + } + + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `Sufficient ${sourceToken} balance on ${getNetworkName(swapChain)}, executing swap for order ${cloid}`, + balance: balance.toString(), + }); + const swapTxHash = await this._submitSwapWithValidation(swapChain, sourceToken, destinationToken, requiredAmount); + await this._redisSaveSwapTxHash(cloid, swapTxHash); + await this._redisUpdateOrderStatus(cloid, STATUS.PENDING_BRIDGE_PRE_DEPOSIT, STATUS.PENDING_SWAP); + } + + // PENDING_SWAP -> COMPLETE (with optional synchronous bridge): + // Check swap tx receipt, then bridge to final destination if needed. + const pendingSwaps = await this._redisGetPendingSwaps(); + if (pendingSwaps.length > 0) { + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: "Orders pending swap confirmation", + pendingSwaps, + }); + } + for (const cloid of pendingSwaps) { + const swapTxHash = await this._redisGetSwapTxHash(cloid); + if (!swapTxHash) { + this.logger.warn({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `No swap tx hash found for order ${cloid} in PENDING_SWAP state, skipping`, + }); + continue; + } + + const orderDetails = await this._redisGetOrderDetails(cloid); + const { destinationToken, destinationChain, sourceChain, sourceToken, amountToTransfer } = orderDetails; + const swapChain = this._getSwapChain(sourceChain, sourceToken); + const provider = await getProvider(swapChain); + const receipt = await provider.getTransactionReceipt(swapTxHash); + + if (!receipt) { + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `Swap tx ${swapTxHash} for order ${cloid} has not been mined yet, waiting`, + }); + continue; + } + + if (receipt.status === ETH_GET_TRANSACTION_RESPONSE_REVERTED_TXN) { + // Re-submit the swap: the reverted tx didn't consume sell tokens so funds are still available. + // Order stays in PENDING_SWAP so the next cycle will check the new tx. + const sourceTokenInfoOnSource = this._getTokenInfo(sourceToken, sourceChain); + const sourceTokenInfoOnSwap = this._getTokenInfo(sourceToken, swapChain); + const swapAmount = this._getAmountConverter( + sourceChain, + sourceTokenInfoOnSource.address, + swapChain, + sourceTokenInfoOnSwap.address + )(amountToTransfer); + this.logger.warn({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `Swap tx ${swapTxHash} for order ${cloid} reverted on-chain. Re-submitting swap for ${swapAmount.toString()} ${sourceToken} on ${getNetworkName(swapChain)}.`, + }); + const newSwapTxHash = await this._submitSwapWithValidation( + swapChain, + sourceToken, + destinationToken, + swapAmount + ); + await this._redisSaveSwapTxHash(cloid, newSwapTxHash); + continue; + } + + // Swap succeeded. + if (destinationChain === swapChain) { + this.logger.info({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `✨ Deleting order ${cloid} because swap completed on final destination chain ${getNetworkName(destinationChain)}`, + }); + await this._redisDeleteOrder(cloid, STATUS.PENDING_SWAP); + } else { + // Need to bridge output tokens to the final destination chain. + // Parse Transfer events from the swap receipt to scope the bridge amount to this order's + // actual swap output, rather than the full wallet balance (which could include unrelated funds). + const destTokenInfo = this._getTokenInfo(destinationToken, swapChain); + const swapOutput = this._getSwapOutputFromReceipt(receipt, destTokenInfo.address.toNative()); + if (swapOutput.lte(bnZero)) { + this.logger.debug({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `Swap completed for order ${cloid} but no ${destinationToken} Transfer to wallet found in receipt, waiting`, + }); + continue; + } + this.logger.info({ + at: "MatchaSwapAdapter.updateRebalanceStatuses", + message: `✨ Swap completed for order ${cloid}; bridging ${destinationToken} from ${getNetworkName(swapChain)} to final destination ${getNetworkName(destinationChain)}`, + swapOutput: swapOutput.toString(), + }); + await this._bridgeToChain(destinationToken, swapChain, destinationChain, swapOutput); + await this._redisDeleteOrder(cloid, STATUS.PENDING_SWAP); + } + } + } + + async sweepIntermediateBalances(): Promise { + // No-op: tokens are on real EVM chains and usable directly. Same pattern as Binance. + } + + async getEstimatedCost( + rebalanceRoute: RebalanceRoute, + amountToTransfer: BigNumber, + debugLog: boolean + ): Promise { + this._assertRouteIsSupported(rebalanceRoute); + const { sourceToken, destinationToken, sourceChain, destinationChain } = rebalanceRoute; + const swapChain = this._getSwapChain(sourceChain, sourceToken); + + const sourceTokenInfo = this._getTokenInfo(sourceToken, swapChain); + const destinationTokenInfo = this._getTokenInfo(destinationToken, swapChain); + const quote = await getMatchaQuote( + swapChain, + sourceTokenInfo.address.toNative(), + destinationTokenInfo.address.toNative(), + amountToTransfer.toString(), + this.baseSignerAddress.toNative(), + this.slippageBps + ); + + // Swap cost = what we sell minus what we get back (adjusted for decimal differences). + const buyAmountInSourceDecimals = this._getAmountConverter( + swapChain, + destinationTokenInfo.address, + swapChain, + sourceTokenInfo.address + )(BigNumber.from(quote.buyAmount)); + const swapCost = amountToTransfer.sub(buyAmountInSourceDecimals); + + // Bridge fees for non-native chains. + const { bridgeToFee, bridgeFromFee } = await this._estimateBridgeFees(rebalanceRoute, amountToTransfer); + + // Gas cost for the swap transaction on the swap chain (0x API provides estimated gas units). + const swapGasCost = await this._estimateGasCostInSourceToken( + swapChain, + Number(quote.transaction.gas), + sourceToken, + sourceChain + ); + + const totalFee = swapCost.add(bridgeToFee).add(bridgeFromFee).add(swapGasCost); + + if (debugLog) { + this.logger.debug({ + at: "MatchaSwapAdapter.getEstimatedCost", + message: `Calculating total fees for rebalance route ${sourceToken} on ${getNetworkName( + sourceChain + )} to ${destinationToken} on ${getNetworkName(destinationChain)} with amount to transfer ${amountToTransfer.toString()}`, + swapChain: getNetworkName(swapChain), + buyAmount: quote.buyAmount, + swapCost: swapCost.toString(), + swapGasCost: swapGasCost.toString(), + bridgeToFee: bridgeToFee.toString(), + bridgeFromFee: bridgeFromFee.toString(), + totalFee: totalFee.toString(), + }); + } + + return totalFee; + } + + async getPendingRebalances(): Promise<{ [chainId: number]: { [token: string]: BigNumber } }> { + this._assertInitialized(); + return this._getPendingRebalancesWithBridgeAccounting(); + } + + async getPendingOrders(): Promise { + return this._redisGetPendingOrders(); + } + + // //////////////////////////////////////////////////////////// + // PRIVATE HELPER METHODS + // //////////////////////////////////////////////////////////// + + /** + * Validates and submits a swap transaction via the 0x API. Returns the tx hash. + * Does NOT wait for the receipt - crash resilience is handled by the state machine. + */ + private async _submitSwapWithValidation( + swapChain: number, + sourceToken: string, + destinationToken: string, + amount: BigNumber + ): Promise { + const sourceTokenInfo = this._getTokenInfo(sourceToken, swapChain); + const destinationTokenInfo = this._getTokenInfo(destinationToken, swapChain); + + // Get firm quote from 0x API. + const quote = await getMatchaQuote( + swapChain, + sourceTokenInfo.address.toNative(), + destinationTokenInfo.address.toNative(), + amount.toString(), + this.baseSignerAddress.toNative(), + this.slippageBps + ); + + // Pre-swap validation. + if (quote.issues?.balance) { + throw new Error( + `0x API reports insufficient balance for swap on ${getNetworkName(swapChain)}: ${JSON.stringify(quote.issues.balance)}` + ); + } + if (quote.issues?.allowance) { + throw new Error( + `0x API reports insufficient allowance for swap on ${getNetworkName(swapChain)}: ${JSON.stringify(quote.issues.allowance)}` + ); + } + + const minBuyAmount = BigNumber.from(quote.minBuyAmount); + assert(minBuyAmount.gt(bnZero), `0x quote returned zero minBuyAmount for swap on ${getNetworkName(swapChain)}`); + + // Verify sufficient source token balance. + const balance = await this._getERC20Balance(swapChain, sourceTokenInfo.address.toNative()); + assert( + balance.gte(amount), + `Insufficient ${sourceToken} balance on ${getNetworkName(swapChain)}: have ${balance.toString()}, need ${amount.toString()}` + ); + + // Submit the swap transaction via _submitTransaction which handles simulation, gas estimation, + // and retry logic. Use method="" for raw transaction mode since the 0x API returns pre-built + // transaction data (to, data, value) rather than a contract method call. + const provider = await getProvider(swapChain); + const connectedSigner = this.baseSigner.connect(provider); + const targetContract = new Contract(quote.transaction.to, [], connectedSigner); + const amountReadable = fromWei(amount, sourceTokenInfo.decimals); + const txHash = await this._submitTransaction({ + contract: targetContract, + chainId: swapChain, + method: "", + args: [quote.transaction.data], + value: quote.transaction.value ? BigNumber.from(quote.transaction.value) : undefined, + nonMulticall: true, + unpermissioned: false, + message: `Swap ${amountReadable} ${sourceToken} -> ${destinationToken} on ${getNetworkName(swapChain)}`, + mrkdwn: `Swap ${amountReadable} ${sourceToken} -> ${destinationToken} on ${getNetworkName(swapChain)}`, + }); + this.logger.info({ + at: "MatchaSwapAdapter._submitSwapWithValidation", + message: `🎰 Submitted swap tx ${txHash} for ${amountReadable} ${sourceToken} -> ${destinationToken} on ${getNetworkName(swapChain)}`, + txHash, + buyAmount: quote.buyAmount, + minBuyAmount: quote.minBuyAmount, + }); + return txHash; + } + + /** + * Parses ERC20 Transfer events from a swap receipt to determine the exact amount of + * destination token received by the wallet. This avoids bridging the full wallet balance + * which could include unrelated funds. + */ + private _getSwapOutputFromReceipt(receipt: ethers.providers.TransactionReceipt, destTokenAddress: string): BigNumber { + const erc20Interface = new ethers.utils.Interface(ERC20.abi); + const transferTopic = erc20Interface.getEventTopic("Transfer"); + const walletAddress = this.baseSignerAddress.toNative().toLowerCase(); + + let totalReceived = bnZero; + for (const log of receipt.logs) { + if ( + log.address.toLowerCase() === destTokenAddress.toLowerCase() && + log.topics[0] === transferTopic && + log.topics.length >= 3 + ) { + const toAddress = ethers.utils.defaultAbiCoder.decode(["address"], log.topics[2])[0] as string; + if (toAddress.toLowerCase() === walletAddress) { + const amount = BigNumber.from(log.data); + totalReceived = totalReceived.add(amount); + } + } + } + return totalReceived; + } + + // Redis helpers for storing/retrieving the swap tx hash alongside order details. + + private _redisSwapTxHashKey(cloid: string): string { + return `${this.REDIS_PREFIX}swap-tx-hash:${cloid}`; + } + + private async _redisSaveSwapTxHash(cloid: string, txHash: string): Promise { + const key = this._redisSwapTxHashKey(cloid); + await this.redisCache.set( + key, + txHash, + process.env.REBALANCER_PENDING_ORDER_TTL ? Number(process.env.REBALANCER_PENDING_ORDER_TTL) : 60 * 60 + ); + } + + private async _redisGetSwapTxHash(cloid: string): Promise { + const key = this._redisSwapTxHashKey(cloid); + return await this.redisCache.get(key); + } +} diff --git a/src/rebalancer/adapters/oftAdapter.ts b/src/rebalancer/adapters/oftAdapter.ts index 11cf193804..6d45d74929 100644 --- a/src/rebalancer/adapters/oftAdapter.ts +++ b/src/rebalancer/adapters/oftAdapter.ts @@ -180,7 +180,9 @@ export class OftAdapter extends BaseAdapter { const nativeFeeUsd = toBNWei(price.price).mul(feeStruct.nativeFee).div(toBNWei(1, nativeTokenInfo.decimals)); const sourceTokenInfo = this._getTokenInfo(sourceToken, sourceChain); const nativeFeeSourceDecimals = ConvertDecimals(nativeTokenInfo.decimals, sourceTokenInfo.decimals)(nativeFeeUsd); - return nativeFeeSourceDecimals; + // Gas cost of send() on source chain (~120k gas). + const gasCost = await this._estimateGasCostInSourceToken(sourceChain, 120_000, sourceToken, sourceChain); + return nativeFeeSourceDecimals.add(gasCost); } async getPendingOrders(): Promise { diff --git a/src/rebalancer/adapters/swapAdapterBase.ts b/src/rebalancer/adapters/swapAdapterBase.ts new file mode 100644 index 0000000000..32b3479f5e --- /dev/null +++ b/src/rebalancer/adapters/swapAdapterBase.ts @@ -0,0 +1,255 @@ +import { assert, BigNumber, bnZero, getNetworkName, Signer, winston } from "../../utils"; +import { RebalanceRoute } from "../utils/interfaces"; +import { BaseAdapter } from "./baseAdapter"; +import { RebalancerConfig } from "../RebalancerConfig"; +import { CctpAdapter } from "./cctpAdapter"; +import { OftAdapter } from "./oftAdapter"; + +/** + * Intermediate base class for swap adapters (Binance, Hyperliquid, Matcha) that share + * a common pattern: bridge tokens to an intermediate "swap chain", execute the swap, + * then bridge back to the final destination chain. + */ +export abstract class SwapAdapterBase extends BaseAdapter { + constructor( + readonly logger: winston.Logger, + readonly config: RebalancerConfig, + readonly baseSigner: Signer, + readonly cctpAdapter: CctpAdapter, + readonly oftAdapter: OftAdapter + ) { + super(logger, config, baseSigner); + } + + // //////////////////////////////////////////////////////////// + // ABSTRACT METHODS - must be implemented by subclasses + // //////////////////////////////////////////////////////////// + + /** + * Returns the chain where the swap will be executed for a given (chainId, token) pair. + * - Hyperliquid: always HYPEREVM + * - Binance: the Binance entrypoint network for the chain/token + * - Matcha: the chain itself if natively supported, otherwise Arbitrum + */ + protected abstract _getSwapChain(chainId: number, token: string): Promise | number; + + // //////////////////////////////////////////////////////////// + // SHARED BRIDGE HELPER METHODS + // //////////////////////////////////////////////////////////// + + protected _getIntermediateAdapter(token: string): CctpAdapter | OftAdapter { + return token === "USDT" ? this.oftAdapter : this.cctpAdapter; + } + + protected _getIntermediateAdapterName(token: string): "oft" | "cctp" { + return token === "USDT" ? "oft" : "cctp"; + } + + protected async _bridgeToChain( + token: string, + originChain: number, + destinationChain: number, + expectedAmountToTransfer: BigNumber + ): Promise { + switch (token) { + case "USDT": + return await this.oftAdapter.initializeRebalance( + { + sourceChain: originChain, + destinationChain, + sourceToken: "USDT", + destinationToken: "USDT", + adapter: "oft", + }, + expectedAmountToTransfer + ); + case "USDC": + return await this.cctpAdapter.initializeRebalance( + { + sourceChain: originChain, + destinationChain, + sourceToken: "USDC", + destinationToken: "USDC", + adapter: "cctp", + }, + expectedAmountToTransfer + ); + default: + throw new Error(`Should never happen: Unsupported bridge for token: ${token}`); + } + } + + /** + * Validates that intermediate CCTP/OFT bridge routes exist for all routes where + * the source or destination chain is not the swap chain. + */ + protected async _validateIntermediateRoutes(routes: RebalanceRoute[], adapterLabel: string): Promise { + for (const route of routes) { + const { sourceToken, destinationToken, sourceChain, destinationChain } = route; + const sourceSwapChain = await this._getSwapChain(sourceChain, sourceToken); + const destinationSwapChain = await this._getSwapChain(destinationChain, destinationToken); + + if (destinationSwapChain !== destinationChain) { + const intermediateRoute = { + ...route, + sourceChain: destinationSwapChain, + sourceToken: destinationToken, + adapter: this._getIntermediateAdapterName(destinationToken), + }; + assert( + this._getIntermediateAdapter(destinationToken).supportsRoute(intermediateRoute), + `Destination chain ${getNetworkName( + destinationChain + )} is not a valid final destination chain for token ${destinationToken} because it doesn't have a ${this._getIntermediateAdapterName( + destinationToken + )} bridge route from the ${adapterLabel} swap chain ${getNetworkName(destinationSwapChain)}` + ); + } + if (sourceSwapChain !== sourceChain) { + const intermediateRoute = { + ...route, + destinationChain: sourceSwapChain, + destinationToken: sourceToken, + adapter: this._getIntermediateAdapterName(sourceToken), + }; + assert( + this._getIntermediateAdapter(sourceToken).supportsRoute(intermediateRoute), + `Source chain ${getNetworkName( + sourceChain + )} is not a valid source chain for token ${sourceToken} because it doesn't have a ${this._getIntermediateAdapterName( + sourceToken + )} bridge route to the ${adapterLabel} swap chain ${getNetworkName(sourceSwapChain)}` + ); + } + } + } + + /** + * Estimates bridge fees for bridging to/from the swap chain. + * Returns { bridgeToFee, bridgeFromFee } in source token units. + */ + protected async _estimateBridgeFees( + rebalanceRoute: RebalanceRoute, + amountToTransfer: BigNumber + ): Promise<{ bridgeToFee: BigNumber; bridgeFromFee: BigNumber }> { + const { sourceToken, destinationToken, sourceChain, destinationChain } = rebalanceRoute; + + // Bridge to swap chain fee: + let bridgeToFee = bnZero; + const sourceSwapChain = await this._getSwapChain(sourceChain, sourceToken); + if (sourceSwapChain !== sourceChain) { + const _rebalanceRoute = { ...rebalanceRoute, destinationChain: sourceSwapChain }; + if ( + sourceToken === "USDT" && + this.oftAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }) + ) { + bridgeToFee = await this.oftAdapter.getEstimatedCost( + { ..._rebalanceRoute, destinationToken: "USDT", adapter: "oft" }, + amountToTransfer + ); + } else if ( + sourceToken === "USDC" && + this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }) + ) { + bridgeToFee = await this.cctpAdapter.getEstimatedCost( + { ..._rebalanceRoute, destinationToken: "USDC", adapter: "cctp" }, + amountToTransfer + ); + } + } + + // Bridge from swap chain fee: + let bridgeFromFee = bnZero; + const destinationSwapChain = await this._getSwapChain(destinationChain, destinationToken); + if (destinationSwapChain !== destinationChain) { + const _rebalanceRoute = { ...rebalanceRoute, sourceChain: destinationSwapChain }; + if ( + destinationToken === "USDT" && + this.oftAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }) + ) { + bridgeFromFee = await this.oftAdapter.getEstimatedCost( + { ..._rebalanceRoute, sourceToken: "USDT", adapter: "oft" }, + amountToTransfer + ); + } else if ( + destinationToken === "USDC" && + this.cctpAdapter.supportsRoute({ ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }) + ) { + bridgeFromFee = await this.cctpAdapter.getEstimatedCost( + { ..._rebalanceRoute, sourceToken: "USDC", adapter: "cctp" }, + amountToTransfer + ); + } + } + + return { bridgeToFee, bridgeFromFee }; + } + + /** + * Computes virtual balance adjustments for pending rebalances with intermediate bridge accounting. + * - For PENDING_BRIDGE_PRE_DEPOSIT orders: subtracts from the swap chain to avoid double-counting + * (since the bridge adapter already adds a credit on the bridge destination). + * - For all pending orders: adds a virtual credit on the final destination chain. + * + * Returns the pending rebalances map. + */ + protected async _getPendingRebalancesWithBridgeAccounting(): Promise<{ + [chainId: number]: { [token: string]: BigNumber }; + }> { + const pendingRebalances: { [chainId: number]: { [token: string]: BigNumber } } = {}; + + // Subtract intermediate bridge credits from the swap chain to avoid double-counting. + const pendingBridges = await this._redisGetPendingBridgesPreDeposit(); + for (const cloid of pendingBridges) { + const orderDetails = await this._redisGetOrderDetails(cloid); + const { sourceChain, sourceToken, amountToTransfer } = orderDetails; + const swapChain = await this._getSwapChain(sourceChain, sourceToken); + const amountConverter = this._getAmountConverter( + sourceChain, + this._getTokenInfo(sourceToken, sourceChain).address, + swapChain, + this._getTokenInfo(sourceToken, swapChain).address + ); + const convertedAmount = amountConverter(amountToTransfer); + pendingRebalances[swapChain] ??= {}; + pendingRebalances[swapChain][sourceToken] = (pendingRebalances[swapChain][sourceToken] ?? bnZero).sub( + convertedAmount + ); + this.logger.debug({ + at: `${this.constructor.name}.getPendingRebalances`, + message: `Subtracting ${convertedAmount.toString()} ${sourceToken} from swap chain ${getNetworkName(swapChain)} for intermediate bridge`, + }); + } + + // Add virtual destination chain credits for all pending orders. + const pendingOrders = await this._redisGetPendingOrders(); + if (pendingOrders.length > 0) { + this.logger.debug({ + at: `${this.constructor.name}.getPendingRebalances`, + message: `Found ${pendingOrders.length} pending orders`, + pendingOrders, + }); + } + for (const cloid of pendingOrders) { + const orderDetails = await this._redisGetOrderDetails(cloid); + const { destinationChain, destinationToken, sourceChain, sourceToken, amountToTransfer } = orderDetails; + const amountConverter = this._getAmountConverter( + sourceChain, + this._getTokenInfo(sourceToken, sourceChain).address, + destinationChain, + this._getTokenInfo(destinationToken, destinationChain).address + ); + const convertedAmount = amountConverter(amountToTransfer); + this.logger.debug({ + at: `${this.constructor.name}.getPendingRebalances`, + message: `Adding ${convertedAmount.toString()} ${destinationToken} for pending order cloid ${cloid} to destination chain ${getNetworkName(destinationChain)}`, + }); + pendingRebalances[destinationChain] ??= {}; + pendingRebalances[destinationChain][destinationToken] = ( + pendingRebalances[destinationChain][destinationToken] ?? bnZero + ).add(convertedAmount); + } + + return pendingRebalances; + } +} diff --git a/src/utils/MatchaUtils.ts b/src/utils/MatchaUtils.ts new file mode 100644 index 0000000000..fa2a32fde2 --- /dev/null +++ b/src/utils/MatchaUtils.ts @@ -0,0 +1,139 @@ +import { assert } from "./"; + +const ZERO_X_API_BASE_URL = "https://api.0x.org"; +const ZERO_X_API_VERSION = "v2"; + +// 0x AllowanceHolder contract address (canonical across all supported chains). +export const ZERO_X_ALLOWANCE_HOLDER = "0x0000000000001fF3684f28c67538d4D072C22734"; + +// 0x free tier rate limit: 5 RPS in fixed 1-second windows. +const ZERO_X_MAX_RPS = Number(process.env.ZERO_X_MAX_RPS ?? 5); + +interface ZeroXTransactionData { + to: string; + data: string; + gas: string; + value: string; +} +interface ZeroXIssues { + allowance: unknown | null; + balance: unknown | null; +} + +interface ZeroXPriceResponse { + buyAmount: string; + issues: ZeroXIssues; +} + +interface ZeroXQuoteResponse extends ZeroXPriceResponse { + transaction: ZeroXTransactionData; + minBuyAmount: string; +} + +/** + * Simple rate limiter for 0x API calls. Uses a fixed 1-second window matching + * the 0x rate limit calculation: up to ZERO_X_MAX_RPS calls per 1-second window. + */ +class ZeroXRateLimiter { + private windowStart = 0; + private requestsInWindow = 0; + private queue: Array<{ resolve: () => void }> = []; + private draining = false; + + async waitForSlot(): Promise { + const now = Date.now(); + + // Reset window if we've moved past the current 1-second window. + if (now - this.windowStart >= 1000) { + this.windowStart = now; + this.requestsInWindow = 0; + } + + // If we have capacity in the current window, proceed immediately. + if (this.requestsInWindow < ZERO_X_MAX_RPS) { + this.requestsInWindow++; + return; + } + + // Otherwise, queue this request and wait until the next window. + return new Promise((resolve) => { + this.queue.push({ resolve }); + this._scheduleDrain(); + }); + } + + private _scheduleDrain(): void { + if (this.draining) { + return; + } + this.draining = true; + + const msUntilNextWindow = 1000 - (Date.now() - this.windowStart); + setTimeout( + () => { + this.draining = false; + this.windowStart = Date.now(); + this.requestsInWindow = 0; + + // Release up to ZERO_X_MAX_RPS queued requests. + const toRelease = Math.min(this.queue.length, ZERO_X_MAX_RPS); + for (let i = 0; i < toRelease; i++) { + this.requestsInWindow++; + this.queue.shift().resolve(); + } + + // If there are still queued requests, schedule another drain. + if (this.queue.length > 0) { + this._scheduleDrain(); + } + }, + Math.max(msUntilNextWindow, 0) + ); + } +} + +const rateLimiter = new ZeroXRateLimiter(); + +function getHeaders(): Record { + const apiKey = process.env.ZERO_X_API_KEY; + assert(apiKey, "ZERO_X_API_KEY environment variable is required for Matcha/0x API calls"); + return { + "0x-api-key": apiKey, + "0x-version": ZERO_X_API_VERSION, + }; +} + +/** + * Get a firm quote from the 0x Swap API. This commits liquidity and returns an executable transaction. + * Use this when ready to execute a swap. + */ +export async function getMatchaQuote( + chainId: number, + sellToken: string, + buyToken: string, + sellAmount: string, + takerAddress: string, + slippageBps?: number +): Promise { + await rateLimiter.waitForSlot(); + + const params = new URLSearchParams({ + chainId: chainId.toString(), + sellToken, + buyToken, + sellAmount, + taker: takerAddress, + }); + if (slippageBps !== undefined) { + // 0x API expects slippage as a decimal (e.g., 0.01 for 1%) + params.set("slippagePercentage", (slippageBps / 10000).toString()); + } + + const url = `${ZERO_X_API_BASE_URL}/swap/allowance-holder/quote?${params.toString()}`; + const response = await fetch(url, { headers: getHeaders() }); + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`0x quote API error (${response.status}): ${errorBody}`); + } + return (await response.json()) as ZeroXQuoteResponse; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index eb88ff3d4e..c01f4093de 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -77,6 +77,7 @@ export * from "./BNUtils"; export * from "./CCTPUtils"; export * from "./RetryUtils"; export * from "./BinanceUtils"; +export * from "./MatchaUtils"; export * from "./OFTUtils"; export * from "./NumberUtils"; export * from "./HyperliquidUtils";