diff --git a/.env.sample b/.env.sample index 0e5baed..2b56f1c 100644 --- a/.env.sample +++ b/.env.sample @@ -4,3 +4,8 @@ VITE_DEFAULT_ASP_URL=https://arkade.computer/ # Get one at https://cloud.reown.com (formerly cloud.walletconnect.com). # Missing/empty → the Swap screen shows "WalletConnect not configured". VITE_WALLETCONNECT_PROJECT_ID= + +# Default Bitcoin Core `submitpackage` broadcast endpoint for the emergency +# unilateral-exit flow. +AVARK_SUBMITPACKAGE_URL= +AVARK_SUBMITPACKAGE_TOKEN= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90d29ab..71f192c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,10 +146,17 @@ jobs: env: VITE_WALLETCONNECT_PROJECT_ID: ${{ vars.VITE_WALLETCONNECT_PROJECT_ID }} VITE_DEFAULT_ASP_URL: ${{ vars.VITE_DEFAULT_ASP_URL }} + AVARK_SUBMITPACKAGE_URL: ${{ vars.AVARK_SUBMITPACKAGE_URL }} + AVARK_SUBMITPACKAGE_TOKEN: ${{ secrets.AVARK_SUBMITPACKAGE_TOKEN }} run: | missing=() [ -n "$VITE_WALLETCONNECT_PROJECT_ID" ] || missing+=(VITE_WALLETCONNECT_PROJECT_ID) [ -n "$VITE_DEFAULT_ASP_URL" ] || missing+=(VITE_DEFAULT_ASP_URL) + if [ -z "$AVARK_SUBMITPACKAGE_URL" ]; then + missing+=("AVARK_SUBMITPACKAGE_URL (URL is a Variable, not a Secret — vars.* reads Secrets as empty)") + elif [ -z "$AVARK_SUBMITPACKAGE_TOKEN" ]; then + echo "::notice::AVARK_SUBMITPACKAGE_URL is set without a token — building a tokenless default endpoint." + fi 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 @@ -178,6 +185,8 @@ jobs: env: VITE_WALLETCONNECT_PROJECT_ID: ${{ vars.VITE_WALLETCONNECT_PROJECT_ID }} VITE_DEFAULT_ASP_URL: ${{ vars.VITE_DEFAULT_ASP_URL }} + AVARK_SUBMITPACKAGE_URL: ${{ vars.AVARK_SUBMITPACKAGE_URL }} + AVARK_SUBMITPACKAGE_TOKEN: ${{ secrets.AVARK_SUBMITPACKAGE_TOKEN }} run: | pnpm tauri android build --apk --target aarch64 -- --features vendored-openssl V='${{ needs.resolve.outputs.version }}' @@ -191,6 +200,8 @@ jobs: env: VITE_WALLETCONNECT_PROJECT_ID: ${{ vars.VITE_WALLETCONNECT_PROJECT_ID }} VITE_DEFAULT_ASP_URL: ${{ vars.VITE_DEFAULT_ASP_URL }} + AVARK_SUBMITPACKAGE_URL: ${{ vars.AVARK_SUBMITPACKAGE_URL }} + AVARK_SUBMITPACKAGE_TOKEN: ${{ secrets.AVARK_SUBMITPACKAGE_TOKEN }} run: | pnpm tauri android build --apk --target armv7 -- --features vendored-openssl V='${{ needs.resolve.outputs.version }}' diff --git a/index.html b/index.html index f0272ed..8ce9738 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,8 @@ - - Tauri + React + Typescript + Avark diff --git a/package.json b/package.json index 3cef1a2..9995d30 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test:watch": "vitest", "android-dev-icons": "node scripts/build-android-dev-icons.mjs", "dev:android": "node scripts/android-dev.mjs", + "dev:ios": "pnpm tauri ios dev --host $(ipconfig getifaddr en0)", "version:bump": "node scripts/version-bump.mjs", "prepare": "git config --local core.hooksPath githooks 2>/dev/null || true" }, diff --git a/public/tauri.svg b/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d674384..a50c7ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -94,8 +94,8 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ark-bdk-wallet" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "anyhow", "ark-client", @@ -113,8 +113,8 @@ dependencies = [ [[package]] name = "ark-client" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "ark-core", "ark-delegator", @@ -152,8 +152,8 @@ dependencies = [ [[package]] name = "ark-core" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "bech32", "bitcoin", @@ -173,8 +173,8 @@ dependencies = [ [[package]] name = "ark-delegator" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "ark-core", "bitcoin", @@ -186,16 +186,16 @@ dependencies = [ [[package]] name = "ark-fees" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "cel", ] [[package]] name = "ark-grpc" -version = "0.9.0" -source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.0#887cb4a1c87124594c13b4d2a1ffc1c7d89934fc" +version = "0.9.2" +source = "git+https://github.com/arkade-os/rust-sdk.git?tag=v0.9.2#8c0e8e3d91ab80e8107415072cf6351573eac19f" dependencies = [ "ark-core", "async-stream", @@ -461,6 +461,7 @@ dependencies = [ "ark-grpc", "bip39", "bitcoin", + "dotenvy", "esplora-client", "futures-util", "hex", @@ -640,8 +641,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", - "rand_core 0.6.4", + "rand 0.7.3", + "rand_core 0.5.1", "serde", "unicode-normalization", "zeroize", @@ -1364,7 +1365,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1553,7 +1554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2817,7 +2818,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3401,7 +3402,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4219,7 +4220,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -4375,7 +4376,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4773,7 +4774,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6109,7 +6110,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6729,7 +6730,7 @@ checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7251,7 +7252,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ba259c..34c8361 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +dotenvy = "0.15" [dependencies] tauri = { version = "2", features = [] } @@ -31,10 +32,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.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" } +ark-client = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2", default-features = false, features = ["tls-webpki-roots", "sqlite"] } +ark-core = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } +ark-bdk-wallet = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } +ark-grpc = { git = "https://github.com/arkade-os/rust-sdk.git", tag = "v0.9.2" } # Bitcoin / BIP39 bip39 = { version = "2", features = ["rand", "zeroize"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..4d3c6f8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,32 @@ +use std::path::Path; + +const DOTENV_KEYS: &[&str] = &["AVARK_SUBMITPACKAGE_URL", "AVARK_SUBMITPACKAGE_TOKEN"]; + +fn forward_dotenv_keys() { + let dotenv = Path::new("../.env"); + if dotenv.exists() { + println!("cargo:rerun-if-changed=../.env"); + } + + let dotenv_values: Vec<(String, String)> = match dotenvy::from_path_iter(dotenv) { + Ok(iter) => iter.filter_map(Result::ok).collect(), + Err(_) => Vec::new(), // no .env — the process env may still provide keys + }; + + for key in DOTENV_KEYS { + println!("cargo:rerun-if-env-changed={key}"); + if std::env::var(key).is_ok_and(|v| !v.is_empty()) { + continue; + } + if let Some((_, value)) = dotenv_values.iter().find(|(k, _)| k == key) { + if !value.is_empty() && !value.contains('\n') { + println!("cargo:rustc-env={key}={value}"); + } + } + } +} + fn main() { + forward_dotenv_keys(); tauri_build::build() } diff --git a/src-tauri/src/commands/lightning.rs b/src-tauri/src/commands/lightning.rs index 07cf12d..a7ed6fa 100644 --- a/src-tauri/src/commands/lightning.rs +++ b/src-tauri/src/commands/lightning.rs @@ -269,7 +269,7 @@ pub async fn get_ln_invoice(app: tauri::AppHandle, amount_sat: u64) -> Result, + branch_count: usize, + tx_count: usize, + failed_outpoints: Vec, + branches: Vec>, + #[serde(default)] + branch_sources: Vec, + /// Everything that may need sweeping after exit trees confirm: the current + /// branch sources plus every VTXO the ASP already reports as unrolled. + /// Captured at refresh time (while connected) so `unilateral_exit_sweep_status` + /// can run offline — the whole point of the exit flow is that the ASP may + /// be gone. `None`-equivalent (empty) for caches written before this field. + #[serde(default)] + sweep_candidates: Vec, + /// Seconds-based CSV exit delay from the ASP's `server_info`, captured at + /// refresh time. `None` for legacy caches or block-based-delay ASPs; both + /// make offline sweep status unavailable until a refresh while connected. + #[serde(default)] + exit_delay_seconds: Option, + /// ASP dust limit (sats) from `server_info`, captured at refresh time. + #[serde(default)] + dust_sat: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnilateralExitCacheStatus { + exists: bool, + generated_at: Option, + network: Option, + branch_count: usize, + tx_count: usize, + failed_count: usize, + last_error: Option, +} + +struct BestEffortExitTrees { + branches: Vec>, + sources: Vec, + /// VTXOs the ASP currently reports as unrolled — already-exited coins that + /// are (or will be) awaiting sweep. Snapshotted here because the ASP won't + /// be reachable when the sweep screen needs them. + unrolled: Vec, + failed_outpoints: Vec, + last_error: Option, +} + +fn now_unix() -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| AppError::Wallet(format!("System clock error: {e}")))?; + Ok(duration.as_secs() as i64) +} + +/// Seconds-based CSV exit delay, or `None` when the ASP uses a block-based +/// delay (or an invalid relative locktime). v0.9.0 removed the SDK's +/// `unilateral_vtxo_exit_delay_seconds()` helper in favour of exposing the raw +/// `Sequence` on `server_info`, so callers can handle both delay kinds. +fn exit_delay_to_seconds(delay: bitcoin::Sequence) -> Option { + match delay.to_relative_lock_time() { + Some(bitcoin::relative::LockTime::Time(t)) => Some(t.value() as u64 * 512), + _ => None, + } +} + +/// Inverse of [`exit_delay_to_seconds`]: rebuild the consensus `Sequence` from +/// the cached seconds value. Exact, because cached values are always computed +/// as `intervals * 512` from a time-based locktime. +fn exit_delay_from_seconds(seconds: u64) -> Option { + let intervals = seconds / 512; + if !seconds.is_multiple_of(512) || intervals > u16::MAX as u64 { + return None; + } + Some(bitcoin::Sequence::from_512_second_intervals( + intervals as u16, + )) +} + +/// Union of two candidate lists, deduped by outpoint; on duplicates the entry +/// from `primary` wins. +fn merge_sweep_candidates( + primary: Vec, + secondary: Vec, +) -> Vec { + let mut seen = HashSet::new(); + primary + .into_iter() + .chain(secondary) + .filter(|c| seen.insert(c.outpoint.clone())) + .collect() +} + +fn cache_status( + cache: &UnilateralExitCache, + last_error: Option, +) -> UnilateralExitCacheStatus { + UnilateralExitCacheStatus { + exists: true, + generated_at: Some(cache.generated_at), + network: Some(cache.network.clone()), + branch_count: cache.branch_count, + tx_count: cache.tx_count, + failed_count: cache.failed_outpoints.len(), + last_error, + } +} + +async fn read_cache(app: &tauri::AppHandle) -> Result, AppError> { + let path = unilateral_exit_cache_path(app)?; + match tokio::fs::read_to_string(&path).await { + Ok(raw) => Ok(Some(serde_json::from_str(&raw)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(AppError::Io(e)), + } +} + +fn remaining_timeout(deadline: Instant, label: &str) -> Result { + let now = Instant::now(); + if now >= deadline { + return Err(AppError::Wallet(format!( + "Recovery package refresh timed out while {label}" + ))); + } + Ok(VTXO_REQUEST_TIMEOUT.min(deadline - now)) +} + +/// Build the dependency-ordered list of virtual transactions that must be +/// broadcast to unilaterally exit the VTXO at `vtxo_txid`, plus the commitment +/// transactions its chain roots in. +/// +/// Walks the ancestor sub-DAG once and emits each transaction only after all of +/// its parents — a topological sort, `O(nodes)`. The SDK's +/// `build_unilateral_exit_tree_txids` instead enumerates every distinct +/// root-to-leaf *path*, which is exponential on branchy mainnet chains: a real +/// VTXO produced ~530k paths and exhausted device memory. +/// +/// Commitment transactions are already confirmed on-chain, so they terminate +/// the walk and are returned separately rather than placed in the broadcast +/// order. +fn build_exit_broadcast_order( + chains: &ark_core::server::VtxoChains, + vtxo_txid: Txid, +) -> Result<(Vec, Vec), AppError> { + use ark_core::server::{ChainedTxType, VtxoChain}; + + let chain_map: HashMap = chains.inner.iter().map(|c| (c.txid, c)).collect(); + + #[derive(PartialEq)] + enum Mark { + InProgress, + Done, + } + + let mut marks: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + let mut commitments: Vec = Vec::new(); + // Each frame: (txid, index of the next parent in `spends` to descend into). + let mut stack: Vec<(Txid, usize)> = vec![(vtxo_txid, 0)]; + + while let Some(&(txid, parent_idx)) = stack.last() { + let chain = chain_map + .get(&txid) + .ok_or_else(|| AppError::Wallet(format!("VTXO chain is missing transaction {txid}")))?; + + if parent_idx == 0 { + match marks.get(&txid) { + Some(Mark::Done) => { + stack.pop(); + continue; + } + Some(Mark::InProgress) => { + return Err(AppError::Wallet(format!( + "VTXO chain contains a cycle at {txid}" + ))); + } + None => { + marks.insert(txid, Mark::InProgress); + } + } + } + + // Commitment transactions are already on-chain — they terminate the + // walk and are never broadcast. + if matches!(chain.tx_type, ChainedTxType::Commitment) { + marks.insert(txid, Mark::Done); + commitments.push(txid); + stack.pop(); + continue; + } + + // A non-commitment transaction with no parents can never connect back + // to an on-chain commitment — the chain the ASP returned is broken. + if chain.spends.is_empty() { + return Err(AppError::Wallet(format!( + "VTXO chain dead-ends at {txid} with no commitment ancestor" + ))); + } + + if parent_idx < chain.spends.len() { + stack.last_mut().expect("stack is non-empty").1 = parent_idx + 1; + let parent = chain.spends[parent_idx]; + if marks.get(&parent) != Some(&Mark::Done) { + stack.push((parent, 0)); + } + continue; + } + + // Every parent has been emitted — this transaction follows them. + marks.insert(txid, Mark::Done); + order.push(txid); + stack.pop(); + } + + if order.is_empty() { + return Err(AppError::Wallet(format!( + "no exit transactions found for VTXO {vtxo_txid}" + ))); + } + + Ok((order, commitments)) +} + +async fn build_exit_trees_best_effort( + app: &tauri::AppHandle, + client: &ark::ArkClient, +) -> Result { + use ark_client::Blockchain; + use ark_core::unilateral_exit::{finalize_unilateral_exit_tree, UnilateralExitTree}; + + let deadline = Instant::now() + REFRESH_TIME_BUDGET; + let (vtxo_list, _) = tokio::time::timeout(LIST_VTXOS_TIMEOUT, client.list_vtxos()) + .await + .map_err(|_| AppError::Asp("Timed out listing VTXOs".into()))? + .map_err(|e| AppError::Wallet(format!("Failed to list VTXOs: {e}")))?; + + let wallet_data = super::wallet::read_wallet_data(app).await?; + let network = wallet_data + .network + .parse::() + .map_err(|e| AppError::Wallet(format!("Invalid network: {e}")))?; + let custom_esplora = { + let state = app.state::(); + let _lock = state.0.read().await; + crate::read_settings(app) + .await + .ok() + .and_then(|s| s.esplora_url) + }; + let esplora = + ark::EsploraBlockchain::new(&ark::esplora_url(network, custom_esplora.as_deref())) + .map_err(|e| AppError::Wallet(format!("Failed to create explorer client: {e}")))?; + + // VtxoList groups unrolled VTXOs into `spent` alongside genuinely-spent + // and swept ones — `is_unrolled` picks out the already-exited set. The ASP + // keeps the flag forever, so this includes long-swept coins; sweep status + // filters those out against esplora at display time. + let unrolled = vtxo_list + .spent() + .filter(|v| v.is_unrolled) + .map(|v| CachedBranchSource { + outpoint: v.outpoint.to_string(), + amount_sat: v.amount.to_sat(), + }) + .collect::>(); + + let mut branches = Vec::new(); + let mut sources = Vec::new(); + let mut failed_outpoints = Vec::new(); + let mut last_error = None; + let exit_candidates = vtxo_list + .could_exit_unilaterally() + .cloned() + .collect::>(); + + for (index, virtual_tx_outpoint) in exit_candidates.iter().enumerate() { + if Instant::now() >= deadline { + let message = "Recovery package refresh time budget reached".to_string(); + for skipped in &exit_candidates[index..] { + failed_outpoints.push(skipped.outpoint.to_string()); + } + last_error = Some(message); + break; + } + + let outpoint = virtual_tx_outpoint.outpoint; + let branch_result: Result>, AppError> = async { + let vtxo_chain_response = tokio::time::timeout( + remaining_timeout(deadline, "fetching VTXO chain")?, + client.network_client().get_vtxo_chain(Some(outpoint), None), + ) + .await + .map_err(|_| AppError::Asp(format!("Timed out fetching VTXO chain for {outpoint}")))? + .map_err(|e| { + AppError::Asp(format!("Failed to fetch VTXO chain for {outpoint}: {e}")) + })?; + + let (broadcast_order, commitment_txids) = + build_exit_broadcast_order(&vtxo_chain_response.chains, outpoint.txid)?; + + let virtual_txs_response = tokio::time::timeout( + remaining_timeout(deadline, "fetching virtual transactions")?, + client.network_client().get_virtual_txs( + broadcast_order.iter().map(ToString::to_string).collect(), + None, + ), + ) + .await + .map_err(|_| AppError::Asp(format!("Timed out fetching virtual TXs for {outpoint}")))? + .map_err(|e| { + AppError::Asp(format!("Failed to fetch virtual TXs for {outpoint}: {e}")) + })?; + + // Index the fetched PSBTs by txid so the branch assembles in O(n). + // The previous implementation rescanned the whole response — and + // recomputed every txid — once per transaction in the tree. + let mut psbt_by_txid: HashMap = virtual_txs_response + .txs + .into_iter() + .map(|tx| (tx.unsigned_tx.compute_txid(), tx)) + .collect(); + + let ordered_psbts = broadcast_order + .iter() + .map(|txid| { + psbt_by_txid.remove(txid).ok_or_else(|| { + AppError::Wallet(format!("No virtual transaction found for {txid}")) + }) + }) + .collect::, _>>()?; + + // One dependency-ordered branch per VTXO. `finalize_unilateral_exit_tree` + // resolves each transaction's witness UTXO against the rest of the + // branch, and the topological order guarantees every parent is present. + let exit_tree = UnilateralExitTree::new(commitment_txids.clone(), vec![ordered_psbts]); + + let mut commitment_txs = Vec::with_capacity(commitment_txids.len()); + for commitment_txid in &commitment_txids { + let commitment_tx = tokio::time::timeout( + remaining_timeout(deadline, "fetching commitment transaction")?, + esplora.find_tx(commitment_txid), + ) + .await + .map_err(|_| { + AppError::Wallet(format!( + "Timed out fetching commitment TX {commitment_txid}" + )) + })? + .map_err(|e| { + AppError::Wallet(format!( + "Failed to fetch commitment TX {commitment_txid}: {e}" + )) + })? + .ok_or_else(|| { + AppError::Wallet(format!("Could not find commitment TX {commitment_txid}")) + })?; + + commitment_txs.push(commitment_tx); + } + + finalize_unilateral_exit_tree(&exit_tree, &commitment_txs).map_err(|e| { + AppError::Wallet(format!("Failed to sign exit tree for {outpoint}: {e}")) + }) + } + .await; + + match branch_result { + Ok(mut vtxo_branches) => { + // Each VTXO produces one branch today (we pass a single ordered_psbts + // list into UnilateralExitTree::new). Push one source per emitted + // branch so `sources` and `branches` stay strictly parallel even if + // that 1:1 invariant ever loosens upstream. + let source = CachedBranchSource { + outpoint: outpoint.to_string(), + amount_sat: virtual_tx_outpoint.amount.to_sat(), + }; + for _ in 0..vtxo_branches.len() { + sources.push(source.clone()); + } + branches.append(&mut vtxo_branches); + } + Err(e) => { + let message = e.to_string(); + warn!(outpoint = %outpoint, error = %message, "skipping VTXO in recovery cache refresh"); + failed_outpoints.push(outpoint.to_string()); + last_error = Some(message); + } + } + } + + Ok(BestEffortExitTrees { + branches, + sources, + unrolled, + failed_outpoints, + last_error, + }) +} + +pub(crate) async fn refresh_unilateral_exit_cache_for_client( + app: &tauri::AppHandle, + client: Arc, +) -> Result { + info!("refreshing unilateral exit recovery cache"); + + let result = build_exit_trees_best_effort(app, &client).await?; + + if result.branches.is_empty() && !result.failed_outpoints.is_empty() { + return Ok(UnilateralExitCacheStatus { + exists: false, + generated_at: None, + network: Some(client.server_info.network.to_string()), + branch_count: 0, + tx_count: 0, + failed_count: result.failed_outpoints.len(), + last_error: result.last_error, + }); + } + + let tx_count = result.branches.iter().map(Vec::len).sum(); + let cached_branches = result + .branches + .into_iter() + .map(|branch| { + branch + .into_iter() + .map(|tx| CachedExitTx { + txid: tx.compute_txid().to_string(), + tx_hex: encode::serialize_hex(&tx), + }) + .collect::>() + }) + .collect::>(); + + let last_error = result.last_error; + let (server_pk, _parity) = client.server_info.signer_pk.x_only_public_key(); + let cache = UnilateralExitCache { + version: CACHE_VERSION, + generated_at: now_unix()?, + asp_digest: client.server_info.digest.clone(), + network: client.server_info.network.to_string(), + server_pk: Some(server_pk.to_string()), + branch_count: cached_branches.len(), + tx_count, + failed_outpoints: result.failed_outpoints, + branches: cached_branches, + sweep_candidates: merge_sweep_candidates(result.sources.clone(), result.unrolled), + branch_sources: result.sources, + exit_delay_seconds: exit_delay_to_seconds(client.server_info.unilateral_exit_delay), + dust_sat: Some(client.server_info.dust.to_sat()), + }; + + let path = unilateral_exit_cache_path(app)?; + if let Some(dir) = path.parent() { + tokio::fs::create_dir_all(dir).await?; + } + + let data = serde_json::to_string_pretty(&cache)?; + let tmp_path = path.with_extension("json.tmp"); + tokio::fs::write(&tmp_path, data).await?; + tokio::fs::rename(&tmp_path, &path).await?; + + info!( + branch_count = cache.branch_count, + tx_count = cache.tx_count, + "unilateral exit recovery cache refreshed" + ); + + Ok(cache_status(&cache, last_error)) +} + +pub(crate) fn spawn_unilateral_exit_cache_refresh( + app: tauri::AppHandle, + client: Arc, +) { + tauri::async_runtime::spawn(async move { + if let Err(e) = refresh_unilateral_exit_cache_for_client(&app, client).await { + warn!("failed to refresh unilateral exit recovery cache: {e}"); + } + }); +} + +#[tauri::command] +pub async fn refresh_unilateral_exit_cache( + app: tauri::AppHandle, +) -> Result { + let client = { + let state = app.state::(); + let guard = state.0.read().await; + Arc::clone( + &guard + .as_ref() + .ok_or_else(|| AppError::Wallet("Wallet not connected".into()))? + .client, + ) + }; + + refresh_unilateral_exit_cache_for_client(&app, client).await +} + +#[tauri::command] +pub async fn get_unilateral_exit_cache_status( + app: tauri::AppHandle, +) -> Result { + match read_cache(&app).await? { + Some(cache) => { + debug!( + branch_count = cache.branch_count, + tx_count = cache.tx_count, + "loaded unilateral exit recovery cache status" + ); + Ok(cache_status(&cache, None)) + } + None => Ok(UnilateralExitCacheStatus { + exists: false, + generated_at: None, + network: None, + branch_count: 0, + tx_count: 0, + failed_count: 0, + last_error: None, + }), + } +} + +// ── Offline broadcast ─────────────────────────────────────────────────── +// +// Everything below runs without the ASP. The broadcast context is built from +// disk (mnemonic in secure storage, network in `wallet.json`, server_pk in +// the recovery cache) so the user can publish the cached exit tree even when +// `connect_wallet` has failed. + +/// Minimal wallet pieces needed to fee-bump and broadcast the cached exit +/// tree without contacting the ASP. None of these talk to the ASP — by design +/// the broadcast path is ASP-free whether or not we're currently connected. +pub(crate) struct OfflineBroadcastCtx { + pub(crate) blockchain: ark::EsploraBlockchain, + pub(crate) wallet: Arc, + pub(crate) timeout: Duration, + pub(crate) submitpackage: Option, +} + +#[derive(Clone)] +pub(crate) struct SubmitpackageEndpoint { + pub(crate) url: String, + pub(crate) token: Option, +} + +const DEFAULT_SUBMITPACKAGE_URL: Option<&str> = option_env!("AVARK_SUBMITPACKAGE_URL"); +const DEFAULT_SUBMITPACKAGE_TOKEN: Option<&str> = option_env!("AVARK_SUBMITPACKAGE_TOKEN"); + +/// An endpoint needs a URL; the token is optional. +fn endpoint_from_parts( + url: Option, + token: Option, +) -> Option { + let url = url.filter(|u| !u.is_empty())?; + let token = token.filter(|t| !t.is_empty()); + Some(SubmitpackageEndpoint { url, token }) +} + +pub(crate) fn default_submitpackage_endpoint( + network: bitcoin::Network, +) -> Option { + if network != bitcoin::Network::Bitcoin { + return None; + } + endpoint_from_parts( + DEFAULT_SUBMITPACKAGE_URL.map(str::to_owned), + DEFAULT_SUBMITPACKAGE_TOKEN.map(str::to_owned), + ) +} + +const OFFLINE_BROADCAST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Best-effort wallet sync with exponential backoff. Mobile networks hostile +/// to esplora (TLS resets, incomplete-message) drop a single sync attempt +/// regularly; retrying a couple of times turns most transient outages into a +/// successful sync within a few seconds. +async fn sync_wallet_with_retry(wallet: &ark::ArkWallet) -> Result<(), String> { + use ark_client::wallet::OnchainWallet; + + const MAX_ATTEMPTS: u32 = 3; + const BASE_DELAY: Duration = Duration::from_millis(300); + + let mut last_error = String::new(); + for attempt in 1..=MAX_ATTEMPTS { + match wallet.sync().await { + Ok(()) => return Ok(()), + Err(e) => { + last_error = e.to_string(); + if attempt < MAX_ATTEMPTS { + let delay = BASE_DELAY * 2u32.pow(attempt - 1); + debug!(attempt, "wallet sync failed; retrying after {delay:?}"); + tokio::time::sleep(delay).await; + } + } + } + } + Err(last_error) +} + +/// Resolve the submitpackage endpoint: a user override in settings wins; +/// otherwise the compiled-in default. `None` means broadcast falls back to +/// esplora's sequential `POST /tx` path. +async fn read_submitpackage_endpoint( + app: &tauri::AppHandle, + network: bitcoin::Network, +) -> Option { + let settings = { + let state = app.state::(); + let _lock = state.0.read().await; + match crate::read_settings(app).await { + Ok(s) => Some(s), + Err(e) => { + warn!(error = %e, "failed to read settings for submitpackage endpoint; using the compiled-in default"); + None + } + } + }; + if let Some(s) = settings { + if let Some(custom) = endpoint_from_parts(s.submitpackage_url, s.submitpackage_token) { + return Some(custom); + } + } + default_submitpackage_endpoint(network) +} + +/// Build the broadcast context. When a connected wallet exists in +/// `GlobalWalletState`, reuse its BDK wallet — it's already been syncing in the +/// background, so it reflects current onchain state without depending on a +/// fresh sync succeeding right now. Constructing a brand-new BDK wallet on every +/// preflight call would discard that cached state and put us at the mercy of a +/// single esplora request on a flaky network. Fall back to the from-disk path +/// only when truly offline. +async fn build_offline_broadcast_ctx( + app: &tauri::AppHandle, +) -> Result { + let connected_wallet = { + let state = app.state::(); + let guard = state.0.read().await; + guard.as_ref().map(|ws| Arc::clone(&ws.wallet)) + }; + + if let Some(wallet) = connected_wallet { + let wallet_data = super::wallet::read_wallet_data(app).await?; + let network = wallet_data + .network + .parse::() + .map_err(|e| AppError::Wallet(format!("Invalid network in wallet.json: {e}")))?; + let submitpackage = read_submitpackage_endpoint(app, network).await; + let custom_esplora = { + let state = app.state::(); + let _lock = state.0.read().await; + crate::read_settings(app) + .await + .ok() + .and_then(|s| s.esplora_url) + }; + let esplora_url = ark::esplora_url(network, custom_esplora.as_deref()); + let blockchain = ark::EsploraBlockchain::new(&esplora_url) + .map_err(|e| AppError::Wallet(format!("Failed to create esplora client: {e}")))?; + + return Ok(OfflineBroadcastCtx { + blockchain, + wallet, + timeout: OFFLINE_BROADCAST_TIMEOUT, + submitpackage, + }); + } + + build_from_disk_broadcast_ctx(app).await +} + +/// True-offline path: rebuild the BDK wallet from disk without any live +/// connected state. Used when `GlobalWalletState` is empty — i.e. the ASP +/// connection failed and the user is in the offline-mode UI. +async fn build_from_disk_broadcast_ctx( + app: &tauri::AppHandle, +) -> Result { + let cache = read_cache(app).await?.ok_or_else(|| { + AppError::Wallet("No recovery package cached. Refresh while connected first.".into()) + })?; + + let server_pk_hex = cache.server_pk.as_deref().ok_or_else(|| { + AppError::Wallet( + "Recovery package was cached before offline broadcast was supported. \ + Refresh while connected to enable." + .into(), + ) + })?; + let server_pk: bitcoin::XOnlyPublicKey = server_pk_hex + .parse() + .map_err(|e| AppError::Wallet(format!("Invalid server_pk in cache: {e}")))?; + + let wallet_data = super::wallet::read_wallet_data(app).await?; + let network = wallet_data + .network + .parse::() + .map_err(|e| AppError::Wallet(format!("Invalid network in wallet.json: {e}")))?; + + if wallet_data.network != cache.network { + return Err(AppError::Wallet(format!( + "Network mismatch: recovery package is for {}, wallet is on {}", + cache.network, wallet_data.network + ))); + } + + let store = secure_storage::SecureStorage::get_instance(app); + let mnemonic_words = load_mnemonic(store)?; + let xpriv = wallet::derive_master_xpriv(&mnemonic_words, network) + .map_err(|e| AppError::Wallet(e.to_string()))?; + + let custom_esplora = { + let state = app.state::(); + let _lock = state.0.read().await; + crate::read_settings(app) + .await + .ok() + .and_then(|s| s.esplora_url) + }; + let esplora_url = ark::esplora_url(network, custom_esplora.as_deref()); + + let blockchain = ark::EsploraBlockchain::new(&esplora_url) + .map_err(|e| AppError::Wallet(format!("Failed to create esplora client: {e}")))?; + + let secp = bitcoin::key::Secp256k1::new(); + let db_path = boarding_db_path(app)?; + let store_for_db = secure_storage::SecureStorage::get_instance(app).clone(); + let db = tokio::task::spawn_blocking(move || { + ark::FileDb::load(db_path, network, server_pk, store_for_db) + }) + .await + .map_err(|e| AppError::Wallet(format!("Boarding DB task panicked: {e}")))? + .map_err(|e| AppError::Wallet(format!("Failed to load boarding DB: {e}")))?; + + let bdk_wallet = ark_bdk_wallet::Wallet::new_from_xpriv(xpriv, secp, network, &esplora_url, db) + .map_err(|e| AppError::Wallet(format!("Failed to create BDK wallet: {e}")))?; + + let submitpackage = read_submitpackage_endpoint(app, network).await; + + Ok(OfflineBroadcastCtx { + blockchain, + wallet: Arc::new(bdk_wallet), + timeout: OFFLINE_BROADCAST_TIMEOUT, + submitpackage, + }) +} + +/// POST the parent+child hex pair to a configured submitpackage endpoint. +/// The endpoint is expected to be a thin proxy in front of Bitcoin Core's +/// `submitpackage` RPC — it accepts `{"hex": ["parent", "child"]}` with a +/// bearer token and returns the JSON-RPC response from Core. +async fn broadcast_via_submitpackage( + endpoint: &SubmitpackageEndpoint, + txs: &[&Transaction], + timeout: Duration, +) -> Result<(), AppError> { + let hex: Vec = txs.iter().map(encode::serialize_hex).collect(); + + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|e| AppError::Wallet(format!("Failed to build HTTP client: {e}")))?; + + let mut request = client + .post(&endpoint.url) + .json(&serde_json::json!({ "hex": hex })); + if let Some(token) = &endpoint.token { + request = request.bearer_auth(token); + } + let resp = request + .send() + .await + .map_err(|e| AppError::Wallet(format!("submitpackage request failed: {e}")))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + if !status.is_success() { + return Err(AppError::Wallet(format!( + "submitpackage endpoint returned HTTP {status}: {body}" + ))); + } + + check_submitpackage_response(&body).map_err(AppError::Wallet)?; + + info!(response = %body, "submitpackage endpoint accepted exit package"); + Ok(()) +} + +/// Interpret a submitpackage endpoint's response body. Core rejects packages +/// two different ways and both must be treated as failure: +/// +/// 1. A top-level JSON-RPC `error` (malformed request, RPC-level failure). +/// 2. Since v27, per-transaction policy rejections are reported *inside* +/// `result` — HTTP 200, `error: null`, but `package_msg != "success"` and +/// the per-tx reasons in `tx-results[].error`. Missing this shape would +/// report a rejected exit package as broadcast. +/// +/// `package_msg: "success"` means every tx is in (or already was in) the +/// mempool. A `result` without `package_msg` is accepted for proxies that +/// reshape the response. +fn check_submitpackage_response(body: &str) -> Result<(), String> { + let parsed: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("submitpackage returned invalid JSON: {e}"))?; + + if let Some(err) = parsed.get("error").filter(|v| !v.is_null()) { + let msg = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!("Bitcoin Core rejected exit package: {msg}")); + } + + // Tolerate a proxy returning Core's bare result instead of the full + // JSON-RPC envelope. + let result = parsed.get("result").unwrap_or(&parsed); + if let Some(msg) = result.get("package_msg").and_then(|m| m.as_str()) { + if msg != "success" { + let tx_errors = result + .get("tx-results") + .and_then(|t| t.as_object()) + .map(|txs| { + txs.values() + .filter_map(|r| r.get("error").and_then(|e| e.as_str())) + .collect::>() + .join("; ") + }) + .unwrap_or_default(); + return Err(if tx_errors.is_empty() { + format!("Bitcoin Core rejected exit package: {msg}") + } else { + format!("Bitcoin Core rejected exit package: {msg} ({tx_errors})") + }); + } + } + + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BroadcastOutcome { + /// Txid of the cached exit-tree transaction we tried to publish. + pub parent_txid: String, + /// Txid of the P2A anchor child built to bump it. `None` when the parent + /// was already published or in dry-run mode and `bump_and_broadcast` + /// short-circuited before constructing one. + pub anchor_txid: Option, + /// Total fee paid by the anchor child (covers the parent + child via CPFP). + pub fee_sat: u64, + /// True when the parent was already in the mempool or confirmed — no + /// broadcast was attempted, no fee was paid. + pub already_published: bool, + /// True when no real broadcast was sent. The anchor tx was constructed and + /// the fee is accurate, but `broadcast_package` was skipped. + pub dry_run: bool, +} + +/// Fee-bump and broadcast a single cached exit-tree transaction. The parent +/// must be one of the cached pre-signed virtual TXs; this function builds a +/// P2A anchor child funded from the user's on-chain UTXOs and sends both as +/// a package. ASP-free. +async fn bump_and_broadcast( + ctx: &OfflineBroadcastCtx, + parent: &Transaction, + dry_run: bool, +) -> Result { + use ark_client::wallet::OnchainWallet; + use ark_client::Blockchain as _; + use ark_core::unilateral_exit::build_anchor_tx; + + let parent_txid = parent.compute_txid(); + + // Mempool or confirmed — nothing to do. Re-broadcasting a known tx wastes + // the anchor fee and can surface a misleading "already known" error from + // esplora. + let already = tokio::time::timeout(ctx.timeout, ctx.blockchain.find_tx(&parent_txid)) + .await + .map_err(|_| AppError::Wallet(format!("Timed out checking status of {parent_txid}")))? + .map_err(|e| AppError::Wallet(format!("Failed to check status of {parent_txid}: {e}")))? + .is_some(); + if already { + return Ok(BroadcastOutcome { + parent_txid: parent_txid.to_string(), + anchor_txid: None, + fee_sat: 0, + already_published: true, + dry_run, + }); + } + + let fee_rate = tokio::time::timeout(ctx.timeout, ctx.blockchain.get_fee_rate()) + .await + .map_err(|_| AppError::Wallet("Timed out fetching fee rate".into()))? + .map_err(|e| AppError::Wallet(format!("Failed to get fee rate: {e}")))?; + + let change_address = ctx + .wallet + .get_onchain_address() + .map_err(|e| AppError::Wallet(format!("Failed to get onchain address: {e}")))?; + + let wallet_for_select = Arc::clone(&ctx.wallet); + let select_coins_fn = move |target_amount: bitcoin::Amount| { + wallet_for_select.select_coins(target_amount).map_err(|e| { + ark_core::Error::ad_hoc(format!("failed to select coins for anchor TX: {e}")) + }) + }; + + let mut psbt = build_anchor_tx(parent, change_address, fee_rate, select_coins_fn) + .map_err(|e| AppError::Wallet(format!("Failed to build anchor tx: {e}")))?; + + ctx.wallet + .sign(&mut psbt) + .map_err(|e| AppError::Wallet(format!("Failed to sign anchor tx: {e}")))?; + + let child = psbt + .clone() + .extract_tx() + .map_err(|e| AppError::Wallet(format!("Failed to extract anchor tx: {e}")))?; + let anchor_txid = child.compute_txid(); + let fee_sat = compute_anchor_fee(&psbt, &child); + + if !dry_run { + match &ctx.submitpackage { + Some(endpoint) => { + broadcast_via_submitpackage(endpoint, &[parent, &child], ctx.timeout).await?; + info!(parent = %parent_txid, anchor = %anchor_txid, fee_sat, "broadcast exit package via submitpackage endpoint"); + } + None => { + // Falls through to esplora's sequential broadcast. This will + // fail on mainnet for TRUC + P2A packages with `-22 min relay + // fee not met`; configuring a submitpackage endpoint in + // Settings is the practical way to actually publish. + ctx.blockchain + .broadcast_package(&[parent, &child]) + .await + .map_err(|e| { + AppError::Wallet(format!("Failed to broadcast exit package: {e}")) + })?; + info!(parent = %parent_txid, anchor = %anchor_txid, fee_sat, "broadcast exit package via esplora"); + } + } + } else { + debug!(parent = %parent_txid, anchor = %anchor_txid, fee_sat, "dry-run anchor tx built (not broadcast)"); + } + + Ok(BroadcastOutcome { + parent_txid: parent_txid.to_string(), + anchor_txid: Some(anchor_txid.to_string()), + fee_sat, + already_published: false, + dry_run, + }) +} + +/// Blocker text for the onchain fee-bumping balance, or `None` if a confirmed +/// balance exists. Distinguishes "deposit needed" from "your own fee-bump +/// change is still in the mempool" — after each broadcast the anchor child +/// returns change unconfirmed, and telling the user to deposit during that +/// window is wrong (it confirms in the same block as the exit tx). +fn onchain_funding_blocker(confirmed_sat: u64, pending_sat: u64) -> Option { + if confirmed_sat > 0 { + return None; + } + if pending_sat > 0 { + return Some(format!( + "Onchain balance is awaiting confirmation ({pending_sat} sats pending) — usually \ + change from your last fee-bump. It becomes spendable once it confirms (~1 block); \ + no new deposit is needed." + )); + } + Some( + "No confirmed plain-onchain BTC for fee-bumping. Boarding outputs cannot fund \ + the anchor child — send sats to the wallet's plain onchain address below." + .into(), + ) +} + +/// Total input value (witness UTXOs in the PSBT) minus total output value. +/// Saturating because a malformed PSBT shouldn't panic; downstream callers +/// can sanity-check. +fn compute_anchor_fee(psbt: &bitcoin::Psbt, child: &Transaction) -> u64 { + let total_in: u64 = psbt + .inputs + .iter() + .filter_map(|i| i.witness_utxo.as_ref().map(|u| u.value.to_sat())) + .sum(); + let total_out: u64 = child.output.iter().map(|o| o.value.to_sat()).sum(); + total_in.saturating_sub(total_out) +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnilateralExitPreflight { + /// True iff `blockers` is empty. The frontend may still surface non-blocking + /// hints (e.g. unusually low onchain balance) on top of this. + pub ready: bool, + /// Human-readable reasons the broadcast cannot proceed. + pub blockers: Vec, + /// Confirmed BDK-wallet onchain BTC available for fee-bumping (sats). This + /// is plain onchain, distinct from boarding outputs — those cannot fund + /// the anchor child because they're locked by the boarding script. + pub onchain_balance_sat: u64, + /// Unconfirmed onchain BTC visible in the mempool. Doesn't unblock broadcast + /// (fee-bumping needs a confirmed UTXO to spend) but lets the UI tell the + /// user "your deposit was seen" while they wait the ~10 min for a block. + pub onchain_pending_sat: u64, + /// Plain onchain address the user should fund to enable fee-bumping. Only + /// populated when the offline ctx builds successfully — `None` indicates a + /// blocker upstream of the address (no cache, stale cache, network mismatch). + pub onchain_address: Option, +} + +/// Quick gate that the recovery screen renders against. Cheap by design: +/// builds the offline ctx (which itself validates server_pk + network match), +/// best-effort syncs the BDK wallet, and reports the confirmed onchain balance. +/// The exact fee-bump cost is computed at broadcast time — pre-flight only +/// blocks the obvious "won't be able to pay anything" case. +#[tauri::command] +pub async fn unilateral_exit_preflight( + app: tauri::AppHandle, +) -> Result { + use ark_client::wallet::OnchainWallet; + + let mut blockers = Vec::new(); + // Set only by blockers that `build_offline_broadcast_ctx` independently + // fails on (no cache, missing server_pk, network mismatch) + let mut ctx_failure_expected = false; + + let cache = read_cache(&app).await?; + + let has_branches = match &cache { + None => { + blockers.push( + "No recovery package cached. Refresh while connected to the ASP first.".into(), + ); + ctx_failure_expected = true; + false + } + Some(cache) => { + if cache.server_pk.is_none() { + blockers.push( + "Recovery package was cached before offline broadcast was supported. \ + Refresh while connected to enable." + .into(), + ); + ctx_failure_expected = true; + } + if cache.branches.is_empty() { + blockers.push("Recovery package contains no transactions to broadcast.".into()); + } + let wallet_data = super::wallet::read_wallet_data(&app).await?; + if wallet_data.network != cache.network { + blockers.push(format!( + "Network mismatch: recovery package is for {}, wallet is on {}", + cache.network, wallet_data.network + )); + ctx_failure_expected = true; + } + !cache.branches.is_empty() + } + }; + + // The balance is informational and independent of the cache blockers + // above — compute it whenever a wallet context can be built, so the UI + // never renders a "0 sats" placeholder over real funds (e.g. fee-bump + // change sitting in the wallet after the last branch finished). + let (onchain_balance_sat, onchain_pending_sat, onchain_address) = + match build_offline_broadcast_ctx(&app).await { + Ok(ctx) => { + // Best-effort sync with retries — on flaky mobile networks a single + // sync often gets reset mid-handshake. The connected-wallet path + // already has cached state from its background loop, so even if + // every retry fails the balance is still correct. + if let Err(e) = sync_wallet_with_retry(&ctx.wallet).await { + warn!("broadcast ctx sync failed after retries (continuing with cached state): {e}"); + } + let bal = ctx.wallet.balance().ok(); + let confirmed = bal.as_ref().map(|b| b.confirmed.to_sat()).unwrap_or(0); + let pending = bal + .as_ref() + .map(|b| b.trusted_pending.to_sat() + b.untrusted_pending.to_sat()) + .unwrap_or(0); + let address = ctx.wallet.get_onchain_address().ok().map(|a| a.to_string()); + // An empty wallet only blocks when there's something left to + // broadcast — prompting for a deposit otherwise is noise. + if has_branches { + if let Some(blocker) = onchain_funding_blocker(confirmed, pending) { + blockers.push(blocker); + } + } + (confirmed, pending, address) + } + Err(e) => { + if !ctx_failure_expected { + blockers.push(format!("Failed to prepare offline broadcast context: {e}")); + } + (0, 0, None) + } + }; + + Ok(UnilateralExitPreflight { + ready: blockers.is_empty(), + blockers, + onchain_balance_sat, + onchain_pending_sat, + onchain_address, + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitTxStatus { + pub txid: String, + pub confirmed_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitBranchStatus { + pub branch_index: usize, + pub txs: Vec, + /// Index within `txs` of the first non-confirmed transaction. `None` once + /// every tx in the branch is confirmed on-chain. + pub next_pending_index: Option, + /// Source VTXO outpoint (e.g. `txid:vout`) this branch exits. `None` for + /// branches cached before the source-tracking field existed — a fresh + /// `refresh_unilateral_exit_cache` populates it. + pub source_outpoint: Option, + /// Amount of the source VTXO in sats. `None` for legacy cache entries. + pub source_amount_sat: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnilateralExitStatus { + pub branches: Vec, +} + +/// Per-branch broadcast progress, queried fresh from esplora. Stateless on +/// the backend — the chain *is* the progress, so this is robust to crashes +/// and app restarts mid-broadcast. +#[tauri::command] +pub async fn unilateral_exit_status( + app: tauri::AppHandle, +) -> Result { + use ark_client::Blockchain as _; + + let cache = read_cache(&app) + .await? + .ok_or_else(|| AppError::Wallet("No recovery package cached.".into()))?; + let ctx = build_offline_broadcast_ctx(&app).await?; + + // Fan all tx-status fetches across all branches out concurrently. A real + // recovery package on mainnet can contain dozens of cached txs and esplora + // round-trips dominate latency on mobile networks; the previous serial loop + // turned this command into an 80s+ wait. `join_all` preserves input order, + // so the flat results come back branch-major and chunk straight back into + // branches by taking `branch.len()` at a time. + let blockchain = &ctx.blockchain; + let timeout = ctx.timeout; + let tx_futures = cache.branches.iter().flatten().map(|cached_tx| { + let cached_txid = cached_tx.txid.clone(); + async move { + let txid: Txid = cached_txid + .parse() + .map_err(|e| AppError::Wallet(format!("Invalid txid in cache: {e}")))?; + let status = tokio::time::timeout(timeout, blockchain.get_tx_status(&txid)) + .await + .map_err(|_| AppError::Wallet(format!("Timed out querying status of {txid}")))? + .map_err(|e| AppError::Wallet(format!("Failed to query status of {txid}: {e}")))?; + Ok::<_, AppError>(ExitTxStatus { + txid: cached_txid, + confirmed_at: status.confirmed_at, + }) + } + }); + + let mut results = futures_util::future::join_all(tx_futures) + .await + .into_iter() + .collect::, _>>()? + .into_iter(); + + let branches = cache + .branches + .iter() + .enumerate() + .map(|(branch_index, branch)| { + let txs: Vec<_> = results.by_ref().take(branch.len()).collect(); + let next_pending_index = txs.iter().position(|t| t.confirmed_at.is_none()); + let source = cache.branch_sources.get(branch_index); + ExitBranchStatus { + branch_index, + txs, + next_pending_index, + source_outpoint: source.map(|s| s.outpoint.clone()), + source_amount_sat: source.map(|s| s.amount_sat), + } + }) + .collect(); + + Ok(UnilateralExitStatus { branches }) +} + +/// Broadcast the next not-yet-published transaction in `branch_index`. +/// `dry_run: true` constructs the bumped anchor and reports the fee without +/// actually publishing — useful for the UI to show "this will cost X sats" +/// before the user confirms. +#[tauri::command(rename_all = "camelCase")] +pub async fn unilateral_exit_broadcast_next( + app: tauri::AppHandle, + branch_index: usize, + dry_run: bool, +) -> Result { + use ark_client::Blockchain as _; + + let cache = read_cache(&app) + .await? + .ok_or_else(|| AppError::Wallet("No recovery package cached.".into()))?; + let branch = cache.branches.get(branch_index).ok_or_else(|| { + AppError::Wallet(format!( + "Branch {branch_index} not found in recovery package" + )) + })?; + + let ctx = build_offline_broadcast_ctx(&app).await?; + if let Err(e) = sync_wallet_with_retry(&ctx.wallet).await { + warn!("broadcast ctx sync failed after retries (continuing with cached state): {e}"); + } + + for cached_tx in branch { + let txid: Txid = cached_tx + .txid + .parse() + .map_err(|e| AppError::Wallet(format!("Invalid txid in cache: {e}")))?; + + // Skip anything already in the mempool or confirmed — we only ever + // broadcast the *first* unpublished tx, never re-broadcast. + let already = tokio::time::timeout(ctx.timeout, ctx.blockchain.find_tx(&txid)) + .await + .map_err(|_| AppError::Wallet(format!("Timed out checking status of {txid}")))? + .map_err(|e| AppError::Wallet(format!("Failed to check status of {txid}: {e}")))? + .is_some(); + if already { + continue; + } + + let parent: Transaction = encode::deserialize_hex(&cached_tx.tx_hex) + .map_err(|e| AppError::Wallet(format!("Invalid cached tx hex for {txid}: {e}")))?; + return bump_and_broadcast(&ctx, &parent, dry_run).await; + } + + // Every tx in the branch is on-chain — exit tree is fully published for + // this VTXO. The post-CSV sweep is a separate flow. + Ok(BroadcastOutcome { + parent_txid: String::new(), + anchor_txid: None, + fee_sat: 0, + already_published: true, + dry_run, + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UnrolledVtxoMaturity { + pub txid: String, + pub vout: u32, + pub amount_sat: u64, + /// Block timestamp of the exit leaf tx's first confirmation. `None` while + /// the tx is still in the mempool. + pub confirmed_at: Option, + /// Unix timestamp at which the CSV expires and the VTXO becomes spendable + /// via the unilateral exit script. Computed as `confirmed_at + csv_delay`. + pub csv_mature_at: Option, + /// True iff `csv_mature_at <= now()` — the VTXO is sweep-ready right now. + pub mature: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExitSweepStatus { + /// CSV delay captured into the recovery cache at refresh time. Seconds + /// after the exit leaf confirms before the VTXO output becomes spendable. + pub csv_delay_seconds: u64, + /// Reference time used for `mature` flags; lets the UI render countdowns + /// against one clock reading instead of re-sampling device time. + pub now: i64, + /// ASP dust limit captured into the recovery cache at refresh time. The + /// SDK's `send_on_chain` rejects sweep amounts below this, so the UI must + /// not offer them. + pub dust_sat: u64, + /// All exited (unrolled) VTXOs awaiting sweep. + pub vtxos: Vec, +} + +/// Per-VTXO post-broadcast sweep maturity, computed offline from the cached +/// recovery package plus esplora — no ASP connection required, matching the +/// rest of the emergency-exit flow (the ASP being gone is the reason this +/// screen exists). The CSV delay, dust limit, and candidate outpoints are +/// captured into the cache at refresh time, while connected; esplora answers +/// "is it on-chain / since when / still unspent" at call time. +#[tauri::command] +pub async fn unilateral_exit_sweep_status( + app: tauri::AppHandle, +) -> Result { + use ark_client::Blockchain as _; + + let cache = read_cache(&app).await?.ok_or_else(|| { + AppError::Wallet( + "No recovery package cached. Refresh while connected to the ASP first.".into(), + ) + })?; + let (csv_delay_seconds, dust_sat) = match (cache.exit_delay_seconds, cache.dust_sat) { + (Some(delay), Some(dust)) => (delay, dust), + _ => { + return Err(AppError::Wallet( + "Recovery package predates offline sweep support — refresh once while \ + connected to the ASP. (Or the ASP uses a block-based exit delay, which \ + avark's sweep flow doesn't support yet.)" + .into(), + )); + } + }; + + // `sweep_candidates` is the full set when written by current code; + // chaining `branch_sources` covers caches written in between the two + // fields existing. Spent/never-broadcast entries are filtered below. + let candidates = merge_sweep_candidates(cache.sweep_candidates, cache.branch_sources); + + let ctx = build_offline_broadcast_ctx(&app).await?; + let now = now_unix()?; + + // One future per candidate, all in flight at once with per-call timeouts + // (same treatment as `unilateral_exit_status`): serial esplora round-trips + // sum painfully on mobile networks, and a call that never answers must not + // hang the command. + let blockchain = &ctx.blockchain; + let timeout = ctx.timeout; + let maturity_futures = candidates.iter().map(|c| async move { + let outpoint: bitcoin::OutPoint = match c.outpoint.parse() { + Ok(o) => o, + Err(e) => { + warn!(outpoint = %c.outpoint, %e, "invalid outpoint in recovery cache"); + return None; + } + }; + + // Is the exit leaf on-chain at all, and since when? `get_tx_status` + // reports both "unknown tx" and "in mempool" as unconfirmed, so the + // unconfirmed case needs `find_tx` to tell "broadcast, awaiting + // confirmation" (list it) from "exit not broadcast yet" (skip — the + // broadcast flow owns that state, and advertising it as awaiting + // sweep would be misleading). Skipping on error/timeout is safe: + // the UI polls, so a transient esplora failure self-heals. + let confirmed_at = + match tokio::time::timeout(timeout, blockchain.get_tx_status(&outpoint.txid)).await { + Ok(Ok(s)) => s.confirmed_at, + Ok(Err(e)) => { + warn!(outpoint = %c.outpoint, %e, "failed to query exit leaf status"); + return None; + } + Err(_) => { + warn!(outpoint = %c.outpoint, "timed out querying exit leaf status"); + return None; + } + }; + if confirmed_at.is_none() { + match tokio::time::timeout(timeout, blockchain.find_tx(&outpoint.txid)).await { + Ok(Ok(Some(_))) => {} // in mempool — list with confirmed_at: None + Ok(Ok(None)) => return None, // exit not broadcast yet + Ok(Err(e)) => { + warn!(outpoint = %c.outpoint, %e, "failed to check exit leaf existence"); + return None; + } + Err(_) => { + warn!(outpoint = %c.outpoint, "timed out checking exit leaf existence"); + return None; + } + } + } + + // A spent outpoint means the coin left this wallet either way: a sweep + // we made, or — if the VTXO had been spent offchain before the (stale) + // exit tree was broadcast — a counterparty's pre-signed checkpoint tx + // claiming the output for them. Neither is ours to sweep, so drop it + // instead of advertising funds the SDK's coin selection will rightly + // refuse. + let spend_txid = match tokio::time::timeout( + timeout, + blockchain.get_output_status(&outpoint.txid, outpoint.vout), + ) + .await + { + Ok(Ok(s)) => s.spend_txid, + // Can't verify spentness (error or timeout) — list the coin + // anyway rather than hiding possibly-sweepable funds. + Ok(Err(e)) => { + warn!(outpoint = %c.outpoint, %e, "failed to check unrolled outpoint spend status"); + None + } + Err(_) => { + warn!(outpoint = %c.outpoint, "timed out checking unrolled outpoint spend status"); + None + } + }; + if let Some(spend_txid) = spend_txid { + debug!(outpoint = %c.outpoint, %spend_txid, "unrolled outpoint already spent; not sweepable"); + return None; + } + + let csv_mature_at = confirmed_at.map(|t| t + csv_delay_seconds as i64); + let mature = csv_mature_at.is_some_and(|t| t <= now); + Some(UnrolledVtxoMaturity { + txid: outpoint.txid.to_string(), + vout: outpoint.vout, + amount_sat: c.amount_sat, + confirmed_at, + csv_mature_at, + mature, + }) + }); + let vtxos: Vec = futures_util::future::join_all(maturity_futures) + .await + .into_iter() + .flatten() + .collect(); + + Ok(ExitSweepStatus { + csv_delay_seconds, + now, + dust_sat, + vtxos, + }) +} + +/// The SDK's `send_on_chain` uses the same fixed fee; mirrored here so the +/// offline path produces an identical transaction shape. +const SWEEP_FEE: bitcoin::Amount = bitcoin::Amount::from_sat(1_000); + +/// Offline replica of the SDK's `Client::send_on_chain` (which hard-requires a +/// connected client even though — per the SDK's own TODO — nothing about a +/// unilateral exit needs the server). Everything it consumes is available +/// without the ASP: the server pubkey + exit delay + dust snapshotted into the +/// recovery cache at refresh time, keys derived locally from the mnemonic, +/// boarding outputs from the local wallet DB, and esplora for UTXO lookup + +/// broadcast. VTXO addresses are discovered by deriving keys at consecutive +/// indices and checking esplora for on-chain history, stopping after a +/// gap-limit run of unused keys — the esplora analogue of the SDK's +/// ASP-backed `discover_keys`. +async fn sweep_unrolled_offline( + app: &tauri::AppHandle, + to_address: bitcoin::Address, + to_amount: bitcoin::Amount, +) -> Result { + use ark_client::wallet::{BoardingWallet, OnchainWallet}; + use ark_client::{Blockchain as _, KeyProvider as _}; + use ark_core::script::extract_checksig_pubkeys; + use ark_core::unilateral_exit::{create_unilateral_exit_transaction, OnChainInput, VtxoInput}; + use ark_core::{ExplorerUtxo, Vtxo}; + + let cache = read_cache(app).await?.ok_or_else(|| { + AppError::Wallet( + "No recovery package cached. Refresh while connected to the ASP first.".into(), + ) + })?; + let (server_pk_hex, exit_delay_seconds, dust_sat) = + match (&cache.server_pk, cache.exit_delay_seconds, cache.dust_sat) { + (Some(pk), Some(delay), Some(dust)) => (pk.clone(), delay, dust), + _ => { + return Err(AppError::Wallet( + "Recovery package predates offline sweep support — refresh once while \ + connected to the ASP." + .into(), + )); + } + }; + let server_pk: bitcoin::XOnlyPublicKey = server_pk_hex + .parse() + .map_err(|e| AppError::Wallet(format!("Invalid server_pk in cache: {e}")))?; + + if to_amount < bitcoin::Amount::from_sat(dust_sat) { + return Err(AppError::Wallet(format!( + "Amount below the dust limit of {dust_sat} sats" + ))); + } + + let exit_delay = exit_delay_from_seconds(exit_delay_seconds).ok_or_else(|| { + AppError::Wallet(format!( + "Invalid cached exit delay: {exit_delay_seconds}s is not a valid time-based locktime" + )) + })?; + + let wallet_data = super::wallet::read_wallet_data(app).await?; + let network = wallet_data + .network + .parse::() + .map_err(|e| AppError::Wallet(format!("Invalid network in wallet.json: {e}")))?; + + let ctx = build_offline_broadcast_ctx(app).await?; + + let store = secure_storage::SecureStorage::get_instance(app); + let mnemonic_words = load_mnemonic(store)?; + let xpriv = wallet::derive_master_xpriv(&mnemonic_words, network) + .map_err(|e| AppError::Wallet(e.to_string()))?; + let derivation_path = std::str::FromStr::from_str(ark_core::DEFAULT_DERIVATION_PATH) + .expect("valid derivation path"); + // start_index 0 is fine: we never derive *new* keys here, only enumerate + // existing ones via discovery indices. + let key_provider = ark_client::Bip32KeyProvider::new_with_index(xpriv, derivation_path, 0); + + let target = to_amount + SWEEP_FEE; + let now = Duration::from_secs(now_unix()? as u64); + let mut selected = bitcoin::Amount::ZERO; + + // Boarding outputs first, mirroring the SDK's selection priority. + let mut onchain_inputs: Vec = Vec::new(); + for boarding_output in ctx + .wallet + .get_boarding_outputs() + .map_err(|e| AppError::Wallet(format!("Failed to list boarding outputs: {e}")))? + { + if selected >= target { + break; + } + let utxos = tokio::time::timeout( + ctx.timeout, + ctx.blockchain.find_outpoints(boarding_output.address()), + ) + .await + .map_err(|_| AppError::Wallet("Timed out querying boarding outputs".into()))? + .map_err(|e| AppError::Wallet(format!("Failed to query boarding outputs: {e}")))?; + + for utxo in utxos { + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(blocktime), + confirmations, + is_spent: false, + } = utxo + { + if boarding_output.can_be_claimed_unilaterally_by_owner( + now, + Duration::from_secs(blocktime), + confirmations, + ) { + onchain_inputs.push(OnChainInput::new( + boarding_output.clone(), + amount, + outpoint, + )); + selected += amount; + } + } + } + } + + // Then unrolled VTXOs, discovered by deriving keys and asking esplora. + let secp = bitcoin::key::Secp256k1::new(); + let mut keypairs: HashMap = HashMap::new(); + let mut vtxo_inputs: Vec = Vec::new(); + let mut consecutive_unused = 0u32; + let mut index = 0u32; + while selected < target && consecutive_unused < ark_client::DEFAULT_GAP_LIMIT { + let keypair = match key_provider + .derive_at_discovery_index(index) + .map_err(|e| AppError::Wallet(format!("Key derivation failed: {e}")))? + { + Some(kp) => kp, + None => break, + }; + index += 1; + + let owner_pk = keypair.x_only_public_key().0; + let vtxo = Vtxo::new_default(&secp, server_pk, owner_pk, exit_delay, network) + .map_err(|e| AppError::Wallet(format!("Failed to derive VTXO script: {e}")))?; + + let utxos = + tokio::time::timeout(ctx.timeout, ctx.blockchain.find_outpoints(vtxo.address())) + .await + .map_err(|_| AppError::Wallet("Timed out querying VTXO outpoints".into()))? + .map_err(|e| AppError::Wallet(format!("Failed to query VTXO outpoints: {e}")))?; + + if utxos.is_empty() { + consecutive_unused += 1; + continue; + } + consecutive_unused = 0; + + for utxo in &utxos { + if let ExplorerUtxo { + outpoint, + amount, + confirmation_blocktime: Some(blocktime), + confirmations, + is_spent: false, + } = utxo + { + if vtxo.can_be_claimed_unilaterally_by_owner( + now, + Duration::from_secs(*blocktime), + *confirmations, + ) { + let spend_info = vtxo.exit_spend_info().map_err(|e| { + AppError::Wallet(format!("Failed to derive exit spend info: {e}")) + })?; + vtxo_inputs.push(VtxoInput::new( + *outpoint, + vtxo.exit_delay(), + bitcoin::TxOut { + value: *amount, + script_pubkey: vtxo.script_pubkey(), + }, + spend_info, + )); + keypairs.insert(owner_pk, keypair); + selected += *amount; + } + } + } + } + + if selected < target { + return Err(AppError::Wallet(format!( + "Insufficient mature funds to sweep: found {selected}, need {target} \ + (amount + {SWEEP_FEE} fee). Outputs still inside the exit delay are not selectable." + ))); + } + + let change_address = ctx + .wallet + .get_onchain_address() + .map_err(|e| AppError::Wallet(format!("Failed to derive change address: {e}")))?; + + let wallet = Arc::clone(&ctx.wallet); + let sign = move |input: &mut bitcoin::psbt::Input, msg: bitcoin::secp256k1::Message| { + let script = input.witness_script.as_ref().ok_or_else(|| { + ark_core::Error::ad_hoc( + "Missing witness script for psbt::Input when signing sweep transaction", + ) + })?; + let mut res = vec![]; + for pk in extract_checksig_pubkeys(script) { + if let Some(keypair) = keypairs.get(&pk) { + let sig = secp.sign_schnorr_no_aux_rand(&msg, keypair); + res.push((sig, pk)); + } + // Boarding inputs are signed by the wallet's own key; errors mean + // "not this wallet's key", mirroring the SDK. + if let Ok(sig) = wallet.sign_for_pk(&pk, &msg) { + res.push((sig, pk)); + } + } + Ok(res) + }; + + let tx = create_unilateral_exit_transaction( + to_address, + to_amount, + change_address, + &onchain_inputs, + &vtxo_inputs, + sign, + ) + .map_err(|e| AppError::Wallet(format!("Failed to build sweep transaction: {e}")))?; + + let txid = tx.compute_txid(); + tokio::time::timeout(ctx.timeout, ctx.blockchain.broadcast(&tx)) + .await + .map_err(|_| AppError::Wallet(format!("Timed out broadcasting sweep {txid}")))? + .map_err(|e| AppError::Wallet(format!("Failed to broadcast sweep {txid}: {e}")))?; + + Ok(txid) +} + +/// Sweep one or more unrolled VTXOs to a regular Bitcoin address (distinct +/// from `commands::send::send_onchain`, which is the *cooperative* offboard +/// path that doesn't pick up unrolled VTXOs). +/// +/// Coin selection picks from boarding outputs + unrolled VTXOs; the resulting +/// tx spends them via their respective exit scripts (with the right CSV +/// sequence). If selected coins haven't matured past the CSV delay, Core +/// will accept the tx but it won't mine until the timelock elapses — so the +/// caller should ideally only sweep mature outputs. +/// +/// Uses the SDK's `send_on_chain` when a connected client exists, and the +/// offline replica otherwise — the sweep must work while the ASP is down, +/// because that's the scenario the emergency-exit flow exists for. +#[tauri::command(rename_all = "camelCase")] +pub async fn sweep_unrolled_to_onchain( + app: tauri::AppHandle, + address: String, + amount_sat: u64, +) -> Result { + let amount = super::send::validate_amount_sat(amount_sat)?; + + // wallet.json records the network at connect time, so address validation + // works identically with or without a live ASP connection. + let wallet_data = super::wallet::read_wallet_data(&app).await?; + let network = wallet_data + .network + .parse::() + .map_err(|e| AppError::Wallet(format!("Invalid network in wallet.json: {e}")))?; + let btc_addr = super::send::parse_onchain_address(&address, network)?; + + let client = { + let state = app.state::(); + let guard = state.0.read().await; + guard.as_ref().map(|ws| Arc::clone(&ws.client)) + }; + + info!(address = %address, amount_sat, offline = client.is_none(), "sweeping unrolled VTXOs to onchain"); + + let txid = match client { + Some(client) => client + .send_on_chain(btc_addr, amount) + .await + .map_err(|e| AppError::Wallet(format!("Sweep failed: {e}")))?, + None => sweep_unrolled_offline(&app, btc_addr, amount).await?, + }; + + info!(txid = %txid, "swept unrolled VTXOs"); + Ok(txid.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ark_core::server::{ChainedTxType, VtxoChain, VtxoChains}; + + /// A distinct, valid 32-byte txid for tests, every byte set to `n`. + fn txid(n: u8) -> Txid { + use bitcoin::hashes::Hash as _; + Txid::from_byte_array([n; 32]) + } + + fn node(id: u8, tx_type: ChainedTxType, spends: &[u8]) -> VtxoChain { + VtxoChain { + txid: txid(id), + tx_type, + spends: spends.iter().map(|&n| txid(n)).collect(), + expires_at: 0, + } + } + + /// Index of `t` within `order`, for asserting relative ordering. + fn pos(order: &[Txid], t: u8) -> usize { + order + .iter() + .position(|x| *x == txid(t)) + .expect("txid in order") + } + + #[test] + fn linear_chain_orders_parents_before_children() { + // commitment(1) <- tree(2) <- ark(3, the VTXO) + let chains = VtxoChains { + inner: vec![ + node(1, ChainedTxType::Commitment, &[]), + node(2, ChainedTxType::Tree, &[1]), + node(3, ChainedTxType::Ark, &[2]), + ], + }; + let (order, commitments) = build_exit_broadcast_order(&chains, txid(3)).unwrap(); + // The commitment is already on-chain — excluded from the broadcast order. + assert_eq!(order, vec![txid(2), txid(3)]); + assert_eq!(commitments, vec![txid(1)]); + } + + #[test] + fn diamond_dag_emits_each_node_once_in_topological_order() { + // commitment(1) <- b(2) <- x(3) ┐ + // b(2) <- y(4) ┴<- a(5, the VTXO) + let chains = VtxoChains { + inner: vec![ + node(1, ChainedTxType::Commitment, &[]), + node(2, ChainedTxType::Checkpoint, &[1]), + node(3, ChainedTxType::Ark, &[2]), + node(4, ChainedTxType::Ark, &[2]), + node(5, ChainedTxType::Ark, &[3, 4]), + ], + }; + let (order, commitments) = build_exit_broadcast_order(&chains, txid(5)).unwrap(); + // The shared ancestor (2) appears exactly once despite two children. + assert_eq!(order.len(), 4); + assert!(pos(&order, 2) < pos(&order, 3)); + assert!(pos(&order, 2) < pos(&order, 4)); + assert!(pos(&order, 3) < pos(&order, 5)); + assert!(pos(&order, 4) < pos(&order, 5)); + assert_eq!(commitments, vec![txid(1)]); + } + + #[test] + fn cycle_is_rejected() { + let chains = VtxoChains { + inner: vec![ + node(2, ChainedTxType::Ark, &[3]), + node(3, ChainedTxType::Ark, &[2]), + ], + }; + let err = build_exit_broadcast_order(&chains, txid(2)).unwrap_err(); + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn missing_transaction_is_rejected() { + // Node 2 spends 3, but 3 is absent from the chain the ASP returned. + let chains = VtxoChains { + inner: vec![node(2, ChainedTxType::Ark, &[3])], + }; + let err = build_exit_broadcast_order(&chains, txid(2)).unwrap_err(); + assert!(err.to_string().contains("missing transaction")); + } + + #[test] + fn dead_end_without_commitment_is_rejected() { + // A non-commitment transaction with no parents cannot reach the chain. + let chains = VtxoChains { + inner: vec![node(2, ChainedTxType::Ark, &[])], + }; + let err = build_exit_broadcast_order(&chains, txid(2)).unwrap_err(); + assert!(err.to_string().contains("dead-ends")); + } + + fn one_input_one_output_tx( + input_value: u64, + output_value: u64, + ) -> (bitcoin::Psbt, Transaction) { + use bitcoin::{ + absolute, transaction, Amount, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Witness, + }; + + let unsigned_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: txid(0), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(output_value), + script_pubkey: ScriptBuf::new(), + }], + }; + + let mut psbt = bitcoin::Psbt::from_unsigned_tx(unsigned_tx.clone()).unwrap(); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: Amount::from_sat(input_value), + script_pubkey: ScriptBuf::new(), + }); + (psbt, unsigned_tx) + } + + #[test] + fn anchor_fee_subtracts_outputs_from_inputs() { + // 2000 sats in (witness_utxo) - 900 sats out = 1100 sats fee. + let (psbt, child) = one_input_one_output_tx(2000, 900); + assert_eq!(compute_anchor_fee(&psbt, &child), 1100); + } + + #[test] + fn anchor_fee_treats_missing_witness_utxo_as_zero_input() { + // A real anchor PSBT always has witness_utxo set; this guards against + // a malformed input panicking via underflow. + use bitcoin::{ + absolute, transaction, Amount, OutPoint, ScriptBuf, Sequence, TxIn, TxOut, Witness, + }; + let unsigned_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: txid(0), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(500), + script_pubkey: ScriptBuf::new(), + }], + }; + let psbt = bitcoin::Psbt::from_unsigned_tx(unsigned_tx.clone()).unwrap(); + // 0 in - 500 out → saturating_sub keeps us at 0 instead of underflowing. + assert_eq!(compute_anchor_fee(&psbt, &unsigned_tx), 0); + } + + #[test] + fn anchor_fee_zero_when_inputs_equal_outputs() { + let (psbt, child) = one_input_one_output_tx(1000, 1000); + assert_eq!(compute_anchor_fee(&psbt, &child), 0); + } + + #[test] + fn endpoint_requires_url_token_optional() { + let with_token = endpoint_from_parts(Some("https://x".into()), Some("tok".into())) + .expect("url + token is a full endpoint"); + assert_eq!(with_token.token.as_deref(), Some("tok")); + + let tokenless = endpoint_from_parts(Some("https://x".into()), None) + .expect("url without token is a valid endpoint"); + assert_eq!(tokenless.token, None); + let empty_token = endpoint_from_parts(Some("https://x".into()), Some("".into())) + .expect("empty token means tokenless"); + assert_eq!(empty_token.token, None); + + assert!(endpoint_from_parts(None, Some("tok".into())).is_none()); + assert!(endpoint_from_parts(Some("".into()), Some("tok".into())).is_none()); + } + + #[test] + fn default_endpoint_is_mainnet_only() { + // The baked-in default fronts a mainnet Core node, so it must never be + // handed to a test-network wallet — regardless of whether THIS build + // compiled one in (option_env! is None under `cargo test`, but the + // network gate holds either way). + assert!(default_submitpackage_endpoint(bitcoin::Network::Signet).is_none()); + assert!(default_submitpackage_endpoint(bitcoin::Network::Testnet).is_none()); + assert!(default_submitpackage_endpoint(bitcoin::Network::Regtest).is_none()); + } + + #[test] + fn submitpackage_success_is_ok() { + let body = r#"{"result":{"package_msg":"success","tx-results":{"aa":{"txid":"bb"}}},"error":null,"id":1}"#; + assert_eq!(check_submitpackage_response(body), Ok(())); + } + + #[test] + fn submitpackage_rpc_error_is_rejected() { + let body = r#"{"result":null,"error":{"code":-25,"message":"package-not-child-with-parents"},"id":1}"#; + let msg = check_submitpackage_response(body).unwrap_err(); + assert!(msg.contains("package-not-child-with-parents"), "{msg}"); + } + + #[test] + fn submitpackage_policy_rejection_inside_result_is_rejected() { + // v27+ shape: HTTP 200, error:null, rejection reported via package_msg + // + per-tx errors. This is the one that used to be reported as success. + let body = r#"{"result":{"package_msg":"transaction failed","tx-results":{"aa":{"txid":"bb","error":"min relay fee not met"}}},"error":null,"id":1}"#; + let msg = check_submitpackage_response(body).unwrap_err(); + assert!(msg.contains("transaction failed"), "{msg}"); + assert!(msg.contains("min relay fee not met"), "{msg}"); + } + + #[test] + fn submitpackage_bare_result_without_envelope_is_handled() { + // A proxy may forward Core's result without the JSON-RPC envelope. + let rejected = r#"{"package_msg":"transaction failed","tx-results":{}}"#; + assert!(check_submitpackage_response(rejected).is_err()); + let ok = r#"{"package_msg":"success","tx-results":{}}"#; + assert_eq!(check_submitpackage_response(ok), Ok(())); + } + + #[test] + fn submitpackage_invalid_json_is_rejected() { + assert!(check_submitpackage_response("502 Bad Gateway").is_err()); + } + + #[test] + fn exit_delay_time_based_resolves_to_seconds() { + let seq = bitcoin::Sequence::from_512_second_intervals(7); + assert_eq!(exit_delay_to_seconds(seq), Some(7 * 512)); + } + + #[test] + fn exit_delay_block_based_is_unsupported() { + let seq = bitcoin::Sequence::from_height(144); + assert_eq!(exit_delay_to_seconds(seq), None); + } + + #[test] + fn exit_delay_seconds_roundtrips_through_sequence() { + let seq = bitcoin::Sequence::from_512_second_intervals(7); + let secs = exit_delay_to_seconds(seq).unwrap(); + assert_eq!(exit_delay_from_seconds(secs), Some(seq)); + } + + #[test] + fn exit_delay_from_seconds_rejects_invalid_values() { + // Not a multiple of 512 — can never have come from a time-based lock. + assert_eq!(exit_delay_from_seconds(1000), None); + // Interval count overflows the u16 sequence field. + assert_eq!(exit_delay_from_seconds((u16::MAX as u64 + 1) * 512), None); + } + + #[test] + fn exit_delay_non_relative_locktime_is_unsupported() { + // Disable flag set — not a valid relative locktime at all. + assert_eq!(exit_delay_to_seconds(bitcoin::Sequence::MAX), None); + } + + fn candidate(outpoint: &str, amount_sat: u64) -> CachedBranchSource { + CachedBranchSource { + outpoint: outpoint.to_string(), + amount_sat, + } + } + + #[test] + fn merge_sweep_candidates_dedupes_primary_wins() { + let merged = merge_sweep_candidates( + vec![candidate("aa:0", 1000), candidate("bb:1", 2000)], + vec![candidate("bb:1", 9999), candidate("cc:0", 3000)], + ); + let as_pairs: Vec<_> = merged + .iter() + .map(|c| (c.outpoint.as_str(), c.amount_sat)) + .collect(); + assert_eq!( + as_pairs, + vec![("aa:0", 1000), ("bb:1", 2000), ("cc:0", 3000)] + ); + } + + #[test] + fn merge_sweep_candidates_handles_empty_sides() { + assert!(merge_sweep_candidates(vec![], vec![]).is_empty()); + let only_secondary = merge_sweep_candidates(vec![], vec![candidate("aa:0", 1)]); + assert_eq!(only_secondary.len(), 1); + } + + #[test] + fn funding_blocker_none_with_confirmed_balance() { + assert_eq!(onchain_funding_blocker(2610, 0), None); + // Confirmed sats unblock regardless of what else is pending. + assert_eq!(onchain_funding_blocker(2610, 500), None); + } + + #[test] + fn funding_blocker_asks_to_wait_when_change_is_pending() { + // Post-broadcast state: the anchor child returned change that hasn't + // confirmed yet. The user must wait, not deposit. + let msg = onchain_funding_blocker(0, 2271).unwrap(); + assert!(msg.contains("2271 sats pending"), "got: {msg}"); + assert!(msg.contains("no new deposit"), "got: {msg}"); + } + + #[test] + fn funding_blocker_asks_to_deposit_when_wallet_is_empty() { + let msg = onchain_funding_blocker(0, 0).unwrap(); + assert!(msg.contains("send sats"), "got: {msg}"); + } +} diff --git a/src-tauri/src/commands/send.rs b/src-tauri/src/commands/send.rs index 3334111..1f10e24 100644 --- a/src-tauri/src/commands/send.rs +++ b/src-tauri/src/commands/send.rs @@ -226,9 +226,7 @@ pub async fn send_ark( address: String, amount_sat: u64, ) -> Result { - if amount_sat == 0 { - return Err(AppError::Wallet("Amount must be greater than zero".into())); - } + let amount = validate_amount_sat(amount_sat)?; let client = { let state = app.state::(); @@ -242,8 +240,6 @@ pub async fn send_ark( let ark_addr = ark_core::ArkAddress::decode(&address) .map_err(|e| AppError::Wallet(format!("Invalid Ark address: {e}")))?; - let amount = bitcoin::Amount::from_sat(amount_sat); - info!(address = %address, amount_sat = amount_sat, "sending Ark payment"); // v0.9.0 replaced `send_vtxo(addr, amount)` with `send(Vec)` @@ -279,9 +275,7 @@ pub async fn send_ark_selected( amount_sat: u64, outpoints: Vec, ) -> Result { - if amount_sat == 0 { - return Err(AppError::Wallet("Amount must be greater than zero".into())); - } + let amount = validate_amount_sat(amount_sat)?; if outpoints.is_empty() { return Err(AppError::Wallet("Select at least one coin to spend".into())); } @@ -298,8 +292,6 @@ pub async fn send_ark_selected( 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, @@ -325,15 +317,39 @@ pub async fn send_ark_selected( }) } +/// Validate a sat amount arriving from the IPC boundary. Rejects zero and +/// anything above MAX_MONEY (21M BTC) +pub(crate) fn validate_amount_sat(amount_sat: u64) -> Result { + if amount_sat == 0 { + return Err(AppError::Wallet("Amount must be greater than zero".into())); + } + let amount = bitcoin::Amount::from_sat(amount_sat); + if amount > bitcoin::Amount::MAX_MONEY { + return Err(AppError::Wallet( + "Amount exceeds the total possible supply of bitcoin".into(), + )); + } + Ok(amount) +} + +pub(crate) fn parse_onchain_address( + address: &str, + network: bitcoin::Network, +) -> Result { + address + .parse::>() + .map_err(|e| AppError::Wallet(format!("Invalid Bitcoin address: {e}")))? + .require_network(network) + .map_err(|_| AppError::Wallet(format!("Address is not valid for {network}"))) +} + #[tauri::command] pub async fn send_onchain( app: tauri::AppHandle, address: String, amount_sat: u64, ) -> Result { - if amount_sat == 0 { - return Err(AppError::Wallet("Amount must be greater than zero".into())); - } + let amount = validate_amount_sat(amount_sat)?; let client = { let state = app.state::(); @@ -344,12 +360,7 @@ pub async fn send_onchain( Arc::clone(&ws.client) }; - let btc_addr = address - .parse::>() - .map_err(|e| AppError::Wallet(format!("Invalid Bitcoin address: {e}")))? - .assume_checked(); - - let amount = bitcoin::Amount::from_sat(amount_sat); + let btc_addr = parse_onchain_address(&address, client.server_info.network)?; info!(address = %address, amount_sat = amount_sat, "offboarding to onchain"); @@ -383,9 +394,7 @@ pub async fn estimate_onchain_send_fee( address: String, amount_sat: u64, ) -> Result { - if amount_sat == 0 { - return Err(AppError::Wallet("Amount must be greater than zero".into())); - } + let amount = validate_amount_sat(amount_sat)?; let client = { let state = app.state::(); @@ -396,12 +405,7 @@ pub async fn estimate_onchain_send_fee( Arc::clone(&ws.client) }; - let btc_addr = address - .parse::>() - .map_err(|e| AppError::Wallet(format!("Invalid Bitcoin address: {e}")))? - .assume_checked(); - - let amount = bitcoin::Amount::from_sat(amount_sat); + let btc_addr = parse_onchain_address(&address, client.server_info.network)?; let mut last_err = String::new(); for attempt in 1..=FEE_ESTIMATE_MAX_RETRIES { @@ -447,6 +451,45 @@ pub async fn estimate_onchain_send_fee( mod tests { use super::*; + #[test] + fn validate_amount_rejects_zero() { + assert!(validate_amount_sat(0).is_err()); + } + + #[test] + fn validate_amount_rejects_above_max_money() { + let max = bitcoin::Amount::MAX_MONEY.to_sat(); + assert!(validate_amount_sat(max).is_ok()); + assert!(validate_amount_sat(max + 1).is_err()); + assert!(validate_amount_sat(u64::MAX).is_err()); + } + + #[test] + fn validate_amount_accepts_normal_values() { + assert_eq!( + validate_amount_sat(2_610).unwrap(), + bitcoin::Amount::from_sat(2_610) + ); + } + + #[test] + fn parse_onchain_address_accepts_matching_network() { + let addr = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + assert!(parse_onchain_address(addr, bitcoin::Network::Bitcoin).is_ok()); + } + + #[test] + fn parse_onchain_address_rejects_wrong_network() { + let addr = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; + let err = parse_onchain_address(addr, bitcoin::Network::Bitcoin).unwrap_err(); + assert!(err.to_string().contains("not valid for bitcoin"), "{err}"); + } + + #[test] + fn parse_onchain_address_rejects_garbage() { + assert!(parse_onchain_address("not-an-address", bitcoin::Network::Bitcoin).is_err()); + } + #[test] fn send_result_serializes() { let result = SendResult { @@ -455,8 +498,6 @@ mod tests { }; let json = serde_json::to_value(&result).unwrap(); assert_eq!(json["txid"], "abc123"); - // `skip_serializing_if = "Option::is_none"` keeps the field out of the - // JSON entirely when absent, so Ark/onchain payloads look unchanged. assert!(json.get("pendingLnSwapId").is_none()); } @@ -508,9 +549,6 @@ mod tests { #[tokio::test] async fn detect_ark_address() { - // tark prefix with valid bech32m — this should be recognized as Ark. - // We can't test with a real address without the SDK, but we can test - // that an invalid address returns an error. let result = detect_address_type("not-a-valid-address".into()).await; assert!(result.is_err()); } @@ -550,11 +588,4 @@ mod tests { let r = result.unwrap(); assert!(matches!(r.address_type, AddressType::Bitcoin)); } - - #[tokio::test] - async fn send_ark_rejects_zero_amount() { - // Can't call send_ark without a real app handle, but we can test - // detect_address_type and serialization. The zero-amount check is - // tested implicitly via the command's validation. - } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 5d285a6..b100d6a 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -22,6 +22,9 @@ pub struct SettingsInfo { pub esplora_url: Option, pub fiat_enabled: bool, pub fiat_currency: String, + pub submitpackage_url: Option, + pub submitpackage_token_configured: bool, + pub submitpackage_default_url: Option, } #[tauri::command(rename_all = "camelCase")] @@ -29,6 +32,15 @@ pub async fn settings(app: tauri::AppHandle) -> Result { let state = app.state::(); let _lock = state.0.read().await; let s = read_settings(&app).await?; + // The compiled-in default is mainnet-only (see default_submitpackage_endpoint); + // only surface it to the UI when the wallet is on mainnet, so the card's + // "Default" badge matches what broadcast will actually use. + let submitpackage_default_url = s + .network + .as_deref() + .and_then(|n| n.parse::().ok()) + .and_then(super::recovery::default_submitpackage_endpoint) + .map(|e| e.url); Ok(SettingsInfo { asp_url: s.asp_url, network: s.network, @@ -36,6 +48,12 @@ pub async fn settings(app: tauri::AppHandle) -> Result { esplora_url: s.esplora_url, fiat_enabled: s.fiat_enabled.unwrap_or(true), fiat_currency: s.fiat_currency.unwrap_or_else(|| "USD".to_string()), + submitpackage_url: s.submitpackage_url, + submitpackage_token_configured: s + .submitpackage_token + .as_deref() + .is_some_and(|t| !t.is_empty()), + submitpackage_default_url, }) } @@ -89,3 +107,81 @@ pub async fn set_esplora_url(app: tauri::AppHandle, url: Option) -> Resu s.esplora_url = url.filter(|u| !u.is_empty()); write_settings(&app, &s).await } + +/// Token update contract for `set_submitpackage_endpoint`. +fn resolve_token_update( + stored: Option, + incoming: Option, + url_present: bool, +) -> Result, AppError> { + match incoming { + None => Ok(stored), + Some(t) if t.is_empty() => Ok(None), + Some(t) => { + if !url_present { + return Err(AppError::Wallet( + "A bearer token requires an endpoint URL".into(), + )); + } + Ok(Some(t)) + } + } +} + +/// Set the `submitpackage` broadcast endpoint override. +#[tauri::command(rename_all = "camelCase")] +pub async fn set_submitpackage_endpoint( + app: tauri::AppHandle, + url: Option, + token: Option, +) -> Result<(), AppError> { + if let Some(ref u) = url { + if !u.is_empty() { + let parsed = + url::Url::parse(u).map_err(|e| AppError::Wallet(format!("Invalid URL: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(AppError::Wallet("URL scheme must be http or https".into())); + } + } + } + let state = app.state::(); + let _lock = state.0.write().await; + let mut s = read_settings(&app).await?; + let new_url = url.filter(|u| !u.is_empty()); + s.submitpackage_token = + resolve_token_update(s.submitpackage_token.take(), token, new_url.is_some())?; + s.submitpackage_url = new_url; + write_settings(&app, &s).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_absent_keeps_stored() { + let kept = resolve_token_update(Some("tok".into()), None, true).unwrap(); + assert_eq!(kept.as_deref(), Some("tok")); + let parked = resolve_token_update(Some("tok".into()), None, false).unwrap(); + assert_eq!(parked.as_deref(), Some("tok")); + } + + #[test] + fn empty_token_removes_stored() { + assert_eq!( + resolve_token_update(Some("tok".into()), Some("".into()), true).unwrap(), + None + ); + assert_eq!( + resolve_token_update(Some("tok".into()), Some("".into()), false).unwrap(), + None + ); + } + + #[test] + fn new_token_replaces_and_requires_url() { + let replaced = resolve_token_update(Some("old".into()), Some("new".into()), true).unwrap(); + assert_eq!(replaced.as_deref(), Some("new")); + assert!(resolve_token_update(None, Some("new".into()), false).is_err()); + } +} diff --git a/src-tauri/src/commands/wallet.rs b/src-tauri/src/commands/wallet.rs index a4f5060..86e029e 100644 --- a/src-tauri/src/commands/wallet.rs +++ b/src-tauri/src/commands/wallet.rs @@ -6,19 +6,21 @@ use tauri::{Emitter, Manager}; use tracing::{debug, info, warn}; use super::lightning::{spawn_pending_swap_recovery, BOLTZ_URL}; +use super::recovery::spawn_unilateral_exit_cache_refresh; use crate::{ ark, boarding_db_path, lendaswap_db_path, load_mnemonic, read_settings, secure_storage, - store_mnemonic, swap_db_path, wallet, wallet_path, write_settings, AppError, AppWalletState, - GlobalWalletState, SettingsLock, WalletCreationLock, MNEMONIC_KEY, ONCHAIN_SYNC_INTERVAL, + store_mnemonic, swap_db_path, unilateral_exit_cache_path, wallet, wallet_path, write_settings, + AppError, AppWalletState, GlobalWalletState, SettingsLock, WalletCreationLock, MNEMONIC_KEY, + ONCHAIN_SYNC_INTERVAL, }; #[derive(Debug, Serialize, serde::Deserialize)] -struct WalletData { - asp_url: String, - network: String, +pub(crate) struct WalletData { + pub(crate) asp_url: String, + pub(crate) network: String, } -async fn read_wallet_data(app: &tauri::AppHandle) -> Result { +pub(crate) async fn read_wallet_data(app: &tauri::AppHandle) -> Result { let path = wallet_path(app)?; let raw = tokio::fs::read_to_string(&path) .await @@ -253,9 +255,10 @@ async fn build_ark_client( asp_url.to_string(), Arc::clone(&swap_storage), BOLTZ_URL.to_string(), + // boltz_referral_id: `None` falls back to the SDK's DEFAULT_BOLTZ_REFERRAL_ID + None, 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. + // delegator_pk + historical_delegator_pks None, vec![], ); @@ -451,7 +454,7 @@ pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { let key_provider_for_recovery = Arc::clone(&key_provider); let (wallet_cancel, cancel_rx) = tokio::sync::watch::channel(()); *global.0.write().await = Some(AppWalletState { - client: client_arc, + client: Arc::clone(&client_arc), wallet: wallet_arc, swap_storage, key_provider, @@ -466,6 +469,7 @@ pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { &app, cancel_rx, ); + spawn_unilateral_exit_cache_refresh(app.clone(), Arc::clone(&client_arc)); info!("wallet connected successfully"); Ok(()) @@ -475,33 +479,25 @@ pub async fn connect_wallet(app: tauri::AppHandle) -> Result<(), AppError> { pub async fn delete_wallet(app: tauri::AppHandle) -> Result<(), AppError> { warn!("deleting wallet data"); - // 1. Drop in-memory state first — this drops wallet_cancel, signalling all - // background tasks (swap recovery, LN claim, onchain sync) to stop. + // Drop in-memory state first — this drops wallet_cancel, signalling all + // background tasks (swap recovery, LN claim, onchain sync) to stop. let global = app.state::(); *global.0.write().await = None; - // Give background tasks a moment to observe cancellation and release file handles. tokio::time::sleep(Duration::from_millis(100)).await; - // 2. Remove wallet.json first — this is what has_wallet() checks, so deleting - // it early ensures the app won't see a "wallet exists" state if later steps fail. let _ = remove_if_exists(&wallet_path(&app)?).await; - // 3. Delete mnemonic and Nostr identity from secure storage. WIPE means a - // clean slate — leaving the nsec behind would resurrect the previous - // npub on re-onboarding. let store = secure_storage::SecureStorage::get_instance(&app); let _ = store.delete(MNEMONIC_KEY); let _ = store.delete(crate::nostr::NSEC_KEY); - // 4. Best-effort cleanup of remaining files — don't bail on individual failures. - // Includes lendaswap.db so a subsequent wallet restore doesn't hit - // `MnemonicMismatch` against the SDK's stored mnemonic. let _ = remove_if_exists(&boarding_db_path(&app)?).await; let _ = remove_if_exists(&swap_db_path(&app)?).await; let _ = remove_if_exists(&lendaswap_db_path(&app)?).await; - // 5. Reset settings. + let _ = remove_if_exists(&unilateral_exit_cache_path(&app)?).await; + let state = app.state::(); let _lock = state.0.write().await; let mut settings = read_settings(&app).await.unwrap_or_default(); @@ -862,8 +858,7 @@ pub async fn settle(app: tauri::AppHandle) -> Result { #[derive(Serialize)] pub struct RoundSchedule { /// Unix timestamp (seconds) of the next round start. Only populated when - /// the ASP publishes a `scheduled_session`; otherwise `None` and the UI - /// falls back to showing `session_duration` as a static cadence. + /// the ASP publishes a `scheduled_session`; next_start_time: Option, /// How long a round stays open, in seconds. Always populated from the /// ASP's `Info.session_duration`. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9900967..db42474 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,6 +44,15 @@ pub(crate) struct Settings { pub(crate) fiat_enabled: Option, #[serde(default)] pub(crate) fiat_currency: Option, + /// HTTPS endpoint that accepts `{"hex": ["parent","child"]}` and forwards + /// to a Bitcoin Core `submitpackage` RPC. Required for offline unilateral + /// exit broadcast + #[serde(default)] + pub(crate) submitpackage_url: Option, + /// Bearer token sent in the `Authorization` header when calling the + /// submitpackage endpoint. + #[serde(default)] + pub(crate) submitpackage_token: Option, } pub(crate) struct AppWalletState { @@ -52,20 +61,12 @@ pub(crate) struct AppWalletState { pub(crate) swap_storage: Arc, pub(crate) key_provider: Arc, pub(crate) _sync_cancel: tokio::sync::watch::Sender<()>, - /// Cancellation token for all background tasks tied to this wallet session. - /// When the wallet is deleted/replaced, the sender is dropped and all - /// receivers see the channel close. pub(crate) wallet_cancel: tokio::sync::watch::Sender<()>, } pub(crate) struct GlobalWalletState(pub(crate) RwLock>); -/// Guards wallet creation so only one can run at a time. pub(crate) struct WalletCreationLock(pub(crate) tokio::sync::Mutex<()>); -/// Guards the read-generate-write sequence in `nostr_generate_identity`. -/// Without this, two concurrent callers can both observe an empty NSEC_KEY, -/// generate different keypairs, and race on `store.set` — leaving the caller -/// holding an `npub` that no longer matches the stored `nsec`. pub(crate) struct NostrGenerateLock(pub(crate) tokio::sync::Mutex<()>); /// Interval for background onchain wallet sync. @@ -125,6 +126,10 @@ pub(crate) fn lendaswap_db_path(app: &tauri::AppHandle) -> Result Result { + app_data_file(app, "unilateral_exit_cache.json") +} + pub(crate) const MNEMONIC_KEY: &str = "wallet-mnemonic"; pub(crate) fn store_mnemonic( @@ -258,10 +263,6 @@ pub fn run() { tokio::sync::Mutex::new(None), )) .setup(|app| { - // Open the swap records DB. Failure here leaves avark's core - // wallet features fully functional; the Swap tab will fail loudly - // on first IPC call. The frontend reconciliation logic surfaces - // that as a user-visible error (`formatLendaSwapError`). let handle = app.handle().clone(); match lendaswap_db_path(&handle) { Ok(db_path) => match tauri::async_runtime::block_on(lendaswap::init(&db_path)) { @@ -296,6 +297,15 @@ pub fn run() { commands::settings::set_esplora_url, commands::settings::set_fiat_enabled, commands::settings::set_fiat_currency, + commands::settings::set_submitpackage_endpoint, + // Recovery package + commands::recovery::refresh_unilateral_exit_cache, + commands::recovery::get_unilateral_exit_cache_status, + commands::recovery::unilateral_exit_preflight, + commands::recovery::unilateral_exit_status, + commands::recovery::unilateral_exit_broadcast_next, + commands::recovery::unilateral_exit_sweep_status, + commands::recovery::sweep_unrolled_to_onchain, // Wallet lifecycle commands::wallet::has_wallet, commands::wallet::create_wallet, diff --git a/src/ReceiveSheet.tsx b/src/ReceiveSheet.tsx index 2243e99..7f8f09f 100644 --- a/src/ReceiveSheet.tsx +++ b/src/ReceiveSheet.tsx @@ -7,6 +7,7 @@ import { Drawer } from 'vaul'; import { useLnInvoice } from './hooks/useLnInvoice'; import { useKeyboardInset } from './hooks/useKeyboardInset'; import { useSatsToFiat } from './context/FiatContext'; +import { parseSatAmount } from './utils/amount'; import { formatSats } from './utils/format'; import { launchConfetti } from './utils/confetti'; import { playSuccessSound, triggerHaptic } from './utils/receiveFeedback'; @@ -407,7 +408,7 @@ function ReceiveSheetContent({ onClose }: { onClose: () => void }) { const [currentPaymentIndex, setCurrentPaymentIndex] = useState(0); const initialBoardingSat = useRef(null); - const amountSats = /^\d+$/.test(amountInput) ? Number(amountInput) : null; + const amountSats = parseSatAmount(amountInput); const amountFiat = useSatsToFiat(amountSats ?? 0); const ln = useLnInvoice(); diff --git a/src/SendSheet.tsx b/src/SendSheet.tsx index 873264b..01a794e 100644 --- a/src/SendSheet.tsx +++ b/src/SendSheet.tsx @@ -6,6 +6,7 @@ import QrScannerView from './QrScanner'; import { useKeyboardInset } from './hooks/useKeyboardInset'; import { useSatsToFiat } from './context/FiatContext'; import { formatSats } from './utils/format'; +import { parseSatAmount } from './utils/amount'; interface SendSheetProps { open: boolean; @@ -64,7 +65,7 @@ function SendSheetContent({ const [txid, setTxid] = useState(null); const [lnPending, setLnPending] = useState(false); - const amountSats = /^\d+$/.test(amountInput) ? Number(amountInput) : null; + const amountSats = parseSatAmount(amountInput); const amountFiat = useSatsToFiat(amountSats ?? 0); const detectTimerRef = useRef | null>(null); const feeAbortRef = useRef(null); @@ -147,9 +148,7 @@ function SendSheetContent({ setAddressError(null); // Trigger fee fetch if the detected type is bitcoin. if (result.address_type === 'bitcoin') { - const sats = /^\d+$/.test(currentAmount) - ? Number(currentAmount) - : null; + const sats = parseSatAmount(currentAmount); if (sats && sats > 0) fetchFee(val.trim(), sats); } }) @@ -185,7 +184,7 @@ function SendSheetContent({ setAmountInput(val); // Re-fetch fee estimate with new amount if bitcoin address. if (currentAddressType === 'bitcoin' && currentAddress.trim()) { - const sats = /^\d+$/.test(val) ? Number(val) : null; + const sats = parseSatAmount(val); if (sats && sats > 0) { fetchFee(currentAddress.trim(), sats); } else { diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 894c7cb..893527b 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,14 +1,41 @@ -import { Outlet } from "@tanstack/react-router"; +import { Link, Outlet } from "@tanstack/react-router"; import { BottomNav } from "./BottomNav"; import { WalletProvider, useWallet } from "../context/WalletContext"; function ConnectionGate() { const { connectionState, connectionError, connectWallet } = useWallet(); - if (connectionState === "connected") { + if (connectionState === "connected" || connectionState === "offline") { return ( <>
+ {connectionState === "offline" && ( +
+
+
+

ASP unreachable

+

+ {connectionError ?? "Wallet opened in offline mode."} +

+
+ + Emergency exit + + +
+
+ )}
diff --git a/src/components/ExitingVtxoCard.tsx b/src/components/ExitingVtxoCard.tsx new file mode 100644 index 0000000..b0c758a --- /dev/null +++ b/src/components/ExitingVtxoCard.tsx @@ -0,0 +1,56 @@ +import { memo } from "react"; +import { formatCountdown, formatSats } from "../utils/format"; +import type { UnrolledVtxoMaturity } from "./recovery/SweepForm"; + +function truncateTxid(txid: string): string { + if (txid.length <= 20) return txid; + return `${txid.slice(0, 10)}...${txid.slice(-8)}`; +} + +/** + * A VTXO mid unilateral-exit: its exit tree is on-chain and the coin is no + * longer offchain-spendable, but the CSV delay hasn't elapsed so it isn't + * sweepable yet either. + */ +export const ExitingVtxoCard = memo(function ExitingVtxoCard({ + vtxo, + now, +}: { + vtxo: UnrolledVtxoMaturity; + now: number; +}) { + const remaining = vtxo.csvMatureAt !== null ? vtxo.csvMatureAt - now : null; + + return ( +
+
+
+
+ + {formatSats(vtxo.amountSat)}{" "} + sats + + + Exiting + +
+

+ {truncateTxid(vtxo.txid)}:{vtxo.vout} +

+
+
+

+ {vtxo.mature + ? "Ready to sweep" + : remaining !== null + ? `Sweepable in ${formatCountdown(remaining)}` + : "Awaiting confirmation"} +

+

+ Settings → Emergency exit +

+
+
+
+ ); +}); diff --git a/src/components/SelectedCoinsSendDrawer.tsx b/src/components/SelectedCoinsSendDrawer.tsx index 152ff6d..f20b7cf 100644 --- a/src/components/SelectedCoinsSendDrawer.tsx +++ b/src/components/SelectedCoinsSendDrawer.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { Drawer } from "vaul"; import { useKeyboardInset } from "../hooks/useKeyboardInset"; import { formatSats } from "../utils/format"; +import { parseSatAmount } from "../utils/amount"; import type { VtxoInfo } from "./VtxoCard"; interface SelectedCoinsSendDrawerProps { @@ -44,7 +45,7 @@ function DrawerBody({ const [txid, setTxid] = useState(null); const [error, setError] = useState(null); - const amountSat = /^\d+$/.test(amountInput) ? Number(amountInput) : null; + const amountSat = parseSatAmount(amountInput); const overTotal = amountSat !== null && amountSat > totalSat; const canSend = selectedVtxos.length > 0 && diff --git a/src/components/VtxoCard.tsx b/src/components/VtxoCard.tsx index e2851fa..ea676e4 100644 --- a/src/components/VtxoCard.tsx +++ b/src/components/VtxoCard.tsx @@ -65,8 +65,11 @@ export const VtxoCard = memo(function VtxoCard({ const expired = vtxo.expires_at < now; const expiring = (vtxo.expires_at - now) / 3600 < 72; const isRecoverable = vtxo.status === "recoverable"; + const isPreconfirmed = vtxo.status === "preconfirmed"; // No renew/recover affordance while picking coins — it would just be noise. - const showAction = canAct && !selectable && (isRecoverable || expiring); + const showAction = canAct && !selectable && (isRecoverable || isPreconfirmed || expiring); + const actionLabel = isRecoverable ? "Recover" : isPreconfirmed ? "Settle" : "Renew"; + const actionClass = isRecoverable ? "theme-danger" : isPreconfirmed ? "theme-accent" : "theme-warning"; return (
- {isRecoverable ? "Recover" : "Renew"} + {actionLabel} )}
diff --git a/src/components/WalletConnectButton.tsx b/src/components/WalletConnectButton.tsx index 3c57984..5925956 100644 --- a/src/components/WalletConnectButton.tsx +++ b/src/components/WalletConnectButton.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef, useState } from "react"; import { Drawer } from "vaul"; import QRCode from "react-qr-code"; import { toast } from "sonner"; @@ -12,6 +11,32 @@ const IS_MOBILE_WEBVIEW = /Android|iPhone|iPad/i.test( typeof navigator !== "undefined" ? navigator.userAgent : "" ); +type MobileWallet = { + name: string; + buildLink: (wcUri: string) => string; +}; + +const MOBILE_WALLETS: MobileWallet[] = [ + { + name: 'MetaMask', + buildLink: (uri) => + `https://metamask.app.link/wc?uri=${encodeURIComponent(uri)}`, + }, + { + name: 'Trust Wallet', + buildLink: (uri) => + `https://link.trustwallet.com/wc?uri=${encodeURIComponent(uri)}`, + }, + { + name: 'Coinbase Wallet', + buildLink: (uri) => `https://go.cb-w.com/wc?uri=${encodeURIComponent(uri)}`, + }, + { + name: 'OneKey', + buildLink: (uri) => `onekey-wallet://wc?uri=${encodeURIComponent(uri)}`, + } +]; + function truncate(address: string): string { return `${address.slice(0, 6)}…${address.slice(-4)}`; } @@ -29,26 +54,6 @@ export function WalletConnectButton() { const sheetOpen = status === "connecting"; - // Track which pairing URI the deep-link attempt failed against. Deriving - // the boolean from `failedUri === pairingUri` means state resets naturally - // as the URI rotates — no synchronous setState in the effect body. - const didDeepLinkRef = useRef(false); - const [failedUri, setFailedUri] = useState(null); - const deepLinkFailed = failedUri !== null && failedUri === pairingUri; - - useEffect(() => { - if (!pairingUri) { - didDeepLinkRef.current = false; - return; - } - if (!IS_MOBILE_WEBVIEW || didDeepLinkRef.current) return; - didDeepLinkRef.current = true; - openUrl(pairingUri).catch(() => { - setFailedUri(pairingUri); - toast.error("No wallet app responded — scan the QR or copy the URI"); - }); - }, [pairingUri]); - function handleClose() { cancelPairing(); } @@ -61,9 +66,18 @@ export function WalletConnectButton() { .catch(() => toast.error("Copy failed")); } - function reopenWallet() { + function openWallet(wallet: MobileWallet) { if (!pairingUri) return; - openUrl(pairingUri).catch(() => toast.error("Couldn't open wallet app")); + openUrl(wallet.buildLink(pairingUri)).catch(() => + toast.error(`Couldn't open ${wallet.name}`), + ); + } + + function openOtherWallet() { + if (!pairingUri) return; + openUrl(pairingUri).catch(() => + toast.error("No wallet app handled the request — copy the URI instead."), + ); } if (status === "initializing") { @@ -201,29 +215,38 @@ export function WalletConnectButton() { {IS_MOBILE_WEBVIEW - ? deepLinkFailed - ? "No wallet app responded. Scan this QR from another device, or copy the URI and paste it into your wallet." - : "Your wallet app should open automatically. If not, copy the URI or try again." + ? "Pick your wallet to open the WalletConnect pairing. Don't see yours? Try “Open another wallet” or copy the URI." : "Scan this QR with MetaMask Mobile, Rainbow, or any WalletConnect-compatible wallet."} {pairingUri ? ( <> - {(!IS_MOBILE_WEBVIEW || deepLinkFailed) && ( + {!IS_MOBILE_WEBVIEW && (
)}
+ {IS_MOBILE_WEBVIEW && + MOBILE_WALLETS.map((wallet) => ( + + ))} {IS_MOBILE_WEBVIEW && ( )} + ); + } + + 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 */} +
+
+
+
+ + +
+
+ + {/* 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 && (
@@ -330,16 +434,16 @@ export function CoinsRoute() { {/* VTXO List */}

- VTXOs ({filtered.length}) + VTXOs ({filtered.length + shownExiting.length})

- {filtered.length === 0 ? ( + {filtered.length === 0 && shownExiting.length === 0 ? (

{filter !== "all" ? "No matching VTXOs" : "No VTXOs"}

) : ( -
+
{filtered.map((vtxo) => { const key = `${vtxo.txid}:${vtxo.vout}`; return ( @@ -348,7 +452,7 @@ export function CoinsRoute() { vtxo={vtxo} now={nowSecs} expanded={expandedId === key} - canAct={!selectMode && !showRenewConfirm && !renewing} + canAct={!selectMode && !showRenewConfirm && !renewing && canSettleVtxo(vtxo, nowSecs)} onToggle={() => setExpandedId(expandedId === key ? null : key)} onAction={() => void startRenew([vtxo])} selectable={selectMode && vtxo.status !== "recoverable"} @@ -357,12 +461,19 @@ export function CoinsRoute() { /> ); })} + {shownExiting.map((vtxo) => ( + + ))}
)}
{/* Coin-control send footer — sits just above the bottom nav */} - {selectMode && ( + {selectMode && !showRenewConfirm && (
{selectedVtxos.length} selected + {selectedSkippedVtxos > 0 ? `, ${selectedSkippedVtxos} not ready` : ""} {formatSats(selectedTotalSat)} sats
+ + + +

+ Broadcasts your cached unilateral-exit tree to Bitcoin without the ASP. Each + transaction is fee-bumped from your onchain balance; you must wait for each to + confirm before broadcasting the next. After every tx in a branch confirms, the + VTXO is on-chain — sweeping it to a regular address is a separate flow that + opens after the exit-delay (CSV) elapses. +

+ + {loading && ( + <> + + + + + Loading recovery state + + + )} + + {loadError && !loading && ( +
+ Couldn't load recovery state: {loadError} +
+ )} + + {!loading && preflight && ( +
+
+

Pre-flight

+ + {preflight.ready ? "Ready" : "Blocked"} + +
+ {preflight.blockers.length > 0 && ( +
    + {preflight.blockers.map((b, i) => ( +
  • + {b} +
  • + ))} +
+ )} + {preflight.onchainBalanceSat === 0 && + preflight.onchainPendingSat === 0 && + preflight.onchainAddress && ( +
+

Plain onchain address

+

+ Send any small amount (~5-10k sats) here to fund fee-bumping. + This is not your boarding address. +

+

+ {preflight.onchainAddress} +

+
+ + +
+
+ )} +
+
+

Onchain available

+

+ {formatSats(preflight.onchainBalanceSat)} sats + {preflight.onchainPendingSat > 0 && ( + + {" · "} + {formatSats(preflight.onchainPendingSat)} pending + + )} +

+
+
+

Branches

+ {status !== null ? ( +

{status.branches.length}

+ ) : statusLoading ? ( + + ) : ( +

+ — +

+ )} +
+
+
+ )} + + {!loading && statusLoading && status === null && } + + {!loading && !statusLoading && status === null && ( +
+ Couldn't load recovery branches. Tap refresh to retry. +
+ )} + + {!loading && status && status.branches.length > 0 && (() => { + // Multiple VTXOs can descend from the same Ark round commitment, so + // their exit chains share early-tree virtual txs. Map each pending + // txid to the branches it's the next-pending for; if more than one + // branch shares it, broadcasting once advances all of them. + const sharedNextPending = new Map(); + for (const b of status.branches) { + if (b.nextPendingIndex !== null) { + const txid = b.txs[b.nextPendingIndex].txid; + const list = sharedNextPending.get(txid) ?? []; + list.push(b.branchIndex); + sharedNextPending.set(txid, list); + } + } + return ( +
+ {status.branches.map((branch) => { + const total = branch.txs.length; + const confirmed = branch.txs.filter((t) => t.confirmedAt !== null).length; + const allDone = branch.nextPendingIndex === null; + const busy = actionBusy === branch.branchIndex; + const showingConfirm = confirmation?.branchIndex === branch.branchIndex; + const nextTx = + branch.nextPendingIndex !== null ? branch.txs[branch.nextPendingIndex] : null; + + return ( +
+
+

+ Branch {branch.branchIndex + 1} + {branch.sourceAmountSat !== null && ( + + · {formatSats(branch.sourceAmountSat)} sats + + )} +

+ + {confirmed} of {total} confirmed + +
+ + {/* Progress bar */} +
+
+
+ + {allDone ? ( +

+ All transactions confirmed. Wait for the exit-delay before sweeping. +

+ ) : ( + <> +
+ Next pending: {shortTxid(nextTx!.txid)} +
+ + {(() => { + const sharers = sharedNextPending.get(nextTx!.txid) ?? []; + const others = sharers.filter((i) => i !== branch.branchIndex); + if (others.length === 0) return null; + const labels = others + .map((i) => `Branch ${i + 1}`) + .join(", "); + return ( +

+ Broadcasting this also advances {labels}. +

+ ); + })()} + + {showingConfirm && confirmation && ( +
+

Confirm broadcast

+

+ Broadcasts {shortTxid(confirmation.outcome.parentTxid)}{" "} + + anchor {shortTxid(confirmation.outcome.anchorTxid ?? "")}. +

+

+ Fee: {formatSats(confirmation.outcome.feeSat)} sats · irrevocable. +

+
+ + +
+
+ )} + + {!showingConfirm && (() => { + const broadcastDisabled = busy || !preflight?.ready; + const hint = disabledHint(preflight, busy); + return ( +
+ + {hint && ( +

{hint}

+ )} +
+ ); + })()} + + )} +
+ ); + })} +
+ ); + })()} + + {!loading && status && status.branches.length === 0 && preflight?.ready && ( +
+ The recovery package is empty — there is nothing to broadcast. +
+ )} + + {!loading && sweepLoading && sweep === null && } + + {!loading && sweep && sweep.vtxos.length > 0 && ( +
+
+

Awaiting sweep

+ + Exit delay: ~{formatExitDelay(sweep.csvDelaySeconds)} + +
+

+ VTXOs you've already broadcast. Each is on-chain but locked by a CSV + timelock; once a countdown reaches zero, the sweep form below moves + all unlocked funds to any Bitcoin address in one transaction. +

+
+ {sweep.vtxos.map((v) => { + const remaining = v.csvMatureAt !== null ? v.csvMatureAt - sweep.now : null; + return ( +
+
+

+ {formatSats(v.amountSat)} sats +

+ + {v.mature + ? "Ready to sweep" + : remaining !== null + ? `In ${formatCountdown(remaining)}` + : "Awaiting confirmation"} + +
+

+ {shortTxid(v.txid)}:{v.vout} +

+
+ ); + })} +
+ {(() => { + const mature = sweep.vtxos.filter((v) => v.mature); + if (mature.length === 0) return null; + const totalSat = mature.reduce((sum, v) => sum + v.amountSat, 0); + return ( +
+
+

+ {formatSats(totalSat)} sats ready +

+ + {mature.length} output{mature.length > 1 ? "s" : ""} + +
+ void loadAll(false)} + /> +
+ ); + })()} +
+ )} +
+ ); +} diff --git a/src/routes/settings-route.tsx b/src/routes/settings-route.tsx index c07e270..f87f57d 100644 --- a/src/routes/settings-route.tsx +++ b/src/routes/settings-route.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { toast } from "sonner"; @@ -6,498 +6,56 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { useTheme } from "../context/ThemeContext"; import { PinSetupFlow, PinDisableFlow, usePinLock } from "../context/PinLockContext"; import { useFiat } from "../context/FiatContext"; -import { - AFRICAN_CURRENCY_CODES, - formatFiat, - SUPPORTED_FIAT_CURRENCIES, - type BtcRate, - type FiatCurrency, -} from "../utils/fiatRates"; -import type { RateStatus } from "../context/FiatContext"; +import { useWallet } from "../context/WalletContext"; +import { formatCacheTime } from "../utils/format"; +import { EsploraSelector } from "../components/settings/EsploraSelector"; +import { FiatTickerCard } from "../components/settings/FiatTickerCard"; +import { NsecBackup } from "../components/settings/NsecBackup"; +import { PackageBroadcastEndpoint } from "../components/settings/PackageBroadcastEndpoint"; +import { SeedPhraseBackup } from "../components/settings/SeedPhraseBackup"; interface SettingsData { asp_url: string | null; network: string | null; esplora_url: string | null; + submitpackage_url?: string | null; + submitpackage_token_configured?: boolean; + submitpackage_default_url?: string | null; } -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; -}) { - 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)

-
- {presetExplorers.map((option) => ( - - ))} - -
- {isCustom && ( - onChange(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.

-
- ); -} - -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) { - // Flat alphabetical while searching — grouping just adds noise. - // Selected still floats to the top if it matches the query. - 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 ( - - ); - }) - )} -
-
- )} -
- ); -} - -const SATS_PER_BTC = 100_000_000; - -function formatQuoteTime(ts: number): string { - // yadio has historically returned ms; guard for seconds just in case - const ms = ts > 1e12 ? ts : ts * 1000; - try { - return new Date(ms).toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return "—"; - } +interface RecoveryCacheStatus { + exists: boolean; + generatedAt: number | null; + network: string | null; + branchCount: number; + txCount: number; + failedCount: number; + lastError: string | null; } -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; - // Keep the last rendered price so the shimmer skeleton during a currency - // swap takes up the exact width a real price would — no layout pop when - // the new quote arrives. - 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. When an initial fetch fails we - // have no rate to show — treat as a hard failure with a retry affordance. - 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 */} -
-
-
-
- - -
-
- - {/* 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 && ( - <> -
- - - )} -
- ); +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error) => { + window.clearTimeout(timer); + reject(error); + }, + ); + }); } export function SettingsRoute() { const navigate = useNavigate(); const { theme, setTheme } = useTheme(); + const { connectionState } = useWallet(); const [settings, setSettings] = useState(null); - const [seedStep, setSeedStep] = useState<"hidden" | "confirm" | "revealed">("hidden"); - const [mnemonic, setMnemonic] = useState(null); - const [loadingSeed, setLoadingSeed] = useState(false); - const [confirmInput, setConfirmInput] = useState(""); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); - const [esploraInput, setEsploraInput] = useState(""); - const [savingEsplora, setSavingEsplora] = useState(false); const { pinEnabled, refreshPinStatus } = usePinLock(); const { enabled: fiatEnabled, @@ -511,16 +69,13 @@ export function SettingsRoute() { 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); - const [confirmNsecInput, setConfirmNsecInput] = useState(""); + const [recoveryCache, setRecoveryCache] = useState(null); + const [refreshingRecoveryCache, setRefreshingRecoveryCache] = useState(false); const fetchSettings = useCallback(async () => { try { const data = await invoke("settings"); setSettings(data); - setEsploraInput(data.esplora_url ?? ""); } catch (e) { console.warn("Failed to load settings:", e); toast.warning("Could not load settings"); @@ -532,6 +87,19 @@ export function SettingsRoute() { void fetchSettings(); }, [fetchSettings]); + const fetchRecoveryCacheStatus = useCallback(async () => { + try { + const status = await invoke("get_unilateral_exit_cache_status"); + setRecoveryCache(status); + } catch (e) { + console.warn("Failed to load recovery cache status:", e); + } + }, []); + + useEffect(() => { + void fetchRecoveryCacheStatus(); + }, [fetchRecoveryCacheStatus]); + useEffect(() => { invoke<{ max_attempts: number }>("get_pin_status") .then((s) => setMaxAttempts(s.max_attempts)) @@ -544,50 +112,6 @@ export function SettingsRoute() { .catch(() => {}); }, []); - const handleRevealSeed = async () => { - if (mnemonic) { - setSeedStep("revealed"); - return; - } - setLoadingSeed(true); - try { - const words = await invoke("get_mnemonic"); - setMnemonic(words); - setSeedStep("revealed"); - } catch (e) { - toast.error(typeof e === "string" ? e : "Failed to retrieve seed phrase"); - } finally { - setLoadingSeed(false); - } - }; - - const handleRevealNsec = async () => { - if (nsec) { - setNsecStep("revealed"); - return; - } - setLoadingNsec(true); - try { - const value = await invoke("nostr_reveal_nsec"); - setNsec(value); - setNsecStep("revealed"); - } catch (e) { - toast.error(typeof e === "string" ? e : "Failed to retrieve private key"); - } finally { - setLoadingNsec(false); - } - }; - - const handleCopyNsec = 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"); - } - }; - const handleThemeToggle = (newTheme: "dark" | "light") => { setTheme(newTheme); }; @@ -605,6 +129,33 @@ export function SettingsRoute() { } }; + const handleRefreshRecoveryCache = async () => { + setRefreshingRecoveryCache(true); + try { + const status = await withTimeout( + invoke("refresh_unilateral_exit_cache"), + 330_000, + "Recovery package refresh timed out. Try again when the ASP is responding.", + ); + setRecoveryCache(status); + if (status.exists && status.failedCount > 0) { + toast.warning(`Recovery package partially refreshed; skipped ${status.failedCount} VTXO(s)`); + } else if (status.exists) { + toast.success("Recovery package refreshed"); + } else { + toast.warning( + status.lastError + ? "ASP timed out while preparing recovery data" + : "No recovery data was cached", + ); + } + } catch (e) { + toast.error(typeof e === "string" ? e : "Failed to refresh recovery package"); + } finally { + setRefreshingRecoveryCache(false); + } + }; + // PIN setup/disable flows render full-screen if (pinFlow === "setup") { return ( @@ -746,75 +297,7 @@ export function SettingsRoute() { {/* Wallet */}

Wallet

- {seedStep === "hidden" && ( -
- -
- )} - {seedStep === "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" - /> -
- - -
-
- )} - {seedStep === "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.

- -
- )} +
{/* Network */} @@ -830,26 +313,79 @@ export function SettingsRoute() {

{settings?.network ?? "—"}

- - Connected + + + {connectionState === "connected" ? "Connected" : "Offline"} +
{ - setSavingEsplora(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 { - setSavingEsplora(false); - } - }} + initialUrl={settings?.esplora_url ?? ""} + /> + +
+
+
+

Emergency exit package

+

+ Cached locally for ASP-independent recovery. +

+
+ + {recoveryCache?.exists ? "Ready" : "Missing"} + +
+
+
+

Last refreshed

+

{formatCacheTime(recoveryCache?.generatedAt ?? null)}

+
+
+

Transactions

+

{recoveryCache?.txCount ?? 0}

+
+
+ {(recoveryCache?.failedCount ?? 0) > 0 && ( +

+ ASP timed out while preparing recovery data. No package was cached. +

+ )} + + + Broadcast emergency exit → + +
+ + void fetchSettings()} /> @@ -880,87 +416,7 @@ export function SettingsRoute() { - {nsecStep === "hidden" && ( -
- -
- )} - {nsecStep === "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 -

- setConfirmNsecInput(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" - /> -
- - -
-
- )} - {nsecStep === "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. -

-
- - -
-
- )} + {/* Recovery */} diff --git a/src/routes/swap-route.tsx b/src/routes/swap-route.tsx index f67d7a7..632c427 100644 --- a/src/routes/swap-route.tsx +++ b/src/routes/swap-route.tsx @@ -16,6 +16,7 @@ import { type TargetTokenId, } from "../lib/lendaswap/client"; import { reconcilePendingSwaps } from "../lib/lendaswap/reconcile"; +import { parseSatAmount } from "../utils/amount"; const TOKENS = [ { id: "usdc_eth", label: "USDC", chain: "Ethereum" }, @@ -62,7 +63,7 @@ export function SwapRoute() { void reconcilePendingSwaps(); }, []); - const parsedAmount = parseAmountSats(amountSats); + const parsedAmount = parseSatAmount(amountSats); const destinationAddress = destMode === "connected" ? (connectedAddress ?? "") : pastedAddress.trim(); @@ -641,8 +642,3 @@ function isBridgeUnavailableError(msg: string): boolean { return /lightning bridge is currently unavailable/i.test(msg); } -function parseAmountSats(input: string): number | null { - const v = parseInt(input, 10); - if (!Number.isFinite(v) || v <= 0) return null; - return v; -} diff --git a/src/utils/amount.test.ts b/src/utils/amount.test.ts new file mode 100644 index 0000000..4f77a06 --- /dev/null +++ b/src/utils/amount.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { parseSatAmount } from "./amount"; + +describe("parseSatAmount", () => { + it("parses plain digit strings", () => { + expect(parseSatAmount("2610")).toBe(2610); + expect(parseSatAmount(" 500 ")).toBe(500); + }); + + it("rejects zero and empty", () => { + expect(parseSatAmount("0")).toBeNull(); + expect(parseSatAmount("")).toBeNull(); + }); + + it("rejects non-digit grammars the old Number() parse accepted", () => { + expect(parseSatAmount("1e3")).toBeNull(); + expect(parseSatAmount("0x10")).toBeNull(); + expect(parseSatAmount("+500")).toBeNull(); + expect(parseSatAmount("-500")).toBeNull(); + expect(parseSatAmount("1.5")).toBeNull(); + expect(parseSatAmount("100abc")).toBeNull(); + }); + + it("rejects values that lose float precision", () => { + // 21 digits — regex passes, but Number() would round it. + expect(parseSatAmount("999999999999999999999")).toBeNull(); + expect(parseSatAmount(String(Number.MAX_SAFE_INTEGER))).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); +}); diff --git a/src/utils/amount.ts b/src/utils/amount.ts new file mode 100644 index 0000000..96ab7a4 --- /dev/null +++ b/src/utils/amount.ts @@ -0,0 +1,15 @@ +/// Strict sat-amount parser for user input. The single grammar for every +/// amount field in the app: whole digits only — exponent notation ("1e3"), +/// hex ("0x10"), signs, decimals, and trailing junk ("100abc") all reject, +/// so what the user sees is exactly what gets spent. +/// +/// Returns null for zero (sat amounts are positive) and for values beyond +/// Number.MAX_SAFE_INTEGER, where Number() would silently lose precision; +/// the backend independently rejects amounts above the total bitcoin supply. +export function parseSatAmount(input: string): number | null { + const trimmed = input.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + if (!Number.isSafeInteger(value) || value <= 0) return null; + return value; +} diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts new file mode 100644 index 0000000..22fb8c1 --- /dev/null +++ b/src/utils/format.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { formatCacheTime, formatCountdown, formatQuoteTime } from "./format"; + +describe("formatQuoteTime", () => { + it("treats large values as milliseconds and small as seconds", () => { + const ts = 1_779_043_842; // seconds + // Same instant either way — must format identically. + expect(formatQuoteTime(ts)).toBe(formatQuoteTime(ts * 1000)); + }); + + it("returns a clock-like string", () => { + expect(formatQuoteTime(Date.now())).toMatch(/\d{1,2}.\d{2}/); + }); +}); + +describe("formatCacheTime", () => { + it("shows 'Never' for null or zero", () => { + expect(formatCacheTime(null)).toBe("Never"); + expect(formatCacheTime(0)).toBe("Never"); + }); + + it("formats unix seconds into a locale date-time", () => { + // Exact output is locale-dependent; assert it's a real formatted string. + const out = formatCacheTime(1_779_043_842); + expect(out).not.toBe("Never"); + expect(out).toMatch(/\d/); + }); +}); + +describe("formatCountdown", () => { + it("shows 'any moment' at or below a minute", () => { + expect(formatCountdown(0)).toBe("any moment"); + expect(formatCountdown(60)).toBe("any moment"); + }); + + it("shows minutes only under an hour", () => { + expect(formatCountdown(25 * 60)).toBe("25m"); + }); + + it("shows hours and minutes, flooring", () => { + expect(formatCountdown(3 * 3600 + 59 * 60 + 59)).toBe("3h 59m"); + // ~167h55m — the CSV countdown of a freshly-broadcast 7-day exit. + expect(formatCountdown(167 * 3600 + 55 * 60)).toBe("167h 55m"); + }); +}); diff --git a/src/utils/format.ts b/src/utils/format.ts index a4b8d47..e6e4280 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -2,6 +2,46 @@ export function formatSats(sats: number): string { return sats.toLocaleString("en-US"); } +export function shortTxid(txid: string): string { + if (txid.length <= 16) return txid; + return `${txid.slice(0, 8)}…${txid.slice(-6)}`; +} + +/// Compact "Xh Ym" countdown. Floors to minutes; returns "any moment" if +/// `secs` is small enough that rounding would show 0m. +export function formatCountdown(secs: number): string { + if (secs <= 60) return "any moment"; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + if (h === 0) return `${m}m`; + return `${h}h ${m}m`; +} + +/// Locale-aware "medium date, short time" for unix-seconds timestamps; +/// "Never" when absent. Used for cache/refresh timestamps in Settings. +export function formatCacheTime(timestamp: number | null): string { + if (!timestamp) return "Never"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(timestamp * 1000)); +} + +/// Short "HH:MM" for a rate-quote timestamp. Accepts ms or seconds — +/// yadio has historically returned ms; the 1e12 threshold guards for +/// seconds just in case. "—" if the timestamp is unformattable. +export function formatQuoteTime(ts: number): string { + const ms = ts > 1e12 ? ts : ts * 1000; + try { + return new Date(ms).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return "—"; + } +} + export function formatDate(timestamp: number | null): string { if (timestamp === null) return "Pending"; return new Date(timestamp * 1000).toLocaleString(undefined, {