diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index bfc27c34..871949cd 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -260,6 +260,7 @@ impl RpcHandler { "wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await, "wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await, "wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await, + "wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await, // Ark protocol (via barkd sidecar) "wallet.ark-status" => self.handle_wallet_ark_status().await, diff --git a/core/archipelago/src/api/rpc/fedimint.rs b/core/archipelago/src/api/rpc/fedimint.rs index 6e11dcec..75473e19 100644 --- a/core/archipelago/src/api/rpc/fedimint.rs +++ b/core/archipelago/src/api/rpc/fedimint.rs @@ -118,6 +118,31 @@ impl RpcHandler { Ok(serde_json::json!({ "removed": removed })) } + /// `wallet.fedimint-send` — spend ecash notes from any joined federation + /// with sufficient balance. Returns the notes token for the recipient + /// (rendered as text + QR by the send modal — the wallet's Fedi rail, + /// split from Cashu 2026-07-22). The heavy lifting already existed in + /// `fedimint_client::spend_from_any`; it was simply never exposed. + pub(super) async fn handle_wallet_fedimint_send( + &self, + params: Option, + ) -> Result { + let amount_sats = params + .as_ref() + .and_then(|p| p.get("amount_sats")) + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?; + anyhow::ensure!(amount_sats > 0, "must be at least 1 sat"); + let (token, federation_id) = + crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats) + .await?; + Ok(serde_json::json!({ + "token": token, + "federation_id": federation_id, + "amount_sats": amount_sats, + })) + } + /// `wallet.fedimint-balance` — total sats across all joined federations. pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result { // Soft-fail to zero when clientd isn't installed/running, so the unified diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index cb81d939..32b61fef 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> Ok(()) } +/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat +/// for 14 HOURS with its RPC answering but the server never finishing +/// startup — synced_to_chain=false, zero peers, every channel inactive — +/// and nothing noticed until a human tried to open a channel. The wedge +/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers +/// with channels that need a peer) persists. A restart reliably clears it +/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so +/// after 15 consecutive bad minutes we bounce the container ourselves, with +/// a 30-minute cooldown so a genuinely broken LND can't restart-loop. +/// RPC-unreachable and locked-wallet states are deliberately NOT handled +/// here — container-down is crash-recovery's job, and unlocking needs the +/// operator. +pub(crate) fn spawn_lnd_health_watchdog() { + tokio::spawn(async move { + let mut bad_minutes: u32 = 0; + let mut last_restart: Option = None; + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let Ok(bytes) = read_lnd_admin_macaroon().await else { + bad_minutes = 0; // no LND on this node (or not set up yet) + continue; + }; + let macaroon_hex = hex::encode(bytes); + let Ok(client) = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(10)) + .danger_accept_invalid_certs(true) + .build() + else { + continue; + }; + let Ok(resp) = client + .get(format!("{LND_REST_BASE_URL}/v1/getinfo")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + else { + bad_minutes = 0; // down/locked — not the wedge signature + continue; + }; + let Ok(info) = resp.json::().await else { + bad_minutes = 0; + continue; + }; + let synced = info + .get("synced_to_chain") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0); + let channels = info + .get("num_active_channels") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + + info + .get("num_inactive_channels") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + + info + .get("num_pending_channels") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let wedged = !synced || (channels > 0 && peers == 0); + if !wedged { + bad_minutes = 0; + continue; + } + bad_minutes += 1; + if bad_minutes < 15 { + continue; + } + if last_restart + .map(|t| t.elapsed() < std::time::Duration::from_secs(1800)) + .unwrap_or(false) + { + continue; + } + tracing::warn!( + synced_to_chain = synced, + num_peers = peers, + channels, + "LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container" + ); + let out = tokio::process::Command::new("podman") + .args(["restart", "lnd"]) + .output() + .await; + match out { + Ok(o) if o.status.success() => { + tracing::info!("LND watchdog restart complete"); + } + Ok(o) => tracing::warn!( + "LND watchdog restart failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => tracing::warn!("LND watchdog restart failed: {e}"), + } + last_restart = Some(tokio::time::Instant::now()); + bad_minutes = 0; + } + }); +} + impl RpcHandler { /// Helper: create an authenticated LND REST client. /// Returns an HTTP client configured for LND's self-signed TLS and the diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 973ef6ed..67d58a17 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -106,6 +106,9 @@ impl Server { // seconds of hitting the mempool (works whenever LND is up; retries // forever otherwise). User req 2026-07-22. crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone()); + // LND wedge watchdog — self-heal the silent "RPC up, server never + // ready" state instead of waiting for a human (100%-uptime req). + crate::api::rpc::lnd::spawn_lnd_health_watchdog(); // Retry Tor address in background — Tor may not be ready at startup if data.server_info.tor_address.is_none() { diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index aff6d099..2f8aa1cc 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -3,12 +3,12 @@
+ >{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}
@@ -49,8 +49,13 @@
-
+

{{ t('sendBitcoin.tokenShareLabel') }}

+ +
+ +

{{ ecashToken }}

@@ -83,7 +88,7 @@