Enhance Privy API integration with fallback RPC support for wallet balance retrieval

- Added an AbortSignal parameter to the makePrivyRequest function to support request cancellation.
- Improved error handling in makePrivyRequest to preserve detailed error information for better debugging.
- Implemented a fallback mechanism in getWalletBalanceImpl to retrieve wallet balances via direct RPC calls when Privy API fails (e.g., 503 errors).
- Introduced a new getWalletBalanceViaRpc function to handle RPC balance retrieval, including detailed logging and error management.
- Enhanced overall error messaging to provide clearer feedback during balance retrieval processes.
This commit is contained in:
2026-01-06 19:55:50 +07:00
parent 949044c73d
commit ad654888f1

View File

@@ -177,7 +177,8 @@ export const makePrivyRequest = async <T>(
body: object = {},
requiresAuth = true,
method: 'GET' | 'POST' = 'POST',
fastify?: FastifyInstance
fastify?: FastifyInstance,
signal?: AbortSignal
): Promise<T> => {
try {
// Resolve secrets from cached memory (loaded once at startup from Infisical in production)
@@ -204,6 +205,7 @@ export const makePrivyRequest = async <T>(
const requestInit: RequestInit = {
method: method,
headers,
signal, // Add abort signal support
};
// Only add body for non-GET requests
@@ -220,12 +222,21 @@ export const makePrivyRequest = async <T>(
});
if (!response.ok) {
throw new Error(`Privy API request failed: ${response.status}`);
const errorText = await response.text().catch(() => '');
const error = new Error(`Privy API request failed: ${response.status}`);
(error as any).status = response.status;
(error as any).statusCode = response.status;
(error as any).response = { status: response.status, text: errorText };
throw error;
}
return await response.json() as T;
} catch (error) {
console.error('Error making Privy API request:', error);
// Preserve error details for fallback logic
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to make Privy API request');
}
};
@@ -1084,8 +1095,155 @@ export const sendTokenImpl = async (
}
};
/**
* Fallback function to get wallet balances using direct RPC calls
* Used when Privy API is unavailable (503, timeout, etc.)
*/
const getWalletBalanceViaRpc = async (
address: string,
assets: Ticker[],
chains: string[],
fastify?: FastifyInstance
): Promise<Balance[]> => {
console.log('🔄 Falling back to RPC calls for wallet balance...');
const balances: Balance[] = [];
try {
// Get GMX SDK client for RPC calls
const sdk = await getClientForAddress(address);
// Get markets info to access token prices
const { tokensData } = await sdk.markets.getMarketsInfo();
if (!tokensData) {
throw new Error('Failed to get tokens data for price lookup');
}
// Process each chain
for (const chainName of chains) {
const chainId = getChainIdFromName(chainName);
if (!chainId) {
console.warn(`⚠️ Unknown chain: ${chainName}, skipping...`);
continue;
}
// Get tokens for this chain
const chainTokens = TOKENS[chainId];
if (!chainTokens) {
console.warn(`⚠️ No tokens configured for chain ${chainId}, skipping...`);
continue;
}
// Process each asset
for (const asset of assets) {
const ticker = asset.toString().toUpperCase();
// Find token in chain tokens
const token = chainTokens.find(t =>
t.symbol === ticker ||
t.assetSymbol === ticker ||
t.baseSymbol === ticker
);
if (!token) {
console.warn(`⚠️ Token ${ticker} not found on chain ${chainId}, skipping...`);
continue;
}
try {
// Get balance via RPC
let rawBalance: bigint;
if (token.isNative) {
// For native tokens (ETH), get balance directly
rawBalance = await sdk.publicClient.getBalance({
address: address as Address
});
} else {
// For ERC20 tokens, use multicall
const balanceResult = await sdk.executeMulticall({
token: {
contractAddress: token.address,
abiId: "Token",
calls: {
balance: {
methodName: "balanceOf",
params: [address]
}
}
}
});
rawBalance = balanceResult?.data?.token?.balance?.returnValues?.[0];
if (rawBalance === undefined || rawBalance === null) {
console.warn(`⚠️ Failed to get balance for ${ticker} on ${chainName}`);
continue;
}
}
// Convert to decimal format
const decimals = token.decimals || 18;
const amount = Number(rawBalance) / Math.pow(10, decimals);
// Get USD price from tokens data
const tokenData = tokensData[token.address];
let usdValue = 0;
let price = 0;
if (tokenData?.prices?.minPrice) {
// GMX prices are in 30 decimals
const tokenPriceUsd = Number(tokenData.prices.minPrice) / 1e30;
price = tokenPriceUsd;
usdValue = amount * tokenPriceUsd;
} else {
// Fallback: try to get price from token config
console.warn(`⚠️ No price data for ${ticker}, USD value will be 0`);
}
// Build chain info
const caip2 = getChainName(chainId);
const chain: Chain = {
id: caip2,
rpcUrl: undefined,
name: getChainNameFromCaip2(caip2),
chainId: chainId
};
balances.push({
tokenImage: '',
tokenName: ticker,
amount: amount,
price: price,
value: usdValue,
tokenAdress: token.address,
chain: chain
});
console.log(`✅ RPC balance for ${ticker} on ${chainName}: ${amount.toFixed(6)} ($${usdValue.toFixed(2)})`);
} catch (error) {
console.error(`❌ Error getting RPC balance for ${ticker} on ${chainName}:`, error);
// Continue with other assets/chains
}
}
}
if (balances.length === 0) {
throw new Error('No balances found via RPC fallback');
}
console.log(`✅ RPC fallback successful: found ${balances.length} balance(s)`);
return balances;
} catch (error) {
console.error('❌ RPC fallback failed:', error);
throw new Error(`RPC fallback failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Gets the wallet balance for a specific address using Privy API
* Falls back to RPC calls if Privy API fails
* @param address The wallet address to get balance for
* @param assets Array of assets to filter by using Ticker enum (e.g. [Ticker.USDC, Ticker.ETH])
* @param chains Array of chain names to filter by (e.g. ['arbitrum', 'ethereum'])
@@ -1098,6 +1256,7 @@ export const getWalletBalanceImpl = async (
fastify?: FastifyInstance
): Promise<Balance[]> => {
try {
// Try Privy API first
// First, get the user by wallet address using Privy Client
const privy = getPrivyClient(fastify);
@@ -1135,7 +1294,7 @@ export const getWalletBalanceImpl = async (
// Add multiple asset parameters
assets.forEach(asset => {
queryParams.append('asset', asset.toLowerCase()); // Convert Ticker enum to lowercase
queryParams.append('asset', asset.toLowerCase());
});
// Add multiple chain parameters
@@ -1147,57 +1306,103 @@ export const getWalletBalanceImpl = async (
queryParams.append('include_currency', 'usd');
balanceUrl += `?${queryParams.toString()}`;
const balanceResponse = await makePrivyRequest<{
balances: Array<{
chain: string;
asset: string;
raw_value: string;
raw_value_decimals: number;
display_values: {
[key: string]: string;
usd: string;
// Add timeout to Privy API call (25 seconds to leave buffer before HttpClient timeout)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000);
try {
const balanceResponse = await makePrivyRequest<{
balances: Array<{
chain: string;
asset: string;
raw_value: string;
raw_value_decimals: number;
display_values: {
[key: string]: string;
usd: string;
};
};
}>;
}>(balanceUrl, {}, true, 'GET', fastify);
}>(balanceUrl, {}, true, 'GET', fastify, controller.signal);
// Convert to Balance interface format (matching ManagingApiTypes.ts)
const balances: Balance[] = balanceResponse.balances
.map(balance => {
// Parse the raw value using decimals
const amount = parseFloat(balance.raw_value) / Math.pow(10, balance.raw_value_decimals);
clearTimeout(timeoutId);
// Get USD price from display_values
const usdValue = parseFloat(balance.display_values.usd);
// Convert to Balance interface format
const balances: Balance[] = balanceResponse.balances
.map(balance => {
const amount = parseFloat(balance.raw_value) / Math.pow(10, balance.raw_value_decimals);
const usdValue = parseFloat(balance.display_values.usd);
const price = amount > 0 ? usdValue / amount : 0;
const chainId = getChainIdFromName(balance.chain);
const caip2 = getChainName(chainId);
// Calculate price per token (if amount > 0)
const price = amount > 0 ? usdValue / amount : 0;
const chain: Chain = {
id: caip2,
rpcUrl: undefined,
name: getChainNameFromCaip2(caip2),
chainId: chainId
};
// Get chain ID from chain name
const chainId = getChainIdFromName(balance.chain);
const caip2 = getChainName(chainId);
return {
tokenImage: '',
tokenName: balance.asset.toUpperCase(),
amount: amount,
price: price,
value: usdValue,
tokenAdress: '',
chain: chain
};
});
const chain: Chain = {
id: caip2,
rpcUrl: undefined,
name: getChainNameFromCaip2(caip2),
chainId: chainId
};
return balances;
} catch (privyError: any) {
clearTimeout(timeoutId);
return {
tokenImage: '', // Not provided in the response
tokenName: balance.asset.toUpperCase(), // Use asset name (usdc -> USDC)
amount: amount,
price: price,
value: usdValue,
tokenAdress: '', // Not provided in this response format
chain: chain
};
});
// Check if this is a retryable error (503, 500, timeout, network error)
const errorMessage = privyError?.message || String(privyError);
const statusCode = privyError?.status || privyError?.statusCode || privyError?.response?.status;
return balances;
const isRetryableError =
statusCode === 503 ||
statusCode === 500 ||
statusCode === 502 ||
statusCode === 504 ||
errorMessage.includes('503') ||
errorMessage.includes('500') ||
errorMessage.includes('timeout') ||
errorMessage.includes('aborted') ||
errorMessage.includes('ECONNRESET') ||
errorMessage.includes('ENOTFOUND') ||
errorMessage.includes('Cloudflare') ||
errorMessage.includes('Internal server error');
if (isRetryableError) {
console.warn(`⚠️ Privy API error (${statusCode || 'unknown'}), falling back to RPC calls...`);
console.warn(`Error details: ${errorMessage}`);
// Fall back to RPC calls
return await getWalletBalanceViaRpc(address, assets, chains, fastify);
}
// For non-retryable errors (like 404, 401), throw immediately
throw privyError;
}
} catch (error) {
console.error('Error getting wallet balance:', error);
throw new Error(`Failed to get wallet balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Final fallback: if everything fails, try RPC
const errorMessage = error instanceof Error ? error.message : String(error);
// Only fallback if it's not already an RPC error
if (!errorMessage.includes('RPC fallback failed')) {
console.warn(`⚠️ Primary method failed, attempting RPC fallback...`);
try {
return await getWalletBalanceViaRpc(address, assets, chains, fastify);
} catch (rpcError) {
console.error('❌ Both Privy API and RPC fallback failed');
throw new Error(`Failed to get wallet balance: ${errorMessage}. RPC fallback also failed: ${rpcError instanceof Error ? rpcError.message : 'Unknown error'}`);
}
}
throw error;
}
};