diff --git a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts index d0d41dd0..77dc3810 100644 --- a/src/Managing.Web3Proxy/src/plugins/custom/privy.ts +++ b/src/Managing.Web3Proxy/src/plugins/custom/privy.ts @@ -177,7 +177,8 @@ export const makePrivyRequest = async ( body: object = {}, requiresAuth = true, method: 'GET' | 'POST' = 'POST', - fastify?: FastifyInstance + fastify?: FastifyInstance, + signal?: AbortSignal ): Promise => { try { // Resolve secrets from cached memory (loaded once at startup from Infisical in production) @@ -204,6 +205,7 @@ export const makePrivyRequest = async ( 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 ( }); 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 => { + 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 => { 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); + + 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 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); + const chain: Chain = { + id: caip2, + rpcUrl: undefined, + name: getChainNameFromCaip2(caip2), + 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 - const usdValue = parseFloat(balance.display_values.usd); - - // Calculate price per token (if amount > 0) - const price = amount > 0 ? usdValue / amount : 0; - - // 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; + // 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; } };