From 645bbe6d95611f9314326ae8c2909b659b9090d6 Mon Sep 17 00:00:00 2001 From: cryptooda Date: Mon, 5 Jan 2026 22:13:18 +0700 Subject: [PATCH] Enhance SpotBot slippage handling and logging - Increased slippage tolerance from 0.6% to 0.7% to account for gas reserves. - Improved logging to provide detailed information when adjusting position quantities due to slippage or when retaining original quantities. - Updated CloseSpotPositionCommandHandler to use the position's opened quantity instead of the entire wallet balance, ensuring gas fees are preserved. - Adjusted Web3ProxyService settings for retry attempts and operation timeouts to improve performance. - Enhanced swap token implementation to handle native tokens correctly and increased operation timeout for better reliability. --- src/Managing.Application/Bots/SpotBot.cs | 51 +++++++++++---- .../CloseSpotPositionCommandHandler.cs | 15 ++--- .../Services/Web3ProxyService.cs | 8 +-- .../src/plugins/custom/gmx.ts | 65 +++++++++++++------ .../test/plugins/swap-tokens.test.ts | 22 ++++--- 5 files changed, 106 insertions(+), 55 deletions(-) diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index 4f2c4c7b..40d75364 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -287,7 +287,7 @@ public class SpotBot : TradingBotBase return false; } - var tolerance = positionQuantity * 0.006m; // 0.6% tolerance for slippage + var tolerance = positionQuantity * 0.007m; // 0.7% tolerance for slippage and gas reserve var difference = Math.Abs(tokenBalance - positionQuantity); if (difference > tolerance) @@ -307,8 +307,25 @@ public class SpotBot : TradingBotBase lastPosition.Status = PositionStatus.Filled; lastPosition.Open.SetStatus(TradeStatus.Filled); - // Update quantity to match actual token balance - lastPosition.Open.Quantity = tokenBalance; + // Only update quantity if actual balance is less than position quantity (slippage loss) + // Don't update if balance is higher (likely includes gas reserve for ETH) + if (tokenBalance < positionQuantity) + { + await LogInformationAsync( + $"šŸ“‰ Adjusting Position Quantity Due to Slippage\n" + + $"Original Quantity: `{positionQuantity:F5}`\n" + + $"Actual Balance: `{tokenBalance:F5}`\n" + + $"Difference: `{difference:F5}`"); + lastPosition.Open.Quantity = tokenBalance; + } + else + { + await LogInformationAsync( + $"ā„¹ļø Keeping Original Position Quantity\n" + + $"Position Quantity: `{positionQuantity:F5}`\n" + + $"Actual Balance: `{tokenBalance:F5}`\n" + + $"Not updating (balance includes gas reserve or is within tolerance)"); + } // Calculate current PnL var currentPrice = await ServiceScopeHelpers.WithScopedService( @@ -402,7 +419,7 @@ public class SpotBot : TradingBotBase // If balance is greater, it could be orphaned tokens from previous positions if (tokenBalanceAmount < positionQuantity) { - var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage + var tolerance = positionQuantity * 0.007m; // 0.7% tolerance to account for slippage and gas reserve var difference = positionQuantity - tokenBalanceAmount; if (difference > tolerance) @@ -413,7 +430,7 @@ public class SpotBot : TradingBotBase $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Difference: `{difference:F5}`\n" + - $"Tolerance (0.6%): `{tolerance:F5}`\n" + + $"Tolerance (0.7%): `{tolerance:F5}`\n" + $"Token balance is significantly lower than expected\n" + $"Skipping position synchronization"); return; // Skip processing if balance is too low @@ -446,23 +463,35 @@ public class SpotBot : TradingBotBase internalPosition.Open.SetStatus(TradeStatus.Filled); positionForSignal.Open.SetStatus(TradeStatus.Filled); - // Update quantity to match actual token balance + // Update quantity to match actual token balance only if difference is within acceptable tolerance + // For ETH, we need to be careful not to include gas reserve in the position quantity var actualTokenBalance = tokenBalance.Amount; - var quantityTolerance = internalPosition.Open.Quantity * 0.006m; // 0.6% tolerance for slippage + var quantityTolerance = internalPosition.Open.Quantity * 0.007m; // 0.7% tolerance for slippage and gas reserve var quantityDifference = Math.Abs(internalPosition.Open.Quantity - actualTokenBalance); - if (quantityDifference > quantityTolerance) + // Only update if the actual balance is LESS than expected (slippage loss) + // Don't update if balance is higher (likely includes gas reserve or orphaned tokens) + if (actualTokenBalance < internalPosition.Open.Quantity && quantityDifference > quantityTolerance) { await LogDebugAsync( - $"šŸ”„ Token Balance Mismatch\n" + + $"šŸ”„ Token Balance Lower Than Expected (Slippage)\n" + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + $"Broker Balance: `{actualTokenBalance:F5}`\n" + $"Difference: `{quantityDifference:F5}`\n" + - $"Tolerance (0.6%): `{quantityTolerance:F5}`\n" + - $"Updating to match broker balance"); + $"Tolerance (0.7%): `{quantityTolerance:F5}`\n" + + $"Updating to match actual balance"); internalPosition.Open.Quantity = actualTokenBalance; positionForSignal.Open.Quantity = actualTokenBalance; } + else if (actualTokenBalance > internalPosition.Open.Quantity) + { + await LogDebugAsync( + $"ā„¹ļø Token Balance Higher Than Position Quantity\n" + + $"Internal Quantity: `{internalPosition.Open.Quantity:F5}`\n" + + $"Broker Balance: `{actualTokenBalance:F5}`\n" + + $"Difference: `{quantityDifference:F5}`\n" + + $"Keeping original position quantity (extra balance likely gas reserve or orphaned tokens)"); + } // Calculate and update PnL based on current price var currentPrice = await ServiceScopeHelpers.WithScopedService( diff --git a/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs index b2052372..1034985c 100644 --- a/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs +++ b/src/Managing.Application/Trading/Handlers/CloseSpotPositionCommandHandler.cs @@ -48,18 +48,13 @@ public class CloseSpotPositionCommandHandler( { // For live trading, call SwapGmxTokensAsync var account = await accountService.GetAccountById(request.AccountId); - var tokenBalance = await exchangeService.GetBalance(account, request.Position.Ticker); - - if (tokenBalance == null || tokenBalance.Amount <= 0) - { - throw new InvalidOperationException( - $"No available balance to close spot position for {request.Position.Ticker}"); - } - - amountToSwap = tokenBalance.Amount; + + // Use the position's opened quantity instead of the entire wallet balance + // This naturally leaves extra ETH for gas fees and avoids the need for explicit reservation + amountToSwap = request.Position.Open.Quantity; logger?.LogInformation( - "Closing spot position: PositionId={PositionId}, Ticker={Ticker}, TokenBalance={TokenBalance}, Swapping to USDC", + "Closing spot position: PositionId={PositionId}, Ticker={Ticker}, PositionQuantity={PositionQuantity}, Swapping to USDC", request.Position.Identifier, request.Position.Ticker, amountToSwap); swapResult = await tradingService.SwapGmxTokensAsync( diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index db129066..c0d29841 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -21,7 +21,7 @@ namespace Managing.Infrastructure.Evm.Services public class Web3ProxySettings { public string BaseUrl { get; set; } = "http://localhost:3000"; - public int MaxRetryAttempts { get; set; } = 2; + public int MaxRetryAttempts { get; set; } = 1; public int RetryDelayMs { get; set; } = 2500; public int TimeoutSeconds { get; set; } = 30; } @@ -619,9 +619,9 @@ namespace Managing.Infrastructure.Evm.Services } public async Task> GetGmxPositionHistoryAsync( - string account, + string account, int pageIndex = 0, - int pageSize = 20, + int pageSize = 20, string? ticker = null, DateTime? fromDate = null, DateTime? toDate = null) @@ -633,7 +633,7 @@ namespace Managing.Infrastructure.Evm.Services pageSize, ticker, fromDateTime = fromDate?.ToString("O"), // ISO 8601 format - toDateTime = toDate?.ToString("O") // ISO 8601 format + toDateTime = toDate?.ToString("O") // ISO 8601 format }; var response = await GetGmxServiceAsync("/position-history", payload); diff --git a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts index 6bbb322e..44379541 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/gmx.ts @@ -4,7 +4,7 @@ import {z} from 'zod' import {GmxSdk} from '../../generated/gmxsdk/index.js' import {arbitrum} from 'viem/chains'; -import {getTokenBySymbol} from '../../generated/gmxsdk/configs/tokens.js'; +import {getTokenBySymbol, NATIVE_TOKEN_ADDRESS} from '../../generated/gmxsdk/configs/tokens.js'; import {createSwapOrderTxn} from '../../generated/gmxsdk/modules/orders/transactions/createSwapOrderTxn.js'; import {MarketInfo, MarketsInfoData} from '../../generated/gmxsdk/types/markets.js'; @@ -60,7 +60,7 @@ interface CacheEntry { const CACHE_TTL = 60 * 60 * 1000; // 60 minutes in milliseconds (increased from 30 minutes) const marketsCache = new Map(); const MAX_CACHE_SIZE = 20; // Increased cache size to allow more markets data -const OPERATION_TIMEOUT = 30000; // 30 seconds timeout for operations +const OPERATION_TIMEOUT = 60000; // 60 seconds timeout for operations (increased to handle slower swaps) const MEMORY_WARNING_THRESHOLD = 0.85; // Warn when memory usage exceeds 85% const MEMORY_CLEAR_THRESHOLD = 0.95; // Clear cache when memory usage exceeds 95% @@ -342,7 +342,7 @@ export async function estimatePositionGasFee( // Fallback RPC configuration const FALLBACK_RPC_URL = "https://radial-shy-cherry.arbitrum-mainnet.quiknode.pro/098e57e961b05b24bcde008c4ca02fff6fb13b51/"; const PRIMARY_RPC_URL = "https://arb1.arbitrum.io/rpc"; -const MAX_RETRY_ATTEMPTS = 2; // Only allow one retry to prevent infinite loops +const MAX_RETRY_ATTEMPTS = 1; // Only allow one retry (2 total attempts: initial + 1 retry) /** @@ -2947,13 +2947,20 @@ export const swapGmxTokensImpl = async ( const fromTokenData = getTokenDataFromTicker(fromTicker, tokensData); const toTokenData = getTokenDataFromTicker(toTicker, tokensData); + // IMPORTANT: For native tokens (ETH), we must use NATIVE_TOKEN_ADDRESS (0x0000...) + // instead of the wrapped token address (WETH) for the swap to work correctly + const fromTokenAddress = fromTokenData?.isNative ? NATIVE_TOKEN_ADDRESS : fromTokenData?.address; + const toTokenAddress = toTokenData?.isNative ? NATIVE_TOKEN_ADDRESS : toTokenData?.address; + console.log(`šŸ”„ Swap details:`, { fromTicker, toTicker, - fromTokenAddress: fromTokenData?.address, + fromTokenAddress, + fromTokenAddressOriginal: fromTokenData?.address, fromTokenIsNative: fromTokenData?.isNative, fromTokenIsSynthetic: fromTokenData?.isSynthetic, - toTokenAddress: toTokenData?.address, + toTokenAddress, + toTokenAddressOriginal: toTokenData?.address, toTokenIsNative: toTokenData?.isNative, toTokenIsSynthetic: toTokenData?.isSynthetic }); @@ -2983,10 +2990,11 @@ export const swapGmxTokensImpl = async ( } // Check for zero addresses (but allow native tokens like ETH) - if (fromTokenData.address === "0x0000000000000000000000000000000000000000" && !fromTokenData.isNative) { + // Native tokens will have NATIVE_TOKEN_ADDRESS (0x0000...) which is valid + if (fromTokenAddress === "0x0000000000000000000000000000000000000000" && !fromTokenData.isNative) { throw new Error(`From token ${fromTicker} has zero address - token not found or invalid`); } - if (toTokenData.address === "0x0000000000000000000000000000000000000000" && !toTokenData.isNative) { + if (toTokenAddress === "0x0000000000000000000000000000000000000000" && !toTokenData.isNative) { throw new Error(`To token ${toTicker} has zero address - token not found or invalid`); } @@ -2997,6 +3005,7 @@ export const swapGmxTokensImpl = async ( walletBalance = await sdk.publicClient.getBalance({ address: sdk.account as Address }); } else { // For ERC20 tokens, get balance using multicall + // Use the original token address (not NATIVE_TOKEN_ADDRESS) for ERC20 balance checks const balanceResult = await sdk.executeMulticall({ token: { contractAddress: fromTokenData.address, @@ -3029,7 +3038,9 @@ export const swapGmxTokensImpl = async ( console.log('šŸ’° Wallet balance check:', { fromTicker, - fromTokenAddress: fromTokenData.address, + fromTokenAddress, + fromTokenAddressOriginal: fromTokenData.address, + isNative: fromTokenData.isNative, requestedAmount: requestedAmountBigInt.toString(), walletBalance: walletBalance.toString(), requestedAmountFormatted: requestedAmountNumber.toFixed(6), @@ -3162,8 +3173,8 @@ export const swapGmxTokensImpl = async ( try { const swapParams = { fromAmount: verifiedFromTokenAmount, - fromTokenAddress: fromTokenData.address, - toTokenAddress: toTokenData.address, + fromTokenAddress, + toTokenAddress, allowedSlippageBps: allowedSlippageBps, referralCodeForTxn: encodeReferralCode("kaigen_ai"), skipSimulation: true, @@ -3173,6 +3184,10 @@ export const swapGmxTokensImpl = async ( swapParams.tokensData = tokensData; console.log('šŸ”„ Attempting SDK swap with slippage:', { + fromTokenAddress, + toTokenAddress, + fromIsNative: fromTokenData.isNative, + toIsNative: toTokenData.isNative, allowedSlippageBps: allowedSlippageBps, slippagePercentage: slippagePercentage }); @@ -3198,8 +3213,8 @@ export const swapGmxTokensImpl = async ( // Find the proper swap path using createFindSwapPath (works for both synthetic and non-synthetic tokens) const findSwapPath = createFindSwapPath({ chainId: sdk.chainId, - fromTokenAddress: fromTokenData.address, - toTokenAddress: toTokenData.address, + fromTokenAddress, + toTokenAddress, marketsInfoData, gasEstimationParams: { gasLimits, @@ -3233,17 +3248,17 @@ export const swapGmxTokensImpl = async ( // No swap path found - this should not happen for valid token pairs // (Synthetic tokens are already validated early, so we won't reach here for them) console.warn('āš ļø No swap path found, using direct path (may not work for all token pairs)'); - swapPath = [fromTokenData.address, toTokenData.address]; + swapPath = [fromTokenAddress, toTokenAddress]; swapsCount = 1; } - + const estimatedGasLimit = estimateExecuteSwapOrderGasLimit(gasLimits, { swapsCount: swapsCount, callbackGasLimit: 0n, }); - + const oraclePriceCount = estimateOrderOraclePriceCount(swapsCount); - + const executionFee = getExecutionFee( sdk.chainId, gasLimits, @@ -3252,11 +3267,11 @@ export const swapGmxTokensImpl = async ( gasPrice, oraclePriceCount ); - + if (!executionFee) { throw new Error("Failed to calculate execution fee"); } - + console.log(`šŸ’° Calculated execution fee: ${(Number(executionFee.feeTokenAmount) / 1e18).toFixed(6)} ETH`); // Calculate minOutputAmount based on swap path stats or fallback to price-based calculation @@ -3297,10 +3312,20 @@ export const swapGmxTokensImpl = async ( }); } + console.log('šŸš€ Creating swap order with:', { + fromTokenAddress, + fromIsNative: fromTokenData.isNative, + toTokenAddress, + toIsNative: toTokenData.isNative, + fromTokenAmount: verifiedFromTokenAmount.toString(), + executionFee: executionFee.feeTokenAmount.toString(), + swapPath + }); + await createSwapOrderTxn(sdk, { - fromTokenAddress: fromTokenData.address, + fromTokenAddress, fromTokenAmount: verifiedFromTokenAmount, - toTokenAddress: toTokenData.address, + toTokenAddress, swapPath: swapPath, orderType: OrderType.MarketSwap, minOutputAmount: minOutputAmount, diff --git a/src/Managing.Web3Proxy/test/plugins/swap-tokens.test.ts b/src/Managing.Web3Proxy/test/plugins/swap-tokens.test.ts index bf9a59b0..6bc7406a 100644 --- a/src/Managing.Web3Proxy/test/plugins/swap-tokens.test.ts +++ b/src/Managing.Web3Proxy/test/plugins/swap-tokens.test.ts @@ -1,32 +1,34 @@ import {describe, it} from 'node:test' import assert from 'node:assert' import {getClientForAddress, swapGmxTokensImpl} from '../../src/plugins/custom/gmx.js' -import {Ticker} from '../../src/generated/ManagingApiTypes.js' describe('swap tokens implementation', () => { - it('should swap SOL to USDC successfully', async () => { + it('should swap $4 worth of ETH to USDC successfully', async () => { try { const testAccount = '0x932167388dD9aad41149b3cA23eBD489E2E2DD78' - + const sdk = await getClientForAddress(testAccount) + console.log('\nšŸ”„ Starting ETH → USDC swap ($4 worth of ETH to USDC)...\n') + + // Swap ~0.0012 ETH to get approximately $4 worth of USDC (at ~$3400 ETH price) + // 0.0012 ETH * $3,400 = ~$4.08 const result = await swapGmxTokensImpl( sdk, - Ticker.USDC, - Ticker.RENDER, - 5, // 5 USDC + 'ETH', + 'USDC', + 0.0012, // ~0.0012 ETH should give us ~$4 worth of USDC 'market', undefined, 0.5 ) - + + console.log('\nāœ… ETH → USDC swap successful! Swapped ~$4 worth of ETH to USDC\n') assert.strictEqual(typeof result, 'string') assert.strictEqual(result, 'swap_order_created') } catch (error) { - console.log('error', error) - - // Test that the error is related to actual execution rather than parameter validation + console.log('\nāŒ ETH → USDC swap failed:', error) assert.fail(error.message) } })