From b813f8081a08abe800559bb0b3d9d874bad7b094 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 1 Jul 2026 18:42:18 -0300 Subject: [PATCH 1/3] feat(errors): surface real bundle failure identity to the user The provider client reads the structured failureDetail/error body and throws a BundleFailedError carrying the real code+message; the flow handlers thread that code through, and a new code->copy mapper renders mapped user copy on the confirmation pages instead of a generic string (#1). --- src/background/handlers/private/deposit.ts | 9 ++- src/background/handlers/private/send.ts | 9 ++- src/background/handlers/private/withdraw.ts | 9 ++- .../services/privacy-provider-client.ts | 61 ++++++++++++++++++- src/popup/pages/deposit-review-page.tsx | 3 +- src/popup/pages/send-confirmation-page.tsx | 3 +- .../pages/withdraw-confirmation-page.tsx | 3 +- src/popup/utils/error-copy.ts | 58 ++++++++++++++++++ 8 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 src/popup/utils/error-copy.ts diff --git a/src/background/handlers/private/deposit.ts b/src/background/handlers/private/deposit.ts index 4a40f1a..288e59d 100644 --- a/src/background/handlers/private/deposit.ts +++ b/src/background/handlers/private/deposit.ts @@ -2,6 +2,7 @@ import { MessageType } from "@/background/messages.ts"; import type { Handler } from "@/background/messages.ts"; import { privateChannels, vault } from "@/background/session.ts"; import { + BundleFailedError, PrivacyProviderAuthError, PrivacyProviderClient, } from "@/background/services/privacy-provider-client.ts"; @@ -530,12 +531,16 @@ export const handleDeposit: Handler = async (message) => { ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error("[deposit] Error:", msg); + // Prefer the real surfaced code (e.g. SOROBAN_1010) when present. + const code = err instanceof BundleFailedError && err.code + ? err.code + : "DEPOSIT_FAILED"; + console.error("[deposit] Error:", code, msg); return { type: MessageType.Deposit, ok: false, error: { - code: "DEPOSIT_FAILED", + code, message: msg, }, }; diff --git a/src/background/handlers/private/send.ts b/src/background/handlers/private/send.ts index e8b6e25..90733b4 100644 --- a/src/background/handlers/private/send.ts +++ b/src/background/handlers/private/send.ts @@ -2,6 +2,7 @@ import { MessageType } from "@/background/messages.ts"; import type { Handler } from "@/background/messages.ts"; import { privateChannels, vault } from "@/background/session.ts"; import { + BundleFailedError, PrivacyProviderAuthError, PrivacyProviderClient, } from "@/background/services/privacy-provider-client.ts"; @@ -629,12 +630,16 @@ export const handleSend: Handler = async (message) => { ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error("[send] Error:", msg); + // Prefer the real surfaced code (e.g. SOROBAN_1010) when present. + const code = err instanceof BundleFailedError && err.code + ? err.code + : "SEND_FAILED"; + console.error("[send] Error:", code, msg); return { type: MessageType.Send, ok: false, error: { - code: "SEND_FAILED", + code, message: msg, }, }; diff --git a/src/background/handlers/private/withdraw.ts b/src/background/handlers/private/withdraw.ts index d0ff12c..86752ab 100644 --- a/src/background/handlers/private/withdraw.ts +++ b/src/background/handlers/private/withdraw.ts @@ -2,6 +2,7 @@ import { MessageType } from "@/background/messages.ts"; import type { Handler } from "@/background/messages.ts"; import { privateChannels, vault } from "@/background/session.ts"; import { + BundleFailedError, PrivacyProviderAuthError, PrivacyProviderClient, } from "@/background/services/privacy-provider-client.ts"; @@ -607,12 +608,16 @@ export const handleWithdraw: Handler = async ( ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - console.error("[withdraw] Error:", msg); + // Prefer the real surfaced code (e.g. SOROBAN_1010) when present. + const code = err instanceof BundleFailedError && err.code + ? err.code + : "WITHDRAW_FAILED"; + console.error("[withdraw] Error:", code, msg); return { type: MessageType.Withdraw, ok: false, error: { - code: "WITHDRAW_FAILED", + code, message: msg, }, }; diff --git a/src/background/services/privacy-provider-client.ts b/src/background/services/privacy-provider-client.ts index a73eb8f..c8944ee 100644 --- a/src/background/services/privacy-provider-client.ts +++ b/src/background/services/privacy-provider-client.ts @@ -26,6 +26,38 @@ export class PrivacyProviderAuthError extends Error { } } +/** + * Carries the structured failure identity surfaced by the provider platform + * for a mid-flight bundle failure (terminal FAILED/EXPIRED poll response, or a + * non-2xx bundle submit body). `.code` is the machine code (e.g. `SOROBAN_1010`, + * `BND_005`) that the UI maps to friendly copy; `.message` is the provider's + * human message. Both may be undefined if the platform did not supply them. + */ +export class BundleFailedError extends Error { + code?: string; + constructor(message: string, code?: string) { + super(message); + this.name = "BundleFailedError"; + this.code = code; + } +} + +/** + * Structured failure body shapes returned by the provider platform: + * - poll GET terminal failure: `data.failureDetail = { code, source, message, name? }` + * - submit POST non-2xx: `{ code, status, message, details }` + */ +function extractStructuredFailure( + body: unknown, +): { code?: string; message?: string } | undefined { + if (!body || typeof body !== "object") return undefined; + const b = body as { code?: unknown; message?: unknown }; + const code = typeof b.code === "string" ? b.code : undefined; + const message = typeof b.message === "string" ? b.message : undefined; + if (!code && !message) return undefined; + return { code, message }; +} + // The wallet stores `{url, label}` per PP. The client derives the API base // (origin or origin + intermediate path) and the PP pubkey from the URL at // construction. Throws on any malformed URL — callers are expected to surface @@ -226,6 +258,17 @@ export class PrivacyProviderClient { "Provider session expired or invalid. Please reconnect to the provider.", ); } + // Non-2xx submit responses carry a structured `{ code, status, message, + // details }` JSON body — surface its code + message to the UI. + const failure = extractStructuredFailure( + (error as { response?: { data?: unknown } })?.response?.data, + ); + if (failure) { + throw new BundleFailedError( + failure.message ?? "Bundle submission failed", + failure.code, + ); + } throw error; } @@ -254,7 +297,15 @@ export class PrivacyProviderClient { const res = await axios.get<{ status: number; message: string; - data: { status: string }; + data: { + status: string; + failureDetail?: { + code?: string; + source?: string; + message?: string; + name?: string; + }; + }; }>( `${this.apiBase}/api/v1/providers/${this.ppPubkey}/entity/bundles/${bundleId}`, { @@ -270,7 +321,13 @@ export class PrivacyProviderClient { return; } if (status === "FAILED" || status === "EXPIRED") { - const err = new Error(`Bundle ${bundleId} ${status}`); + // Terminal failures carry a structured `failureDetail` identity — + // surface its code + message rather than the bare status string. + const detail = res.data.data?.failureDetail; + const err = new BundleFailedError( + detail?.message ?? `Bundle ${bundleId} ${status}`, + detail?.code, + ); if (span) endSpan(span, { code: 2, message: err.message }); throw err; } diff --git a/src/popup/pages/deposit-review-page.tsx b/src/popup/pages/deposit-review-page.tsx index 44e2b88..596efcd 100644 --- a/src/popup/pages/deposit-review-page.tsx +++ b/src/popup/pages/deposit-review-page.tsx @@ -3,6 +3,7 @@ import { usePopup } from "@/popup/hooks/state.tsx"; import { getPrivateChannels } from "@/popup/api/get-private-channels.ts"; import { deposit, prepareDeposit } from "@/popup/api/deposit.ts"; import { showAsyncSubmitted, showError } from "@/popup/utils/toast.tsx"; +import { errorCopy } from "@/popup/utils/error-copy.ts"; import { DepositReviewTemplate } from "@/popup/templates/deposit-review-template.tsx"; import type { ChainNetwork } from "@/persistence/stores/chain.types.ts"; import type { PrivateChannel } from "@/persistence/stores/private-channels.types.ts"; @@ -162,7 +163,7 @@ export function DepositReviewPage() { }); if (!result.ok) { - const msg = result.error?.message ?? "Deposit failed"; + const msg = errorCopy(result.error); setError(msg); showError(msg); return; diff --git a/src/popup/pages/send-confirmation-page.tsx b/src/popup/pages/send-confirmation-page.tsx index 5da9e40..2d279f9 100644 --- a/src/popup/pages/send-confirmation-page.tsx +++ b/src/popup/pages/send-confirmation-page.tsx @@ -3,6 +3,7 @@ import { usePopup } from "@/popup/hooks/state.tsx"; import { getPrivateChannels } from "@/popup/api/get-private-channels.ts"; import { send } from "@/popup/api/send.ts"; import { showAsyncSubmitted, showError } from "@/popup/utils/toast.tsx"; +import { errorCopy } from "@/popup/utils/error-copy.ts"; import { SubpageShell } from "@/popup/templates/subpage-shell.tsx"; import { Button } from "@/popup/atoms/button.tsx"; import { Card, CardContent, CardFooter } from "@/popup/atoms/card.tsx"; @@ -127,7 +128,7 @@ export function SendConfirmationPage() { }); if (!result.ok) { - const msg = result.error?.message ?? "Failed to execute transaction"; + const msg = errorCopy(result.error); setError(msg); showError(msg); setBusy(false); diff --git a/src/popup/pages/withdraw-confirmation-page.tsx b/src/popup/pages/withdraw-confirmation-page.tsx index 4ffc53e..2854483 100644 --- a/src/popup/pages/withdraw-confirmation-page.tsx +++ b/src/popup/pages/withdraw-confirmation-page.tsx @@ -3,6 +3,7 @@ import { usePopup } from "@/popup/hooks/state.tsx"; import { getPrivateChannels } from "@/popup/api/get-private-channels.ts"; import { withdraw } from "@/popup/api/withdraw.ts"; import { showAsyncSubmitted, showError } from "@/popup/utils/toast.tsx"; +import { errorCopy } from "@/popup/utils/error-copy.ts"; import { SubpageShell } from "@/popup/templates/subpage-shell.tsx"; import { Button } from "@/popup/atoms/button.tsx"; import { Card, CardContent, CardFooter } from "@/popup/atoms/card.tsx"; @@ -127,7 +128,7 @@ export function WithdrawConfirmationPage() { }); if (!result.ok) { - const msg = result.error?.message ?? "Failed to execute transaction"; + const msg = errorCopy(result.error); setError(msg); showError(msg); setBusy(false); diff --git a/src/popup/utils/error-copy.ts b/src/popup/utils/error-copy.ts new file mode 100644 index 0000000..f55c07d --- /dev/null +++ b/src/popup/utils/error-copy.ts @@ -0,0 +1,58 @@ +// Single source of truth mapping surfaced error codes (from the provider +// platform's StructuredError identity, threaded through the flow handlers as +// `error.code`) to friendly, human-readable copy for the popup. Keeps raw +// internal strings and stack traces off the user surface. + +const ERROR_COPY: Record = { + SOROBAN_1010: "Your authorization expired — please try again.", + SOROBAN_2002: "Those funds were already spent. Refresh and try again.", + SOROBAN_2003: "The payment amounts didn't balance. Please try again.", + SOROBAN_3006: "That operation wasn't authorized.", + SOROBAN_3007: "The amount must be greater than zero.", + BND_005: "Some funds are no longer available. Refresh and try again.", + BND_011: "Your account isn't approved for payments yet.", + BND_015: + "This channel is temporarily withdraw-only — you can still withdraw.", + PROVIDER_EXECUTION_FAILED: + "The payment couldn't be completed. Please try again.", + AUTH_FAILED: "Your session expired. Please sign in again.", +}; + +const GENERIC_FALLBACK = "Something went wrong. Please try again."; + +/** + * Heuristic guard so an unknown code's carried message is only shown when it + * reads like human copy — never a raw internal string, XDR blob, or stack. + */ +function isHumanSafe(message: string): boolean { + const m = message.trim(); + if (!m) return false; + if (m.length > 160) return false; + if (m.includes("\n")) return false; + // Stack frames like "at fn (file.ts:12)". + if (/\bat\s+\S+.*:\d+/.test(m)) return false; + // Raw internal thrown strings / serialized payloads / hex or base64 blobs. + if ( + /(MLXDR|XDR|undefined|\[object |0x[0-9a-fA-F]{6,}|[A-Za-z0-9+/]{40,})/ + .test(m) + ) { + return false; + } + // Bare bundle status strings, e.g. "Bundle abc123 FAILED". + if (/^Bundle\s+\S+\s+(FAILED|EXPIRED)/.test(m)) return false; + return true; +} + +/** + * Maps a flow-handler `error` object to user-facing copy. A known code wins; + * otherwise the carried message is shown when human-safe, else a generic line. + */ +export function errorCopy( + error?: { code?: string; message?: string } | null, +): string { + if (!error) return GENERIC_FALLBACK; + const { code, message } = error; + if (code && ERROR_COPY[code]) return ERROR_COPY[code]; + if (message && isHumanSafe(message)) return message; + return GENERIC_FALLBACK; +} From a4c5d122124f717d81a1d5561bcb3f989bfcd97f Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 2 Jul 2026 16:55:27 -0300 Subject: [PATCH 2/3] chore: run deno fmt on style.css (satisfy fmt gate) --- src/popup/style.css | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/popup/style.css b/src/popup/style.css index 02c9ae3..0b7d321 100644 --- a/src/popup/style.css +++ b/src/popup/style.css @@ -187,7 +187,8 @@ body { } @keyframes twinkle { - 0%, 100% { + 0%, + 100% { opacity: var(--star-opacity, 0.3); transform: scale(1); } @@ -203,7 +204,8 @@ body { } @keyframes glow-ring { - 0%, 100% { + 0%, + 100% { opacity: var(--ring-min-opacity, 0.1); transform: translate(-50%, -50%) scale(1); } @@ -219,7 +221,8 @@ body { } @keyframes float { - 0%, 100% { + 0%, + 100% { transform: translateY(0px); } 50% { From 9a3b8b7fa1f956eef480707133dc637b55e513a7 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 2 Jul 2026 16:55:27 -0300 Subject: [PATCH 3/3] chore: bump version to 0.1.5 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 700b279..82cf2b5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Stellar Custom Wallet", - "version": "0.1.4", + "version": "0.1.5", "description": "A Stellar wallet with custom capabilities built with Deno and React.", "permissions": ["storage"], "host_permissions": ["https://*/*", "http://localhost/*"],