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
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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/*"],
Expand Down
9 changes: 7 additions & 2 deletions src/background/handlers/private/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -530,12 +531,16 @@ export const handleDeposit: Handler<MessageType.Deposit> = 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,
},
};
Expand Down
9 changes: 7 additions & 2 deletions src/background/handlers/private/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -629,12 +630,16 @@ export const handleSend: Handler<MessageType.Send> = 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,
},
};
Expand Down
9 changes: 7 additions & 2 deletions src/background/handlers/private/withdraw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -607,12 +608,16 @@ export const handleWithdraw: Handler<MessageType.Withdraw> = 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,
},
};
Expand Down
61 changes: 59 additions & 2 deletions src/background/services/privacy-provider-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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}`,
{
Expand All @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/popup/pages/deposit-review-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/popup/pages/send-confirmation-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/popup/pages/withdraw-confirmation-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions src/popup/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ body {
}

@keyframes twinkle {
0%, 100% {
0%,
100% {
opacity: var(--star-opacity, 0.3);
transform: scale(1);
}
Expand All @@ -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);
}
Expand All @@ -219,7 +221,8 @@ body {
}

@keyframes float {
0%, 100% {
0%,
100% {
transform: translateY(0px);
}
50% {
Expand Down
58 changes: 58 additions & 0 deletions src/popup/utils/error-copy.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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;
}
Loading