Implement on-chain token balance retrieval and enhance SpotBot logging

- Added a new method in IWeb3ProxyService to retrieve token balances directly from the blockchain, ensuring accurate decimal handling.
- Updated ExchangeService to utilize the new on-chain balance method, replacing the previous balance retrieval logic.
- Enhanced SpotBot logging to provide clearer warnings when token balances are significantly lower than expected, and to log cases of excess token balances.
- Introduced a new API endpoint for fetching token balances on-chain, improving the overall functionality of the service.
This commit is contained in:
2026-01-05 20:30:24 +07:00
parent 25a2b202a1
commit 700d975da7
10 changed files with 317 additions and 34 deletions

View File

@@ -19,7 +19,9 @@ namespace Managing.Application.Abstractions.Services
Task<SwapInfos> SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null); Task<SwapInfos> SendTokenAsync(string senderAddress, string recipientAddress, Ticker ticker, decimal amount, int? chainId = null);
Task<List<Balance>> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains); Task<List<Balance>> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains);
Task<Balance> GetTokenBalanceOnChainAsync(string address, Ticker ticker, int? chainId = null);
Task<decimal> GetEstimatedGasFeeUsdAsync(); Task<decimal> GetEstimatedGasFeeUsdAsync();
Task<GasFeeData> GetGasFeeDataAsync(); Task<GasFeeData> GetGasFeeDataAsync();

View File

@@ -47,11 +47,14 @@ namespace Managing.Application.Tests
evmProcessor evmProcessor
}; };
var web3ProxyService = new Mock<IWeb3ProxyService>().Object;
return new ExchangeService( return new ExchangeService(
loggerFactory.CreateLogger<ExchangeService>(), loggerFactory.CreateLogger<ExchangeService>(),
GetCandleRepository(), GetCandleRepository(),
exchangeProcessors, exchangeProcessors,
evmManager); evmManager,
web3ProxyService);
} }
public static ILogger<TradingBotBase> CreateTradingBotLogger() public static ILogger<TradingBotBase> CreateTradingBotLogger()

View File

@@ -392,27 +392,45 @@ public class SpotBot : TradingBotBase
if (tokenBalance is { Amount: > 0 }) 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 positionQuantity = internalPosition.Open.Quantity;
var tokenBalanceAmount = tokenBalance.Amount; var tokenBalanceAmount = tokenBalance.Amount;
if (positionQuantity > 0) if (positionQuantity > 0)
{ {
var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage // Only check tolerance if token balance is LESS than position quantity
var difference = Math.Abs(tokenBalanceAmount - positionQuantity); // If balance is greater, it could be orphaned tokens from previous positions
if (tokenBalanceAmount < positionQuantity)
if (difference > tolerance)
{ {
await LogWarningAsync( var tolerance = positionQuantity * 0.006m; // 0.6% tolerance to account for slippage
$"⚠️ Token Balance Mismatch - Position Verification Failed\n" + 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: `{internalPosition.Identifier}`\n" +
$"Position Quantity: `{positionQuantity:F5}`\n" + $"Position Quantity: `{positionQuantity:F5}`\n" +
$"Token Balance: `{tokenBalanceAmount:F5}`\n" + $"Token Balance: `{tokenBalanceAmount:F5}`\n" +
$"Difference: `{difference:F5}`\n" + $"Excess: `{excess:F5}`\n" +
$"Tolerance (0.6%): `{tolerance:F5}`\n" + $"Proceeding with position synchronization");
$"Token balance does not match position amount within tolerance\n" +
$"Skipping position synchronization");
return; // Skip processing if amounts don't match
} }
} }

View File

@@ -18,17 +18,20 @@ namespace Managing.Infrastructure.Exchanges
private readonly ICandleRepository _candleRepository; private readonly ICandleRepository _candleRepository;
private readonly IEnumerable<IExchangeProcessor> _exchangeProcessor; private readonly IEnumerable<IExchangeProcessor> _exchangeProcessor;
private readonly IEvmManager _evmManager; private readonly IEvmManager _evmManager;
private readonly IWeb3ProxyService _web3ProxyService;
public ExchangeService( public ExchangeService(
ILogger<ExchangeService> logger, ILogger<ExchangeService> logger,
ICandleRepository candleRepository, ICandleRepository candleRepository,
IEnumerable<IExchangeProcessor> processor, IEnumerable<IExchangeProcessor> processor,
IEvmManager evmManager) IEvmManager evmManager,
IWeb3ProxyService web3ProxyService)
{ {
_logger = logger; _logger = logger;
_candleRepository = candleRepository; _candleRepository = candleRepository;
_exchangeProcessor = processor; _exchangeProcessor = processor;
_evmManager = evmManager; _evmManager = evmManager;
_web3ProxyService = web3ProxyService;
} }
#region Trades #region Trades
@@ -271,22 +274,11 @@ namespace Managing.Infrastructure.Exchanges
string.Equals(balance.TokenName, ticker.ToString(), StringComparison.InvariantCultureIgnoreCase)); 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 balance;
{
return null;
}
return new Balance
{
TokenName = evmBalance.TokenName,
Price = evmBalance.Price,
Value = evmBalance.Value,
Amount = evmBalance.Balance,
TokenAdress = evmBalance.TokenAddress,
Chain = evmBalance.Chain
};
} }
public async Task<decimal> GetFee(Account account, bool isForPaperTrading = false) public async Task<decimal> GetFee(Account account, bool isForPaperTrading = false)

View File

@@ -34,12 +34,14 @@ namespace Managing.Infrastructure.Tests
{ {
evmProcessor evmProcessor
}; };
var web3ProxyService = new Mock<IWeb3ProxyService>().Object;
_exchangeService = new ExchangeService( _exchangeService = new ExchangeService(
doesntDoMuch.CreateLogger<ExchangeService>(), doesntDoMuch.CreateLogger<ExchangeService>(),
candleRepository, candleRepository,
exchangeProcessors, exchangeProcessors,
evmManager); evmManager,
web3ProxyService);
} }
[Theory] [Theory]

View File

@@ -153,6 +153,18 @@ namespace Managing.Infrastructure.Evm.Models.Proxy
public List<Balance> Balances { get; set; } public List<Balance> Balances { get; set; }
} }
/// <summary>
/// Response for single token balance on-chain operations
/// </summary>
public class Web3ProxyTokenBalanceResponse : Web3ProxyResponse
{
/// <summary>
/// Token balance if successful
/// </summary>
[JsonPropertyName("balance")]
public Balance Balance { get; set; }
}
/// <summary> /// <summary>
/// Response model for gas fee information /// Response model for gas fee information
/// </summary> /// </summary>

View File

@@ -402,6 +402,35 @@ namespace Managing.Infrastructure.Evm.Services
return response.Balances ?? new List<Balance>(); return response.Balances ?? new List<Balance>();
} }
public async Task<Balance> GetTokenBalanceOnChainAsync(string address, Ticker ticker, int? chainId = null)
{
var payload = new
{
address,
ticker = ticker.ToString(),
chainId
};
var response = await GetPrivyServiceAsync<Web3ProxyTokenBalanceResponse>("/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<decimal> GetEstimatedGasFeeUsdAsync() public async Task<decimal> GetEstimatedGasFeeUsdAsync()
{ {
var response = await GetGmxServiceAsync<GasFeeResponse>("/gas-fee", null); var response = await GetGmxServiceAsync<GasFeeResponse>("/gas-fee", null);

View File

@@ -47,6 +47,7 @@ declare module 'fastify' {
initAddress: typeof initAddress; initAddress: typeof initAddress;
sendToken: typeof sendToken; sendToken: typeof sendToken;
getWalletBalance: typeof getWalletBalance; 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<Balance> => {
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 * The use of fastify-plugin is required to be able
* to export the decorators to the outer scope * 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); 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) // Test the Privy client initialization (non-blocking)
// Don't throw on error - let the app start so we can debug // Don't throw on error - let the app start so we can debug
try { try {

View File

@@ -161,20 +161,70 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
async function (request, reply) { async function (request, reply) {
try { try {
const { address, asset, chain } = request.query; const { address, asset, chain } = request.query;
// Convert single values to arrays if needed // Convert single values to arrays if needed
const assetsArray = Array.isArray(asset) ? asset : [asset]; const assetsArray = Array.isArray(asset) ? asset : [asset];
const chainsArray = Array.isArray(chain) ? chain : [chain]; const chainsArray = Array.isArray(chain) ? chain : [chain];
// Convert asset strings to Ticker enums // Convert asset strings to Ticker enums
const assetEnums = assetsArray.map(a => a as Ticker); const assetEnums = assetsArray.map(a => a as Ticker);
return await request.getWalletBalance(reply, address, assetEnums, chainsArray); return await request.getWalletBalance(reply, address, assetEnums, chainsArray);
} catch (error) { } catch (error) {
return handleError(request, reply, error, 'privy/wallet-balance'); 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 export default plugin

View File

@@ -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')
})
})