diff --git a/src/__tests__/wallet-tx-error.test.ts b/src/__tests__/wallet-tx-error.test.ts new file mode 100644 index 000000000..0e581e94d --- /dev/null +++ b/src/__tests__/wallet-tx-error.test.ts @@ -0,0 +1,33 @@ +import { AbortError } from '../util/abort-error'; +import { TranslatedError } from '../util/translated-error'; +import { resolveWalletTxError } from '../util/wallet-tx-error'; + +describe('resolveWalletTxError', () => { + it('treats an EIP-1193 user rejection (code 4001) as a silent cancel', () => { + expect(resolveWalletTxError({ code: 4001, message: 'User rejected' })).toEqual({ cancelled: true }); + }); + + it('treats an AbortError as a silent cancel', () => { + expect(resolveWalletTxError(new AbortError('User cancelled'))).toEqual({ cancelled: true }); + }); + + it('surfaces the message of a TranslatedError as the key to display', () => { + const message = 'There is already a request pending. Please confirm it in your MetaMask and retry.'; + expect(resolveWalletTxError(new TranslatedError(message))).toEqual({ cancelled: false, messageKey: message }); + }); + + it('keeps the generic fallback for an unknown error (no message surfaced)', () => { + expect(resolveWalletTxError(new Error('insufficient funds for intrinsic transaction cost'))).toEqual({ + cancelled: false, + }); + }); + + it('keeps the generic fallback for a TranslatedError without a message', () => { + expect(resolveWalletTxError(new TranslatedError(''))).toEqual({ cancelled: false }); + }); + + it('does not throw on null or undefined', () => { + expect(resolveWalletTxError(null)).toEqual({ cancelled: false }); + expect(resolveWalletTxError(undefined)).toEqual({ cancelled: false }); + }); +}); diff --git a/src/screens/sell.screen.tsx b/src/screens/sell.screen.tsx index c629a1114..6ca7a232b 100644 --- a/src/screens/sell.screen.tsx +++ b/src/screens/sell.screen.tsx @@ -46,6 +46,7 @@ import { useLayoutContext } from 'src/contexts/layout.context'; import { useWindowContext } from 'src/contexts/window.context'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { ErrorHint } from '../components/error-hint'; +import { resolveWalletTxError } from '../util/wallet-tx-error'; import { ExchangeRate } from '../components/exchange-rate'; import { SellCompletion } from '../components/payment/sell-completion'; import { QuoteErrorHint } from '../components/quote-error-hint'; @@ -602,10 +603,16 @@ export default function SellScreen(): JSX.Element { } setTxDone(true); } catch (error: any) { + const { cancelled, messageKey } = resolveWalletTxError(error); // User rejected in wallet - silently return, user stays on form - if (error.code === 4001) return; - // Other errors - show message, user can click Retry to see deposit address for manual transfer - setErrorMessage(translate('screens/sell', 'Transaction failed. Click Retry to see the deposit address for manual transfer.')); + if (cancelled) return; + // A curated wallet error (e.g. a request still pending in the wallet) already tells the user what to + // do; surface it. Otherwise keep the generic hint that points to the manual deposit address. + setErrorMessage( + messageKey + ? translate('screens/home', messageKey) + : translate('screens/sell', 'Transaction failed. Click Retry to see the deposit address for manual transfer.'), + ); } finally { setIsProcessing(false); } diff --git a/src/screens/swap.screen.tsx b/src/screens/swap.screen.tsx index 1311eea19..06255d5a2 100644 --- a/src/screens/swap.screen.tsx +++ b/src/screens/swap.screen.tsx @@ -60,6 +60,7 @@ import { useBlockchain } from '../hooks/blockchain.hook'; import { useAddressGuard } from '../hooks/guard.hook'; import { useNavigation } from '../hooks/navigation.hook'; import { useTxHelper } from '../hooks/tx-helper.hook'; +import { resolveWalletTxError } from '../util/wallet-tx-error'; enum Side { SPEND = 'SPEND', @@ -660,12 +661,15 @@ export default function SwapScreen(): JSX.Element { setTxDone(true); } catch (error: any) { - if (error.code === 4001) return; + const { cancelled, messageKey } = resolveWalletTxError(error); + if (cancelled) return; setErrorMessage( - translate( - 'screens/swap', - 'Transaction failed. Click Retry to see the deposit address for manual transfer.', - ), + messageKey + ? translate('screens/home', messageKey) + : translate( + 'screens/swap', + 'Transaction failed. Click Retry to see the deposit address for manual transfer.', + ), ); } finally { setIsProcessing(false); diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index 3f4c5b2dd..734c08dd7 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -490,6 +490,12 @@ "Please confirm the connection in your wallet.": "Bitte bestätige die Verbindung in Deiner Wallet.", "Please confirm the connection with your Ledger.": "Bitte bestätige die Verbindung mit Deinem Ledger.", "Connection failed!": "Verbindung fehlgeschlagen!", + "Failed to confirm sell transaction": "Der Verkauf konnte nicht bestätigt werden. Bitte versuche es erneut.", + "Failed to confirm swap transaction": "Der Tausch konnte nicht bestätigt werden. Bitte versuche es erneut.", + "Failed to execute gasless sell transaction": "Der gebührenfreie Verkauf konnte nicht ausgeführt werden. Bitte versuche es erneut.", + "Failed to execute gasless swap transaction": "Der gebührenfreie Tausch konnte nicht ausgeführt werden. Bitte versuche es erneut.", + "There is already a request pending. Please confirm it in your MetaMask and retry.": "Es ist bereits eine Anfrage offen. Bitte bestätige sie in deiner MetaMask und versuche es erneut.", + "There is already a request pending. Please reload the page and retry.": "Es ist bereits eine Anfrage offen. Bitte lade die Seite neu und versuche es erneut.", "Please make sure that all other applications and browser extensions that are connected to the ledger are completely closed when you try to connect.\nThis includes third-party wallets (Ledger Live, Metamask, Daedalus, MyEtherWallet) or other applications that could interfere with the connection between DFX.swiss and your ledger device.": "Bitte stelle sicher, dass alle anderen Anwendungen und Browsererweiterungen, die mit dem Ledger verbunden sind, vollständig geschlossen sind, wenn Du versuchst, eine Verbindung herzustellen.\nDazu gehören Wallets von Drittanbietern (Ledger Live, Metamask, Daedalus, MyEtherWallet) oder andere Anwendungen, die die Verbindung zwischen DFX.swiss und Deinem Ledger-Gerät stören könnten.", "Connect your {{device}} with your computer": "Verbinde Deinen {{device}} mit Deinem Computer", "Click on \"Connect\"": "Klicke auf \"Verbinden\"", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 735188177..316bf0f6b 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -490,6 +490,12 @@ "Please confirm the connection in your wallet.": "Veuillez confirmer la connexion dans votre portefeuille.", "Please confirm the connection with your Ledger.": "Veuillez confirmer la connexion avec votre Ledger.", "Connection failed!": "Échec de la connexion !", + "Failed to confirm sell transaction": "La vente n'a pas pu être confirmée. Veuillez réessayer.", + "Failed to confirm swap transaction": "L'échange n'a pas pu être confirmé. Veuillez réessayer.", + "Failed to execute gasless sell transaction": "La vente sans frais de gas n'a pas pu être exécutée. Veuillez réessayer.", + "Failed to execute gasless swap transaction": "L'échange sans frais de gas n'a pas pu être exécuté. Veuillez réessayer.", + "There is already a request pending. Please confirm it in your MetaMask and retry.": "Une demande est déjà en attente. Veuillez la confirmer dans votre MetaMask et réessayer.", + "There is already a request pending. Please reload the page and retry.": "Une demande est déjà en attente. Veuillez recharger la page et réessayer.", "Please make sure that all other applications and browser extensions that are connected to the ledger are completely closed when you try to connect.\nThis includes third-party wallets (Ledger Live, Metamask, Daedalus, MyEtherWallet) or other applications that could interfere with the connection between DFX.swiss and your ledger device.": "Veuillez vous assurer que toutes les autres applications et extensions de navigateur connectées au Ledger sont complètement fermées lorsque vous essayez de vous connecter.\nCela inclut les portefeuilles tiers (Ledger Live, Metamask, Daedalus, MyEtherWallet) ou d'autres applications qui pourraient interférer avec la connexion entre DFX.swiss et votre appareil Ledger.", "Connect your {{device}} with your computer": "Connectez votre {{device}} à votre ordinateur", "Click on \"Connect\"": "Cliquez sur \"Connecter\"", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 509b37f74..0de015322 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -490,6 +490,12 @@ "Please confirm the connection in your wallet.": "Confermare la connessione nel portafoglio.", "Please confirm the connection with your Ledger.": "Confermare il collegamento con il Ledger.", "Connection failed!": "Connessione fallita!", + "Failed to confirm sell transaction": "Non è stato possibile confermare la vendita. Riprova.", + "Failed to confirm swap transaction": "Non è stato possibile confermare lo scambio. Riprova.", + "Failed to execute gasless sell transaction": "Non è stato possibile eseguire la vendita senza commissioni di gas. Riprova.", + "Failed to execute gasless swap transaction": "Non è stato possibile eseguire lo scambio senza commissioni di gas. Riprova.", + "There is already a request pending. Please confirm it in your MetaMask and retry.": "È già presente una richiesta in sospeso. Confermala nel tuo MetaMask e riprova.", + "There is already a request pending. Please reload the page and retry.": "È già presente una richiesta in sospeso. Ricarica la pagina e riprova.", "Please make sure that all other applications and browser extensions that are connected to the ledger are completely closed when you try to connect.\nThis includes third-party wallets (Ledger Live, Metamask, Daedalus, MyEtherWallet) or other applications that could interfere with the connection between DFX.swiss and your ledger device.": "Assicurarsi che tutte le altre applicazioni e le estensioni del browser collegate al ledger siano completamente chiuse quando si cerca di connettersi.\nCiò include portafogli di terze parti (Ledger Live, Metamask, Daedalus, MyEtherWallet) o altre applicazioni che potrebbero interferire con la connessione tra DFX.swiss e il dispositivo ledger.", "Connect your {{device}} with your computer": "Collegare il {{device}} al computer", "Click on \"Connect\"": "Fare clic su \"Collegare\"", diff --git a/src/util/wallet-tx-error.ts b/src/util/wallet-tx-error.ts new file mode 100644 index 000000000..2ec2e6867 --- /dev/null +++ b/src/util/wallet-tx-error.ts @@ -0,0 +1,31 @@ +import { AbortError } from './abort-error'; +import { TranslatedError } from './translated-error'; + +export interface WalletTxErrorResult { + /** The user rejected the request in their wallet — stay on the form and show nothing. */ + cancelled: boolean; + /** + * A curated, translatable wallet-error key (thrown as a TranslatedError, e.g. a request that is + * still pending in the wallet). Undefined for unknown errors, where the caller keeps its generic + * "transaction failed" hint instead of surfacing a raw technical message. + */ + messageKey?: string; +} + +/** + * Classifies an error thrown while sending a sell or swap transaction from the connected wallet, so + * both screens react the same way: a user cancellation stays silent, a curated TranslatedError is + * surfaced to the user (it already explains what to do), and anything else falls back to the generic + * hint that points to the manual deposit address. + */ +export function resolveWalletTxError(error: unknown): WalletTxErrorResult { + if ((error as { code?: number } | null)?.code === 4001 || error instanceof AbortError) { + return { cancelled: true }; + } + + if (error instanceof TranslatedError && error.message) { + return { cancelled: false, messageKey: error.message }; + } + + return { cancelled: false }; +}