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:
@@ -20,6 +20,8 @@ namespace Managing.Application.Abstractions.Services
|
||||
|
||||
Task<List<Balance>> GetWalletBalanceAsync(string address, Ticker[] assets, string[] chains);
|
||||
|
||||
Task<Balance> GetTokenBalanceOnChainAsync(string address, Ticker ticker, int? chainId = null);
|
||||
|
||||
Task<decimal> GetEstimatedGasFeeUsdAsync();
|
||||
|
||||
Task<GasFeeData> GetGasFeeDataAsync();
|
||||
|
||||
@@ -47,11 +47,14 @@ namespace Managing.Application.Tests
|
||||
evmProcessor
|
||||
};
|
||||
|
||||
var web3ProxyService = new Mock<IWeb3ProxyService>().Object;
|
||||
|
||||
return new ExchangeService(
|
||||
loggerFactory.CreateLogger<ExchangeService>(),
|
||||
GetCandleRepository(),
|
||||
exchangeProcessors,
|
||||
evmManager);
|
||||
evmManager,
|
||||
web3ProxyService);
|
||||
}
|
||||
|
||||
public static ILogger<TradingBotBase> CreateTradingBotLogger()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,20 @@ namespace Managing.Infrastructure.Exchanges
|
||||
private readonly ICandleRepository _candleRepository;
|
||||
private readonly IEnumerable<IExchangeProcessor> _exchangeProcessor;
|
||||
private readonly IEvmManager _evmManager;
|
||||
private readonly IWeb3ProxyService _web3ProxyService;
|
||||
|
||||
public ExchangeService(
|
||||
ILogger<ExchangeService> logger,
|
||||
ICandleRepository candleRepository,
|
||||
IEnumerable<IExchangeProcessor> 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<decimal> GetFee(Account account, bool isForPaperTrading = false)
|
||||
|
||||
@@ -34,12 +34,14 @@ namespace Managing.Infrastructure.Tests
|
||||
{
|
||||
evmProcessor
|
||||
};
|
||||
var web3ProxyService = new Mock<IWeb3ProxyService>().Object;
|
||||
|
||||
_exchangeService = new ExchangeService(
|
||||
doesntDoMuch.CreateLogger<ExchangeService>(),
|
||||
candleRepository,
|
||||
exchangeProcessors,
|
||||
evmManager);
|
||||
evmManager,
|
||||
web3ProxyService);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -153,6 +153,18 @@ namespace Managing.Infrastructure.Evm.Models.Proxy
|
||||
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>
|
||||
/// Response model for gas fee information
|
||||
/// </summary>
|
||||
|
||||
@@ -402,6 +402,35 @@ namespace Managing.Infrastructure.Evm.Services
|
||||
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()
|
||||
{
|
||||
var response = await GetGmxServiceAsync<GasFeeResponse>("/gas-fee", null);
|
||||
|
||||
@@ -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<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
|
||||
* 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 {
|
||||
|
||||
@@ -175,6 +175,56 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user