diff --git a/src/chains/ethereum/routes/poll.ts b/src/chains/ethereum/routes/poll.ts index 5b61359c9b..3d90df9e06 100644 --- a/src/chains/ethereum/routes/poll.ts +++ b/src/chains/ethereum/routes/poll.ts @@ -109,8 +109,9 @@ export async function pollEthereumTransaction( signature, txBlock, txStatus, + fee: null, + error: null, txData: toEthereumTransactionResponse(txData), - fee: null, // Optional field }; } catch (error) { if (error.statusCode) { diff --git a/src/chains/solana/routes/poll.ts b/src/chains/solana/routes/poll.ts index 34adf238c3..b9f0266d70 100644 --- a/src/chains/solana/routes/poll.ts +++ b/src/chains/solana/routes/poll.ts @@ -4,13 +4,12 @@ import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../ import { logger } from '../../../services/logger'; import { SolanaPollRequest } from '../schemas'; import { Solana } from '../solana'; +import { parseSolanaError } from '../solana-error-parser'; export async function pollSolanaTransaction( _fastify: FastifyInstance, network: string, signature: string, - tokens?: string[], - walletAddress?: string, ): Promise { const solana = await Solana.getInstance(network); @@ -24,9 +23,9 @@ export async function pollSolanaTransaction( signature, txBlock: null, txStatus: 0, - txData: null, fee: null, - error: 'Invalid transaction signature format', + error: 'INVALID_INPUT: Invalid transaction signature format', + txData: null, }; } @@ -38,69 +37,26 @@ export async function pollSolanaTransaction( signature, txBlock: null, txStatus: 0, - txData: null, fee: null, + error: null, + txData: null, }; } const txStatus = await solana.getTransactionStatusCode(txData as any); - let fee: number; - let tokenBalanceChanges: Record | undefined; - - // Calculate token balance changes if tokens array is provided and not empty, and wallet address is provided - if (tokens && tokens.length > 0 && walletAddress) { - try { - // Convert symbols to addresses - const tokenAddresses: string[] = []; - const tokenMap = new Map(); // Map from input value to address - - for (const token of tokens) { - const tokenInfo = await solana.getToken(token); - if (tokenInfo) { - tokenAddresses.push(tokenInfo.address); - tokenMap.set(token, tokenInfo.address); - } else { - logger.warn(`Could not find token info for: ${token}`); - } - } + // Extract fee from transaction + const fee = txData.meta?.fee ? txData.meta.fee / 1e9 : 0; // Convert lamports to SOL - if (tokenAddresses.length > 0) { - const result = await solana.extractBalanceChangesAndFee(signature, walletAddress, tokenAddresses); - fee = result.fee; - - // Build balance changes dictionary with original input values as keys - tokenBalanceChanges = {}; - let i = 0; - for (const token of tokens) { - const address = tokenMap.get(token); - if (address) { - tokenBalanceChanges[token] = result.balanceChanges[i]; - i++; - } - } - - logger.info( - `Transaction ${signature} - Status: ${txStatus}, Fee: ${fee} SOL, Balance Changes: ${JSON.stringify(tokenBalanceChanges)}`, - ); - } else { - logger.warn('No valid tokens found'); - fee = 0; - } - } catch (error) { - logger.error(`Error calculating balance changes for transaction ${signature}: ${error.message}`); - // Set fee to 0 on error - fee = 0; - } + // Check for transaction error and parse it + let error: string | null = null; + if (txData.meta?.err) { + const errorStr = JSON.stringify(txData.meta.err); + const parsed = parseSolanaError(errorStr); + error = `${parsed.type} (${parsed.errorCodeHex || 'unknown'}): ${parsed.message}`; + logger.info(`Transaction ${signature} failed: ${error}`); } else { - // Just get the fee when no tokens specified or empty array - const feeResult = await solana.extractBalanceChangesAndFee( - signature, - walletAddress || '', // Use provided wallet address or empty string - [], - ); - fee = feeResult.fee; - logger.info(`Polling for transaction ${signature}, Status: ${txStatus}, Fee: ${fee} SOL`); + logger.info(`Transaction ${signature} - Status: ${txStatus}, Fee: ${fee} SOL`); } return { @@ -109,19 +65,19 @@ export async function pollSolanaTransaction( txBlock: txData.slot, txStatus, fee, - tokenBalanceChanges, + error, txData, }; - } catch (error) { - logger.error(`Error polling transaction ${signature}: ${error.message}`); + } catch (err) { + logger.error(`Error polling transaction ${signature}: ${(err as Error).message}`); return { currentBlock: await solana.getCurrentBlockNumber(), signature, txBlock: null, txStatus: 0, - txData: null, fee: null, error: 'Transaction not found or invalid', + txData: null, }; } } @@ -143,8 +99,8 @@ export const pollRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { network, signature, tokens, walletAddress } = request.body; - return await pollSolanaTransaction(fastify, network, signature, tokens, walletAddress); + const { network, signature } = request.body; + return await pollSolanaTransaction(fastify, network, signature); }, ); }; diff --git a/src/chains/solana/routes/unwrap.ts b/src/chains/solana/routes/unwrap.ts index 811fa1fd4d..6c8591c015 100644 --- a/src/chains/solana/routes/unwrap.ts +++ b/src/chains/solana/routes/unwrap.ts @@ -99,7 +99,7 @@ export async function unwrapSolana( } // Simulate transaction with proper error handling before sending - await solana.simulateWithErrorHandling(transaction, fastify); + await solana.simulateWithErrorHandling(transaction); // Send and confirm transaction const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); diff --git a/src/chains/solana/routes/wrap.ts b/src/chains/solana/routes/wrap.ts index c23181841a..5e7d890ded 100644 --- a/src/chains/solana/routes/wrap.ts +++ b/src/chains/solana/routes/wrap.ts @@ -63,7 +63,7 @@ export async function wrapSolana( } // Simulate transaction with proper error handling before sending - await solana.simulateWithErrorHandling(transaction, fastify); + await solana.simulateWithErrorHandling(transaction); // Send and confirm transaction const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); diff --git a/src/chains/solana/schemas.ts b/src/chains/solana/schemas.ts index 51c9e5b9c2..a023ae2469 100644 --- a/src/chains/solana/schemas.ts +++ b/src/chains/solana/schemas.ts @@ -60,18 +60,6 @@ export const SolanaPollRequest = Type.Object({ description: 'Transaction signature to poll', examples: [EXAMPLE_SIGNATURE], }), - tokens: Type.Optional( - Type.Array(Type.String(), { - description: 'Tokens to track balance changes for', - examples: [EXAMPLE_TOKENS], - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address to track balance changes for', - default: solanaChainConfig.defaultWallet, - }), - ), }); // Quote swap request schema diff --git a/src/chains/solana/solana-error-parser.ts b/src/chains/solana/solana-error-parser.ts new file mode 100644 index 0000000000..7f3be8f63a --- /dev/null +++ b/src/chains/solana/solana-error-parser.ts @@ -0,0 +1,375 @@ +/** + * Solana Program Error Parser + * + * Utility for parsing transaction errors from various Solana programs + * and extracting structured error types. + */ + +export type SolanaErrorType = + | 'SLIPPAGE_EXCEEDED' + | 'INSUFFICIENT_BALANCE' + | 'INVALID_POSITION' + | 'PRICE_LIMIT_OVERFLOW' + | 'ACCOUNT_NOT_FOUND' + | 'MATH_OVERFLOW' + | 'UNKNOWN'; + +export interface ParsedSolanaError { + type: SolanaErrorType; + program: string; + errorCode: number | null; + errorCodeHex: string | null; + message: string; + rawError: string; +} + +/** + * Known Solana program IDs + */ +export const PROGRAM_IDS = { + JUPITER: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', + METEORA_DLMM: 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo', + RAYDIUM_CLMM: 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK', + RAYDIUM_AMM: '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8', + ORCA_WHIRLPOOL: 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc', +} as const; + +/** + * Program-specific error code mappings + * Error codes are in decimal format + */ +const PROGRAM_ERROR_CODES: Record> = { + // Jupiter error codes + [PROGRAM_IDS.JUPITER]: { + 6001: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Slippage tolerance exceeded. The output amount would be less than your minimum.', + }, + 6002: { + type: 'INVALID_POSITION', + message: 'Invalid calculation result.', + }, + }, + + // Meteora DLMM error codes (lb_clmm program) + [PROGRAM_IDS.METEORA_DLMM]: { + 6004: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Exceeded slippage tolerance. The swap output is less than minimum amount.', + }, + 6018: { + type: 'MATH_OVERFLOW', + message: 'Math operation overflow.', + }, + 6040: { + type: 'INVALID_POSITION', + message: 'Invalid position width. Use a position width of 69 bins or lower.', + }, + }, + + // Raydium CLMM error codes + [PROGRAM_IDS.RAYDIUM_CLMM]: { + 6029: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Price slippage check failed. The calculated price does not match expected values.', + }, + 6030: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too little output received. Slippage tolerance exceeded.', + }, + 6031: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too much input paid. Slippage tolerance exceeded.', + }, + 6037: { + type: 'PRICE_LIMIT_OVERFLOW', + message: 'Square root price limit overflow.', + }, + }, + + // Orca Whirlpool error codes (same as Raydium CLMM since they share similar design) + [PROGRAM_IDS.ORCA_WHIRLPOOL]: { + 6029: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Price slippage check failed.', + }, + 6030: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too little output received. Slippage tolerance exceeded.', + }, + 6031: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too much input paid. Slippage tolerance exceeded.', + }, + }, +}; + +/** + * Generic error codes that may appear across multiple programs + * Used as fallback when program ID is not identified + * Hex -> Decimal mappings: + * - 0x1771 = 6001 (Jupiter SlippageToleranceExceeded) + * - 0x1785 = 6021 (CLMM PriceSlippageCheck) + * - 0x1786 = 6022 (CLMM TooLittleOutputReceived) + * - 0x1787 = 6023 (CLMM TooMuchInputPaid) + * - 0x177d = 6013 (CLMM SqrtPriceLimitOverflow) + * - 0x1798 = 6040 (Meteora InvalidPositionWidth) + */ +const GENERIC_ERROR_CODES: Record = { + // Slippage errors + 6001: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Slippage tolerance exceeded. The output amount would be less than your minimum.', + }, + 6004: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Exceeded slippage tolerance. The swap output is less than minimum amount.', + }, + 6021: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Price slippage check failed. The calculated price does not match expected values.', + }, + 6022: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too little output received. Slippage tolerance exceeded.', + }, + 6023: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too much input paid. Slippage tolerance exceeded.', + }, + 6029: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Price slippage check failed.', + }, + 6030: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too little output received. Slippage tolerance exceeded.', + }, + 6031: { + type: 'SLIPPAGE_EXCEEDED', + message: 'Too much input paid. Slippage tolerance exceeded.', + }, + // Price/position errors + 6013: { + type: 'PRICE_LIMIT_OVERFLOW', + message: 'Square root price limit overflow.', + }, + 6037: { + type: 'PRICE_LIMIT_OVERFLOW', + message: 'Square root price limit overflow.', + }, + 6040: { + type: 'INVALID_POSITION', + message: 'Invalid position width. Use a position width of 69 bins or lower.', + }, + // Math errors + 6018: { + type: 'MATH_OVERFLOW', + message: 'Math operation overflow.', + }, +}; + +/** + * Generic error patterns that apply across programs + * These are checked when program-specific codes don't match + */ +const GENERIC_ERROR_PATTERNS: Array<{ pattern: RegExp; type: SolanaErrorType; message: string }> = [ + { + pattern: /InsufficientFunds|insufficient/i, + type: 'INSUFFICIENT_BALANCE', + message: 'Insufficient funds for transaction.', + }, + { + pattern: /AccountNotFound/i, + type: 'ACCOUNT_NOT_FOUND', + message: 'Required account not found.', + }, + { + pattern: /slippage/i, + type: 'SLIPPAGE_EXCEEDED', + message: 'Slippage tolerance exceeded.', + }, +]; + +/** + * Extract error code from error message + * Handles formats like: + * - "custom program error: 0x1771" + * - {"Custom":6001} + * - "Error Code: SlippageToleranceExceeded" + */ +function extractErrorCode(errorMessage: string): { code: number | null; hex: string | null } { + // Try hex format: "custom program error: 0x1771" + const hexMatch = errorMessage.match(/custom program error: (0x[0-9a-fA-F]+)/); + if (hexMatch) { + const hex = hexMatch[1]; + const code = parseInt(hex, 16); + return { code, hex }; + } + + // Try JSON format: {"Custom":6001} or "Custom":6001 + const jsonMatch = errorMessage.match(/"Custom"\s*:\s*(\d+)/); + if (jsonMatch) { + const code = parseInt(jsonMatch[1], 10); + const hex = '0x' + code.toString(16); + return { code, hex }; + } + + // Try decimal format in InstructionError + const decimalMatch = errorMessage.match(/InstructionError.*?(\d{4,})/); + if (decimalMatch) { + const code = parseInt(decimalMatch[1], 10); + const hex = '0x' + code.toString(16); + return { code, hex }; + } + + return { code: null, hex: null }; +} + +/** + * Extract program ID from error message + */ +function extractProgramId(errorMessage: string): string | null { + // Look for program invocation in logs + const programMatch = errorMessage.match(/Program ([A-Za-z0-9]{32,44}) (?:invoke|failed)/); + if (programMatch) { + return programMatch[1]; + } + + // Check for known program names in the error + if (errorMessage.includes('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4')) { + return PROGRAM_IDS.JUPITER; + } + if (errorMessage.includes('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo')) { + return PROGRAM_IDS.METEORA_DLMM; + } + if (errorMessage.includes('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK')) { + return PROGRAM_IDS.RAYDIUM_CLMM; + } + if (errorMessage.includes('whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc')) { + return PROGRAM_IDS.ORCA_WHIRLPOOL; + } + if (errorMessage.includes('675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8')) { + return PROGRAM_IDS.RAYDIUM_AMM; + } + + return null; +} + +/** + * Get program name from program ID + */ +function getProgramName(programId: string | null): string { + if (!programId) return 'Unknown'; + + const names: Record = { + [PROGRAM_IDS.JUPITER]: 'Jupiter', + [PROGRAM_IDS.METEORA_DLMM]: 'Meteora DLMM', + [PROGRAM_IDS.RAYDIUM_CLMM]: 'Raydium CLMM', + [PROGRAM_IDS.RAYDIUM_AMM]: 'Raydium AMM', + [PROGRAM_IDS.ORCA_WHIRLPOOL]: 'Orca Whirlpool', + }; + + return names[programId] || programId.slice(0, 8) + '...'; +} + +/** + * Parse a Solana transaction error message and return structured error info + */ +export function parseSolanaError(errorMessage: string): ParsedSolanaError { + const { code, hex } = extractErrorCode(errorMessage); + const programId = extractProgramId(errorMessage); + const programName = getProgramName(programId); + + // Try program-specific error code lookup + if (programId && code !== null && PROGRAM_ERROR_CODES[programId]) { + const errorInfo = PROGRAM_ERROR_CODES[programId][code]; + if (errorInfo) { + return { + type: errorInfo.type, + program: programName, + errorCode: code, + errorCodeHex: hex, + message: errorInfo.message, + rawError: errorMessage, + }; + } + } + + // Try generic error code lookup (for when program is unknown or code not in program-specific list) + if (code !== null && GENERIC_ERROR_CODES[code]) { + const errorInfo = GENERIC_ERROR_CODES[code]; + return { + type: errorInfo.type, + program: programName, + errorCode: code, + errorCodeHex: hex, + message: errorInfo.message, + rawError: errorMessage, + }; + } + + // Try generic error patterns + for (const { pattern, type, message } of GENERIC_ERROR_PATTERNS) { + if (pattern.test(errorMessage)) { + return { + type, + program: programName, + errorCode: code, + errorCodeHex: hex, + message, + rawError: errorMessage, + }; + } + } + + // Unknown error + return { + type: 'UNKNOWN', + program: programName, + errorCode: code, + errorCodeHex: hex, + message: 'Transaction failed with an unknown error.', + rawError: errorMessage, + }; +} + +/** + * Check if an error is a slippage error + */ +export function isSlippageError(errorMessage: string): boolean { + const parsed = parseSolanaError(errorMessage); + return parsed.type === 'SLIPPAGE_EXCEEDED'; +} + +/** + * Check if an error is an insufficient balance error + */ +export function isInsufficientBalanceError(errorMessage: string): boolean { + const parsed = parseSolanaError(errorMessage); + return parsed.type === 'INSUFFICIENT_BALANCE'; +} + +/** + * Get a user-friendly error message for a Solana error + */ +export function getUserFriendlyErrorMessage(errorMessage: string): string { + const parsed = parseSolanaError(errorMessage); + + switch (parsed.type) { + case 'SLIPPAGE_EXCEEDED': + return `Swap failed: ${parsed.message} Consider increasing your slippage tolerance or the market price has moved significantly.`; + case 'INSUFFICIENT_BALANCE': + return `Transaction failed: ${parsed.message} Please check your token balance.`; + case 'INVALID_POSITION': + return `Position error: ${parsed.message}`; + case 'PRICE_LIMIT_OVERFLOW': + return `Swap failed: ${parsed.message} Adjust price limit/direction or retry with default limits.`; + case 'ACCOUNT_NOT_FOUND': + return `Transaction failed: ${parsed.message} The pool or token accounts may not be initialized.`; + case 'MATH_OVERFLOW': + return `Transaction failed: ${parsed.message} Try reducing the amount or adjusting parameters.`; + default: + return `Transaction failed. ${parsed.errorCodeHex ? `Error code: ${parsed.errorCodeHex}` : 'Unknown error.'}`; + } +} diff --git a/src/chains/solana/solana.ts b/src/chains/solana/solana.ts index 81760038de..3bded2ffc1 100644 --- a/src/chains/solana/solana.ts +++ b/src/chains/solana/solana.ts @@ -1178,7 +1178,12 @@ export class Solana { // Check if transaction is already confirmed but had an error if (txData.meta?.err) { - throw new Error(`Transaction failed with error: ${JSON.stringify(txData.meta.err)}`); + const { parseSolanaError, getUserFriendlyErrorMessage } = await import('./solana-error-parser'); + const errorStr = JSON.stringify(txData.meta.err); + const parsed = parseSolanaError(errorStr); + const friendlyMsg = getUserFriendlyErrorMessage(errorStr); + logger.error(`Transaction ${signature} failed: ${parsed.type} (code: ${parsed.errorCodeHex || 'unknown'})`); + throw new Error(friendlyMsg); } // More definitive check using slot confirmation @@ -1209,56 +1214,12 @@ export class Solana { return 0; } - // Base fee from meta (in lamports) - const baseFee = txData.meta.fee || 0; + // meta.fee is the TOTAL fee paid (already includes base fee + priority fee) + // Solana RPC returns the complete fee in this field + const totalFeeLamports = txData.meta.fee || 0; + const totalFee = totalFeeLamports * LAMPORT_TO_SOL; - // Extract priority fee from compute budget instructions - let priorityFee = 0; - try { - const computeBudgetProgramId = 'ComputeBudget111111111111111111111111111111'; - const instructions = txData.transaction?.message?.instructions || []; - const accountKeys = txData.transaction?.message?.accountKeys || []; - - // Find SetComputeUnitPrice instruction - for (const ix of instructions) { - const programId = accountKeys[ix.programIdIndex]?.toString() || accountKeys[ix.programIdIndex]; - - if (programId === computeBudgetProgramId && ix.data) { - // Decode base58 instruction data - const data = typeof ix.data === 'string' ? bs58.decode(ix.data) : ix.data; - - // SetComputeUnitPrice instruction has discriminator [3] and u64 microLamports - if (data.length >= 9 && data[0] === 3) { - // Read u64 little-endian (microlamports per CU) - const microLamportsPerCU = - data[1] | - (data[2] << 8) | - (data[3] << 16) | - (data[4] << 24) | - (data[5] << 32) | - (data[6] << 40) | - (data[7] << 48) | - (data[8] << 56); - - // Priority fee = (microlamports per CU) * (CUs consumed) / 1,000,000 - const computeUnitsConsumed = txData.meta.computeUnitsConsumed || 0; - priorityFee = Math.floor((microLamportsPerCU * computeUnitsConsumed) / 1_000_000); - break; - } - } - } - } catch (error) { - logger.warn(`Failed to extract priority fee: ${error.message}`); - } - - // Total fee = base fee + priority fee (convert to SOL) - const totalFee = (baseFee + priorityFee) * LAMPORT_TO_SOL; - - if (priorityFee > 0) { - logger.info( - `Transaction fees: base=${baseFee} lamports, priority=${priorityFee} lamports, total=${baseFee + priorityFee} lamports (${totalFee.toFixed(9)} SOL)`, - ); - } + logger.info(`Transaction fee: ${totalFeeLamports} lamports (${totalFee.toFixed(9)} SOL)`); return totalFee; } @@ -1562,6 +1523,25 @@ export class Solana { return { confirmed: true, txData }; } else { logger.warn(`❌ Transaction ${signature} not confirmed via WebSocket within timeout`); + // WebSocket timed out - do a final check to see if transaction landed on-chain + // It could have succeeded, failed, or still be pending + const txData = await this._fetchTransactionWithRetry(signature, 2, 500); + if (txData) { + // Transaction is on-chain - check if it succeeded or failed + const failed = txData.meta?.err !== null; + if (failed) { + const { parseSolanaError } = await import('./solana-error-parser'); + const errorStr = JSON.stringify(txData.meta?.err); + const parsed = parseSolanaError(errorStr); + logger.error( + `❌ Transaction ${signature} failed on-chain: ${parsed.type} - ${parsed.message} (code: ${parsed.errorCodeHex || 'unknown'})`, + ); + } else { + logger.info(`✅ Transaction ${signature} confirmed on-chain (missed WebSocket notification)`); + } + return { confirmed: !failed, txData }; + } + // Transaction not found on-chain yet - return as pending return { confirmed: false, txData: null }; } } catch (wsError: any) { @@ -1590,7 +1570,12 @@ export class Solana { if (status) { if (status.err) { - logger.error(`❌ Transaction ${signature} failed with error:`, status.err); + const { parseSolanaError } = await import('./solana-error-parser'); + const errorStr = JSON.stringify(status.err); + const parsed = parseSolanaError(errorStr); + logger.error( + `❌ Transaction ${signature} failed: ${parsed.type} - ${parsed.message} (code: ${parsed.errorCodeHex || 'unknown'})`, + ); return { confirmed: false, txData: null }; } @@ -1891,83 +1876,44 @@ export class Solana { /** * Helper function to simulate transaction with proper error handling * @param transaction Transaction to simulate - * @param fastify Fastify instance for error responses * @returns Promise that resolves if simulation succeeds, throws descriptive error otherwise */ - public async simulateWithErrorHandling( - transaction: VersionedTransaction | Transaction, - fastify?: any, - ): Promise { + public async simulateWithErrorHandling(transaction: VersionedTransaction | Transaction): Promise { try { await this.simulateTransaction(transaction); } catch (simulationError: any) { const errorMessage = simulationError?.message || ''; - // Helpers to safely create HTTP-style errors even if fastify is undefined - const httpErrors = fastify?.httpErrors; - const asBadRequest = (msg: string) => { - if (httpErrors?.badRequest) return httpErrors.badRequest(msg); - const e = new Error(msg) as Error & { statusCode?: number }; - e.statusCode = 400; - return e; - }; - - // Known program-specific messages - if ( - errorMessage.includes('Error Code: InvalidPositionWidth') || - errorMessage.includes('custom program error: 0x1798') - ) { - throw asBadRequest( - 'Error Code: InvalidPositionWidth. Error Number: 6040. Error Message: Invalid position width. ' + - 'Please use a position width of 69 bins or lower.', - ); - } - if ( - errorMessage.includes('Error Code: PriceSlippageCheck') || - errorMessage.includes('custom program error: 0x1785') - ) { - throw asBadRequest( - 'Position/Swap failed: Price slippage check failed. The calculated price from ticks does not match expected values. ' + - "This can happen if: (1) price moved significantly since quote was calculated, (2) token amounts don't match the price range, " + - 'or (3) tick spacing constraints are not met. Try: increasing slippage tolerance, adjusting token amounts to better match current price, ' + - 'or using a wider price range.', - ); - } - if ( - errorMessage.includes('Error Code: TooLittleOutputReceived') || - errorMessage.includes('custom program error: 0x1786') - ) { - throw asBadRequest( - 'Swap failed: Slippage tolerance exceeded. Output would be less than your minimum. Consider increasing slippage.', - ); - } - if ( - errorMessage.includes('Error Code: TooMuchInputPaid') || - errorMessage.includes('custom program error: 0x1787') - ) { - throw asBadRequest( - 'Swap failed: Slippage tolerance exceeded. Input would be more than your maximum. Consider increasing slippage.', - ); - } - if (errorMessage.includes('SqrtPriceLimitOverflow') || errorMessage.includes('custom program error: 0x177d')) { - throw asBadRequest( - 'Swap failed: Square root price limit overflow. Adjust price limit/direction or retry with default limits.', - ); - } - if (errorMessage.includes('InsufficientFunds') || errorMessage.toLowerCase().includes('insufficient')) { - throw asBadRequest('Transaction failed: Insufficient funds. Please check your token balance.'); + // Import error helpers and parser + const { simulationFailed, insufficientBalance, slippageExceeded } = await import('../../services/error-handler'); + const { parseSolanaError } = await import('./solana-error-parser'); + + // Parse the error using the utility + const parsedError = parseSolanaError(errorMessage); + + // Throw appropriate error based on parsed type + switch (parsedError.type) { + case 'SLIPPAGE_EXCEEDED': + throw slippageExceeded(parsedError.message); + + case 'INSUFFICIENT_BALANCE': + throw insufficientBalance(parsedError.message); + + case 'INVALID_POSITION': + case 'PRICE_LIMIT_OVERFLOW': + case 'ACCOUNT_NOT_FOUND': + case 'MATH_OVERFLOW': + throw simulationFailed(parsedError.message); + + default: + // Generic simulation failure + logger.error('Transaction simulation failed:', simulationError); + throw simulationFailed( + parsedError.errorCodeHex + ? `Transaction simulation failed. Error code: ${parsedError.errorCodeHex}.` + : 'Transaction simulation failed.', + ); } - if (errorMessage.includes('AccountNotFound')) { - throw asBadRequest( - 'Transaction failed: One or more required accounts not found. The pool or token accounts may not be initialized.', - ); - } - - // Generic fallback - logger.error('Transaction simulation failed:', simulationError); - throw asBadRequest( - 'Transaction simulation failed. This usually means the swap parameters are invalid or market conditions changed. Try again.', - ); } } diff --git a/src/connectors/jupiter/jupiter.ts b/src/connectors/jupiter/jupiter.ts index 871c86a285..32ac8b5dc3 100644 --- a/src/connectors/jupiter/jupiter.ts +++ b/src/connectors/jupiter/jupiter.ts @@ -130,10 +130,15 @@ export class Jupiter { throw new Error(`Token not found: ${!inputToken ? inputTokenIdentifier : outputTokenIdentifier}`); } - const slippageBps = Math.round((slippagePct ?? this.config.slippagePct) * 100); + const effectiveSlippagePct = slippagePct ?? this.config.slippagePct; + const slippageBps = Math.round(effectiveSlippagePct * 100); const tokenDecimals = swapMode === 'ExactOut' ? outputToken.decimals : inputToken.decimals; const quoteAmount = Math.floor(amount * 10 ** tokenDecimals); + logger.info( + `Jupiter quote: ${inputToken.symbol}->${outputToken.symbol}, amount=${amount}, slippagePct=${effectiveSlippagePct}% (${slippageBps} bps), swapMode=${swapMode}, onlyDirectRoutes=${onlyDirectRoutes}, restrictIntermediateTokens=${restrictIntermediateTokens}`, + ); + // Build query parameters for the REST API // Note: maxAccounts parameter has been deprecated const params = { diff --git a/src/connectors/jupiter/router-routes/executeQuote.ts b/src/connectors/jupiter/router-routes/executeQuote.ts index 2887bd3816..4a30492d62 100644 --- a/src/connectors/jupiter/router-routes/executeQuote.ts +++ b/src/connectors/jupiter/router-routes/executeQuote.ts @@ -45,7 +45,9 @@ export async function executeQuote( // Jupiter needs to build the transaction with the actual user's public key // We'll pass the hardware wallet address to Jupiter's buildSwapTransactionForHardwareWallet - logger.info(`Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol} with hardware wallet`); + logger.info( + `Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol}, slippageBps=${quote.slippageBps} (hardware wallet)`, + ); // Build the swap transaction for hardware wallet transaction = await jupiter.buildSwapTransactionForHardwareWallet(walletAddress, quote, maxLamports, priorityLevel); @@ -58,7 +60,9 @@ export async function executeQuote( const keypair = await solana.getWallet(walletAddress); const wallet = new Wallet(keypair as any); - logger.info(`Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol}`); + logger.info( + `Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol}, slippageBps=${quote.slippageBps}`, + ); // Build the swap transaction (will be signed by Jupiter) transaction = await jupiter.buildSwapTransaction(wallet, quote, maxLamports, priorityLevel); diff --git a/src/connectors/jupiter/router-routes/quoteSwap.ts b/src/connectors/jupiter/router-routes/quoteSwap.ts index 681a813612..28213a1f27 100644 --- a/src/connectors/jupiter/router-routes/quoteSwap.ts +++ b/src/connectors/jupiter/router-routes/quoteSwap.ts @@ -59,11 +59,11 @@ export async function quoteSwap( const errorMessage = error?.message || String(error); const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; const swapMode = side === 'BUY' ? 'ExactOut' : 'ExactIn'; - throw httpErrors.notFound(`No route found for ${tokenPair} (${swapMode}). ${errorMessage}`); + throw httpErrors.noRouteFound(`No route found for ${tokenPair} (${swapMode}). ${errorMessage}`); } if (!quoteResponse) { - throw httpErrors.notFound('No routes found for this swap'); + throw httpErrors.noRouteFound('No routes found for this swap'); } const bestRoute = quoteResponse; diff --git a/src/connectors/raydium/amm-routes/addLiquidity.ts b/src/connectors/raydium/amm-routes/addLiquidity.ts index 9821ae3efe..29be6f1233 100644 --- a/src/connectors/raydium/amm-routes/addLiquidity.ts +++ b/src/connectors/raydium/amm-routes/addLiquidity.ts @@ -207,7 +207,7 @@ async function addLiquidity( )) as Transaction; } - await solana.simulateWithErrorHandling(signedTransaction, _fastify); + await solana.simulateWithErrorHandling(signedTransaction); const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(signedTransaction); if (confirmed && txData) { diff --git a/src/connectors/raydium/amm-routes/removeLiquidity.ts b/src/connectors/raydium/amm-routes/removeLiquidity.ts index a1bcbd02a1..271ebfe09e 100644 --- a/src/connectors/raydium/amm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/amm-routes/removeLiquidity.ts @@ -209,7 +209,7 @@ async function removeLiquidity( )) as Transaction; } - await solana.simulateWithErrorHandling(signedTransaction, _fastify); + await solana.simulateWithErrorHandling(signedTransaction); const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(signedTransaction); if (confirmed && txData) { diff --git a/src/schemas/chain-schema.ts b/src/schemas/chain-schema.ts index 961fe17070..6e7f49d896 100644 --- a/src/schemas/chain-schema.ts +++ b/src/schemas/chain-schema.ts @@ -89,16 +89,6 @@ export const PollRequestSchema = Type.Object( { network: Type.Optional(Type.String()), signature: Type.String({ description: 'Transaction signature/hash' }), - tokens: Type.Optional( - Type.Array(Type.String(), { - description: 'Array of token symbols or addresses for balance change calculation', - }), - ), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address for balance change calculation (required if tokens provided)', - }), - ), }, { $id: 'PollRequest' }, ); @@ -109,15 +99,10 @@ export const PollResponseSchema = Type.Object( currentBlock: Type.Number(), signature: Type.String(), txBlock: Type.Union([Type.Number(), Type.Null()]), - txStatus: Type.Number(), + txStatus: Type.Number({ description: 'Transaction status: 1 = confirmed, 0 = pending, -1 = failed' }), fee: Type.Union([Type.Number(), Type.Null()]), - tokenBalanceChanges: Type.Optional( - Type.Record(Type.String(), Type.Number(), { - description: 'Dictionary of token balance changes keyed by token input value (symbol or address)', - }), - ), + error: Type.Union([Type.String({ description: 'Error info if failed: "TYPE (code): message"' }), Type.Null()]), txData: Type.Union([Type.Record(Type.String(), Type.Any()), Type.Null()]), - error: Type.Optional(Type.String()), }, { $id: 'PollResponse' }, ); diff --git a/src/services/error-handler.ts b/src/services/error-handler.ts index e68c97d69f..895f5d9016 100644 --- a/src/services/error-handler.ts +++ b/src/services/error-handler.ts @@ -1,8 +1,14 @@ // Error codes for specific error types +// Retryable: TRANSACTION_TIMEOUT (tx submitted but confirmation timed out) +// Non-retryable: all others (simulation failed, invalid params, etc.) +// Special: NO_ROUTE_FOUND (can retry with flipped direction - ExactIn instead of ExactOut) export const ErrorCode = { - TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', - INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', - INVALID_PARAMS: 'INVALID_PARAMS', + TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', // Retryable - tx may have succeeded + SIMULATION_FAILED: 'SIMULATION_FAILED', // Non-retryable - tx would fail on-chain + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', // Non-retryable - not enough funds + INVALID_PARAMS: 'INVALID_PARAMS', // Non-retryable - bad request params + SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', // Non-retryable - price moved too much + NO_ROUTE_FOUND: 'NO_ROUTE_FOUND', // Can retry with flipped direction (ExactIn vs ExactOut) } as const; export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; @@ -77,6 +83,22 @@ export function transactionTimeout(message: string): HttpError { return new HttpError(504, message, ErrorCode.TRANSACTION_TIMEOUT); } +export function simulationFailed(message: string): HttpError { + return new HttpError(400, message, ErrorCode.SIMULATION_FAILED); +} + +export function insufficientBalance(message: string): HttpError { + return new HttpError(400, message, ErrorCode.INSUFFICIENT_BALANCE); +} + +export function slippageExceeded(message: string): HttpError { + return new HttpError(400, message, ErrorCode.SLIPPAGE_EXCEEDED); +} + +export function noRouteFound(message: string): HttpError { + return new HttpError(400, message, ErrorCode.NO_ROUTE_FOUND); +} + /** * HTTP errors object - drop-in replacement for fastify.httpErrors */ @@ -87,5 +109,9 @@ export const httpErrors = { serviceUnavailable, forbidden, transactionTimeout, + simulationFailed, + insufficientBalance, + slippageExceeded, + noRouteFound, createError: (statusCode: number, message: string) => new HttpError(statusCode, message), }; diff --git a/src/services/fetch-utils.ts b/src/services/fetch-utils.ts new file mode 100644 index 0000000000..ed768d7509 --- /dev/null +++ b/src/services/fetch-utils.ts @@ -0,0 +1,91 @@ +/** + * Minimal fetch utilities - drop-in replacement for axios + * Maintains error.response.status/data pattern for compatibility + */ + +interface FetchOptions { + params?: Record | URLSearchParams; + headers?: Record; + timeout?: number; +} + +interface FetchResponse { + data: T; + status: number; +} + +class FetchError extends Error { + response?: { status: number; data: any }; + code?: string; +} + +async function request( + method: string, + url: string, + options?: FetchOptions & { body?: any }, +): Promise> { + const { params, headers, timeout = 30000, body } = options ?? {}; + + // Build URL with query params + let fullUrl = url; + if (params) { + const searchParams = params instanceof URLSearchParams ? params : new URLSearchParams(); + if (!(params instanceof URLSearchParams)) { + for (const [key, value] of Object.entries(params)) { + searchParams.append(key, String(value)); + } + } + fullUrl = `${url}?${searchParams}`; + } + + const response = await fetch(fullUrl, { + method, + headers: body ? { 'Content-Type': 'application/json', ...headers } : headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }); + + let data: any; + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + data = await response.json(); + } else { + const text = await response.text(); + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + if (!response.ok) { + const error = new FetchError(`HTTP ${response.status}`); + error.response = { status: response.status, data }; + throw error; + } + + return { data, status: response.status }; +} + +/** HTTP client with baseURL - similar to axios.create() */ +export function createHttpClient(config: { baseURL: string; timeout?: number; headers?: Record }) { + const { baseURL, timeout = 30000, headers: defaultHeaders = {} } = config; + const base = baseURL.replace(/\/$/, ''); + + return { + get: (path: string, options?: FetchOptions) => + request('GET', `${base}${path}`, { ...options, timeout, headers: { ...defaultHeaders, ...options?.headers } }), + post: (path: string, body?: any, options?: FetchOptions) => + request('POST', `${base}${path}`, { + ...options, + body, + timeout, + headers: { ...defaultHeaders, ...options?.headers }, + }), + }; +} + +/** One-off GET request */ +export async function httpGet(url: string, options?: FetchOptions): Promise> { + return request('GET', url, options); +} diff --git a/test/chains/solana/solana-error-parser.test.ts b/test/chains/solana/solana-error-parser.test.ts new file mode 100644 index 0000000000..a2c2dec517 --- /dev/null +++ b/test/chains/solana/solana-error-parser.test.ts @@ -0,0 +1,338 @@ +import { + parseSolanaError, + isSlippageError, + isInsufficientBalanceError, + getUserFriendlyErrorMessage, + PROGRAM_IDS, +} from '../../../src/chains/solana/solana-error-parser'; + +describe('Solana Error Parser', () => { + describe('parseSolanaError', () => { + describe('Jupiter errors', () => { + it('should parse Jupiter slippage error (hex format)', () => { + const errorMessage = `Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 failed: custom program error: 0x1771`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Jupiter'); + expect(result.errorCode).toBe(6001); + expect(result.errorCodeHex).toBe('0x1771'); + }); + + it('should parse Jupiter slippage error (JSON format)', () => { + const errorMessage = `{"Custom":6001} Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 invoke`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6001); + }); + }); + + describe('Meteora DLMM errors', () => { + it('should parse Meteora slippage error (hex format 0x1774 = 6004)', () => { + const errorMessage = `Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo failed: custom program error: 0x1774`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Meteora DLMM'); + expect(result.errorCode).toBe(6004); + expect(result.errorCodeHex).toBe('0x1774'); + }); + + it('should parse Meteora slippage error (JSON format)', () => { + const errorMessage = `{"Custom":6004} Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo invoke`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6004); + }); + + it('should parse Meteora slippage error via generic fallback when program ID not identified', () => { + // Error message without program ID in expected format + const errorMessage = `Transaction simulation failed: custom program error: 0x1774`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6004); + }); + + it('should parse Meteora math overflow error', () => { + const errorMessage = `Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo failed: custom program error: 0x1782`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('MATH_OVERFLOW'); + expect(result.errorCode).toBe(6018); + }); + + it('should parse Meteora invalid position width error', () => { + const errorMessage = `Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo failed: custom program error: 0x1798`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('INVALID_POSITION'); + expect(result.errorCode).toBe(6040); + }); + }); + + describe('Raydium CLMM errors', () => { + it('should parse Raydium CLMM price slippage error (6029)', () => { + const errorMessage = `Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x178d`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Raydium CLMM'); + expect(result.errorCode).toBe(6029); + }); + + it('should parse Raydium CLMM too little output error (6030)', () => { + const errorMessage = `Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x178e`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6030); + }); + + it('should parse Raydium CLMM too much input error (6031)', () => { + const errorMessage = `Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x178f`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6031); + }); + + it('should parse Raydium CLMM sqrt price limit overflow error', () => { + const errorMessage = `Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x1795`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('PRICE_LIMIT_OVERFLOW'); + expect(result.errorCode).toBe(6037); + }); + }); + + describe('Orca Whirlpool errors', () => { + it('should parse Orca slippage error via program ID string match', () => { + const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178d`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6029); + }); + + it('should parse Orca too little output error (6030)', () => { + const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178e`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6030); + }); + + it('should parse Orca too much input error (6031)', () => { + const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178f`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6031); + }); + }); + + describe('Raydium AMM errors', () => { + it('should identify Raydium AMM program from error message', () => { + const errorMessage = `Program 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 failed: custom program error: 0x1771`; + const result = parseSolanaError(errorMessage); + + expect(result.program).toBe('Raydium AMM'); + // Falls back to generic error code mapping + expect(result.errorCode).toBe(6001); + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + }); + }); + + describe('Generic error code fallback', () => { + it('should handle slippage error when program ID not identified', () => { + const errorMessage = `Transaction simulation failed: custom program error: 0x1771`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6001); + expect(result.program).toBe('Unknown'); + }); + + it('should handle error code 6004 (Meteora slippage) via generic fallback', () => { + const errorMessage = `InstructionError: {"Custom":6004}`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6004); + }); + + it('should handle error code 6029 via generic fallback', () => { + const errorMessage = `custom program error: 0x178d`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6029); + }); + + it('should handle error code 6030 via generic fallback', () => { + const errorMessage = `custom program error: 0x178e`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6030); + }); + + it('should handle error code 6031 via generic fallback', () => { + const errorMessage = `custom program error: 0x178f`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.errorCode).toBe(6031); + }); + }); + + describe('Generic error patterns', () => { + it('should detect insufficient funds from error message pattern', () => { + const errorMessage = `Transaction simulation failed: InsufficientFunds`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('INSUFFICIENT_BALANCE'); + }); + + it('should detect slippage from error message pattern', () => { + const errorMessage = `Transaction failed: slippage tolerance exceeded`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + }); + + it('should detect account not found error', () => { + const errorMessage = `AccountNotFound: Token account does not exist`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('ACCOUNT_NOT_FOUND'); + }); + }); + + describe('Unknown errors', () => { + it('should return UNKNOWN for unrecognized errors', () => { + const errorMessage = `Some random error with no matching pattern`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('UNKNOWN'); + expect(result.errorCode).toBeNull(); + }); + + it('should return UNKNOWN for unrecognized error codes', () => { + const errorMessage = `custom program error: 0x9999`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('UNKNOWN'); + expect(result.errorCode).toBe(39321); // 0x9999 in decimal + }); + }); + + describe('Error code extraction formats', () => { + it('should extract hex format error codes', () => { + const errorMessage = `custom program error: 0x1771`; + const result = parseSolanaError(errorMessage); + + expect(result.errorCode).toBe(6001); + expect(result.errorCodeHex).toBe('0x1771'); + }); + + it('should extract JSON format error codes', () => { + const errorMessage = `{"Custom":6001}`; + const result = parseSolanaError(errorMessage); + + expect(result.errorCode).toBe(6001); + expect(result.errorCodeHex).toBe('0x1771'); + }); + + it('should extract decimal format from InstructionError', () => { + const errorMessage = `InstructionError at index 2: 6001`; + const result = parseSolanaError(errorMessage); + + expect(result.errorCode).toBe(6001); + }); + }); + }); + + describe('isSlippageError', () => { + it('should return true for Jupiter slippage errors', () => { + expect( + isSlippageError(`Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 failed: custom program error: 0x1771`), + ).toBe(true); + }); + + it('should return true for Meteora slippage errors', () => { + expect( + isSlippageError(`Program LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo failed: custom program error: 0x1774`), + ).toBe(true); + }); + + it('should return true for Raydium CLMM slippage errors', () => { + expect( + isSlippageError(`Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x178e`), + ).toBe(true); + }); + + it('should return true for Orca slippage errors', () => { + expect( + isSlippageError(`Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178e`), + ).toBe(true); + }); + + it('should return false for non-slippage errors', () => { + expect(isSlippageError(`InsufficientFunds`)).toBe(false); + }); + }); + + describe('isInsufficientBalanceError', () => { + it('should return true for insufficient balance errors', () => { + expect(isInsufficientBalanceError(`Transaction failed: InsufficientFunds`)).toBe(true); + }); + + it('should return false for slippage errors', () => { + expect(isInsufficientBalanceError(`custom program error: 0x1771`)).toBe(false); + }); + }); + + describe('getUserFriendlyErrorMessage', () => { + it('should return user-friendly message for slippage errors', () => { + const message = getUserFriendlyErrorMessage(`custom program error: 0x1771`); + expect(message).toContain('Swap failed'); + expect(message).toContain('slippage'); + }); + + it('should return user-friendly message for insufficient balance', () => { + const message = getUserFriendlyErrorMessage(`InsufficientFunds`); + expect(message).toContain('Transaction failed'); + expect(message).toContain('balance'); + }); + + it('should return user-friendly message for price limit overflow', () => { + const message = getUserFriendlyErrorMessage( + `Program CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK failed: custom program error: 0x1795`, + ); + expect(message).toContain('Swap failed'); + expect(message).toContain('price limit'); + }); + + it('should return generic message for unknown errors', () => { + const message = getUserFriendlyErrorMessage(`Unknown error occurred`); + expect(message).toContain('Transaction failed'); + }); + }); + + describe('PROGRAM_IDS', () => { + it('should have all expected program IDs defined', () => { + expect(PROGRAM_IDS.JUPITER).toBe('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'); + expect(PROGRAM_IDS.METEORA_DLMM).toBe('LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo'); + expect(PROGRAM_IDS.RAYDIUM_CLMM).toBe('CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'); + expect(PROGRAM_IDS.RAYDIUM_AMM).toBe('675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'); + expect(PROGRAM_IDS.ORCA_WHIRLPOOL).toBe('whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc'); + }); + }); +}); diff --git a/test/connectors/jupiter/router-routes/quoteSwap.test.ts b/test/connectors/jupiter/router-routes/quoteSwap.test.ts index 3071211d88..9ceb547ba1 100644 --- a/test/connectors/jupiter/router-routes/quoteSwap.test.ts +++ b/test/connectors/jupiter/router-routes/quoteSwap.test.ts @@ -165,7 +165,7 @@ describe('GET /quote-swap', () => { expect(JSON.parse(response.body)).toHaveProperty('error'); }); - it('should return 404 if no routes found', async () => { + it('should return 400 if no routes found', async () => { const mockSolanaInstance = { getToken: jest.fn().mockResolvedValueOnce(mockSOL).mockResolvedValueOnce(mockUSDC), }; @@ -189,11 +189,11 @@ describe('GET /quote-swap', () => { }, }); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(400); expect(JSON.parse(response.body)).toHaveProperty('error'); }); - it('should return 404 with Jupiter error message when ExactOut fails for BUY side', async () => { + it('should return 400 with Jupiter error message when ExactOut fails for BUY side', async () => { const mockSolanaInstance = { getToken: jest.fn().mockResolvedValueOnce(mockSOL).mockResolvedValueOnce(mockUSDC), }; @@ -217,7 +217,7 @@ describe('GET /quote-swap', () => { }, }); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(400); const body = JSON.parse(response.body); expect(body).toHaveProperty('error'); expect(body.message).toContain('No route found for');