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]);