diff --git a/src/adapter/bridges/BinanceCEXBridge.ts b/src/adapter/bridges/BinanceCEXBridge.ts index 9525e745c9..80a686c060 100644 --- a/src/adapter/bridges/BinanceCEXBridge.ts +++ b/src/adapter/bridges/BinanceCEXBridge.ts @@ -10,6 +10,7 @@ import { isDefined, getTimestampForBlock, getBinanceApiClient, + getBinanceDepositAddress, floatToBN, CHAIN_IDs, compareAddressesSimple, @@ -77,7 +78,7 @@ export class BinanceCEXBridge extends BaseBridgeAdapter { const binanceApiClient = await this.getBinanceClient(); - const depositAddress = await binanceApiClient.depositAddress({ + const depositAddress = await getBinanceDepositAddress(binanceApiClient, { coin: this.tokenSymbol, network: "ETH", }); diff --git a/src/adapter/bridges/BinanceCEXNativeBridge.ts b/src/adapter/bridges/BinanceCEXNativeBridge.ts index 48625256ef..2db2dba4f8 100644 --- a/src/adapter/bridges/BinanceCEXNativeBridge.ts +++ b/src/adapter/bridges/BinanceCEXNativeBridge.ts @@ -1,4 +1,14 @@ -import { Contract, BigNumber, Signer, Provider, EvmAddress, assert, bnZero, winston } from "../../utils"; +import { + Contract, + BigNumber, + Signer, + Provider, + EvmAddress, + assert, + bnZero, + getBinanceDepositAddress, + winston, +} from "../../utils"; import { CONTRACT_ADDRESSES } from "../../common"; import { BridgeTransactionDetails } from "./BaseBridgeAdapter"; import { BinanceCEXBridge } from "./"; @@ -40,7 +50,7 @@ export class BinanceCEXNativeBridge extends BinanceCEXBridge { // Fetch the deposit address from the binance API. const binanceApiClient = await this.getBinanceClient(); - const depositAddress = await binanceApiClient.depositAddress({ + const depositAddress = await getBinanceDepositAddress(binanceApiClient, { coin: this.tokenSymbol, network: "ETH", }); diff --git a/src/adapter/l2Bridges/BinanceCEXBridge.ts b/src/adapter/l2Bridges/BinanceCEXBridge.ts index eacac91372..1b71351121 100644 --- a/src/adapter/l2Bridges/BinanceCEXBridge.ts +++ b/src/adapter/l2Bridges/BinanceCEXBridge.ts @@ -8,6 +8,7 @@ import { Signer, EvmAddress, getBinanceApiClient, + getBinanceDepositAddress, getTranslatedTokenAddress, floatToBN, getTimestampForBlock, @@ -66,7 +67,7 @@ export class BinanceCEXBridge extends BaseL2BridgeAdapter { ): Promise { const binanceApiClient = await this.getBinanceClient(); const l2TokenInfo = getTokenInfo(l2Token, this.l2chainId); - const depositAddress = await binanceApiClient.depositAddress({ + const depositAddress = await getBinanceDepositAddress(binanceApiClient, { coin: this.l1TokenInfo.symbol, network: this.depositNetwork, }); diff --git a/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts b/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts index c4f1439a35..68f9fd1bdf 100644 --- a/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts +++ b/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts @@ -1,6 +1,7 @@ import { BigNumber, createFormatFunction, + getBinanceDepositAddress, getNetworkName, Signer, Contract, @@ -27,7 +28,7 @@ export class BinanceCEXNativeBridge extends BinanceCEXBridge { const weth = new Contract(l2Token.toNative(), WETH_ABI, this.l2Signer); const binanceApiClient = await this.getBinanceClient(); const l2TokenInfo = getTokenInfo(l2Token, this.l2chainId); - const depositAddress = await binanceApiClient.depositAddress({ + const depositAddress = await getBinanceDepositAddress(binanceApiClient, { coin: this.l1TokenInfo.symbol, network: this.depositNetwork, }); diff --git a/src/finalizer/utils/binance.ts b/src/finalizer/utils/binance.ts index 877bbb144a..76ee3bf061 100644 --- a/src/finalizer/utils/binance.ts +++ b/src/finalizer/utils/binance.ts @@ -23,6 +23,7 @@ import { getBinanceDepositType, BinanceTransactionType, getBinanceWithdrawalType, + submitBinanceWithdrawal, isCompletedBinanceWithdrawal, truncate, } from "../../utils"; @@ -207,7 +208,7 @@ export async function binanceFinalizer( amountToFinalize = Math.floor(amountToFinalize * DECIMAL_PRECISION) / DECIMAL_PRECISION; // Balance from Binance is in 8 decimal places, so we need to truncate to 8 decimal places. coinBalance = Number((coinBalance - amountToFinalize).toFixed(8)); - const withdrawalId = await binanceApi.withdraw({ + const withdrawalId = await submitBinanceWithdrawal(binanceApi, { coin: symbol, address, network: withdrawNetwork, @@ -243,7 +244,7 @@ export async function binanceFinalizer( }); // Lastly, we need to truncate the amount to withdraw to 6 decimal places const amountToSweep = truncate(cappedWithdraw, 6); - const withdrawalId = await binanceApi.withdraw({ + const withdrawalId = await submitBinanceWithdrawal(binanceApi, { coin: symbol, address, network: withdrawNetwork, diff --git a/src/rebalancer/adapters/binance.ts b/src/rebalancer/adapters/binance.ts index a333366af6..5deb4c1acc 100644 --- a/src/rebalancer/adapters/binance.ts +++ b/src/rebalancer/adapters/binance.ts @@ -14,7 +14,10 @@ import { forEachAsync, fromWei, getAccountCoins, + getBinanceAllOrders, getBinanceApiClient, + getBinanceDepositAddress, + getBinanceTradeFees, getBinanceTransactionTypeKey, getBinanceWithdrawals, getNetworkName, @@ -24,6 +27,8 @@ import { setBinanceDepositType, setBinanceWithdrawalType, Signer, + submitBinanceOrder, + submitBinanceWithdrawal, toBNWei, truncate, winston, @@ -678,7 +683,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { const { sourceToken, destinationToken, sourceChain, destinationChain } = rebalanceRoute; const spotMarketMeta = this._getSpotMarketMetaForRoute(sourceToken, destinationToken); // Commission is denominated in percentage points. - const tradeFeePct = (await this.binanceApiClient.tradeFee()).find( + const tradeFeePct = (await getBinanceTradeFees(this.binanceApiClient)).find( (fee) => fee.symbol === spotMarketMeta.symbol ).takerCommission; const tradeFee = toBNWei(tradeFeePct, 18).mul(amountToTransfer).div(toBNWei(100, 18)); @@ -848,7 +853,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { private async _depositToBinance(sourceToken: string, sourceChain: number, amountToDeposit: BigNumber): Promise { assert(isDefined(BINANCE_NETWORKS[sourceChain]), "Source chain should be a Binance network"); - const depositAddress = await this.binanceApiClient.depositAddress({ + const depositAddress = await getBinanceDepositAddress(this.binanceApiClient, { coin: sourceToken, network: BINANCE_NETWORKS[sourceChain], }); @@ -958,7 +963,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { ): Promise<{ matchingFill: QueryOrderResult; expectedAmountToReceive: string } | undefined> { const orderDetails = await this._redisGetOrderDetails(cloid); const spotMarketMeta = this._getSpotMarketMetaForRoute(orderDetails.sourceToken, orderDetails.destinationToken); - const allOrders = await this.binanceApiClient.allOrders({ + const allOrders = await getBinanceAllOrders(this.binanceApiClient, { symbol: spotMarketMeta.symbol, }); const matchingFill = allOrders.find((order) => order.clientOrderId === cloid && order.status === "FILLED"); @@ -984,7 +989,6 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { side: spotMarketMeta.isBuy ? "BUY" : "SELL", type: OrderType.MARKET, quantity: szForOrder.toString(), - recvWindow: 60000, }; this.logger.debug({ at: "BinanceStablecoinSwapAdapter._placeMarketOrder", @@ -993,7 +997,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { } with size ${szForOrder}`, orderStruct, }); - const response = await this.binanceApiClient.order(orderStruct as NewOrderSpot); + const response = await submitBinanceOrder(this.binanceApiClient, orderStruct as NewOrderSpot); assert(response.status == "FILLED", `Market order was not filled: ${JSON.stringify(response)}`); this.logger.info({ at: "BinanceStablecoinSwapAdapter._placeMarketOrder", @@ -1139,7 +1143,7 @@ export class BinanceStablecoinSwapAdapter extends BaseAdapter { // We need to truncate the amount to withdraw to the destination chain's decimal places. const destinationTokenInfo = this._getTokenInfo(destinationToken, destinationEntrypointNetwork); const amountToWithdraw = truncate(quantity, destinationTokenInfo.decimals); - const withdrawalId = await this.binanceApiClient.withdraw({ + const withdrawalId = await submitBinanceWithdrawal(this.binanceApiClient, { coin: destinationToken, address: this.baseSignerAddress.toNative(), amount: Number(amountToWithdraw), diff --git a/src/utils/BinanceUtils.ts b/src/utils/BinanceUtils.ts index d160b177f2..1ac4137e7f 100644 --- a/src/utils/BinanceUtils.ts +++ b/src/utils/BinanceUtils.ts @@ -17,11 +17,33 @@ const KNOWN_BINANCE_ERROR_REASONS = [ "TypeError: fetch failed", ]; +// Binance only accepts a signed request while its timestamp remains within recvWindow. +// We keep write-state calls tight so delayed accepted requests cannot submit orders/withdrawals much later than intended. +// Signed reads can tolerate a much larger window because a delayed accepted request still returns current server data. +export const BINANCE_WRITE_RECV_WINDOW_MS = 5_000; +export const BINANCE_READ_RECV_WINDOW_MS = 60_000; + type WithdrawalQuota = { wdQuota: number; usedWdQuota: number; }; +type BinanceApiWithRecvWindow = BinanceApi & { + tradeFee(options?: { recvWindow?: number; useServerTime?: boolean }): ReturnType; + withdraw( + options: Parameters[0] & { + recvWindow?: number; + withdrawOrderId?: string; + } + ): ReturnType; + withdrawHistory( + options: Parameters[0] & { + recvWindow?: number; + } + ): ReturnType; + accountCoins(options?: { recvWindow?: number }): Promise; +}; + // Alias for Binance network symbols. export const BINANCE_NETWORKS: { [chainId: number]: string } = { [CHAIN_IDs.ARBITRUM]: "ARBITRUM", @@ -142,13 +164,47 @@ async function retrieveBinanceSecretKeyFromCLIArgs(): Promise { - const unparsedQuota = await binanceApi.privateRequest("GET" as HttpMethod, "/sapi/v1/capital/withdraw/quota", {}); + const unparsedQuota = await binanceApi.privateRequest("GET" as HttpMethod, "/sapi/v1/capital/withdraw/quota", { + recvWindow: BINANCE_READ_RECV_WINDOW_MS, + }); return { wdQuota: unparsedQuota["wdQuota"], usedWdQuota: unparsedQuota["usedWdQuota"], }; } +export async function getBinanceTradeFees(binanceApi: BinanceApi): ReturnType { + return (binanceApi as BinanceApiWithRecvWindow).tradeFee({ recvWindow: BINANCE_READ_RECV_WINDOW_MS }); +} + +export async function getBinanceDepositAddress( + binanceApi: BinanceApi, + options: Parameters[0] +): ReturnType { + return binanceApi.depositAddress({ ...options, recvWindow: BINANCE_READ_RECV_WINDOW_MS }); +} + +export async function getBinanceAllOrders( + binanceApi: BinanceApi, + options: Parameters[0] +): ReturnType { + return binanceApi.allOrders({ ...options, recvWindow: BINANCE_READ_RECV_WINDOW_MS }); +} + +export async function submitBinanceOrder( + binanceApi: BinanceApi, + options: Parameters[0] +): ReturnType { + return binanceApi.order({ ...options, recvWindow: BINANCE_WRITE_RECV_WINDOW_MS }); +} + +export async function submitBinanceWithdrawal( + binanceApi: BinanceApi, + options: Parameters[0] +): ReturnType { + return (binanceApi as BinanceApiWithRecvWindow).withdraw({ ...options, recvWindow: BINANCE_WRITE_RECV_WINDOW_MS }); +} + export enum BinanceTransactionType { BRIDGE, // A deposit into Binance from one network designed to be withdrawn to another network. SWAP, // A deposit into Binance from one network designed to be swapped and then withdrawn to another network. @@ -255,7 +311,7 @@ export async function getBinanceDeposits( ): Promise { let depositHistory: DepositHistoryResponse; try { - depositHistory = await binanceApi.depositHistory({ startTime }); + depositHistory = await binanceApi.depositHistory({ startTime, recvWindow: BINANCE_READ_RECV_WINDOW_MS }); } catch (_err) { const err = _err.toString(); if (KNOWN_BINANCE_ERROR_REASONS.some((errorReason) => err.includes(errorReason)) && nRetries < maxRetries) { @@ -290,7 +346,11 @@ export async function getBinanceWithdrawals( ): Promise { let withdrawHistory: WithdrawHistoryResponse; try { - withdrawHistory = await binanceApi.withdrawHistory({ coin, startTime }); + withdrawHistory = await (binanceApi as BinanceApiWithRecvWindow).withdrawHistory({ + coin, + startTime, + recvWindow: BINANCE_READ_RECV_WINDOW_MS, + }); } catch (_err) { const err = _err.toString(); if (KNOWN_BINANCE_ERROR_REASONS.some((errorReason) => err.includes(errorReason)) && nRetries < maxRetries) { @@ -321,7 +381,11 @@ export async function getBinanceWithdrawals( * @returns A typed `AccountCoins` response. */ export async function getAccountCoins(binanceApi: BinanceApi): Promise { - const coins = Object.values(await binanceApi["accountCoins"]()); + const coins = Object.values( + await (binanceApi as BinanceApiWithRecvWindow).accountCoins({ + recvWindow: BINANCE_READ_RECV_WINDOW_MS, + }) + ); return coins.map((coin) => { const networkList = coin["networkList"]?.map((network) => { return { diff --git a/test/BinanceUtils.ts b/test/BinanceUtils.ts index 74eaf60e68..ae35eefa71 100644 --- a/test/BinanceUtils.ts +++ b/test/BinanceUtils.ts @@ -1,8 +1,16 @@ import { expect } from "./utils"; import { + BINANCE_READ_RECV_WINDOW_MS, + BINANCE_WRITE_RECV_WINDOW_MS, BinanceDeposit, BinanceWithdrawal, + getBinanceAllOrders, + getBinanceDepositAddress, + getBinanceTradeFees, + getBinanceWithdrawalLimits, getOutstandingBinanceDeposits, + submitBinanceOrder, + submitBinanceWithdrawal, isCompletedBinanceWithdrawal, } from "../src/utils"; @@ -104,6 +112,92 @@ describe("BinanceUtils: getOutstandingBinanceDeposits", function () { expect(deposits.length).to.equal(1); }); }); + +describe("BinanceUtils recvWindow helpers", function () { + it("applies the read recvWindow to signed read helpers", async function () { + const calls: Record = {}; + const binanceApi = { + privateRequest: async (_method: string, _url: string, payload: object) => { + calls.privateRequest = payload; + return { wdQuota: 10, usedWdQuota: 1 }; + }, + tradeFee: async (payload: object) => { + calls.tradeFee = payload; + return []; + }, + depositAddress: async (payload: object) => { + calls.depositAddress = payload; + return { address: "0x1", tag: "", coin: "USDT", url: "" }; + }, + allOrders: async (payload: object) => { + calls.allOrders = payload; + return []; + }, + } as unknown as Parameters[0]; + + await getBinanceWithdrawalLimits(binanceApi); + await getBinanceTradeFees(binanceApi); + await getBinanceDepositAddress(binanceApi, { coin: "USDT", network: "ETH" }); + await getBinanceAllOrders(binanceApi, { symbol: "USDCUSDT" }); + + expect(calls.privateRequest).to.deep.equal({ recvWindow: BINANCE_READ_RECV_WINDOW_MS }); + expect(calls.tradeFee).to.deep.equal({ recvWindow: BINANCE_READ_RECV_WINDOW_MS }); + expect(calls.depositAddress).to.deep.equal({ + coin: "USDT", + network: "ETH", + recvWindow: BINANCE_READ_RECV_WINDOW_MS, + }); + expect(calls.allOrders).to.deep.equal({ + symbol: "USDCUSDT", + recvWindow: BINANCE_READ_RECV_WINDOW_MS, + }); + }); + + it("applies the write recvWindow to signed write helpers", async function () { + const calls: Record = {}; + const binanceApi = { + order: async (payload: object) => { + calls.order = payload; + return { status: "FILLED" }; + }, + withdraw: async (payload: object) => { + calls.withdraw = payload; + return { id: "withdrawal-id" }; + }, + } as unknown as Parameters[0]; + + await submitBinanceOrder(binanceApi, { + symbol: "USDCUSDT", + side: "BUY", + type: "MARKET", + quantity: "1", + } as Parameters[1]); + await submitBinanceWithdrawal(binanceApi, { + coin: "USDT", + address: "0x1", + network: "ETH", + amount: 1, + transactionFeeFlag: false, + }); + + expect(calls.order).to.deep.equal({ + symbol: "USDCUSDT", + side: "BUY", + type: "MARKET", + quantity: "1", + recvWindow: BINANCE_WRITE_RECV_WINDOW_MS, + }); + expect(calls.withdraw).to.deep.equal({ + coin: "USDT", + address: "0x1", + network: "ETH", + amount: 1, + transactionFeeFlag: false, + recvWindow: BINANCE_WRITE_RECV_WINDOW_MS, + }); + }); +}); + describe("BinanceUtils withdrawal helpers", function () { it("only treats completed Binance withdrawals as completed", function () { expect(isCompletedBinanceWithdrawal(6)).to.equal(true);