From e5b6878c3b33ccac3eea057a29352e5ee92d9059 Mon Sep 17 00:00:00 2001 From: meem08 <103323075+meem08@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:52:17 +0000 Subject: [PATCH] feat: map Soroban simulation/sign failures to player-friendly BetModal errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate common Soroban simulation and Freighter signing errors into human-readable copy in the BetModal (fee estimate + submit flows) so players see actionable messages instead of raw SDK internals. The raw error is kept in the console for debugging, and unknown errors fall back to a safe generic message. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/components/BetModal.test.tsx | 6 +- src/components/BetModal.tsx | 13 +-- src/components/__tests__/BetModal.test.tsx | 6 +- src/lib/__tests__/xelma-contract.test.ts | 78 +++++++++++++++- src/lib/xelma-contract.ts | 101 +++++++++++++++++++++ 5 files changed, 194 insertions(+), 10 deletions(-) diff --git a/src/components/BetModal.test.tsx b/src/components/BetModal.test.tsx index 662c7a2..6e179e5 100644 --- a/src/components/BetModal.test.tsx +++ b/src/components/BetModal.test.tsx @@ -26,6 +26,10 @@ let placeBetImpl: () => Promise<{ txHash: string }> = async () => ({ txHash: 'TX vi.mock('../lib/xelma-contract', () => ({ place_bet: (...args: any[]) => placeBetImpl(), place_precision_prediction: (...args: any[]) => placeBetImpl(), + humanizeContractError: (error: unknown) => + error instanceof Error && /reject|cancel/i.test(error.message) + ? 'You cancelled the request in your wallet. No transaction was sent.' + : 'Something went wrong while submitting your prediction. Please try again.', estimatePlaceBet: vi.fn().mockResolvedValue({ baseFee: '0.0000100', resourceFee: '0.0000500', @@ -171,7 +175,7 @@ describe('BetModal — transaction pending state (#163)', () => { await waitFor(() => { expect(screen.getByText(/transaction failed/i)).toBeInTheDocument(); }); - expect(screen.getByText(/user rejected/i)).toBeInTheDocument(); + expect(screen.getByText('You cancelled the request in your wallet. No transaction was sent.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); }); diff --git a/src/components/BetModal.tsx b/src/components/BetModal.tsx index 032ee9b..1d3530e 100644 --- a/src/components/BetModal.tsx +++ b/src/components/BetModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useWalletStore, selectIsWalletConnected } from '../store/useWalletStore'; import { useAuthStore } from '../store/useAuthStore'; -import { place_bet, place_precision_prediction, estimatePlaceBet, estimatePrecisionPrediction, type FeeEstimate } from '../lib/xelma-contract'; +import { place_bet, place_precision_prediction, estimatePlaceBet, estimatePrecisionPrediction, humanizeContractError, type FeeEstimate } from '../lib/xelma-contract'; import { predictionsApi, type UserPrediction } from '../lib/api-client'; import XdrPreviewDrawer from './XdrPreviewDrawer'; import { MODAL_OVERLAY, MODAL_CONTENT } from '../utils/motion'; @@ -137,8 +137,9 @@ export default function BetModal({ isOpen, onClose, predictionData, onSuccess, o } } catch (err) { if (!cancelled) { - const msg = err instanceof Error ? err.message : 'Failed to estimate fee'; - setFeeEstimateError(msg); + // The raw error stays in the console via humanizeContractError; + // only the friendly copy is surfaced in the modal. + setFeeEstimateError(humanizeContractError(err, 'estimate')); setFeeEstimateStatus('failed'); } } @@ -286,9 +287,9 @@ export default function BetModal({ isOpen, onClose, predictionData, onSuccess, o onSuccess(result.txHash); } } catch (err: unknown) { - const error = err as Error; - console.error('Prediction submission error:', error); - tx.fail(error.message || 'An unexpected error occurred'); + // Map the raw simulation/signing error to player-friendly copy; the raw + // error is kept in the console for debugging by humanizeContractError. + tx.fail(humanizeContractError(err, 'place_bet')); if (onPredictionError) { onPredictionError(); } diff --git a/src/components/__tests__/BetModal.test.tsx b/src/components/__tests__/BetModal.test.tsx index 13208a0..7feca2f 100644 --- a/src/components/__tests__/BetModal.test.tsx +++ b/src/components/__tests__/BetModal.test.tsx @@ -10,6 +10,10 @@ import { predictionsApi } from '../../lib/api-client'; vi.mock('../../lib/xelma-contract', () => ({ place_bet: vi.fn(), place_precision_prediction: vi.fn(), + humanizeContractError: (error: unknown) => + error instanceof Error && /reject|cancel/i.test(error.message) + ? 'You cancelled the request in your wallet. No transaction was sent.' + : 'Something went wrong while submitting your prediction. Please try again.', estimatePlaceBet: vi.fn().mockResolvedValue({ baseFee: '0.0000100', resourceFee: '0.0000500', @@ -181,7 +185,7 @@ describe('BetModal Component', () => { await waitFor(() => { expect(screen.getAllByText('Transaction Failed')[0]).toBeInTheDocument(); - expect(screen.getByText('User rejected Freighter signature')).toBeInTheDocument(); + expect(screen.getByText('You cancelled the request in your wallet. No transaction was sent.')).toBeInTheDocument(); }); // Retry should be visible diff --git a/src/lib/__tests__/xelma-contract.test.ts b/src/lib/__tests__/xelma-contract.test.ts index cc1ff66..083723c 100644 --- a/src/lib/__tests__/xelma-contract.test.ts +++ b/src/lib/__tests__/xelma-contract.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { extractInspectorFields, inspectSorobanState, place_bet, place_precision_prediction } from '../xelma-contract'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CONTRACT_ERROR_FALLBACK, extractInspectorFields, humanizeContractError, inspectSorobanState, place_bet, place_precision_prediction } from '../xelma-contract'; import { signTransaction } from '@stellar/freighter-api'; // Mock the Freighter API @@ -170,6 +170,80 @@ describe('Smart Contract Bindings', () => { }); }); +describe('humanizeContractError', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ['insufficient balance', 'Simulation failed: HostError: Status(ContractError(7)) insufficient balance'], + ['tx_insufficient_balance', 'Transaction rejected by network: tx_insufficient_balance'], + ])('maps an insufficient-balance failure (%s) to friendly copy', (_label, raw) => { + expect(humanizeContractError(new Error(raw))).toBe( + "You don't have enough XLM in your wallet to cover this prediction and its fees. Fund your wallet and try again." + ); + }); + + it('maps a Soroban budget/resource failure to friendly copy', () => { + expect(humanizeContractError(new Error('Simulation failed: HostError: Status(Budget)'))).toBe( + 'This prediction would use more network resources than allowed. Try a smaller stake.' + ); + }); + + it('maps a closed-round failure to friendly copy', () => { + expect(humanizeContractError(new Error('Simulation failed: ContractError: round is closed'))).toBe( + 'The current round has closed and is no longer accepting predictions. Try again in the next round.' + ); + }); + + it('maps a user-cancelled Freighter signature to friendly copy', () => { + expect(humanizeContractError(new Error('Freighter signing rejected: User rejected the request'))).toBe( + 'You cancelled the request in your wallet. No transaction was sent.' + ); + }); + + it('maps an authentication failure to friendly copy', () => { + expect(humanizeContractError(new Error('Simulation failed: HostError: Status(AuthenticationError)'))).toBe( + "Your wallet couldn't be verified for this transaction. Approve it in Freighter and try again." + ); + }); + + it('maps an unfunded account to friendly copy', () => { + expect(humanizeContractError(new Error('Stellar account not found or unfunded on Testnet. Please fund your address first.'))).toBe( + 'Your Stellar account is not funded yet. Add XLM before placing a prediction.' + ); + }); + + it('maps a network timeout to friendly copy', () => { + expect(humanizeContractError(new Error('Transaction polling timed out after 60 seconds.'))).toBe( + "The Stellar network didn't respond. Check your connection and try again." + ); + }); + + it('maps a generic contract panic to friendly copy', () => { + expect(humanizeContractError(new Error('Simulation failed: Contract invocation panicked'))).toBe( + 'The prediction contract rejected this request. Review your details and try again.' + ); + }); + + it('falls back to a safe message for unknown errors', () => { + expect(humanizeContractError(new Error('Some cryptic internal error code 0xdeadbeef'))).toBe( + CONTRACT_ERROR_FALLBACK + ); + expect(humanizeContractError('not even an error object')).toBe(CONTRACT_ERROR_FALLBACK); + expect(humanizeContractError(null)).toBe(CONTRACT_ERROR_FALLBACK); + }); + + it('keeps the raw error in the console for debugging', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const raw = new Error('Simulation failed: HostError: Status(Budget)'); + + humanizeContractError(raw, 'place_bet'); + + expect(consoleSpy).toHaveBeenCalledWith('[xelma-contract:place_bet] raw error:', raw); + }); +}); + describe('extractInspectorFields', () => { it('reads snake_case field names', () => { const fields = extractInspectorFields( diff --git a/src/lib/xelma-contract.ts b/src/lib/xelma-contract.ts index bb6c443..9140064 100644 --- a/src/lib/xelma-contract.ts +++ b/src/lib/xelma-contract.ts @@ -223,6 +223,107 @@ export async function inspectSorobanState(userAddress: string): Promise