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
40 changes: 25 additions & 15 deletions frontend/src/hooks/use-stacks-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -132,7 +142,7 @@ export function useStacksTx() {
setStatus("idle");
return null;
}
setError(err?.message ?? "Transaction failed");
setError(walletErrorDetail(err));
setStatus("error");
return null;
}
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/lib/wallet-errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 46 additions & 22 deletions frontend/src/providers/stacks-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]/;
Expand Down Expand Up @@ -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
Expand All @@ -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]);
Expand Down
Loading