+ {IS_MOBILE_WEBVIEW &&
+ MOBILE_WALLETS.map((wallet) => (
+
+ ))}
{IS_MOBILE_WEBVIEW && (
)}
+ );
+}
+
+export function PreflightSkeleton() {
+ return (
+
+ );
+}
+
+export function BranchesSkeleton() {
+ return (
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+ );
+}
+
+export function SweepSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {[0, 1].map((i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/recovery/SweepForm.tsx b/src/components/recovery/SweepForm.tsx
new file mode 100644
index 0000000..6e39da1
--- /dev/null
+++ b/src/components/recovery/SweepForm.tsx
@@ -0,0 +1,172 @@
+import { useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+import { parseSatAmount } from "../../utils/amount";
+import { formatSats, shortTxid } from "../../utils/format";
+import {
+ maxSweepSat,
+ poolBelowDust,
+ sweepAmountError,
+ SWEEP_FEE_BUFFER_SATS,
+ type SweepAmountError,
+} from "./sweepAmount";
+
+export interface UnrolledVtxoMaturity {
+ txid: string;
+ vout: number;
+ amountSat: number;
+ confirmedAt: number | null;
+ csvMatureAt: number | null;
+ mature: boolean;
+}
+
+function errorMessage(err: SweepAmountError): string {
+ switch (err.kind) {
+ case "invalid":
+ return "Enter a whole number of sats";
+ case "belowDust":
+ return `Below the dust limit — must be at least ${formatSats(err.dustSat)} sats`;
+ case "exceedsMax":
+ return `Max ~${formatSats(err.maxSat)} sats after the ~${formatSats(
+ SWEEP_FEE_BUFFER_SATS,
+ )} sat fee`;
+ }
+}
+
+/**
+ * Sweeps ALL mature unrolled outputs in one transaction.
+ */
+export function SweepForm({
+ totalSat,
+ dustSat,
+ outputCount,
+ onSwept,
+}: {
+ totalSat: number;
+ dustSat: number;
+ outputCount: number;
+ onSwept: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [address, setAddress] = useState("");
+ const [amount, setAmount] = useState(String(maxSweepSat(totalSat)));
+ const [amountTouched, setAmountTouched] = useState(false);
+ const [busy, setBusy] = useState(false);
+ // Re-seed the prefill on change unless the user has typed their own amount
+ const [seededFromSat, setSeededFromSat] = useState(totalSat);
+ if (totalSat !== seededFromSat) {
+ setSeededFromSat(totalSat);
+ if (!amountTouched) setAmount(String(maxSweepSat(totalSat)));
+ }
+
+ if (poolBelowDust(totalSat, dustSat)) {
+ return (
+
+ Too small to sweep yet — {formatSats(totalSat)} sats minus the ~
+ {formatSats(SWEEP_FEE_BUFFER_SATS)} sat fee is below the{" "}
+ {formatSats(dustSat)} sat dust limit. The sweep unlocks when more
+ outputs mature.
+
+ );
+ }
+
+ const parsedAmount = parseSatAmount(amount);
+ const amountErr = sweepAmountError(parsedAmount ?? NaN, totalSat, dustSat);
+
+ async function submit() {
+ const trimmed = address.trim();
+ if (!trimmed) {
+ toast.error("Enter a destination address");
+ return;
+ }
+ if (amountErr || parsedAmount === null) {
+ toast.error(amountErr ? errorMessage(amountErr) : "Enter a whole number of sats");
+ return;
+ }
+ setBusy(true);
+ try {
+ const txid = await invoke
("sweep_unrolled_to_onchain", {
+ address: trimmed,
+ amountSat: parsedAmount,
+ });
+ toast.success(`Swept — ${shortTxid(txid)}`);
+ setOpen(false);
+ setAddress("");
+ setAmountTouched(false);
+ onSwept();
+ } catch (e) {
+ const msg = typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
+ toast.error(msg);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ if (!open) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {amountErr && (
+
{errorMessage(amountErr)}
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/recovery/sweepAmount.test.ts b/src/components/recovery/sweepAmount.test.ts
new file mode 100644
index 0000000..7cd9821
--- /dev/null
+++ b/src/components/recovery/sweepAmount.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import {
+ maxSweepSat,
+ poolBelowDust,
+ sweepAmountError,
+ SWEEP_FEE_BUFFER_SATS,
+} from "./sweepAmount";
+
+const DUST = 330;
+
+describe("maxSweepSat", () => {
+ it("subtracts the fee buffer", () => {
+ expect(maxSweepSat(5029)).toBe(5029 - SWEEP_FEE_BUFFER_SATS);
+ });
+
+ it("floors at zero when the pool can't cover the fee", () => {
+ expect(maxSweepSat(500)).toBe(0);
+ });
+});
+
+describe("poolBelowDust", () => {
+ // The bug report case: a lone 1,289-sat VTXO leaves 289 after the fee,
+ // below the 330-sat dust limit — unsweepable on its own.
+ it("flags a pool whose max is below dust", () => {
+ expect(poolBelowDust(1289, DUST)).toBe(true);
+ });
+
+ it("accepts a pool whose max meets dust", () => {
+ expect(poolBelowDust(1330, DUST)).toBe(false);
+ expect(poolBelowDust(5029, DUST)).toBe(false);
+ });
+});
+
+describe("sweepAmountError", () => {
+ it("rejects non-positive, fractional, and non-numeric amounts", () => {
+ expect(sweepAmountError(0, 5029, DUST)).toEqual({ kind: "invalid" });
+ expect(sweepAmountError(-5, 5029, DUST)).toEqual({ kind: "invalid" });
+ expect(sweepAmountError(12.5, 5029, DUST)).toEqual({ kind: "invalid" });
+ expect(sweepAmountError(NaN, 5029, DUST)).toEqual({ kind: "invalid" });
+ });
+
+ it("rejects amounts below the dust limit", () => {
+ expect(sweepAmountError(289, 5029, DUST)).toEqual({
+ kind: "belowDust",
+ dustSat: DUST,
+ });
+ expect(sweepAmountError(DUST - 1, 5029, DUST)).toEqual({
+ kind: "belowDust",
+ dustSat: DUST,
+ });
+ });
+
+ it("rejects amounts the pool can't cover after the fee", () => {
+ expect(sweepAmountError(4030, 5029, DUST)).toEqual({
+ kind: "exceedsMax",
+ maxSat: 4029,
+ });
+ });
+
+ it("accepts dust-or-above amounts up to the max", () => {
+ expect(sweepAmountError(DUST, 5029, DUST)).toBeNull();
+ expect(sweepAmountError(4029, 5029, DUST)).toBeNull();
+ });
+});
diff --git a/src/components/recovery/sweepAmount.ts b/src/components/recovery/sweepAmount.ts
new file mode 100644
index 0000000..8c4cdc2
--- /dev/null
+++ b/src/components/recovery/sweepAmount.ts
@@ -0,0 +1,45 @@
+// The SDK's `send_on_chain` uses a fixed 1000 sat internal fee; matching it
+// here keeps the form's max-after-fee suggestion realistic.
+export const SWEEP_FEE_BUFFER_SATS = 1000;
+
+/** Most sats that can leave a sweep of `totalSat` worth of mature outputs. */
+export function maxSweepSat(totalSat: number): number {
+ return Math.max(0, totalSat - SWEEP_FEE_BUFFER_SATS);
+}
+
+/**
+ * True when the mature pool can't produce any acceptable sweep: even sending
+ * the whole pool minus the fee would land below the ASP's dust limit. The
+ * only way out is waiting for more outputs to mature into the pool.
+ */
+export function poolBelowDust(totalSat: number, dustSat: number): boolean {
+ return maxSweepSat(totalSat) < dustSat;
+}
+
+export type SweepAmountError =
+ | { kind: "invalid" }
+ | { kind: "belowDust"; dustSat: number }
+ | { kind: "exceedsMax"; maxSat: number };
+
+/**
+ * Why `amountSat` can't be swept from a pool of `totalSat`, or `null` if it
+ * can. Mirrors the SDK's `send_on_chain` checks (dust floor, fixed fee) so
+ * the form rejects impossible amounts before invoking the backend.
+ */
+export function sweepAmountError(
+ amountSat: number,
+ totalSat: number,
+ dustSat: number,
+): SweepAmountError | null {
+ if (!Number.isInteger(amountSat) || amountSat <= 0) {
+ return { kind: "invalid" };
+ }
+ if (amountSat < dustSat) {
+ return { kind: "belowDust", dustSat };
+ }
+ const maxSat = maxSweepSat(totalSat);
+ if (amountSat > maxSat) {
+ return { kind: "exceedsMax", maxSat };
+ }
+ return null;
+}
diff --git a/src/components/settings/CurrencyPicker.tsx b/src/components/settings/CurrencyPicker.tsx
new file mode 100644
index 0000000..97be4c3
--- /dev/null
+++ b/src/components/settings/CurrencyPicker.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ AFRICAN_CURRENCY_CODES,
+ SUPPORTED_FIAT_CURRENCIES,
+ type FiatCurrency,
+} from "../../utils/fiatRates";
+
+export function CurrencyPicker({
+ currency,
+ onChange,
+}: {
+ currency: string;
+ onChange: (code: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const rootRef = useRef(null);
+
+ const selected = SUPPORTED_FIAT_CURRENCIES.find((c) => c.code === currency);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = () => {
+ setOpen(false);
+ setQuery("");
+ };
+ const handlePointerDown = (e: MouseEvent | TouchEvent) => {
+ const target = e.target as Node | null;
+ if (rootRef.current && target && !rootRef.current.contains(target)) {
+ close();
+ }
+ };
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") close();
+ };
+ document.addEventListener("touchstart", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("touchstart", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [open]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ const byName = (a: FiatCurrency, b: FiatCurrency) => a.name.localeCompare(b.name);
+ const selectedEntry = SUPPORTED_FIAT_CURRENCIES.find((c) => c.code === currency);
+
+ if (q) {
+ const matches = SUPPORTED_FIAT_CURRENCIES.filter(
+ (c) =>
+ c.code !== currency &&
+ (c.code.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)),
+ ).sort(byName);
+ const selectedMatches =
+ selectedEntry &&
+ (selectedEntry.code.toLowerCase().includes(q) ||
+ selectedEntry.name.toLowerCase().includes(q));
+ return selectedMatches ? [selectedEntry, ...matches] : matches;
+ }
+
+ const african: FiatCurrency[] = [];
+ const rest: FiatCurrency[] = [];
+ for (const c of SUPPORTED_FIAT_CURRENCIES) {
+ if (c.code === currency) continue;
+ (AFRICAN_CURRENCY_CODES.has(c.code) ? african : rest).push(c);
+ }
+ const grouped = [...african.sort(byName), ...rest.sort(byName)];
+ return selectedEntry ? [selectedEntry, ...grouped] : grouped;
+ }, [query, currency]);
+
+ return (
+
+
+ {open && (
+
+
setQuery(e.target.value)}
+ placeholder="Search currency"
+ autoCapitalize="none"
+ autoCorrect="off"
+ className="w-full rounded-lg theme-input px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-lime-300/50"
+ />
+
+ {filtered.length === 0 ? (
+
No matches
+ ) : (
+ filtered.map((c) => {
+ const active = c.code === currency;
+ return (
+
+ );
+ })
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/EsploraSelector.tsx b/src/components/settings/EsploraSelector.tsx
new file mode 100644
index 0000000..1af21d4
--- /dev/null
+++ b/src/components/settings/EsploraSelector.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+
+function defaultExplorerForNetwork(network: string | null | undefined): {
+ label: string;
+ url: string;
+} {
+ switch (network?.toLowerCase()) {
+ case "testnet":
+ return { label: "Blockstream", url: "https://blockstream.info/testnet/api" };
+ case "signet":
+ return { label: "Mutinynet", url: "https://mutinynet.com/api" };
+ case "regtest":
+ return { label: "Local", url: "http://localhost:7070" };
+ case "bitcoin":
+ default:
+ return { label: "Blockstream", url: "https://blockstream.info/api" };
+ }
+}
+
+function mempoolExplorerForNetwork(network: string | null | undefined): string {
+ switch (network?.toLowerCase()) {
+ case "testnet":
+ return "https://mempool.space/testnet/api";
+ case "signet":
+ return "https://mempool.space/signet/api";
+ case "bitcoin":
+ default:
+ return "https://mempool.space/api";
+ }
+}
+
+/// Settings card for choosing the esplora server used for onchain sync.
+/// Owns its draft + saving state so typing a custom URL re-renders only this
+/// card, not the whole settings route. Remounted via `key` when the canonical
+/// saved value changes. Saving the network default stores `null` (no
+/// override) rather than the literal URL.
+export function EsploraSelector({
+ network,
+ initialUrl,
+}: {
+ network: string | null | undefined;
+ initialUrl: string;
+}) {
+ const [value, setValue] = useState(initialUrl);
+ const [saving, setSaving] = useState(false);
+
+ const save = async (url: string | null) => {
+ setSaving(true);
+ try {
+ await invoke("set_esplora_url", { url });
+ toast.success("Explorer saved — takes effect on next app restart");
+ } catch (e) {
+ toast.error(typeof e === "string" ? e : "Failed to save");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const defaultExplorer = defaultExplorerForNetwork(network);
+ const presetExplorers = [
+ defaultExplorer,
+ { label: "Mempool.space", url: mempoolExplorerForNetwork(network) },
+ ];
+ const presetUrls = new Set(presetExplorers.map((e) => e.url));
+ const effectiveValue = value === "" ? defaultExplorer.url : value;
+ const isCustom = !presetUrls.has(effectiveValue);
+ const urlToSave = effectiveValue === defaultExplorer.url ? null : effectiveValue;
+
+ return (
+
+
Block Explorer (Esplora)
+
+ {presetExplorers.map((option) => (
+
+ ))}
+
+
+ {isCustom && (
+
setValue(e.target.value)}
+ placeholder="https://your-esplora-server.com/api"
+ className="w-full rounded-xl theme-input px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-lime-300/50 font-mono"
+ />
+ )}
+
+
Esplora server for onchain sync. Takes effect on next app restart.
+
+ );
+}
diff --git a/src/components/settings/FiatTickerCard.tsx b/src/components/settings/FiatTickerCard.tsx
new file mode 100644
index 0000000..7e61957
--- /dev/null
+++ b/src/components/settings/FiatTickerCard.tsx
@@ -0,0 +1,212 @@
+import { useState } from "react";
+import { formatFiat, type BtcRate } from "../../utils/fiatRates";
+import type { RateStatus } from "../../context/FiatContext";
+import { formatQuoteTime } from "../../utils/format";
+import { CurrencyPicker } from "./CurrencyPicker";
+
+const SATS_PER_BTC = 100_000_000;
+
+export function FiatTickerCard({
+ enabled,
+ onToggle,
+ currency,
+ rate,
+ status,
+ onRefresh,
+ onCurrencyChange,
+}: {
+ enabled: boolean;
+ onToggle: (next: boolean) => void;
+ currency: string;
+ rate: BtcRate | null;
+ status: RateStatus;
+ onRefresh: () => void;
+ onCurrencyChange: (code: string) => void;
+}) {
+ const price = enabled && rate ? formatFiat(SATS_PER_BTC, rate.rate, currency) : null;
+ const [lastPrice, setLastPrice] = useState("");
+ if (price && price !== lastPrice) {
+ setLastPrice(price);
+ }
+
+ // Derive the visible state. When a background refresh fails we keep the
+ // last rate visible but mark it stale.
+ const tickerState: "off" | "live" | "loading" | "stale" | "failed" = !enabled
+ ? "off"
+ : status === "ready" && rate
+ ? "live"
+ : status === "error" && rate
+ ? "stale"
+ : status === "error"
+ ? "failed"
+ : "loading";
+
+ const badge =
+ tickerState === "live"
+ ? { label: "Live", color: "var(--color-accent)", text: "theme-accent", pulse: false, ping: true }
+ : tickerState === "loading"
+ ? { label: "Syncing", color: "var(--color-accent)", text: "theme-accent", pulse: true, ping: false }
+ : tickerState === "stale"
+ ? { label: "Stale", color: "var(--color-warning)", text: "theme-warning", pulse: false, ping: false }
+ : tickerState === "failed"
+ ? { label: "Failed", color: "var(--color-danger)", text: "theme-danger", pulse: false, ping: false }
+ : { label: "Off", color: "var(--color-text-faint)", text: "theme-text-muted", pulse: false, ping: false };
+
+ return (
+
+ {/* Header: status badge + segmented ON / OFF */}
+
+
+
+ {badge.ping && (
+
+ )}
+
+
+
+ {badge.label}
+
+
+
+
+
+
+
+
+ {/* Quote readout */}
+
+ {tickerState === "live" && price && rate ? (
+
+
+
+ {price}
+
+ / BTC
+
+
+ quoted {formatQuoteTime(rate.timestamp)} · yadio.io
+
+
+ ) : tickerState === "stale" && price && rate ? (
+
+
+
+ {price}
+
+ / BTC
+
+
+
+ last quote {formatQuoteTime(rate.timestamp)} · refresh failed
+
+
+
+
+ ) : tickerState === "failed" ? (
+
+
+
+ Couldn't reach yadio.io
+
+
+
+
+
+ check your connection
+
+
+
+ ) : tickerState === "loading" ? (
+
+
+
+ {/* Invisible sizer — matches the footprint of the real price */}
+
+ {lastPrice || "$00,000.00"}
+
+
+
+ / BTC
+
+
+ updating · yadio.io
+
+
+ ) : (
+
+
+
+ — — —
+
+ / BTC
+
+
+ fiat equivalents hidden beneath sat amounts
+
+
+ )}
+
+
+ {enabled && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/settings/NsecBackup.tsx b/src/components/settings/NsecBackup.tsx
new file mode 100644
index 0000000..5fd33f4
--- /dev/null
+++ b/src/components/settings/NsecBackup.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+
+export function NsecBackup() {
+ const [step, setStep] = useState<"hidden" | "confirm" | "revealed">("hidden");
+ const [nsec, setNsec] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [confirmInput, setConfirmInput] = useState("");
+
+ const reveal = async () => {
+ if (nsec) {
+ setStep("revealed");
+ return;
+ }
+ setLoading(true);
+ try {
+ const value = await invoke("nostr_reveal_nsec");
+ setNsec(value);
+ setStep("revealed");
+ } catch (e) {
+ toast.error(typeof e === "string" ? e : "Failed to retrieve private key");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const copy = async () => {
+ if (!nsec) return;
+ try {
+ await navigator.clipboard.writeText(nsec);
+ toast.success("nsec copied — paste into a trusted password manager only");
+ } catch {
+ toast.error("Failed to copy");
+ }
+ };
+
+ return (
+ <>
+ {step === "hidden" && (
+
+
+
+ )}
+ {step === "confirm" && (
+
+
Reveal private key?
+
+ Your nsec controls your Nostr identity. Anyone with it can sign and impersonate you. Before continuing:
+
+
+ - Make sure no one can see your screen
+ - Save it to a trusted password manager — not chat or notes apps
+ - It will only unlock your Nostr identity, not your wallet funds
+
+
+ Type reveal my nsec to continue
+
+
setConfirmInput(e.target.value)}
+ placeholder="reveal my nsec"
+ className="w-full rounded-xl theme-input px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-yellow-500/50 mb-4 font-mono"
+ />
+
+
+
+
+
+ )}
+ {step === "revealed" && nsec && (
+
+
Save this somewhere safe — anyone with it controls your Nostr identity
+
{nsec}
+
+ Clipboard data can be read by other apps. Only paste into a trusted password manager — never into messaging apps.
+
+
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/settings/PackageBroadcastEndpoint.tsx b/src/components/settings/PackageBroadcastEndpoint.tsx
new file mode 100644
index 0000000..cb21157
--- /dev/null
+++ b/src/components/settings/PackageBroadcastEndpoint.tsx
@@ -0,0 +1,183 @@
+import { useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+
+export function PackageBroadcastEndpoint({
+ configuredUrl,
+ defaultUrl,
+ tokenConfigured,
+ onSaved,
+}: {
+ configuredUrl: string | null;
+ defaultUrl: string | null;
+ tokenConfigured: boolean;
+ onSaved: () => void;
+}) {
+ const [urlInput, setUrlInput] = useState(configuredUrl ?? "");
+ const [tokenInput, setTokenInput] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [editing, setEditing] = useState(false);
+
+ const hasCustom = configuredUrl !== null;
+ const usingDefault = !hasCustom && defaultUrl !== null;
+ const showForm = hasCustom || editing || defaultUrl === null;
+
+ const persist = async (
+ url: string | null,
+ token: string | null,
+ successMessage?: string,
+ ) => {
+ setSaving(true);
+ try {
+ await invoke("set_submitpackage_endpoint", { url, token });
+ toast.success(
+ successMessage ??
+ (url
+ ? "Custom endpoint saved"
+ : defaultUrl
+ ? "Using the built-in endpoint"
+ : "Endpoint cleared"),
+ );
+ setEditing(false);
+ onSaved();
+ } catch (e) {
+ toast.error(typeof e === "string" ? e : "Failed to save endpoint");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const save = async () => {
+ const url = urlInput.trim();
+ const token = tokenInput.trim();
+ if (!url && token) {
+ toast.error("Enter the endpoint URL for the new token.");
+ return;
+ }
+ // Empty token field → null → keep whatever token is stored.
+ await persist(url || null, token || null);
+ };
+
+ const removeToken = async () => {
+ await persist(configuredUrl, "", "Bearer token removed");
+ };
+
+ const badge = hasCustom ? "Custom" : usingDefault ? "Default" : "Not set";
+
+ return (
+
+
+
+
+ Package broadcast endpoint
+
+
+ {badge}
+
+
+
+ Required for actually broadcasting the cached exit tree on
+ mainnet. HTTPS endpoint that wraps Bitcoin Core's{" "}
+ submitpackage{" "}
+ RPC. If unset, broadcast falls back to esplora and will fail with
+ a min-fee error.
+
+
+
+ {usingDefault && !editing && (
+ <>
+
+
Built-in endpoint
+
{defaultUrl}
+
+
+ >
+ )}
+
+ {showForm && (
+ <>
+
+
+
+ {editing && !hasCustom && (
+
+ )}
+ {hasCustom && defaultUrl && (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/settings/SeedPhraseBackup.tsx b/src/components/settings/SeedPhraseBackup.tsx
new file mode 100644
index 0000000..a9d829c
--- /dev/null
+++ b/src/components/settings/SeedPhraseBackup.tsx
@@ -0,0 +1,101 @@
+import { useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+
+export function SeedPhraseBackup() {
+ const [step, setStep] = useState<"hidden" | "confirm" | "revealed">("hidden");
+ const [mnemonic, setMnemonic] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [confirmInput, setConfirmInput] = useState("");
+
+ const reveal = async () => {
+ if (mnemonic) {
+ setStep("revealed");
+ return;
+ }
+ setLoading(true);
+ try {
+ const words = await invoke("get_mnemonic");
+ setMnemonic(words);
+ setStep("revealed");
+ } catch (e) {
+ toast.error(typeof e === "string" ? e : "Failed to retrieve seed phrase");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+ {step === "hidden" && (
+
+
+
+ )}
+ {step === "confirm" && (
+
+
Reveal seed phrase?
+
Your seed phrase gives full access to your funds. Before continuing:
+
+ - Make sure no one can see your screen
+ - Do not screenshot or copy to clipboard
+ - Write the words down on paper only
+
+
+ Type reveal my seed to continue
+
+
setConfirmInput(e.target.value)}
+ placeholder="reveal my seed"
+ className="w-full rounded-xl theme-input px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-yellow-500/50 mb-4 font-mono"
+ />
+
+
+
+
+
+ )}
+ {step === "revealed" && mnemonic && (
+
+
Write these words down on paper — do not copy digitally
+
{mnemonic}
+
Never screenshot, copy to clipboard, or store digitally. Clipboard data can be read by other apps.
+
+
+ )}
+ >
+ );
+}
diff --git a/src/context/WalletConnectContext.tsx b/src/context/WalletConnectContext.tsx
index 580e306..37d8051 100644
--- a/src/context/WalletConnectContext.tsx
+++ b/src/context/WalletConnectContext.tsx
@@ -19,11 +19,14 @@ const RELAY_URL = import.meta.env.VITE_WALLETCONNECT_RELAY_URL as
| string
| undefined;
-// We never invoke wallet-side RPC methods — the LendaSwap claim flow is
-// preimage-POST via Gelato (see commands/lendaswap.rs), so we only need the
-// session for its account address. Leaving `methods: []` means wallets that
-// don't advertise signing still pair.
-const EIP155_METHODS: string[] = [];
+const EIP155_METHODS = [
+ "personal_sign",
+ "eth_sign",
+ "eth_signTypedData",
+ "eth_signTypedData_v4",
+ "eth_sendTransaction",
+ "eth_signTransaction",
+];
const EIP155_EVENTS = ["accountsChanged", "chainChanged"];
const ETH_MAINNET = "eip155:1";
@@ -54,6 +57,10 @@ function addressFromAccount(account: string | undefined): string | null {
return parts.length === 3 ? parts[2] ?? null : null;
}
+function sessionAddress(session: SessionTypes.Struct): string | null {
+ return addressFromAccount(session.namespaces.eip155?.accounts[0]);
+}
+
export function WalletConnectProvider({
children,
}: {
@@ -71,6 +78,24 @@ export function WalletConnectProvider({
: "VITE_WALLETCONNECT_PROJECT_ID is not set. Add it to .env and rebuild."
);
const [pairingUri, setPairingUri] = useState(null);
+ const deadTopicsRef = useRef>(new Set());
+
+ const rejectAccountlessSession = useCallback(
+ (client: SignClient, session: SessionTypes.Struct) => {
+ deadTopicsRef.current.add(session.topic);
+ client
+ .disconnect({
+ topic: session.topic,
+ reason: { code: 6000, message: "Session has no eip155 account" },
+ })
+ .catch(() => {
+ // Best-effort: the topic is in deadTopicsRef, so even if the
+ // relay is unreachable and the session lingers in the store, it
+ // will never be adopted.
+ });
+ },
+ []
+ );
useEffect(() => {
if (!PROJECT_ID) return;
@@ -96,11 +121,13 @@ export function WalletConnectProvider({
// sessions to localStorage by default — survives app restart.
const sessions = client.session.getAll();
const session = sessions.length > 0 ? sessions[sessions.length - 1] : null;
- if (session) {
+ const restoredAddress = session ? sessionAddress(session) : null;
+ if (session && restoredAddress) {
sessionRef.current = session;
- setAddress(addressFromAccount(session.namespaces.eip155?.accounts[0]));
+ setAddress(restoredAddress);
setStatus("connected");
} else {
+ if (session) rejectAccountlessSession(client, session);
setStatus("idle");
}
@@ -131,6 +158,41 @@ export function WalletConnectProvider({
};
}, []);
+ // Re-sync from the SignClient session store while the app is running.
+ useEffect(() => {
+ function syncFromStore() {
+ const client = clientRef.current;
+ if (!client) return;
+ const sessions = client.session.getAll();
+ const latest =
+ sessions.length > 0 ? sessions[sessions.length - 1] : null;
+ if (!latest) return;
+ if (latest.topic === sessionRef.current?.topic) return;
+ if (deadTopicsRef.current.has(latest.topic)) return;
+ const addr = sessionAddress(latest);
+ if (!addr) {
+ rejectAccountlessSession(client, latest);
+ return;
+ }
+ sessionRef.current = latest;
+ setAddress(addr);
+ setPairingUri(null);
+ setError(null);
+ setStatus("connected");
+ }
+ function onVisibility() {
+ if (document.visibilityState === "visible") syncFromStore();
+ }
+ const intervalId = window.setInterval(syncFromStore, 1500);
+ document.addEventListener("visibilitychange", onVisibility);
+ window.addEventListener("focus", syncFromStore);
+ return () => {
+ window.clearInterval(intervalId);
+ document.removeEventListener("visibilitychange", onVisibility);
+ window.removeEventListener("focus", syncFromStore);
+ };
+ }, []);
+
const connect = useCallback(async () => {
const client = clientRef.current;
if (!client) return;
@@ -138,7 +200,8 @@ export function WalletConnectProvider({
setStatus("connecting");
try {
const { uri, approval } = await client.connect({
- requiredNamespaces: {
+ requiredNamespaces: {},
+ optionalNamespaces: {
eip155: {
chains: [ETH_MAINNET],
methods: EIP155_METHODS,
@@ -148,15 +211,28 @@ export function WalletConnectProvider({
});
if (uri) setPairingUri(uri);
const session = await approval();
+ const addr = sessionAddress(session);
+ if (!addr) {
+ rejectAccountlessSession(client, session);
+ setPairingUri(null);
+ setStatus("error");
+ setError(
+ "The wallet approved the connection without an Ethereum account. " +
+ "Reconnect and select an Ethereum account in your wallet."
+ );
+ return;
+ }
sessionRef.current = session;
- setAddress(addressFromAccount(session.namespaces.eip155?.accounts[0]));
+ setAddress(addr);
setPairingUri(null);
setStatus("connected");
} catch (e) {
console.error("[wc] connect() failed:", e);
setPairingUri(null);
- setStatus("error");
- setError((e as Error).message ?? String(e));
+ if (!sessionRef.current) {
+ setStatus("error");
+ setError((e as Error).message ?? String(e));
+ }
}
}, []);
@@ -168,6 +244,8 @@ export function WalletConnectProvider({
const disconnect = useCallback(async () => {
const client = clientRef.current;
const session = sessionRef.current;
+ // Mark the topic dead BEFORE the relay round-trip
+ if (session) deadTopicsRef.current.add(session.topic);
sessionRef.current = null;
setAddress(null);
setStatus("idle");
@@ -178,7 +256,7 @@ export function WalletConnectProvider({
reason: { code: 6000, message: "User disconnected" },
});
} catch {
- // Already disconnected / topic invalid — state is already cleared.
+ // Relay unreachable or topic already gone.
}
}
}, []);
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
index 1e6efb5..dada777 100644
--- a/src/context/WalletContext.tsx
+++ b/src/context/WalletContext.tsx
@@ -18,6 +18,7 @@ export type ConnectionState =
| "connecting"
| "loading"
| "connected"
+ | "offline"
| "error";
type FetchMode = "initial" | "manual" | "auto";
@@ -100,9 +101,21 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
const cancelledRef = useRef(false);
const fetchIdRef = useRef(0);
const autoFailureStreakRef = useRef(0);
+ const connectionStateRef = useRef("checking");
+
+ useEffect(() => {
+ connectionStateRef.current = connectionState;
+ }, [connectionState]);
const fetchData = useCallback(
async (mode: FetchMode = "auto") => {
+ if (connectionStateRef.current === "offline") {
+ if (mode === "manual") {
+ toast.error("ASP is unreachable. Retry the connection before refreshing.");
+ }
+ return;
+ }
+
const id = ++fetchIdRef.current;
setRefreshing(true);
try {
@@ -182,6 +195,11 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
);
const fetchVtxos = useCallback(async (force = false) => {
+ if (connectionStateRef.current === "offline") {
+ toast.error("ASP is unreachable. Retry the connection before loading coins.");
+ return;
+ }
+
if (!force) {
const age = Date.now() - lastVtxosFetchRef.current;
if (lastVtxosFetchRef.current > 0 && age < VTXOS_STALE_TIME_MS) return;
@@ -230,7 +248,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
const message =
typeof error === "string" ? error : "Failed to connect to ASP";
setConnectionError(message);
- setConnectionState("error");
+ setConnectionState("offline");
});
})
.catch((error) => {
diff --git a/src/router.tsx b/src/router.tsx
index d06312e..23cbe14 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -10,6 +10,7 @@ import { SwapRoute } from "./routes/swap-route";
import { SwapCheckoutRoute } from "./routes/swap-checkout-route";
import { SwapHistoryRoute } from "./routes/swap-history-route";
import { RecoverLnRoute } from "./routes/recover-ln-route";
+import { RecoveryRoute } from "./routes/recovery-route";
import { ProfileRoute } from "./routes/profile-route";
import { AppLayout } from "./components/AppLayout";
@@ -95,6 +96,12 @@ const recoverLnRoute = createRoute({
component: RecoverLnRoute,
});
+const recoveryRoute = createRoute({
+ getParentRoute: () => appLayoutRoute,
+ path: "/recover/exit",
+ component: RecoveryRoute,
+});
+
const profileRoute = createRoute({
getParentRoute: () => appLayoutRoute,
path: "/profile",
@@ -112,6 +119,7 @@ const routeTree = rootRoute.addChildren([
swapCheckoutRoute,
swapHistoryRoute,
recoverLnRoute,
+ recoveryRoute,
profileRoute,
settingsRoute,
]),
diff --git a/src/routes/coins-route.tsx b/src/routes/coins-route.tsx
index 0539a34..67cb930 100644
--- a/src/routes/coins-route.tsx
+++ b/src/routes/coins-route.tsx
@@ -5,6 +5,8 @@ import { formatSats } from "../utils/format";
import { useWallet } from "../context/WalletContext";
import { VtxoCard } from "../components/VtxoCard";
import type { VtxoInfo } from "../components/VtxoCard";
+import { ExitingVtxoCard } from "../components/ExitingVtxoCard";
+import type { UnrolledVtxoMaturity } from "../components/recovery/SweepForm";
import SelectedCoinsSendDrawer from "../components/SelectedCoinsSendDrawer";
interface FeeEstimate {
@@ -16,21 +18,51 @@ interface RenewResult {
txid: string | null;
}
+/// Subset of the recovery screen's ExitSweepStatus that the coins list needs.
+interface ExitSweepStatus {
+ now: number;
+ vtxos: UnrolledVtxoMaturity[];
+}
+
type SortKey = "expiry" | "amount";
-type FilterStatus = "all" | "confirmed" | "preconfirmed" | "recoverable";
+type FilterStatus = "all" | "confirmed" | "preconfirmed" | "recoverable" | "exiting";
const FILTERS: { value: FilterStatus; label: string }[] = [
{ value: "all", label: "All" },
{ value: "confirmed", label: "Spendable" },
{ value: "preconfirmed", label: "Pre-confirmed" },
{ value: "recoverable", label: "Recoverable" },
+ { value: "exiting", label: "Exiting" },
];
+const SETTLE_ELIGIBILITY_WINDOW_SECS = 28 * 24 * 60 * 60;
+
function isExpiring(expiresAt: number): boolean {
const now = Date.now() / 1000;
return (expiresAt - now) / 3600 < 72;
}
+function canSettleVtxo(vtxo: VtxoInfo, now: number): boolean {
+ return vtxo.status === "recoverable" || vtxo.expires_at - now <= SETTLE_ELIGIBILITY_WINDOW_SECS;
+}
+
+function actionForTargets(targets: VtxoInfo[]): "recover" | "settle" | "renew" {
+ if (targets.length > 0 && targets.every((v) => v.status === "recoverable")) return "recover";
+ if (targets.some((v) => v.status === "preconfirmed")) return "settle";
+ return "renew";
+}
+
+function actionText(action: "recover" | "settle" | "renew") {
+ switch (action) {
+ case "recover":
+ return { label: "Recover", active: "Recovering...", done: "recovered" };
+ case "settle":
+ return { label: "Settle", active: "Settling...", done: "settled" };
+ case "renew":
+ return { label: "Renew", active: "Renewing...", done: "renewed" };
+ }
+}
+
export function CoinsRoute() {
const {
fetchData,
@@ -50,20 +82,40 @@ export function CoinsRoute() {
const [selectMode, setSelectMode] = useState(false);
const [selectedKeys, setSelectedKeys] = useState>(new Set());
const [sendDrawerOpen, setSendDrawerOpen] = useState(false);
+ const [exitSweep, setExitSweep] = useState(null);
const loading = !vtxosLoaded;
- // On mount, refresh in the background. fetchVtxos() (no-arg) skips the
- // network call when the cache is still fresh, so re-navigating between
- // tabs is cheap; first-ever mount triggers the full load.
+ // VTXOs mid unilateral-exit. Best-effort: needs the ASP (CSV delay) plus
+ // esplora, and most wallets have none — failure just hides the section.
+ const fetchExiting = useCallback(async () => {
+ try {
+ setExitSweep(await invoke("unilateral_exit_sweep_status"));
+ } catch {
+ setExitSweep(null);
+ }
+ }, []);
+
useEffect(() => {
void fetchVtxos();
- }, [fetchVtxos]);
+ void fetchExiting();
+ }, [fetchVtxos, fetchExiting]);
+
+ const nowSecs = Date.now() / 1000;
- const nowSecs = useMemo(() => Date.now() / 1000, [vtxos]);
+ // The ASP's vtxo list and the exiting list overlap during the window where
+ // an exit leaf is in the mempool but the ASP hasn't flagged the VTXO
+ // unrolled yet
+ const spendableVtxos = useMemo(() => {
+ const exiting = exitSweep?.vtxos;
+ if (!exiting || exiting.length === 0) return vtxos;
+ const exitingKeys = new Set(exiting.map((v) => `${v.txid}:${v.vout}`));
+ return vtxos.filter((v) => !exitingKeys.has(`${v.txid}:${v.vout}`));
+ }, [vtxos, exitSweep]);
const filtered = useMemo(() => {
- let result = vtxos;
+ if (filter === "exiting") return [];
+ let result = spendableVtxos;
if (filter !== "all") {
result = result.filter((v) => v.status === filter);
}
@@ -71,21 +123,37 @@ export function CoinsRoute() {
if (sortKey === "expiry") return a.expires_at - b.expires_at;
return b.amount_sat - a.amount_sat;
});
- }, [vtxos, filter, sortKey]);
+ }, [spendableVtxos, filter, sortKey]);
+
+
+ const shownExiting = useMemo(
+ () => (filter === "all" || filter === "exiting" ? (exitSweep?.vtxos ?? []) : []),
+ [filter, exitSweep],
+ );
+
+ const preconfirmedVtxos = useMemo(
+ () => spendableVtxos.filter((v) => v.status === "preconfirmed"),
+ [spendableVtxos],
+ );
+
+ const settleablePreconfirmedVtxos = useMemo(
+ () => preconfirmedVtxos.filter((v) => canSettleVtxo(v, nowSecs)),
+ [preconfirmedVtxos, nowSecs],
+ );
const expiringVtxos = useMemo(
- () => vtxos.filter((v) => v.status !== "recoverable" && isExpiring(v.expires_at)),
- [vtxos],
+ () => spendableVtxos.filter((v) => v.status === "confirmed" && isExpiring(v.expires_at)),
+ [spendableVtxos],
);
const recoverableVtxos = useMemo(
- () => vtxos.filter((v) => v.status === "recoverable"),
- [vtxos],
+ () => spendableVtxos.filter((v) => v.status === "recoverable"),
+ [spendableVtxos],
);
const selectedVtxos = useMemo(
- () => vtxos.filter((v) => selectedKeys.has(`${v.txid}:${v.vout}`)),
- [vtxos, selectedKeys],
+ () => spendableVtxos.filter((v) => selectedKeys.has(`${v.txid}:${v.vout}`)),
+ [spendableVtxos, selectedKeys],
);
const selectedTotalSat = useMemo(
@@ -93,28 +161,43 @@ export function CoinsRoute() {
[selectedVtxos],
);
+ const selectedSettleableVtxos = useMemo(
+ () => selectedVtxos.filter((v) => canSettleVtxo(v, nowSecs)),
+ [selectedVtxos, nowSecs],
+ );
+
+ const selectedSkippedVtxos = selectedVtxos.length - selectedSettleableVtxos.length;
+
const startRenew = useCallback(async (targets: VtxoInfo[]) => {
if (renewing) return;
- if (targets.length === 0) {
- toast.info("Nothing to renew");
+ const eligibleTargets = targets.filter((v) => canSettleVtxo(v, Date.now() / 1000));
+ const skippedTargets = targets.length - eligibleTargets.length;
+
+ if (eligibleTargets.length === 0) {
+ const action = actionText(actionForTargets(targets));
+ toast.info(`No VTXOs are ready to ${action.label.toLowerCase()} yet`);
return;
}
+ if (skippedTargets > 0) {
+ toast.info(`${skippedTargets} VTXO${skippedTargets > 1 ? "s" : ""} skipped; not close enough to expiry yet`);
+ }
if (quickRenew) {
// Skip confirmation
+ const action = actionText(actionForTargets(eligibleTargets));
setRenewing(true);
- const outpoints = targets.map((v) => `${v.txid}:${v.vout}`);
+ const outpoints = eligibleTargets.map((v) => `${v.txid}:${v.vout}`);
try {
const result = await invoke("renew_vtxos", { outpoints });
if (result.renewed) {
- toast.success(`${outpoints.length} VTXO${outpoints.length > 1 ? "s" : ""} renewed${result.txid ? ` (txid: ${result.txid})` : ""}`);
+ toast.success(`${outpoints.length} VTXO${outpoints.length > 1 ? "s" : ""} ${action.done}${result.txid ? ` (txid: ${result.txid})` : ""}`);
void fetchVtxos(true);
void fetchData();
} else {
- toast.info("Nothing to renew");
+ toast.info(`Nothing to ${action.label.toLowerCase()}`);
}
} catch (e) {
- toast.error(typeof e === "string" ? e : "Failed to renew VTXOs");
+ toast.error(typeof e === "string" ? e : `Failed to ${action.label.toLowerCase()} VTXOs`);
} finally {
setRenewing(false);
}
@@ -122,8 +205,8 @@ export function CoinsRoute() {
}
setRenewing(true);
- setPendingRenewTargets(targets);
- const outpoints = targets.map((v) => `${v.txid}:${v.vout}`);
+ setPendingRenewTargets(eligibleTargets);
+ const outpoints = eligibleTargets.map((v) => `${v.txid}:${v.vout}`);
try {
const fee = await invoke("estimate_renew_fees", { outpoints });
setFeeEstimate(fee);
@@ -138,6 +221,7 @@ export function CoinsRoute() {
const confirmRenew = useCallback(async () => {
const targets = pendingRenewTargets ?? [];
+ const action = actionText(actionForTargets(targets));
const outpoints = targets.map((v) => `${v.txid}:${v.vout}`);
setRenewing(true);
setShowRenewConfirm(false);
@@ -145,19 +229,27 @@ export function CoinsRoute() {
try {
const result = await invoke("renew_vtxos", { outpoints });
if (result.renewed) {
- toast.success(`${outpoints.length} VTXO${outpoints.length > 1 ? "s" : ""} renewed${result.txid ? ` (txid: ${result.txid})` : ""}`);
+ toast.success(`${outpoints.length} VTXO${outpoints.length > 1 ? "s" : ""} ${action.done}${result.txid ? ` (txid: ${result.txid})` : ""}`);
+ setSelectMode(false);
+ setSelectedKeys(new Set());
void fetchVtxos(true);
void fetchData();
} else {
- toast.info("Nothing to renew");
+ toast.info(`Nothing to ${action.label.toLowerCase()}`);
}
} catch (e) {
- toast.error(typeof e === "string" ? e : "Failed to renew VTXOs");
+ toast.error(typeof e === "string" ? e : `Failed to ${action.label.toLowerCase()} VTXOs`);
} finally {
setRenewing(false);
}
}, [pendingRenewTargets, fetchVtxos, fetchData]);
+ const pendingRenewAction = actionText(actionForTargets(pendingRenewTargets ?? []));
+ const selectedRenewAction = actionText(actionForTargets(selectedSettleableVtxos));
+ const selectedSettleLabel = selectedSettleableVtxos.length > 0
+ ? `${selectedRenewAction.label} ${selectedSettleableVtxos.length} selected`
+ : "No ready VTXOs";
+
const toggleSelectMode = useCallback(() => {
setExpandedId(null);
setShowRenewConfirm(false);
@@ -213,7 +305,10 @@ export function CoinsRoute() {
{selectMode ? "Cancel" : "Select"}
{/* Action buttons */}
- {!selectMode && !showRenewConfirm && (expiringVtxos.length > 0 || recoverableVtxos.length > 0) && (
+ {!selectMode && !showRenewConfirm && (settleablePreconfirmedVtxos.length > 0 || expiringVtxos.length > 0 || recoverableVtxos.length > 0) && (
{recoverableVtxos.length > 0 && (
)}
+ {settleablePreconfirmedVtxos.length > 0 && (
+
+ )}
{expiringVtxos.length > 0 && (