Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/BetModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});

Expand Down
13 changes: 7 additions & 6 deletions src/components/BetModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
}
Expand Down Expand Up @@ -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();
}
Expand Down
6 changes: 5 additions & 1 deletion src/components/__tests__/BetModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
78 changes: 76 additions & 2 deletions src/lib/__tests__/xelma-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
101 changes: 101 additions & 0 deletions src/lib/xelma-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,107 @@ export async function inspectSorobanState(userAddress: string): Promise<SorobanI
}
}

/**
* Safe fallback copy shown when a contract error doesn't match any known
* case. Deliberately generic and actionable — never leaks raw SDK / HostError
* internals to players.
*/
export const CONTRACT_ERROR_FALLBACK =
'Something went wrong while submitting your prediction. Please try again.';

interface ErrorMapping {
/** Regex tested against the raw error message (case-insensitive). */
test: RegExp;
/** Player-friendly copy to show in the modal. */
message: string;
}

/**
* Raw → friendly translations for common Soroban simulation and Freighter
* signing failures. Order matters: more specific patterns are listed first so
* e.g. `Transaction rejected by network: tx_insufficient_balance` maps to the
* balance case rather than the generic contract-rejection case.
*/
const ERROR_MAPPINGS: ErrorMapping[] = [
{
// Wallet doesn't have enough XLM for the stake + fees.
test: /insufficient (balance|funds)|tx_insufficient_balance|not enough xlm|balance too low/i,
message:
"You don't have enough XLM in your wallet to cover this prediction and its fees. Fund your wallet and try again.",
},
{
// Soroban resource budget exceeded (CPU / ledger footprint too large).
test: /\bbudget\b|resource (limit|usage)|exceeded.*(limit|maximum)|instructions.*exceed|failed to (assemble|prepare)/i,
message:
'This prediction would use more network resources than allowed. Try a smaller stake.',
},
{
// Contract no longer accepts bets for the current round.
test: /round.*(closed|ended|not open)|betting.*(closed|ended)|no active round|market closed/i,
message:
'The current round has closed and is no longer accepting predictions. Try again in the next round.',
},
{
// User declined / cancelled the Freighter signature prompt.
test: /signing (cancelled|rejected)|user (cancelled|rejected|declined)|cancelled or rejected|cancelled.*(request|signature)|rejected the (request|signature|transaction)/i,
message: 'You cancelled the request in your wallet. No transaction was sent.',
},
{
// Wallet couldn't produce a valid signature.
test: /failed to sign|sign.*failed|wallet.*(error|failed)/i,
message: "Your wallet couldn't sign this transaction. Please try again.",
},
{
// Soroban authentication / invoker verification failure.
test: /authentication|invalid invoker|soroban.*auth/i,
message:
"Your wallet couldn't be verified for this transaction. Approve it in Freighter and try again.",
},
{
// Account doesn't exist or has never been funded on the network.
test: /unfunded|not found or unfunded|fund your (account|address)|account.*doesn'?t exist/i,
message: 'Your Stellar account is not funded yet. Add XLM before placing a prediction.',
},
{
// Network / RPC hiccups and long-running polling timeouts.
test: /network|timeout|timed out|unavailable|offline|failed to (broadcast|connect)|fetch failed|rpc|connection/i,
message: "The Stellar network didn't respond. Check your connection and try again.",
},
{
// Any other on-chain rejection or contract panic (catch-all).
test: /panicked|hosterror|contract (error|invocation)|invocation.*failed|tx_failed|failed on-chain|rejected by network/i,
message: 'The prediction contract rejected this request. Review your details and try again.',
},
];

/**
* Maps a raw Soroban simulation / Freighter signing error to player-friendly
* copy safe to show in the BetModal.
*
* The raw error is always logged to the console (with `[xelma-contract]`
* prefix) for debugging, and unknown errors fall back to
* {@link CONTRACT_ERROR_FALLBACK} instead of surfacing internals to the user.
*/
export function humanizeContractError(error: unknown, context = 'contract call'): string {
const raw =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: '';

// Keep the full raw error (message + stack) in the console for debugging.
console.error(`[xelma-contract:${context}] raw error:`, error);

if (!raw) return CONTRACT_ERROR_FALLBACK;

for (const mapping of ERROR_MAPPINGS) {
if (mapping.test.test(raw)) return mapping.message;
}

return CONTRACT_ERROR_FALLBACK;
}

/**
* Common transaction preparation and sign/submit wrapper
*/
Expand Down