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:
@@ -177,7 +177,8 @@ export const makePrivyRequest = async <T>(
|
|||||||
body: object = {},
|
body: object = {},
|
||||||
requiresAuth = true,
|
requiresAuth = true,
|
||||||
method: 'GET' | 'POST' = 'POST',
|
method: 'GET' | 'POST' = 'POST',
|
||||||
fastify?: FastifyInstance
|
fastify?: FastifyInstance,
|
||||||
|
signal?: AbortSignal
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
// Resolve secrets from cached memory (loaded once at startup from Infisical in production)
|
// 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 = {
|
const requestInit: RequestInit = {
|
||||||
method: method,
|
method: method,
|
||||||
headers,
|
headers,
|
||||||
|
signal, // Add abort signal support
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add body for non-GET requests
|
// Only add body for non-GET requests
|
||||||
@@ -220,12 +222,21 @@ export const makePrivyRequest = async <T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
return await response.json() as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error making Privy API request:', 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');
|
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
|
* 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 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 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'])
|
* @param chains Array of chain names to filter by (e.g. ['arbitrum', 'ethereum'])
|
||||||
@@ -1098,6 +1256,7 @@ export const getWalletBalanceImpl = async (
|
|||||||
fastify?: FastifyInstance
|
fastify?: FastifyInstance
|
||||||
): Promise<Balance[]> => {
|
): Promise<Balance[]> => {
|
||||||
try {
|
try {
|
||||||
|
// Try Privy API first
|
||||||
// First, get the user by wallet address using Privy Client
|
// First, get the user by wallet address using Privy Client
|
||||||
const privy = getPrivyClient(fastify);
|
const privy = getPrivyClient(fastify);
|
||||||
|
|
||||||
@@ -1135,7 +1294,7 @@ export const getWalletBalanceImpl = async (
|
|||||||
|
|
||||||
// Add multiple asset parameters
|
// Add multiple asset parameters
|
||||||
assets.forEach(asset => {
|
assets.forEach(asset => {
|
||||||
queryParams.append('asset', asset.toLowerCase()); // Convert Ticker enum to lowercase
|
queryParams.append('asset', asset.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add multiple chain parameters
|
// Add multiple chain parameters
|
||||||
@@ -1147,57 +1306,103 @@ export const getWalletBalanceImpl = async (
|
|||||||
queryParams.append('include_currency', 'usd');
|
queryParams.append('include_currency', 'usd');
|
||||||
|
|
||||||
balanceUrl += `?${queryParams.toString()}`;
|
balanceUrl += `?${queryParams.toString()}`;
|
||||||
const balanceResponse = await makePrivyRequest<{
|
|
||||||
balances: Array<{
|
// Add timeout to Privy API call (25 seconds to leave buffer before HttpClient timeout)
|
||||||
chain: string;
|
const controller = new AbortController();
|
||||||
asset: string;
|
const timeoutId = setTimeout(() => controller.abort(), 25000);
|
||||||
raw_value: string;
|
|
||||||
raw_value_decimals: number;
|
try {
|
||||||
display_values: {
|
const balanceResponse = await makePrivyRequest<{
|
||||||
[key: string]: string;
|
balances: Array<{
|
||||||
usd: string;
|
chain: string;
|
||||||
|
asset: string;
|
||||||
|
raw_value: string;
|
||||||
|
raw_value_decimals: number;
|
||||||
|
display_values: {
|
||||||
|
[key: string]: string;
|
||||||
|
usd: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}>(balanceUrl, {}, true, 'GET', fastify, controller.signal);
|
||||||
}>(balanceUrl, {}, true, 'GET', fastify);
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
// Convert to Balance interface format (matching ManagingApiTypes.ts)
|
const chain: Chain = {
|
||||||
const balances: Balance[] = balanceResponse.balances
|
id: caip2,
|
||||||
.map(balance => {
|
rpcUrl: undefined,
|
||||||
// Parse the raw value using decimals
|
name: getChainNameFromCaip2(caip2),
|
||||||
const amount = parseFloat(balance.raw_value) / Math.pow(10, balance.raw_value_decimals);
|
chainId: chainId
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenImage: '',
|
||||||
|
tokenName: balance.asset.toUpperCase(),
|
||||||
|
amount: amount,
|
||||||
|
price: price,
|
||||||
|
value: usdValue,
|
||||||
|
tokenAdress: '',
|
||||||
|
chain: chain
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return balances;
|
||||||
|
} catch (privyError: any) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
// Get USD price from display_values
|
// Fall back to RPC calls
|
||||||
const usdValue = parseFloat(balance.display_values.usd);
|
return await getWalletBalanceViaRpc(address, assets, chains, fastify);
|
||||||
|
}
|
||||||
// Calculate price per token (if amount > 0)
|
|
||||||
const price = amount > 0 ? usdValue / amount : 0;
|
// For non-retryable errors (like 404, 401), throw immediately
|
||||||
|
throw privyError;
|
||||||
// Get chain ID from chain name
|
}
|
||||||
const chainId = getChainIdFromName(balance.chain);
|
|
||||||
const caip2 = getChainName(chainId);
|
|
||||||
|
|
||||||
const chain: Chain = {
|
|
||||||
id: caip2,
|
|
||||||
rpcUrl: undefined,
|
|
||||||
name: getChainNameFromCaip2(caip2),
|
|
||||||
chainId: chainId
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return balances;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting wallet balance:', error);
|
// Final fallback: if everything fails, try RPC
|
||||||
throw new Error(`Failed to get wallet balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user