diff --git a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs index eb1b27df..1b5254c8 100644 --- a/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs +++ b/src/Managing.Application.Abstractions/Services/IWeb3ProxyService.cs @@ -19,7 +19,9 @@ namespace Managing.Application.Abstractions.Services Task SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null); Task> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains); - + + Task GetTokenBalanceOnChainAsync(string address, Ticker ticker, int? chainId = null); + Task GetEstimatedGasFeeUsdAsync(); Task GetGasFeeDataAsync(); diff --git a/src/Managing.Application.Tests/TradingBaseTests.cs b/src/Managing.Application.Tests/TradingBaseTests.cs index bfdf17e6..74accc73 100644 --- a/src/Managing.Application.Tests/TradingBaseTests.cs +++ b/src/Managing.Application.Tests/TradingBaseTests.cs @@ -47,11 +47,14 @@ namespace Managing.Application.Tests evmProcessor }; + var web3ProxyService = new Mock().Object; + return new ExchangeService( loggerFactory.CreateLogger(), GetCandleRepository(), exchangeProcessors, - evmManager); + evmManager, + web3ProxyService); } public static ILogger CreateTradingBotLogger() diff --git a/src/Managing.Application/Bots/SpotBot.cs b/src/Managing.Application/Bots/SpotBot.cs index 0e92b929..31fa2bb2 100644 --- a/src/Managing.Application/Bots/SpotBot.cs +++ b/src/Managing.Application/Bots/SpotBot.cs @@ -392,27 +392,45 @@ public class SpotBot : TradingBotBase if (tokenBalance is { Amount: > 0 }) { - // Verify that the token balance matches the position amount with 0.1% tolerance + // Verify that the token balance matches the position amount var positionQuantity = internalPosition.Open.Quantity; var tokenBalanceAmount = tokenBalance.Amount; if (positionQuantity > 0) { - var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage - var difference = Math.Abs(tokenBalanceAmount - positionQuantity); - - if (difference > tolerance) + // Only check tolerance if token balance is LESS than position quantity + // If balance is greater, it could be orphaned tokens from previous positions + if (tokenBalanceAmount < positionQuantity) { - await LogWarningAsync( - $"⚠️ Token Balance Mismatch - Position Verification Failed\n" + + var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage + var difference = positionQuantity - tokenBalanceAmount; + + if (difference > tolerance) + { + await LogWarningAsync( + $"⚠️ Token Balance Below Position Quantity\n" + + $"Position: `{internalPosition.Identifier}`\n" + + $"Position Quantity: `{positionQuantity:F5}`\n" + + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + + $"Difference: `{difference:F5}`\n" + + $"Tolerance (0.6%): `{tolerance:F5}`\n" + + $"Token balance is significantly lower than expected\n" + + $"Skipping position synchronization"); + return; // Skip processing if balance is too low + } + } + else if (tokenBalanceAmount > positionQuantity) + { + // Token balance is higher than position - likely orphaned tokens + // Log but continue with synchronization + var excess = tokenBalanceAmount - positionQuantity; + await LogDebugAsync( + $"ℹ️ Token Balance Exceeds Position Quantity\n" + $"Position: `{internalPosition.Identifier}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" + - $"Difference: `{difference:F5}`\n" + - $"Tolerance (0.6%): `{tolerance:F5}`\n" + - $"Token balance does not match position amount within tolerance\n" + - $"Skipping position synchronization"); - return; // Skip processing if amounts don't match + $"Excess: `{excess:F5}`\n" + + $"Proceeding with position synchronization"); } } diff --git a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs index 1a45f082..91f7c5d2 100644 --- a/src/Managing.Infrastructure.Exchanges/ExchangeService.cs +++ b/src/Managing.Infrastructure.Exchanges/ExchangeService.cs @@ -18,17 +18,20 @@ namespace Managing.Infrastructure.Exchanges private readonly ICandleRepository _candleRepository; private readonly IEnumerable _exchangeProcessor; private readonly IEvmManager _evmManager; + private readonly IWeb3ProxyService _web3ProxyService; public ExchangeService( ILogger logger, ICandleRepository candleRepository, IEnumerable processor, - IEvmManager evmManager) + IEvmManager evmManager, + IWeb3ProxyService web3ProxyService) { _logger = logger; _candleRepository = candleRepository; _exchangeProcessor = processor; _evmManager = evmManager; + _web3ProxyService = web3ProxyService; } #region Trades @@ -271,22 +274,11 @@ namespace Managing.Infrastructure.Exchanges string.Equals(balance.TokenName, ticker.ToString(), StringComparison.InvariantCultureIgnoreCase)); } - var evmBalance = await _evmManager.GetTokenBalance(Constants.Chains.Arbitrum, ticker, account.Key); + // Use Web3Proxy's on-chain balance endpoint for accurate decimal-adjusted balances + // This uses Viem to call the ERC20 balanceOf function directly, ensuring proper decimal handling + var balance = await _web3ProxyService.GetTokenBalanceOnChainAsync(account.Key, ticker, 42161); // Arbitrum chainId - if (evmBalance == null) - { - return null; - } - - return new Balance - { - TokenName = evmBalance.TokenName, - Price = evmBalance.Price, - Value = evmBalance.Value, - Amount = evmBalance.Balance, - TokenAdress = evmBalance.TokenAddress, - Chain = evmBalance.Chain - }; + return balance; } public async Task GetFee(Account account, bool isForPaperTrading = false) diff --git a/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs b/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs index d03937c0..d3f2208d 100644 --- a/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs +++ b/src/Managing.Infrastructure.Tests/ExchangeServicesTests.cs @@ -34,12 +34,14 @@ namespace Managing.Infrastructure.Tests { evmProcessor }; + var web3ProxyService = new Mock().Object; _exchangeService = new ExchangeService( doesntDoMuch.CreateLogger(), candleRepository, exchangeProcessors, - evmManager); + evmManager, + web3ProxyService); } [Theory] diff --git a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs index b474d2aa..9bac784a 100644 --- a/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs +++ b/src/Managing.Infrastructure.Web3/Models/Proxy/Web3ProxyError.cs @@ -153,6 +153,18 @@ namespace Managing.Infrastructure.Evm.Models.Proxy public List Balances { get; set; } } + /// + /// Response for single token balance on-chain operations + /// + public class Web3ProxyTokenBalanceResponse : Web3ProxyResponse + { + /// + /// Token balance if successful + /// + [JsonPropertyName("balance")] + public Balance Balance { get; set; } + } + /// /// Response model for gas fee information /// diff --git a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs index e9b9e4f7..db129066 100644 --- a/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs +++ b/src/Managing.Infrastructure.Web3/Services/Web3ProxyService.cs @@ -402,6 +402,35 @@ namespace Managing.Infrastructure.Evm.Services return response.Balances ?? new List(); } + public async Task GetTokenBalanceOnChainAsync(string address, Ticker ticker, int? chainId = null) + { + var payload = new + { + address, + ticker = ticker.ToString(), + chainId + }; + + var response = await GetPrivyServiceAsync("/token-balance", payload); + + if (response == null) + { + throw new Web3ProxyException("Token balance response is null"); + } + + if (!response.Success) + { + throw new Web3ProxyException($"Token balance request failed: {response.Error}"); + } + + if (response.Balance == null) + { + throw new Web3ProxyException("Token balance is null in response"); + } + + return response.Balance; + } + public async Task GetEstimatedGasFeeUsdAsync() { var response = await GetGmxServiceAsync("/gas-fee", null); diff --git a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts index 24bbbdc8..d0d41dd0 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts @@ -47,6 +47,7 @@ declare module 'fastify' { initAddress: typeof initAddress; sendToken: typeof sendToken; getWalletBalance: typeof getWalletBalance; + getTokenBalanceOnChain: typeof getTokenBalanceOnChain; } } @@ -1373,6 +1374,151 @@ export async function sendToken( } } +/** + * Gets the on-chain token balance for a specific address using Viem ERC20 contract call + * This provides accurate decimal-adjusted balances directly from the blockchain + * @param address The wallet address to get balance for + * @param ticker The token ticker (e.g., 'ETH', 'USDC', 'BTC') + * @param chainId The chain ID (optional, defaults to ARBITRUM) + * @returns Balance object with properly formatted amount and USD value + */ +export const getTokenBalanceOnChainImpl = async ( + address: string, + ticker: string, + chainId?: number +): Promise => { + try { + chainId = chainId ?? ARBITRUM; + + // Get SDK client for this address + const sdk = await getClientForAddress(address); + + // Get token data from GMX configuration + const chainTokens = TOKENS[chainId]; + const token = chainTokens?.find(t => t.symbol === ticker); + + if (!token) { + throw new Error(`Token not found: ${ticker} on chain ${chainId}. Available tokens: ${chainTokens?.map(t => t.symbol).join(', ')}`); + } + + // Get token balance - different approach for native vs ERC20 tokens + let rawBalance: bigint; + + if (token.isNative) { + // For native tokens (ETH), get balance directly from the chain + rawBalance = await sdk.publicClient.getBalance({ address: address as Address }); + } else { + // For ERC20 tokens, get balance using multicall + const result = await sdk.executeMulticall({ + token: { + contractAddress: token.address, + abiId: "Token", + calls: { + balance: { + methodName: "balanceOf", + params: [address] + } + } + } + }); + + // Extract balance from multicall result + rawBalance = result?.data?.token?.balance?.returnValues?.[0]; + + if (rawBalance === undefined || rawBalance === null) { + throw new Error(`Failed to extract balance from multicall result for ${ticker}`); + } + } + + // Convert to decimal format using token decimals + const amount = parseFloat(rawBalance.toString()) / Math.pow(10, token.decimals); + + // Get token price (from last candle or current price) + // For now, use 0 or fetch from exchange if needed + let price = 0; + let usdValue = 0; + + // Try to get price from token data if available + try { + const {tokensData} = await sdk.tokens.getTokensData(); + const tokenData = tokensData[token.address]; + if (tokenData?.prices?.minPrice) { + // GMX prices are in USD with 30 decimals + price = parseFloat(tokenData.prices.minPrice.toString()) / Math.pow(10, 30); + usdValue = amount * price; + } + } catch (priceError) { + console.warn(`Could not fetch price for ${ticker}:`, priceError); + } + + // Get chain information + const caip2 = getChainName(chainId); + const chain: Chain = { + id: caip2, + rpcUrl: undefined, + name: getChainNameFromCaip2(caip2), + chainId: chainId + }; + + return { + tokenImage: '', + tokenName: ticker.toUpperCase(), + amount: amount, + price: price, + value: usdValue, + tokenAdress: token.address, + chain: chain + }; + } catch (error) { + console.error('Error getting token balance on-chain:', error); + throw new Error(`Failed to get token balance on-chain: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +}; + +/** + * Gets on-chain token balance using Viem + * @param this The FastifyRequest instance + * @param reply The FastifyReply instance + * @param address The wallet address to get balance for + * @param ticker The token ticker (e.g., 'ETH', 'USDC', 'BTC') + * @param chainId The chain ID (optional, defaults to ARBITRUM) + * @returns The response object with success status and balance + */ +export async function getTokenBalanceOnChain( + this: FastifyRequest, + reply: FastifyReply, + address: string, + ticker: string, + chainId?: number +) { + try { + if (!address) { + throw new Error('Wallet address is required for balance retrieval'); + } + + if (!ticker) { + throw new Error('Token ticker is required for balance retrieval'); + } + + // Call the implementation function + const balance = await getTokenBalanceOnChainImpl(address, ticker, chainId); + + return { + success: true, + balance: balance + }; + } catch (error) { + this.log.error(error); + + // Return appropriate error response + reply.status(500); + return { + success: false, + error: error instanceof Error ? error.message : 'An unknown error occurred' + }; + } +} + /** * The use of fastify-plugin is required to be able * to export the decorators to the outer scope @@ -1401,6 +1547,10 @@ export default fp(async (fastify) => { return getWalletBalance.call(this, reply, address, assets, chains); }); + fastify.decorateRequest('getTokenBalanceOnChain', async function(this: FastifyRequest, reply: FastifyReply, address: string, ticker: string, chainId?: number) { + return getTokenBalanceOnChain.call(this, reply, address, ticker, chainId); + }); + // Test the Privy client initialization (non-blocking) // Don't throw on error - let the app start so we can debug try { diff --git a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts index c5bec67b..a046db80 100644 --- a/src/Managing.Web3Proxy/src/routes/api/privy/index.ts +++ b/src/Managing.Web3Proxy/src/routes/api/privy/index.ts @@ -161,20 +161,70 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => { async function (request, reply) { try { const { address, asset, chain } = request.query; - + // Convert single values to arrays if needed const assetsArray = Array.isArray(asset) ? asset : [asset]; const chainsArray = Array.isArray(chain) ? chain : [chain]; - + // Convert asset strings to Ticker enums const assetEnums = assetsArray.map(a => a as Ticker); - + return await request.getWalletBalance(reply, address, assetEnums, chainsArray); } catch (error) { return handleError(request, reply, error, 'privy/wallet-balance'); } } ) + + fastify.get( + '/token-balance', + { + schema: { + querystring: Type.Object({ + address: Type.String(), + ticker: Type.String(), + chainId: Type.Optional(Type.Number()) + }), + response: { + 200: Type.Object({ + success: Type.Boolean(), + balance: Type.Optional(Type.Object({ + tokenImage: Type.Optional(Type.String()), + tokenName: Type.Optional(Type.String()), + amount: Type.Optional(Type.Number()), + price: Type.Optional(Type.Number()), + value: Type.Optional(Type.Number()), + tokenAdress: Type.Optional(Type.String()), + chain: Type.Optional(Type.Object({ + id: Type.Optional(Type.String()), + rpcUrl: Type.Optional(Type.String()), + name: Type.Optional(Type.String()), + chainId: Type.Optional(Type.Number()) + })) + })), + error: Type.Optional(Type.String()) + }), + 400: Type.Object({ + success: Type.Boolean(), + error: Type.String() + }), + 500: Type.Object({ + success: Type.Boolean(), + error: Type.String() + }) + }, + tags: ['Privy'] + } + }, + async function (request, reply) { + try { + const { address, ticker, chainId } = request.query; + return await request.getTokenBalanceOnChain(reply, address, ticker, chainId); + } catch (error) { + return handleError(request, reply, error, 'privy/token-balance'); + } + } + ) } export default plugin \ No newline at end of file diff --git a/src/Managing.Web3Proxy/test/plugins/get-token-balance.test.ts b/src/Managing.Web3Proxy/test/plugins/get-token-balance.test.ts new file mode 100644 index 00000000..d30d0138 --- /dev/null +++ b/src/Managing.Web3Proxy/test/plugins/get-token-balance.test.ts @@ -0,0 +1,25 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import { getTokenBalanceOnChainImpl } from '../../src/plugins/custom/privy' + +test('Privy get token balance on-chain', async (t) => { + await t.test('should get BTC balance with correct decimals', async () => { + const result = await getTokenBalanceOnChainImpl( + '0x932167388dD9aad41149b3cA23eBD489E2E2DD78', + 'BTC', + 42161 // Arbitrum + ) + + assert.ok(result, 'Balance result should be defined') + assert.strictEqual(result.tokenName, 'BTC', 'Token name should be BTC') + assert.ok(typeof result.amount === 'number', 'Amount should be a number') + assert.ok(result.amount >= 0, 'Amount should be non-negative') + assert.ok(typeof result.price === 'number', 'Price should be a number') + assert.ok(result.price >= 0, 'Price should be non-negative') + assert.ok(typeof result.value === 'number', 'Value should be a number') + // For ERC20 tokens like BTC (WBTC), should have a real contract address + assert.ok(result.tokenAdress && result.tokenAdress !== '0x0000000000000000000000000000000000000000', 'BTC should have a valid contract address') + assert.ok(result.chain, 'Chain should be defined') + assert.strictEqual(result.chain.chainId, 42161, 'Chain ID should be Arbitrum') + }) +})