From 09441d3b2b7c4301dd4e5d212babe007f466d4a0 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Mon, 13 Jul 2026 08:58:17 +0100 Subject: [PATCH] fix(frontend): classify wallet errors, auto-retry dropped requests, actionable toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Couldn't reach the wallet' was a dead-end catch-all that collapsed three distinct failures into one useless message: - code -31000 'Provider did not return a response': the extension's background worker was suspended by the browser and dropped the request while waking. Now retried automatically once — succeeds in practice, user never sees an error. - 'No installed Stacks wallet found': the page can't see an injected provider (e.g. per-site extension access). Now explains exactly what to do: click the extension icon once to grant site access. - User cancels arriving in foreign shapes: instanceof JsonRpcError is unreliable across module copies and wallet-thrown plain objects. New wallet-errors module classifies by numeric code and message shape. Unknown failures now surface the actual error detail in the toast, so field reports are debuggable instead of generic. Same hardening applied to the transaction signing path (stx_callContract), which shares the sleeping-worker failure mode. --- frontend/src/hooks/use-stacks-tx.ts | 40 ++++++++----- frontend/src/lib/wallet-errors.ts | 47 +++++++++++++++ frontend/src/providers/stacks-provider.tsx | 68 +++++++++++++++------- 3 files changed, 118 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/wallet-errors.ts diff --git a/frontend/src/hooks/use-stacks-tx.ts b/frontend/src/hooks/use-stacks-tx.ts index cfb6b9d..c66adc3 100644 --- a/frontend/src/hooks/use-stacks-tx.ts +++ b/frontend/src/hooks/use-stacks-tx.ts @@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { PostConditionMode } from "@stacks/transactions"; import { waitForTxConfirmation, clarityErrorMessage } from "@/lib/stacks"; +import { isUserCancel, isNoResponse, walletErrorDetail } from "@/lib/wallet-errors"; type TxStatus = "idle" | "pending" | "confirming" | "success" | "error"; @@ -48,28 +49,37 @@ export function useStacksTx() { // JSON-RPC bridge. The legacy openContractCall/StacksProvider path is // deprecated by Leather and silently hangs (no popup, dangling // promise) on current wallet versions. - const { request, JsonRpcError } = await import("@stacks/connect"); + const { request } = await import("@stacks/connect"); - const response = await request("stx_callContract", { - contract: `${options.contractAddress}.${options.contractName}`, + const callParams = { + contract: + `${options.contractAddress}.${options.contractName}` as `${string}.${string}`, functionName: options.functionName, functionArgs: options.functionArgs, network: options.network, postConditions: options.postConditions ?? [], postConditionMode: options.postConditionMode === PostConditionMode.Allow - ? "allow" - : "deny", - }).catch((err: unknown) => { - // -32000 UserRejection / -31001 UserCanceled → treated as cancel - if ( - err instanceof JsonRpcError && - (err.code === -32000 || err.code === -31001) - ) { - throw new Error("cancelled"); + ? ("allow" as const) + : ("deny" as const), + }; + + const response = await request("stx_callContract", callParams).catch( + async (err: unknown) => { + if (isUserCancel(err)) throw new Error("cancelled"); + if (isNoResponse(err)) { + // Extension background worker was asleep and dropped the + // request while waking — one automatic retry succeeds. + return request("stx_callContract", callParams).catch( + (retryErr: unknown) => { + if (isUserCancel(retryErr)) throw new Error("cancelled"); + throw retryErr; + } + ); + } + throw err; } - throw err; - }); + ); const rawId = response?.txid; if (!rawId) throw new Error("Wallet returned no transaction id"); @@ -132,7 +142,7 @@ export function useStacksTx() { setStatus("idle"); return null; } - setError(err?.message ?? "Transaction failed"); + setError(walletErrorDetail(err)); setStatus("error"); return null; } diff --git a/frontend/src/lib/wallet-errors.ts b/frontend/src/lib/wallet-errors.ts new file mode 100644 index 0000000..4038792 --- /dev/null +++ b/frontend/src/lib/wallet-errors.ts @@ -0,0 +1,47 @@ +/** + * Wallet (JSON-RPC) error classification for @stacks/connect v8. + * + * Errors can arrive as JsonRpcError instances, plain Errors, or foreign + * objects from wallet extensions — never rely on instanceof. Classify by + * numeric code first, message shape second. + */ + +interface WalletErrorLike { + code?: number; + message?: string; +} + +function asErrorLike(err: unknown): WalletErrorLike { + if (typeof err === "object" && err !== null) return err as WalletErrorLike; + return { message: String(err) }; +} + +/** -31001 UserCanceled (connect UI) · -32000 UserRejection (wallet) */ +export function isUserCancel(err: unknown): boolean { + const e = asErrorLike(err); + if (e.code === -31001 || e.code === -32000) return true; + return /cancel|rejected|denied/i.test(e.message ?? ""); +} + +/** + * -31000: "Provider did not return a response". In practice this means the + * extension's background worker was suspended by the browser and dropped the + * request while waking up — an immediate retry succeeds. + */ +export function isNoResponse(err: unknown): boolean { + const e = asErrorLike(err); + if (e.code === -31000) return true; + return /did not return a response/i.test(e.message ?? ""); +} + +/** Thrown when the page cannot see any injected Stacks wallet provider. */ +export function isNoWalletFound(err: unknown): boolean { + return /no installed stacks wallet/i.test(asErrorLike(err).message ?? ""); +} + +/** Human-readable detail for unknown failures, so toasts are debuggable. */ +export function walletErrorDetail(err: unknown): string { + const e = asErrorLike(err); + const msg = e.message?.slice(0, 140) ?? "Unknown error"; + return e.code !== undefined ? `${msg} (code ${e.code})` : msg; +} diff --git a/frontend/src/providers/stacks-provider.tsx b/frontend/src/providers/stacks-provider.tsx index dde0d82..3920bba 100644 --- a/frontend/src/providers/stacks-provider.tsx +++ b/frontend/src/providers/stacks-provider.tsx @@ -20,6 +20,12 @@ import { type ReactNode, useEffect, useCallback } from "react"; import { toast } from "sonner"; import { useWalletStore } from "@/stores/wallet-store"; +import { + isUserCancel, + isNoResponse, + isNoWalletFound, + walletErrorDetail, +} from "@/lib/wallet-errors"; /** Stacks addresses start with S + P/M (mainnet) or T/N (testnet). */ const STX_ADDRESS_RE = /^S[PMTN]/; @@ -72,33 +78,51 @@ export function useStacksAuth() { const handleConnect = useCallback(async () => { setConnecting(true); try { - const { - connect, - disconnect: walletDisconnect, - JsonRpcError, - } = await import("@stacks/connect"); + const { connect, disconnect: walletDisconnect } = await import( + "@stacks/connect" + ); // Drop any cached approval/addresses so the wallet is always asked // fresh — otherwise a reconnect can silently return the previously // approved account even after the user switched accounts in the wallet. - walletDisconnect(); + try { + walletDisconnect(); + } catch { + /* nothing cached */ + } + + const doConnect = () => connect({ forceWalletSelect: true }); - const res = await connect({ forceWalletSelect: true }).catch( - (err: unknown) => { - // User closed the chooser or rejected in the wallet — not an error - if ( - err instanceof JsonRpcError && - (err.code === -32000 || err.code === -31001) - ) { - return null; + let res; + try { + res = await doConnect(); + } catch (err) { + if (isUserCancel(err)) { + setConnecting(false); + return; + } + if (isNoResponse(err)) { + // The extension's background worker was asleep and dropped the + // request while waking — one automatic retry succeeds in practice. + try { + res = await doConnect(); + } catch (retryErr) { + if (isUserCancel(retryErr)) { + setConnecting(false); + return; + } + throw retryErr; } + } else if (isNoWalletFound(err)) { + toast.error( + "No Stacks wallet detected on this page. Click your wallet extension's icon once to grant it access to this site, then try again.", + { duration: 8000 } + ); + setConnecting(false); + return; + } else { throw err; } - ); - - if (!res) { - setConnecting(false); - return; } // Use the wallet's fresh response (authoritative for the currently @@ -118,9 +142,9 @@ export function useStacksAuth() { ); } catch (err) { console.error("[wallet connect]", err); - toast.error( - "Couldn't reach the wallet. Unlock the extension, then try again." - ); + toast.error(`Wallet connection failed: ${walletErrorDetail(err)}`, { + duration: 8000, + }); setConnecting(false); } }, [setAddress, setConnecting]);