import type { ParsedError, SolanaErrorCode } from './types'; export class DubsApiError extends Error { public readonly code: string; public readonly httpStatus: number; constructor(code: string, message: string, httpStatus: number) { super(message); this.name = 'DubsApiError'; this.code = code; this.httpStatus = httpStatus; } } /** Custom error codes from dubs_solana_program (6000-6049) */ export const SOLANA_PROGRAM_ERRORS: Record = { 6000: { code: 'invalid_amount', message: 'Amount must be greater than 0' }, 6001: { code: 'invalid_max_players', message: 'Max players must be between 1 and 20' }, 6002: { code: 'game_not_active', message: 'Game is not active' }, 6003: { code: 'game_full', message: 'Game is full' }, 6004: { code: 'player_already_joined', message: 'Player has already joined this game' }, 6005: { code: 'insufficient_funds', message: 'Insufficient funds in pot' }, 6006: { code: 'unauthorized', message: 'Only the game creator can perform this action' }, 6007: { code: 'invalid_winner_index', message: 'Invalid winner index' }, 6008: { code: 'game_still_active', message: 'Game is still active — must close before resetting' }, 6009: { code: 'pot_not_empty', message: 'Pot must be empty before resetting — distribute winnings first' }, 6010: { code: 'no_winners_specified', message: 'At least one winner must be specified' }, 6011: { code: 'mismatched_winners_percentages', message: 'Number of winners must match number of percentages' }, 6012: { code: 'percentages_invalid', message: 'Percentages must sum to exactly 100' }, 6013: { code: 'winner_account_mismatch', message: 'Winner account does not match expected player' }, 6014: { code: 'invalid_operator_fee', message: 'Operator fee must be between 0 and 100' }, 6015: { code: 'operator_wallet_mismatch', message: 'Operator wallet does not match game settings' }, 6016: { code: 'winner_not_in_game', message: 'Winner address is not a player in this game' }, 6017: { code: 'voting_not_enabled', message: 'Voting is not enabled for this game' }, 6018: { code: 'voter_not_in_game', message: 'Voter is not a player in this game' }, 6019: { code: 'voted_for_not_in_game', message: 'Cannot vote for someone who is not in the game' }, 6020: { code: 'voting_incomplete', message: 'Voting incomplete — need majority of players to vote' }, 6021: { code: 'invalid_referee_commission', message: 'Referee commission must be between 0 and 100' }, 6022: { code: 'total_fees_exceed_100', message: 'Total fees (operator + referee) cannot exceed 100%' }, 6023: { code: 'referee_mode_requires_referee', message: 'Referee mode requires a referee address' }, 6024: { code: 'referee_must_earn_commission', message: 'Referee must earn commission' }, 6025: { code: 'referee_cannot_be_player', message: 'Referee cannot join as a player (conflict of interest)' }, 6026: { code: 'only_referee_can_vote', message: 'Only the referee can vote in Referee mode' }, 6027: { code: 'referee_account_mismatch', message: 'Referee account does not match game settings' }, 6028: { code: 'invalid_lock_time', message: 'Lock time must be in the future' }, 6029: { code: 'game_locked', message: 'Game is locked — no more players can join' }, 6030: { code: 'lock_time_passed', message: 'Lock time has passed — game is now locked' }, 6031: { code: 'unauthorized_oracle', message: 'Only authorized oracle can resolve this game' }, 6032: { code: 'game_not_locked', message: 'Game must be locked before it can be resolved' }, 6033: { code: 'already_resolved', message: 'Game has already been resolved' }, 6034: { code: 'game_not_resolved', message: 'Game has not been resolved yet' }, 6035: { code: 'player_not_in_game', message: 'Player is not in this game' }, 6036: { code: 'not_a_winner', message: 'Player did not win this game' }, 6037: { code: 'invalid_game_mode', message: 'Invalid game mode for this operation' }, 6038: { code: 'already_claimed', message: 'Player has already claimed their winnings' }, 6039: { code: 'lock_time_too_soon', message: 'Lock time must be at least 2 minutes in the future' }, 6040: { code: 'operator_account_missing', message: 'Operator account must be provided' }, 6041: { code: 'no_winners_to_distribute', message: 'No winners to distribute funds to' }, 6042: { code: 'insufficient_funds_for_rent', message: 'Insufficient funds to maintain rent exemption' }, 6043: { code: 'cannot_resolve_before_lock', message: 'Cannot resolve game before lock time has passed' }, 6044: { code: 'emergency_refund_not_available', message: 'Emergency refund not available yet — must wait 7 days after lock time' }, 6045: { code: 'sponsor_account_missing', message: 'Sponsor account must be provided for tie refund' }, 6046: { code: 'sponsor_wallet_mismatch', message: 'Sponsor wallet does not match stored sponsor' }, 6047: { code: 'invalid_bet_amount', message: 'Bet amount must be greater than zero' }, 6048: { code: 'no_survivors_to_distribute', message: 'No survivors to distribute winnings to' }, 6049: { code: 'too_many_survivors', message: 'Too many survivors (max 50 per batch)' }, }; /** Known Solana built-in instruction errors */ const SOLANA_BUILTIN_ERRORS: Record = { 0: { code: 'generic_error', message: 'Generic instruction error' }, 1: { code: 'invalid_argument', message: 'Invalid argument passed to program' }, 2: { code: 'invalid_instruction_data', message: 'Invalid instruction data' }, 3: { code: 'invalid_account_data', message: 'Invalid account data' }, 4: { code: 'account_data_too_small', message: 'Account data too small' }, 5: { code: 'insufficient_funds', message: 'Insufficient funds for transaction' }, 6: { code: 'incorrect_program_id', message: 'Incorrect program ID' }, 7: { code: 'missing_required_signature', message: 'Missing required signature' }, 8: { code: 'account_already_initialized', message: 'Account already initialized' }, 9: { code: 'uninitialized_account', message: 'Attempt to operate on uninitialized account' }, }; /** * Parse a raw Solana transaction error into a human-readable { code, message }. * Handles: { InstructionError: [idx, { Custom: N }] }, string errors, etc. */ export function parseSolanaError(err: unknown): ParsedError { if (!err) return { code: 'unknown_error', message: 'Unknown transaction error' }; let parsed = err; if (typeof parsed === 'string') { try { const jsonMatch = parsed.match(/\{.*\}/s); if (jsonMatch) parsed = JSON.parse(jsonMatch[0]); else return { code: 'transaction_failed', message: parsed }; } catch { return { code: 'transaction_failed', message: parsed as string }; } } if (typeof parsed === 'object' && parsed !== null && 'InstructionError' in parsed) { const [ixIndex, details] = (parsed as { InstructionError: [number, unknown] }).InstructionError; if (details && typeof details === 'object' && 'Custom' in details) { const customCode = (details as { Custom: number }).Custom; const known = SOLANA_PROGRAM_ERRORS[customCode]; if (known) return known; return { code: `program_error_${customCode}`, message: `Program error code ${customCode} (instruction ${ixIndex})` }; } if (typeof details === 'string') { const snake = details.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); return { code: snake, message: details.replace(/([a-z])([A-Z])/g, '$1 $2') }; } if (typeof details === 'number') { const known = SOLANA_BUILTIN_ERRORS[details]; if (known) return known; return { code: `instruction_error_${details}`, message: `Instruction error ${details}` }; } return { code: 'instruction_error', message: `Instruction ${ixIndex} failed: ${JSON.stringify(details)}` }; } return { code: 'transaction_failed', message: typeof parsed === 'object' ? JSON.stringify(parsed) : String(parsed) }; }