From f88495691b034d3a2ea7c710f2395d18e5c2993c Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Thu, 14 May 2026 11:43:22 +0100 Subject: [PATCH 1/5] Add app version display with release build validation --- .github/workflows/release.yml | 18 ++++++++++++++++++ src/routes/settings-route.tsx | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c36cfd9..90d29ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,6 +137,24 @@ jobs: - name: Install JS dependencies run: pnpm install --frozen-lockfile + # Vite inlines VITE_* at build time — an empty value silently bakes a + # broken config into the signed APK with a fully green pipeline. Fail + # loudly here instead. These are repo Variables (Settings → Secrets and + # variables → Actions → Variables), not Secrets — `vars.*` only sees + # the Variables tab. + - name: Verify release build env is populated + env: + VITE_WALLETCONNECT_PROJECT_ID: ${{ vars.VITE_WALLETCONNECT_PROJECT_ID }} + VITE_DEFAULT_ASP_URL: ${{ vars.VITE_DEFAULT_ASP_URL }} + run: | + missing=() + [ -n "$VITE_WALLETCONNECT_PROJECT_ID" ] || missing+=(VITE_WALLETCONNECT_PROJECT_ID) + [ -n "$VITE_DEFAULT_ASP_URL" ] || missing+=(VITE_DEFAULT_ASP_URL) + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Release build env vars are empty: ${missing[*]}. Add them in repo Settings → Secrets and variables → Actions → Variables tab (not Secrets — vars.* can't read Secrets)." + exit 1 + fi + - name: Write release keystore env: KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} diff --git a/src/routes/settings-route.tsx b/src/routes/settings-route.tsx index 5a799d6..8d1ec1e 100644 --- a/src/routes/settings-route.tsx +++ b/src/routes/settings-route.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { getVersion } from "@tauri-apps/api/app"; import { toast } from "sonner"; import { Link, useNavigate } from "@tanstack/react-router"; import { useTheme } from "../context/ThemeContext"; @@ -475,6 +476,7 @@ export function SettingsRoute() { } = useFiat(); const [pinFlow, setPinFlow] = useState<"none" | "setup" | "disable">("none"); const [maxAttempts, setMaxAttempts] = useState(10); + const [appVersion, setAppVersion] = useState(null); const [nsecStep, setNsecStep] = useState<"hidden" | "confirm" | "revealed">("hidden"); const [nsec, setNsec] = useState(null); const [loadingNsec, setLoadingNsec] = useState(false); @@ -502,6 +504,12 @@ export function SettingsRoute() { .catch(() => {}); }, [pinEnabled]); + useEffect(() => { + getVersion() + .then(setAppVersion) + .catch(() => {}); + }, []); + const handleRevealSeed = async () => { if (mnemonic) { setSeedStep("revealed"); @@ -958,7 +966,9 @@ export function SettingsRoute() {
Version - 0.1.0 + + {appVersion ?? "—"} +
From d996533e7827e66422d8b785608237101fd0d3da Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Thu, 14 May 2026 12:07:43 +0100 Subject: [PATCH 2/5] improve explorer selection --- src/routes/settings-route.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/routes/settings-route.tsx b/src/routes/settings-route.tsx index 8d1ec1e..5b4fe86 100644 --- a/src/routes/settings-route.tsx +++ b/src/routes/settings-route.tsx @@ -21,8 +21,10 @@ interface SettingsData { esplora_url: string | null; } +// Blockstream is the default — an unset `esplora_url` resolves to this. +const DEFAULT_EXPLORER_URL = "https://blockstream.info/api"; const PRESET_EXPLORERS = [ - { label: "Blockstream", url: "https://blockstream.info/api" }, + { label: "Blockstream", url: DEFAULT_EXPLORER_URL }, { label: "Mempool.space", url: "https://mempool.space/api" }, ]; const PRESET_URLS = new Set(PRESET_EXPLORERS.map((e) => e.url)); @@ -38,9 +40,12 @@ function EsploraSelector({ saving: boolean; onSave: (url: string | null) => void; }) { - const isCustom = value !== "" && !PRESET_URLS.has(value); - // Blockstream is the default — saving it is equivalent to clearing - const urlToSave = value === "https://blockstream.info/api" || value === "" ? null : value; + // An unset value means "default", which is Blockstream — resolve it so a + // radio reflects the active explorer instead of showing nothing selected. + const effectiveValue = value === "" ? DEFAULT_EXPLORER_URL : value; + const isCustom = !PRESET_URLS.has(effectiveValue); + // Saving Blockstream (the default) is equivalent to clearing the override. + const urlToSave = effectiveValue === DEFAULT_EXPLORER_URL ? null : effectiveValue; return (
@@ -51,11 +56,11 @@ function EsploraSelector({ key={option.url} onClick={() => onChange(option.url)} className={`w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${ - value === option.url ? "theme-accent-bg" : "theme-card-elevated" + effectiveValue === option.url ? "theme-accent-bg" : "theme-card-elevated" }`} > {option.label} From 3962d3d05e8dd17512db901c2906f6f2378e6d11 Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Thu, 14 May 2026 15:10:38 +0100 Subject: [PATCH 3/5] feat(coins): add coin-control send for explicit VTXO selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a coin-control path so the user can spend specific coins: - send_ark_selected command — wraps the SDK's send_vtxo_selection to spend exactly the named outpoints, skipping automatic selection. Ark-only; the SDK has no onchain-offboard selection variant. - Coins screen "Select" mode — pick VTXOs, then a drawer to send them to an Ark address --- src-tauri/src/commands/coins.rs | 2 +- src-tauri/src/commands/send.rs | 55 ++++ src-tauri/src/lib.rs | 1 + src/components/SelectedCoinsSendDrawer.tsx | 291 +++++++++++++++++++++ src/components/VtxoCard.tsx | 26 +- src/routes/coins-route.tsx | 133 ++++++++-- 6 files changed, 479 insertions(+), 29 deletions(-) create mode 100644 src/components/SelectedCoinsSendDrawer.tsx diff --git a/src-tauri/src/commands/coins.rs b/src-tauri/src/commands/coins.rs index 32d1089..ec84b47 100644 --- a/src-tauri/src/commands/coins.rs +++ b/src-tauri/src/commands/coins.rs @@ -6,7 +6,7 @@ use tracing::info; use crate::{ark, AppError, GlobalWalletState}; -fn parse_outpoints(outpoints: &[String]) -> Result, AppError> { +pub(crate) fn parse_outpoints(outpoints: &[String]) -> Result, AppError> { outpoints .iter() .map(|s| { diff --git a/src-tauri/src/commands/send.rs b/src-tauri/src/commands/send.rs index 453753a..366d673 100644 --- a/src-tauri/src/commands/send.rs +++ b/src-tauri/src/commands/send.rs @@ -259,6 +259,61 @@ pub async fn send_ark( }) } +/// Send an Ark payment spending **only** the named VTXO outpoints, bypassing +/// automatic coin selection. +/// +/// `send_ark` lets the SDK pick VTXOs automatically. That breaks once the +/// wallet holds an asset-carrying VTXO: the asset-blind SDK builds a plain-BTC +/// tx with no asset packet and the ASP rejects it (`T_VALIDATION_FAILED`). +/// Naming the exact coins to spend lets the caller exclude the asset VTXO. +#[tauri::command] +pub async fn send_ark_selected( + app: tauri::AppHandle, + address: String, + amount_sat: u64, + outpoints: Vec, +) -> Result { + if amount_sat == 0 { + return Err(AppError::Wallet("Amount must be greater than zero".into())); + } + if outpoints.is_empty() { + return Err(AppError::Wallet("Select at least one coin to spend".into())); + } + + let client = { + let state = app.state::(); + let guard = state.0.read().await; + let ws = guard + .as_ref() + .ok_or_else(|| AppError::Wallet("Wallet not connected".into()))?; + Arc::clone(&ws.client) + }; + + let ark_addr = ark_core::ArkAddress::decode(&address) + .map_err(|e| AppError::Wallet(format!("Invalid Ark address: {e}")))?; + let vtxo_outpoints = super::coins::parse_outpoints(&outpoints)?; + let amount = bitcoin::Amount::from_sat(amount_sat); + + info!( + address = %address, + amount_sat = amount_sat, + coins = vtxo_outpoints.len(), + "sending Ark payment with explicit VTXO selection" + ); + + let txid = client + .send_vtxo_selection(&vtxo_outpoints, ark_addr, amount) + .await + .map_err(|e| AppError::Wallet(format!("Send failed: {e}")))?; + + info!(txid = %txid, "Ark payment sent (selected coins)"); + + Ok(SendResult { + txid: txid.to_string(), + pending_ln_swap_id: None, + }) +} + #[tauri::command] pub async fn send_onchain( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dbc786d..9900967 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -334,6 +334,7 @@ pub fn run() { commands::send::detect_address_type, commands::send::send_lightning, commands::send::send_ark, + commands::send::send_ark_selected, commands::send::send_onchain, commands::send::estimate_onchain_send_fee, // PIN security diff --git a/src/components/SelectedCoinsSendDrawer.tsx b/src/components/SelectedCoinsSendDrawer.tsx new file mode 100644 index 0000000..152ff6d --- /dev/null +++ b/src/components/SelectedCoinsSendDrawer.tsx @@ -0,0 +1,291 @@ +import { useMemo, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import { Drawer } from "vaul"; +import { useKeyboardInset } from "../hooks/useKeyboardInset"; +import { formatSats } from "../utils/format"; +import type { VtxoInfo } from "./VtxoCard"; + +interface SelectedCoinsSendDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedVtxos: VtxoInfo[]; + onSuccess: () => void; +} + +interface SendResult { + txid: string; +} + +type Step = "form" | "sending" | "success" | "error"; + +// Spends exactly the picked VTXOs via `send_ark_selected`, bypassing the +// SDK's automatic coin selection. Ark-only: there is no selection variant +// for onchain offboarding in the SDK. +function DrawerBody({ + selectedVtxos, + onSuccess, + onClose, +}: { + selectedVtxos: VtxoInfo[]; + onSuccess: () => void; + onClose: () => void; +}) { + const totalSat = useMemo( + () => selectedVtxos.reduce((sum, v) => sum + v.amount_sat, 0), + [selectedVtxos], + ); + + const [address, setAddress] = useState(""); + // Pre-fill the full selected total — the common case here is sweeping + // funds out. The user can edit it down to leave change behind. + const [amountInput, setAmountInput] = useState(String(totalSat)); + const [step, setStep] = useState("form"); + const [txid, setTxid] = useState(null); + const [error, setError] = useState(null); + + const amountSat = /^\d+$/.test(amountInput) ? Number(amountInput) : null; + const overTotal = amountSat !== null && amountSat > totalSat; + const canSend = + selectedVtxos.length > 0 && + address.trim().length > 0 && + amountSat !== null && + amountSat > 0 && + !overTotal; + + const handleConfirm = async () => { + if (!canSend || amountSat === null) return; + setStep("sending"); + setError(null); + try { + const outpoints = selectedVtxos.map((v) => `${v.txid}:${v.vout}`); + const result = await invoke("send_ark_selected", { + address: address.trim(), + amountSat, + outpoints, + }); + setTxid(result.txid); + setStep("success"); + onSuccess(); + } catch (e) { + setError(typeof e === "string" ? e : "Send failed"); + setStep("error"); + } + }; + + if (step === "sending") { + return ( +
+ + + + +

Sending...

+
+ ); + } + + if (step === "success") { + return ( +
+
+ + + +
+

Sent!

+

+ {amountSat != null ? formatSats(amountSat) : ""} sats via Ark +

+ {txid && ( + + )} + +
+ ); + } + + if (step === "error") { + return ( +
+
+ + + + + +
+

Failed

+

{error}

+
+ + +
+
+ ); + } + + // ── form ───────────────────────────────────────────────────── + return ( +
+
+
+ Spending + + {selectedVtxos.length} coin{selectedVtxos.length === 1 ? "" : "s"} + +
+
+ Total selected + + {formatSats(totalSat)} sats + +
+
+ +
+ + setAddress(e.target.value)} + autoCapitalize="none" + autoCorrect="off" + className="w-full rounded-xl theme-card px-4 py-3 text-sm theme-text outline-none placeholder:opacity-20 font-mono" + /> +

+ Ark address only — onchain offboarding can't target specific coins. +

+
+ +
+ +
+ { + const v = e.target.value; + if (v === "" || /^\d+$/.test(v)) setAmountInput(v); + }} + className="flex-1 bg-transparent text-sm font-medium theme-text outline-none placeholder:opacity-20 tabular-nums" + /> + sats +
+
+ {overTotal ? ( +

Exceeds selected coins

+ ) : ( + + )} + +
+
+ + +
+ ); +} + +function SelectedCoinsSendDrawer({ + open, + onOpenChange, + selectedVtxos, + onSuccess, +}: SelectedCoinsSendDrawerProps) { + const kbInset = useKeyboardInset(); + return ( + + + + 0 ? `calc(100dvh - ${kbInset}px - 16px)` : undefined, + bottom: kbInset, + paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 32px)", + }} + > + + + Send selected coins + + + Spend exactly the VTXOs you picked, bypassing automatic coin selection + +
+ {open && ( + onOpenChange(false)} + /> + )} +
+
+
+
+ ); +} + +export default SelectedCoinsSendDrawer; diff --git a/src/components/VtxoCard.tsx b/src/components/VtxoCard.tsx index 552f95d..e2851fa 100644 --- a/src/components/VtxoCard.tsx +++ b/src/components/VtxoCard.tsx @@ -46,6 +46,9 @@ interface VtxoCardProps { canAct: boolean; onToggle: () => void; onAction: () => void; + selectable?: boolean; + selected?: boolean; + onToggleSelect?: () => void; } export const VtxoCard = memo(function VtxoCard({ @@ -55,19 +58,32 @@ export const VtxoCard = memo(function VtxoCard({ canAct, onToggle, onAction, + selectable = false, + selected = false, + onToggleSelect, }: VtxoCardProps) { const expired = vtxo.expires_at < now; const expiring = (vtxo.expires_at - now) / 3600 < 72; const isRecoverable = vtxo.status === "recoverable"; - const showAction = canAct && (isRecoverable || expiring); + // No renew/recover affordance while picking coins — it would just be noise. + const showAction = canAct && !selectable && (isRecoverable || expiring); return (
-
+ {selectable && ( + + )} +
{formatSats(vtxo.amount_sat)}{" "} @@ -106,7 +122,7 @@ export const VtxoCard = memo(function VtxoCard({ )}
- {expanded && ( + {expanded && !selectable && (
diff --git a/src/routes/coins-route.tsx b/src/routes/coins-route.tsx index 1719a06..0539a34 100644 --- a/src/routes/coins-route.tsx +++ b/src/routes/coins-route.tsx @@ -5,6 +5,7 @@ import { formatSats } from "../utils/format"; import { useWallet } from "../context/WalletContext"; import { VtxoCard } from "../components/VtxoCard"; import type { VtxoInfo } from "../components/VtxoCard"; +import SelectedCoinsSendDrawer from "../components/SelectedCoinsSendDrawer"; interface FeeEstimate { fee_sat: number; @@ -46,6 +47,9 @@ export function CoinsRoute() { const [feeEstimate, setFeeEstimate] = useState(null); const [showRenewConfirm, setShowRenewConfirm] = useState(false); const [pendingRenewTargets, setPendingRenewTargets] = useState(null); + const [selectMode, setSelectMode] = useState(false); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [sendDrawerOpen, setSendDrawerOpen] = useState(false); const loading = !vtxosLoaded; @@ -79,6 +83,16 @@ export function CoinsRoute() { [vtxos], ); + const selectedVtxos = useMemo( + () => vtxos.filter((v) => selectedKeys.has(`${v.txid}:${v.vout}`)), + [vtxos, selectedKeys], + ); + + const selectedTotalSat = useMemo( + () => selectedVtxos.reduce((sum, v) => sum + v.amount_sat, 0), + [selectedVtxos], + ); + const startRenew = useCallback(async (targets: VtxoInfo[]) => { if (renewing) return; if (targets.length === 0) { @@ -144,6 +158,33 @@ export function CoinsRoute() { } }, [pendingRenewTargets, fetchVtxos, fetchData]); + const toggleSelectMode = useCallback(() => { + setExpandedId(null); + setShowRenewConfirm(false); + setPendingRenewTargets(null); + setSelectMode((on) => { + if (on) setSelectedKeys(new Set()); + return !on; + }); + }, []); + + const toggleCoin = useCallback((key: string) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const handleSendSuccess = useCallback(() => { + setSendDrawerOpen(false); + setSelectMode(false); + setSelectedKeys(new Set()); + void fetchVtxos(true); + void fetchData(); + }, [fetchVtxos, fetchData]); + if (loading) { return (
@@ -160,30 +201,42 @@ export function CoinsRoute() {

Coins

- + {selectMode ? "Cancel" : "Select"} + + +
{/* Action buttons */} - {!showRenewConfirm && (expiringVtxos.length > 0 || recoverableVtxos.length > 0) && ( + {!selectMode && !showRenewConfirm && (expiringVtxos.length > 0 || recoverableVtxos.length > 0) && (
{recoverableVtxos.length > 0 && (
) : ( -
+
{filtered.map((vtxo) => { const key = `${vtxo.txid}:${vtxo.vout}`; return ( @@ -295,15 +348,49 @@ export function CoinsRoute() { vtxo={vtxo} now={nowSecs} expanded={expandedId === key} - canAct={!showRenewConfirm && !renewing} + canAct={!selectMode && !showRenewConfirm && !renewing} onToggle={() => setExpandedId(expandedId === key ? null : key)} onAction={() => void startRenew([vtxo])} + selectable={selectMode && vtxo.status !== "recoverable"} + selected={selectedKeys.has(key)} + onToggleSelect={() => toggleCoin(key)} /> ); })}
)}
+ + {/* Coin-control send footer — sits just above the bottom nav */} + {selectMode && ( +
+
+ + {selectedVtxos.length} selected + + + {formatSats(selectedTotalSat)} sats + +
+ +
+ )} + + ); } From c24b9b322f774f6e2029fde23fffbf7801eb0d2a Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Fri, 15 May 2026 00:59:06 +0100 Subject: [PATCH 4/5] feat(sync): make wallet sync resilient to flaky mobile networks - Add an exponential-backoff retry layer for transient esplora transport errors and a socket timeout so a stalled connection can't hang a syn cycle. - Gate sync-failure UI toasts behind a sustained failure streak so a lone device-sleep failure stays silent while real outages still get reported. - Make the Esplora explorer presets network-aware. --- src-tauri/Cargo.toml | 5 + src-tauri/src/ark.rs | 187 ++++++++++++++++++++++++--- src-tauri/src/commands/wallet.rs | 152 ++++++++++++++++++++-- src/context/WalletContext.tsx | 73 +++++++---- src/routes/settings-route.tsx | 58 +++++++-- src/utils/fetchFailureStreak.test.ts | 37 ++++++ src/utils/fetchFailureStreak.ts | 25 ++++ 7 files changed, 466 insertions(+), 71 deletions(-) create mode 100644 src/utils/fetchFailureStreak.test.ts create mode 100644 src/utils/fetchFailureStreak.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d9b011..f4aab5b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -71,6 +71,11 @@ keyring = { version = "3", features = ["sync-secret-service", "apple-native", "w jni = "0.21" ndk-context = "0.1" +[dev-dependencies] +# Async test harness for the esplora retry layer. The runtime/macros features +# aren't needed at runtime — Tauri supplies the tokio runtime in production. +tokio = { version = "1", features = ["macros", "rt", "time"] } + [features] vendored-openssl = ["dep:openssl"] diff --git a/src-tauri/src/ark.rs b/src-tauri/src/ark.rs index 8ad2f41..8f65629 100644 --- a/src-tauri/src/ark.rs +++ b/src-tauri/src/ark.rs @@ -6,25 +6,90 @@ use esplora_client::OutputStatus; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::RwLock; +use std::time::Duration; use zeroize::Zeroize; +/// Total attempts (first try included) for a transient esplora request. +const ESPLORA_MAX_ATTEMPTS: usize = 3; +/// Backoff before the first retry; doubles for each subsequent retry. +const ESPLORA_RETRY_BASE_DELAY: Duration = Duration::from_millis(300); +/// Socket timeout for esplora HTTP requests. Without it a stalled connection +/// hangs an entire sync cycle indefinitely. +const ESPLORA_TIMEOUT_SECS: u64 = 30; + pub struct EsploraBlockchain { client: esplora_client::AsyncClient, } impl EsploraBlockchain { pub fn new(url: &str) -> Result { - let client = esplora_client::Builder::new(url).build_async_with_sleeper()?; + let client = esplora_client::Builder::new(url) + .timeout(ESPLORA_TIMEOUT_SECS) + .build_async_with_sleeper()?; Ok(Self { client }) } } +/// Whether an esplora error is a transport-level failure worth retrying. +/// +/// `Reqwest` covers connection resets, incomplete responses, mid-handshake TLS +/// aborts and sporadic DNS failures — rampant on hostile mobile networks and +/// almost always gone on the next attempt. `HttpResponse` is deliberately +/// excluded: esplora-client already retries 429/500/503 internally, and other +/// status codes are not transient. +fn is_transient_esplora_error(e: &esplora_client::Error) -> bool { + matches!(e, esplora_client::Error::Reqwest(_)) +} + +/// Retry an idempotent async operation while `is_transient` keeps returning +/// true, up to `max_attempts` total, with exponential backoff from `base_delay`. +async fn retry_transient( + max_attempts: usize, + base_delay: Duration, + is_transient: impl Fn(&E) -> bool, + mut op: F, +) -> Result +where + E: std::fmt::Display, + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut attempt = 1usize; + loop { + match op().await { + Ok(value) => return Ok(value), + Err(e) if attempt < max_attempts && is_transient(&e) => { + let delay = base_delay * 2u32.pow((attempt - 1) as u32); + tracing::debug!(attempt, %e, "transient esplora error; retrying after {delay:?}"); + tokio::time::sleep(delay).await; + attempt += 1; + } + Err(e) => return Err(e), + } + } +} + +/// [`retry_transient`] specialized for esplora reads. Never use this for +/// `broadcast`: a lost response on an already-accepted submission would re-POST +/// the transaction and surface a spurious "already known" error. +async fn esplora_retry(op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + retry_transient( + ESPLORA_MAX_ATTEMPTS, + ESPLORA_RETRY_BASE_DELAY, + is_transient_esplora_error, + op, + ) + .await +} + impl Blockchain for EsploraBlockchain { async fn find_outpoints(&self, address: &Address) -> Result, Error> { let script_pubkey = address.script_pubkey(); - let txs = self - .client - .scripthash_txs(&script_pubkey, None) + let txs = esplora_retry(|| self.client.scripthash_txs(&script_pubkey, None)) .await .map_err(Error::consumer)?; @@ -51,11 +116,12 @@ impl Blockchain for EsploraBlockchain { let mut utxos = Vec::with_capacity(candidates.len()); for output in candidates { - let status = self - .client - .get_output_status(&output.outpoint.txid, output.outpoint.vout as u64) - .await - .map_err(Error::consumer)?; + let status = esplora_retry(|| { + self.client + .get_output_status(&output.outpoint.txid, output.outpoint.vout as u64) + }) + .await + .map_err(Error::consumer)?; utxos.push(match status { Some(OutputStatus { spent: true, .. }) => ExplorerUtxo { @@ -70,13 +136,13 @@ impl Blockchain for EsploraBlockchain { } async fn find_tx(&self, txid: &Txid) -> Result, Error> { - self.client.get_tx(txid).await.map_err(Error::consumer) + esplora_retry(|| self.client.get_tx(txid)) + .await + .map_err(Error::consumer) } async fn get_tx_status(&self, txid: &Txid) -> Result { - let info = self - .client - .get_tx_info(txid) + let info = esplora_retry(|| self.client.get_tx_info(txid)) .await .map_err(Error::consumer)?; @@ -86,9 +152,7 @@ impl Blockchain for EsploraBlockchain { } async fn get_output_status(&self, txid: &Txid, vout: u32) -> Result { - let status = self - .client - .get_output_status(txid, vout as u64) + let status = esplora_retry(|| self.client.get_output_status(txid, vout as u64)) .await .map_err(Error::consumer)?; @@ -102,9 +166,7 @@ impl Blockchain for EsploraBlockchain { } async fn get_fee_rate(&self) -> Result { - let estimates = self - .client - .get_fee_estimates() + let estimates = esplora_retry(|| self.client.get_fee_estimates()) .await .map_err(Error::consumer)?; // Target ~6 blocks confirmation, fall back to 1.0 sat/vB @@ -648,4 +710,91 @@ mod tests { fn esplora_url_empty_custom_uses_default() { assert!(esplora_url(bitcoin::Network::Bitcoin, Some("")).contains("blockstream.info")); } + + #[test] + fn http_response_errors_are_not_transient() { + // esplora-client already retries 429/500/503 internally — re-retrying + // here would just double the load. Other status codes aren't transient. + let e = esplora_client::Error::HttpResponse { + status: 503, + message: "service unavailable".into(), + }; + assert!(!is_transient_esplora_error(&e)); + } + + #[tokio::test] + async fn retry_transient_succeeds_without_retrying() { + let attempts = std::cell::Cell::new(0); + let result: Result<&str, String> = retry_transient( + 3, + Duration::ZERO, + |_| true, + || { + attempts.set(attempts.get() + 1); + async { Ok("ok") } + }, + ) + .await; + assert_eq!(result.unwrap(), "ok"); + assert_eq!(attempts.get(), 1, "a first-try success must not retry"); + } + + #[tokio::test] + async fn retry_transient_recovers_after_transient_failures() { + let attempts = std::cell::Cell::new(0); + let result: Result<&str, String> = retry_transient( + 3, + Duration::ZERO, + |_| true, + || { + let n = attempts.get() + 1; + attempts.set(n); + async move { + if n < 3 { + Err(format!("transient {n}")) + } else { + Ok("recovered") + } + } + }, + ) + .await; + assert_eq!(result.unwrap(), "recovered"); + assert_eq!(attempts.get(), 3); + } + + #[tokio::test] + async fn retry_transient_stops_at_max_attempts() { + let attempts = std::cell::Cell::new(0); + let result: Result<&str, String> = retry_transient( + 3, + Duration::ZERO, + |_| true, + || { + let n = attempts.get() + 1; + attempts.set(n); + async move { Err(format!("fail {n}")) } + }, + ) + .await; + assert_eq!(result.unwrap_err(), "fail 3"); + assert_eq!(attempts.get(), 3, "must give up after max_attempts"); + } + + #[tokio::test] + async fn retry_transient_does_not_retry_non_transient_errors() { + let attempts = std::cell::Cell::new(0); + let result: Result<&str, String> = retry_transient( + 3, + Duration::ZERO, + |_| false, + || { + attempts.set(attempts.get() + 1); + async { Err("permanent".to_string()) } + }, + ) + .await; + assert_eq!(result.unwrap_err(), "permanent"); + assert_eq!(attempts.get(), 1, "non-transient errors fail immediately"); + } } diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index e7a1359..b277f48 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -51,42 +51,78 @@ fn friendly_sync_error(raw: &str) -> String { "Onchain sync paused — rate limited by block explorer. Will retry automatically.".into() } else if lower.contains("timeout") || lower.contains("timed out") { "Onchain sync failed — request timed out. Will retry automatically.".into() - } else if lower.contains("connection") || lower.contains("dns") || lower.contains("resolve") { + } else if lower.contains("connection") + || lower.contains("dns") + || lower.contains("resolve") + || lower.contains("incompletemessage") + || lower.contains("incomplete message") + || lower.contains("reset by peer") + || lower.contains("ssl") + || lower.contains("tls") + || lower.contains("handshake") + { "Onchain sync failed — network error. Check your connection.".into() } else { "Onchain sync failed — will retry automatically.".into() } } +/// Minimum interval between UI error notifications for consecutive sync failures. +const ERROR_NOTIFY_COOLDOWN: Duration = Duration::from_secs(5 * 60); + +/// Number of *consecutive* background sync failures before the first UI toast. +/// +/// A single failure is almost always transient — the device went to sleep and +/// the OS suspended the network/process, so the in-flight `wallet.sync()` fails +/// once and then recovers on the next tick after wake. Surfacing a toast for +/// that is pure noise. A sustained outage produces many consecutive failures +/// and still gets reported, just a few backoff cycles later. +const MIN_FAILURES_BEFORE_NOTIFY: u32 = 3; + +/// Whether a run of consecutive sync failures warrants a UI toast: the failure +/// streak must be sustained *and* the per-notification cooldown must have +/// elapsed. `since_last_notify` is `None` when no toast has fired yet. +fn should_notify_sync_failure( + consecutive_failures: u32, + since_last_notify: Option, +) -> bool { + if consecutive_failures < MIN_FAILURES_BEFORE_NOTIFY { + return false; + } + since_last_notify + .map(|d| d >= ERROR_NOTIFY_COOLDOWN) + .unwrap_or(true) +} + /// Spawn a background task that periodically syncs the onchain wallet. /// /// Returns a `watch::Sender` whose drop signals the task to stop. /// -/// The loop emits `wallet-sync-error` events to the UI, throttled to at most -/// once per `ERROR_NOTIFY_COOLDOWN` of consecutive failures so the user stays -/// informed without being spammed. +/// The loop emits `wallet-sync-error` events to the UI, gated by +/// `should_notify_sync_failure` so a lone device-sleep failure stays silent +/// and sustained outages are still throttled to once per `ERROR_NOTIFY_COOLDOWN`. async fn spawn_onchain_sync( wallet: Arc, app: &tauri::AppHandle, ) -> tokio::sync::watch::Sender<()> { use ark_client::wallet::OnchainWallet; - /// Minimum interval between UI error notifications for consecutive sync failures. - const ERROR_NOTIFY_COOLDOWN: Duration = Duration::from_secs(5 * 60); /// Maximum backoff interval after repeated sync failures. const MAX_SYNC_BACKOFF: Duration = Duration::from_secs(600); - if let Err(e) = wallet.sync().await { + let initial_failures = if let Err(e) = wallet.sync().await { warn!("initial onchain sync failed: {e}"); - let _ = app.emit("wallet-sync-error", friendly_sync_error(&e.to_string())); - } + 1 + } else { + 0 + }; let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(()); let bg_wallet = Arc::clone(&wallet); let bg_app = app.clone(); tokio::spawn(async move { let mut last_error_notify: Option = None; - let mut consecutive_failures: u32 = 0; + let mut consecutive_failures: u32 = initial_failures; loop { let delay = if consecutive_failures == 0 { @@ -119,9 +155,10 @@ async fn spawn_onchain_sync( "background onchain sync failed: {e}" ); - let should_notify = last_error_notify - .map(|t| t.elapsed() >= ERROR_NOTIFY_COOLDOWN) - .unwrap_or(true); + let should_notify = should_notify_sync_failure( + consecutive_failures, + last_error_notify.map(|t| t.elapsed()), + ); if should_notify { let _ = @@ -885,3 +922,92 @@ pub async fn get_receive_address(app: tauri::AppHandle) -> Result { + async (mode: FetchMode = "auto") => { const id = ++fetchIdRef.current; setRefreshing(true); try { @@ -109,25 +113,16 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { ]); if (cancelledRef.current || id !== fetchIdRef.current) return; - if (balResult.status === "fulfilled") { - setBalance(balResult.value); - } else if (!initial) { - toast.error("Failed to fetch balance"); - } + if (balResult.status === "fulfilled") setBalance(balResult.value); + if (txsResult.status === "fulfilled") setTransactions(txsResult.value); + setSwaps(swapsResult.status === "fulfilled" ? swapsResult.value : []); - if (txsResult.status === "fulfilled") { - setTransactions(txsResult.value); - } else if (!initial) { - toast.error("Failed to fetch transactions"); - } + const balFailed = balResult.status === "rejected"; + const txsFailed = txsResult.status === "rejected"; - setSwaps( - swapsResult.status === "fulfilled" ? swapsResult.value : [], - ); - - if (initial) { - // Only block initial load if balance fails — that's the critical data. - // Transactions and swaps failing is non-fatal. + if (mode === "initial") { + // Only block initial load if balance fails — that's the critical + // data. Transactions and swaps failing is non-fatal. if (balResult.status === "rejected") { const message = typeof balResult.reason === "string" @@ -138,17 +133,45 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { } else { setConnectionState("connected"); } + return; + } + + // A clean poll (any mode) ends an outage streak. + if (!balFailed && !txsFailed) { + autoFailureStreakRef.current = 0; + } + + if (mode === "manual") { + // The user explicitly tapped refresh — report each failure now. + if (balFailed) toast.error("Failed to fetch balance"); + if (txsFailed) toast.error("Failed to fetch transactions"); + return; + } + + // mode === "auto": a lone failed poll is almost always transient + // (device asleep, brief network drop). Toast only once the failure + // streak is sustained — see advanceFailureStreak. + if (balFailed || txsFailed) { + const { streak, shouldToast } = advanceFailureStreak( + autoFailureStreakRef.current, + ); + autoFailureStreakRef.current = streak; + if (shouldToast) { + toast.error("Couldn't refresh wallet data — will retry automatically"); + } } } catch (error) { if (cancelledRef.current || id !== fetchIdRef.current) return; const message = typeof error === "string" ? error : "Failed to fetch wallet data"; - if (initial) { + if (mode === "initial") { setConnectionError(message); setConnectionState("error"); - } else { + } else if (mode === "manual") { toast.error(message); } + // mode === "auto": swallow — Promise.allSettled never rejects, so this + // branch is unreachable for the invoke calls anyway. } finally { if (!cancelledRef.current && id === fetchIdRef.current) { setRefreshing(false); @@ -189,7 +212,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { if (loaded) { setConnectionState("loading"); - void fetchData(true); + void fetchData("initial"); return; } @@ -199,7 +222,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { .then(() => { if (!cancelledRef.current) { setConnectionState("loading"); - void fetchData(true); + void fetchData("initial"); } }) .catch((error) => { @@ -244,7 +267,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { toast.success( `Received ${formatSats(event.payload.amount_sat)} sats`, ); - setTimeout(() => void fetchData(), 1500); + setTimeout(() => void fetchData("auto"), 1500); }), listen("ln-swap-error", (event) => { toast.error(event.payload); @@ -266,7 +289,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const poll = () => { timer = setTimeout(() => { if (cancelled) return; - fetchData().finally(() => { + fetchData("auto").finally(() => { if (!cancelled) poll(); }); }, DEFAULT_REFRESH_INTERVAL); @@ -289,7 +312,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { refreshing, autoRefresh, setAutoRefresh, - fetchData: () => fetchData(), + fetchData: () => fetchData("manual"), connectWallet, vtxos, vtxosLoaded, diff --git a/src/routes/settings-route.tsx b/src/routes/settings-route.tsx index 5b4fe86..c07e270 100644 --- a/src/routes/settings-route.tsx +++ b/src/routes/settings-route.tsx @@ -21,37 +21,66 @@ interface SettingsData { esplora_url: string | null; } -// Blockstream is the default — an unset `esplora_url` resolves to this. -const DEFAULT_EXPLORER_URL = "https://blockstream.info/api"; -const PRESET_EXPLORERS = [ - { label: "Blockstream", url: DEFAULT_EXPLORER_URL }, - { label: "Mempool.space", url: "https://mempool.space/api" }, -]; -const PRESET_URLS = new Set(PRESET_EXPLORERS.map((e) => e.url)); +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"; + } +} function EsploraSelector({ value, + network, onChange, saving, onSave, }: { value: string; + network: string | null | undefined; onChange: (v: string) => void; saving: boolean; onSave: (url: string | null) => void; }) { - // An unset value means "default", which is Blockstream — resolve it so a - // radio reflects the active explorer instead of showing nothing selected. - const effectiveValue = value === "" ? DEFAULT_EXPLORER_URL : value; - const isCustom = !PRESET_URLS.has(effectiveValue); - // Saving Blockstream (the default) is equivalent to clearing the override. - const urlToSave = effectiveValue === DEFAULT_EXPLORER_URL ? null : effectiveValue; + const defaultExplorer = defaultExplorerForNetwork(network); + const presetExplorers = [ + defaultExplorer, + { label: "Mempool.space", url: mempoolExplorerForNetwork(network) }, + ]; + const presetUrls = new Set(presetExplorers.map((e) => e.url)); + // An unset value means "network default" — resolve it so a radio reflects + // the active explorer instead of showing nothing selected. + const effectiveValue = value === "" ? defaultExplorer.url : value; + const isCustom = !presetUrls.has(effectiveValue); + // Saving the default is equivalent to clearing the override. + const urlToSave = effectiveValue === defaultExplorer.url ? null : effectiveValue; return (

Block Explorer (Esplora)

- {PRESET_EXPLORERS.map((option) => ( + {presetExplorers.map((option) => (
{ diff --git a/src/utils/fetchFailureStreak.test.ts b/src/utils/fetchFailureStreak.test.ts new file mode 100644 index 0000000..30669da --- /dev/null +++ b/src/utils/fetchFailureStreak.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { + AUTO_FAILURES_BEFORE_TOAST, + advanceFailureStreak, +} from "./fetchFailureStreak"; + +describe("advanceFailureStreak", () => { + test("increments the streak on each failure", () => { + expect(advanceFailureStreak(0).streak).toBe(1); + expect(advanceFailureStreak(1).streak).toBe(2); + expect(advanceFailureStreak(5).streak).toBe(6); + }); + + test("does not toast before the threshold", () => { + expect(advanceFailureStreak(0).shouldToast).toBe(false); + expect( + advanceFailureStreak(AUTO_FAILURES_BEFORE_TOAST - 2).shouldToast, + ).toBe(false); + }); + + test("toasts exactly when the streak first reaches the threshold", () => { + const { streak, shouldToast } = advanceFailureStreak( + AUTO_FAILURES_BEFORE_TOAST - 1, + ); + expect(streak).toBe(AUTO_FAILURES_BEFORE_TOAST); + expect(shouldToast).toBe(true); + }); + + test("does not toast again past the threshold — one toast per outage", () => { + expect( + advanceFailureStreak(AUTO_FAILURES_BEFORE_TOAST).shouldToast, + ).toBe(false); + expect( + advanceFailureStreak(AUTO_FAILURES_BEFORE_TOAST + 10).shouldToast, + ).toBe(false); + }); +}); diff --git a/src/utils/fetchFailureStreak.ts b/src/utils/fetchFailureStreak.ts new file mode 100644 index 0000000..34d39f0 --- /dev/null +++ b/src/utils/fetchFailureStreak.ts @@ -0,0 +1,25 @@ +/** + * Consecutive failed auto-refresh polls before a "couldn't refresh" toast. + * + * A single failed poll is almost always transient — the device went to sleep + * and the OS suspended the network, so one poll fails and the next recovers. + * Surfacing a toast for that is noise. A sustained outage produces many + * consecutive failures and still gets reported. + */ +export const AUTO_FAILURES_BEFORE_TOAST = 3; + +/** + * Advance the consecutive-auto-failure streak after a failed poll and decide + * whether this failure should surface a toast. + * + * The toast fires exactly once — when the streak first *reaches* the + * threshold — not on every subsequent failure, so one outage produces one + * toast. A clean poll resets the streak; that reset is the caller's job. + */ +export function advanceFailureStreak(current: number): { + streak: number; + shouldToast: boolean; +} { + const streak = current + 1; + return { streak, shouldToast: streak === AUTO_FAILURES_BEFORE_TOAST }; +} From 998573801aefb55e21588fad782cad2ac87f9f2d Mon Sep 17 00:00:00 2001 From: Tobi Adeyemi Date: Thu, 21 May 2026 03:09:41 +0100 Subject: [PATCH 5/5] chore(deps): bump ark-rust-sdk from v0.8.0 to v0.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four breaking changes that surface against avark's call sites: - `OfflineClient::new` now takes 10 args instead of 8 (added`delegator_pk` + `historical_delegator_pks` for 3rd-party delegator support). Pass `None` + `vec![]` to preserve no-delegator behavior. - `ExplorerUtxo` gained a required `confirmations: u64` field for block-based unilateral exit delay support. Set to `0` until/unless we need to surface confirmation counts (the only ExplorerUtxo construction in `find_outpoints` doesn't have confirmation data ready; the spent-side literal inherits via struct-update). - `Client::send_vtxo(addr, amount)` → `Client::send(vec![SendReceiver::bitcoin(addr, amount)])`. Same for `send_vtxo_selection` → `send_selection`. New shape accommodates multi-receiver + asset transfers. - `wait_for_invoice_paid` now resolves with the preimage (`[u8; 32]`) instead of `()`. Discard — Boltz has already claimed against it by the time we observe settlement. --- src-tauri/Cargo.lock | 34 ++++++++++++++++++++++---------- src-tauri/Cargo.toml | 8 ++++---- src-tauri/src/ark.rs | 8 ++++++++ src-tauri/src/commands/coins.rs | 1 + src-tauri/src/commands/send.rs | 17 +++++++++++++--- src-tauri/src/commands/wallet.rs | 4 ++++ 6 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3226125..6a58a65 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -94,8 +94,8 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ark-bdk-wallet" -version = "0.7.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.8.0#593b5439669e140cf48dbe5ce7c1348253e5e2e9" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" dependencies = [ "anyhow", "ark-client", @@ -113,10 +113,11 @@ dependencies = [ [[package]] name = "ark-client" -version = "0.7.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.8.0#593b5439669e140cf48dbe5ce7c1348253e5e2e9" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" dependencies = [ "ark-core", + "ark-delegator", "ark-fees", "ark-grpc", "async-stream", @@ -151,8 +152,8 @@ dependencies = [ [[package]] name = "ark-core" -version = "0.7.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.8.0#593b5439669e140cf48dbe5ce7c1348253e5e2e9" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" dependencies = [ "bech32", "bitcoin", @@ -170,18 +171,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ark-delegator" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +dependencies = [ + "ark-core", + "bitcoin", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "ark-fees" -version = "0.7.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.8.0#593b5439669e140cf48dbe5ce7c1348253e5e2e9" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" dependencies = [ "cel", ] [[package]] name = "ark-grpc" -version = "0.7.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.8.0#593b5439669e140cf48dbe5ce7c1348253e5e2e9" +version = "0.9.0" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" dependencies = [ "ark-core", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4aab5b..51bf0be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,10 +31,10 @@ thiserror = { version = "2", default-features = false } tokio = { version = "1", default-features = false, features = ["sync", "fs", "time"] } # Ark protocol -ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0", default-features = false, features = ["tls-webpki-roots", "sqlite"] } -ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" } -ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" } -ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.8.0" } +ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0", default-features = false, features = ["tls-webpki-roots", "sqlite"] } +ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" } +ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" } +ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.0" } # Bitcoin / BIP39 bip39 = { version = "2", features = ["rand", "zeroize"] } diff --git a/src-tauri/src/ark.rs b/src-tauri/src/ark.rs index 8f65629..6282446 100644 --- a/src-tauri/src/ark.rs +++ b/src-tauri/src/ark.rs @@ -109,6 +109,14 @@ impl Blockchain for EsploraBlockchain { }, amount: Amount::from_sat(v.value), confirmation_blocktime: block_time, + // v0.9.0 added this field for block-based unilateral + // exit delay support. We don't currently surface + // confirmation count from esplora here — `0` is safe + // for time-based exit delays (the only kind avark + // currently exits against). If we ever need to + // support block-based exit-delay ASPs, fetch the + // chain tip and compute `tip_height - block_height + 1`. + confirmations: 0, is_spent: false, }) }) diff --git a/src-tauri/src/commands/coins.rs b/src-tauri/src/commands/coins.rs index ec84b47..c491285 100644 --- a/src-tauri/src/commands/coins.rs +++ b/src-tauri/src/commands/coins.rs @@ -196,6 +196,7 @@ mod tests { commitment_txids: vec![], settled_by: None, ark_txid: None, + assets: vec![], } } diff --git a/src-tauri/src/commands/send.rs b/src-tauri/src/commands/send.rs index 366d673..3334111 100644 --- a/src-tauri/src/commands/send.rs +++ b/src-tauri/src/commands/send.rs @@ -189,7 +189,7 @@ pub async fn send_lightning( }; match wait_result { - Ok(Ok(())) => { + Ok(Ok(_preimage)) => { info!(swap_id = %result.swap_id, txid = %result.txid, "Lightning invoice paid"); Ok(SendResult { txid: result.txid.to_string(), @@ -246,8 +246,14 @@ pub async fn send_ark( info!(address = %address, amount_sat = amount_sat, "sending Ark payment"); + // v0.9.0 replaced `send_vtxo(addr, amount)` with `send(Vec)` + // — the new shape accommodates multi-receiver + asset transfers. For our + // single-bitcoin-receiver case, `SendReceiver::bitcoin` is the convenience + // constructor. let txid = client - .send_vtxo(ark_addr, amount) + .send(vec![ark_core::send::SendReceiver::bitcoin( + ark_addr, amount, + )]) .await .map_err(|e| AppError::Wallet(format!("Send failed: {e}")))?; @@ -301,8 +307,13 @@ pub async fn send_ark_selected( "sending Ark payment with explicit VTXO selection" ); + // v0.9.0: `send_vtxo_selection(outpoints, addr, amount)` → + // `send_selection(outpoints, Vec)`. let txid = client - .send_vtxo_selection(&vtxo_outpoints, ark_addr, amount) + .send_selection( + &vtxo_outpoints, + vec![ark_core::send::SendReceiver::bitcoin(ark_addr, amount)], + ) .await .map_err(|e| AppError::Wallet(format!("Send failed: {e}")))?; diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index b277f48..a4f5060 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -254,6 +254,10 @@ async fn build_ark_client( Arc::clone(&swap_storage), BOLTZ_URL.to_string(), Duration::from_secs(30), + // v0.9.0 added 3rd-party delegator support. We don't use it — + // `None` + `vec![]` preserves the v0.8.0 "no delegator" behavior. + None, + vec![], ); let client = offline_client