Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/components/connect/ConnectIntentHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export function ConnectIntentHandler() {
coinId={params.coinId as string}
memo={params.memo as string | undefined}
onResolve={() => resolveIntent({ success: true })}
onReject={(message) => rejectIntent(ERROR_CODES.TRANSFER_FAILED, message)}
onCancel={handleClose}
/>
);
Expand All @@ -123,6 +124,7 @@ export function ConnectIntentHandler() {
coinId={params.coinId as string}
message={params.message as string | undefined}
onResolve={(requestId) => resolveIntent({ success: true, requestId })}
onReject={(message) => rejectIntent(ERROR_CODES.INTERNAL_ERROR, message)}
onCancel={handleClose}
/>
);
Expand Down
9 changes: 4 additions & 5 deletions src/components/connect/IntentConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ interface IntentConfirmModalProps {
icon: LucideIcon;
/** Body content (the amount/recipient card). */
children: ReactNode;
/** Inline error shown above the action buttons. */
error?: string | null;
/** Disables the confirm button (e.g. insufficient balance). */
confirmDisabled?: boolean;
/** True while the underlying action is running. */
Expand All @@ -32,12 +30,15 @@ interface IntentConfirmModalProps {
* amount input here. This matches how every major wallet treats a
* dApp-requested transfer (MetaMask, Phantom, WalletConnect): the amount
* travels in base units and the wallet only formats it for display.
*
* On failure the consumer rejects the intent (so the dApp is informed) and the
* wallet's global query handler toasts the error — so this shell renders no
* inline error.
*/
export function IntentConfirmModal({
title,
icon,
children,
error,
confirmDisabled,
busy,
confirmLabel,
Expand All @@ -52,8 +53,6 @@ export function IntentConfirmModal({
<div className="px-6 py-5 flex-1 flex flex-col justify-center">
{children}

{error && <div className="text-red-500 text-sm mb-3 text-center">{error}</div>}

<div className="flex gap-3">
<Button variant="secondary" fullWidth onClick={onCancel} disabled={busy}>
Cancel
Expand Down
15 changes: 7 additions & 8 deletions src/components/connect/PaymentRequestIntentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface PaymentRequestIntentModalProps {
message?: string;
/** Called after the request is sent (resolves the intent). */
onResolve: (requestId?: string) => void;
/** Called when sending the request fails (rejects the intent with the message). */
onReject: (message: string) => void;
/** Called when the user cancels (rejects the intent). */
onCancel: () => void;
}
Expand All @@ -24,19 +26,20 @@ interface PaymentRequestIntentModalProps {
* specifies who to bill, the coin and the amount (in base units); the user
* approves or rejects — the amount is NOT editable here. The base-unit amount is
* passed to the SDK verbatim; `formatAmount` renders a human-readable figure for
* review only. Coin metadata comes from the registry (a request needs no balance).
* review only. Coin metadata comes from the registry (a request needs no
* balance). A failed send rejects the intent rather than leaving it hanging.
*/
export function PaymentRequestIntentModal({
to,
amount,
coinId,
message,
onResolve,
onReject,
onCancel,
}: PaymentRequestIntentModalProps) {
const { sphere } = useSphereContext();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const registry = TokenRegistry.getInstance();
const def = registry.getDefinition(coinId);
Expand All @@ -47,9 +50,8 @@ export function PaymentRequestIntentModal({
const displayAmount = formatAmount(amount, { decimals, symbol, maxFractionDigits: 8 });

const handleSend = async () => {
setError(null);
if (!sphere) {
setError('Wallet not available');
onReject('Wallet not available');
return;
}
setBusy(true);
Expand All @@ -63,9 +65,7 @@ export function PaymentRequestIntentModal({
if (!result.success) throw new Error(result.error || 'Failed to send payment request');
onResolve(result.requestId || undefined);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setBusy(false);
onReject(getErrorMessage(err));
}
};

Expand All @@ -76,7 +76,6 @@ export function PaymentRequestIntentModal({
busy={busy}
confirmLabel="Send Request"
busyLabel="Sending…"
error={error}
onConfirm={handleSend}
onCancel={onCancel}
>
Expand Down
16 changes: 8 additions & 8 deletions src/components/connect/SendIntentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SendIntentModalProps {
memo?: string;
/** Called after the transfer succeeds (resolves the intent). */
onResolve: () => void;
/** Called when the transfer fails (rejects the intent with the message). */
onReject: (message: string) => void;
/** Called when the user cancels (rejects the intent). */
onCancel: () => void;
}
Expand All @@ -24,13 +26,13 @@ interface SendIntentModalProps {
* recipient, coin and amount (in base units); the user approves or rejects — the
* amount is NOT editable here (a different amount = a different dApp request).
* The base-unit amount is handed to the SDK verbatim; `formatAmount` is used only
* to render a human-readable figure for review.
* to render a human-readable figure for review. A failed transfer rejects the
* intent (the dApp is told) rather than leaving it hanging.
*/
export function SendIntentModal({ to, amount, coinId, memo, onResolve, onCancel }: SendIntentModalProps) {
export function SendIntentModal({ to, amount, coinId, memo, onResolve, onReject, onCancel }: SendIntentModalProps) {
const { assets } = useAssets();
const { transfer } = useTransfer();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

// Prefer the held asset (gives balance); fall back to the registry for
// metadata when the coin isn't held, so we can still display it sensibly.
Expand All @@ -49,16 +51,15 @@ export function SendIntentModal({ to, amount, coinId, memo, onResolve, onCancel
const displayAmount = formatAmount(amount, { decimals, symbol, maxFractionDigits: 8 });

const handleSend = async () => {
setError(null);
setBusy(true);
try {
const recipient = to.startsWith('DIRECT://') ? to : to.replace(/^@/, '');
await transfer({ coinId, amount, recipient, ...(memo ? { memo } : {}) });
onResolve();
} catch (err) {
setError(getErrorMessage(err));
} finally {
setBusy(false);
// Tell the dApp it failed instead of leaving the request hanging; the
// wallet's global query handler also surfaces a toast.
onReject(getErrorMessage(err));
}
};

Expand All @@ -70,7 +71,6 @@ export function SendIntentModal({ to, amount, coinId, memo, onResolve, onCancel
confirmLabel="Send"
busyLabel="Sending…"
confirmDisabled={insufficient}
error={error}
onConfirm={handleSend}
onCancel={onCancel}
>
Expand Down
44 changes: 3 additions & 41 deletions src/components/wallet/L3/modals/SendModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowRight, Loader2, User, CheckCircle, Coins, Hash, Copy, Check } from 'lucide-react';
import type { Asset } from '@unicitylabs/sphere-sdk';
Expand All @@ -11,22 +11,12 @@ import { ModalHeader, Button } from '../../ui';

type Step = 'asset' | 'details' | 'confirm' | 'processing' | 'success';

export interface SendPrefill {
to: string;
amount: string;
coinId: string;
memo?: string;
}

interface SendModalProps {
isOpen: boolean;
onClose: (result?: { success: boolean }) => void;
prefill?: SendPrefill;
/** Render as centered modal dialog instead of slide-in panel */
asModal?: boolean;
}

export function SendModal({ isOpen, onClose, prefill, asModal }: SendModalProps) {
export function SendModal({ isOpen, onClose }: SendModalProps) {
const { assets: sdkAssets } = useAssets();
const { transfer, isLoading: isTransferring } = useTransfer();
const { sphere } = useSphereContext();
Expand Down Expand Up @@ -54,33 +44,6 @@ export function SendModal({ isOpen, onClose, prefill, asModal }: SendModalProps)
const [amountInput, setAmountInput] = useState('');
const [memoInput, setMemoInput] = useState('');

// Pre-fill from connect intent (dApp request)
const prefillApplied = useRef(false);
useEffect(() => {
if (!prefill || !isOpen || prefillApplied.current) return;
if (assets.length === 0) return;

const { to, amount, coinId } = prefill;

if (to.startsWith('DIRECT://')) {
setRecipientMode('direct');
setRecipient(to);
} else {
setRecipientMode('nametag');
setRecipient(to.replace(/^@/, ''));
}

setAmountInput(amount);
if (prefill.memo) setMemoInput(prefill.memo);

const asset = assets.find(a => a.coinId === coinId);
if (asset) {
setSelectedAsset(asset);
setStep('confirm');
prefillApplied.current = true;
}
}, [prefill, isOpen, assets]);

const handleRecipientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (recipientMode === 'nametag') {
const value = e.target.value.toLowerCase();
Expand All @@ -103,7 +66,6 @@ export function SendModal({ isOpen, onClose, prefill, asModal }: SendModalProps)
setAmountInput('');
setMemoInput('');
setRecipientError(null);
prefillApplied.current = false;
};

const handleClose = () => {
Expand Down Expand Up @@ -201,7 +163,7 @@ export function SendModal({ isOpen, onClose, prefill, asModal }: SendModalProps)
};

return (
<WalletScreen isOpen={isOpen} onClose={handleClose} asModal={asModal}>
<WalletScreen isOpen={isOpen} onClose={handleClose}>
<ModalHeader variant="screen" title={getTitle()} onClose={getBackHandler()} />

<div className="px-6 py-8 flex-1 overflow-y-auto">
Expand Down
43 changes: 3 additions & 40 deletions src/components/wallet/L3/modals/SendPaymentRequestModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowRight, Loader2, User, CheckCircle, Hash, Receipt } from 'lucide-react';
import { TokenRegistry, parseTokenAmount, safeParseTokenAmount } from '@unicitylabs/sphere-sdk';
Expand All @@ -17,22 +17,12 @@ interface CoinOption {
iconUrl?: string;
}

export interface PaymentRequestPrefill {
to: string;
amount: string;
coinId: string;
message?: string;
}

interface SendPaymentRequestModalProps {
isOpen: boolean;
onClose: (result?: { success: boolean; requestId?: string }) => void;
prefill?: PaymentRequestPrefill;
/** Render as centered modal dialog instead of slide-in panel */
asModal?: boolean;
}

export function SendPaymentRequestModal({ isOpen, onClose, prefill, asModal }: SendPaymentRequestModalProps) {
export function SendPaymentRequestModal({ isOpen, onClose }: SendPaymentRequestModalProps) {
const { sphere } = useSphereContext();

const [step, setStep] = useState<Step>('coin');
Expand Down Expand Up @@ -66,32 +56,6 @@ export function SendPaymentRequestModal({ isOpen, onClose, prefill, asModal }: S
setAvailableCoins(coins);
}, [isOpen]);

const prefillApplied = useRef(false);
useEffect(() => {
if (!prefill || !isOpen || prefillApplied.current) return;
if (availableCoins.length === 0) return;

const { to, amount, coinId, message } = prefill;

if (to.startsWith('DIRECT://')) {
setRecipientMode('direct');
setRecipient(to);
} else {
setRecipientMode('nametag');
setRecipient(to.replace(/^@/, ''));
}

setAmountInput(amount);
if (message) setMessageInput(message);

const coin = availableCoins.find(c => c.coinId === coinId);
if (coin) {
setSelectedCoin(coin);
setStep('confirm');
prefillApplied.current = true;
}
}, [prefill, isOpen, availableCoins]);

const handleRecipientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (recipientMode === 'nametag') {
const value = e.target.value.toLowerCase();
Expand All @@ -115,7 +79,6 @@ export function SendPaymentRequestModal({ isOpen, onClose, prefill, asModal }: S
setRecipientError(null);
setError(null);
setRequestId(null);
prefillApplied.current = false;
};

const handleClose = () => {
Expand Down Expand Up @@ -215,7 +178,7 @@ export function SendPaymentRequestModal({ isOpen, onClose, prefill, asModal }: S
};

return (
<WalletScreen isOpen={isOpen} onClose={handleClose} asModal={asModal}>
<WalletScreen isOpen={isOpen} onClose={handleClose}>
<ModalHeader variant="screen" title={getTitle()} onClose={getBackHandler()} />

<div className="px-6 py-8 flex-1 overflow-y-auto">
Expand Down
33 changes: 31 additions & 2 deletions src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,40 @@ const FRIENDLY_OVERRIDES: Partial<Record<SphereErrorCode, string>> = {
MODULE_NOT_AVAILABLE: 'Feature not available',
};

/**
* Turn a raw, non-user-facing error string into something safe to display.
* Backends (gateways/proxies) sometimes return an HTML error page (e.g. a 503)
* instead of a structured error; that markup must never reach the UI.
*/
function humanizeRawError(message: string): string {
const msg = message.trim();
// Raw HTML/markup error page (gateway 5xx, proxy, etc.): surface the server's
// OWN text (e.g. "503 Service Unavailable No server is available to handle this
// request.") rather than a canned line — but never the markup itself.
if (msg.startsWith('<')) {
const text = msg
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\s+/g, ' ')
.trim();
if (!text) return 'Service temporarily unavailable. Try again later';
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
if (/\bservice unavailable\b|\b50[234]\b|bad gateway|gateway timeout/i.test(msg)) {
return 'Service temporarily unavailable. Try again later';
}
return message;
}

export function getErrorMessage(err: unknown): string {
if (isSphereError(err)) {
return FRIENDLY_OVERRIDES[err.code] ?? err.message;
return FRIENDLY_OVERRIDES[err.code] ?? humanizeRawError(err.message);
}
return err instanceof Error ? err.message : 'Something went wrong';
if (err instanceof Error) return humanizeRawError(err.message);
return 'Something went wrong';
}

export function getErrorCode(err: unknown): SphereErrorCode | null {
Expand Down
Loading