feat(wallet): LND wedge watchdog + Fedi send rail + token QRs + Auto hidden
Demo images / Build & push demo images (push) Failing after 33s

- LND watchdog: framework-pt's LND sat wedged 14h (RPC up, server never
  ready, channels inactive) with nothing noticing. 15 consecutive bad
  minutes now bounce the container automatically (30min cooldown) —
  silent multi-hour Lightning outages become self-healed blips.
- Send modal: Auto tab hidden; Ecash splits into Cashu and Fedi (new
  wallet.fedimint-send RPC wrapping the existing spend_from_any); both
  rails render the generated token as a scannable QR alongside the
  copyable text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-22 20:55:37 -04:00
co-authored by Claude Fable 5
parent e68fe071a7
commit df9b9905b6
5 changed files with 161 additions and 5 deletions
@@ -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,
+25
View File
@@ -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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
+102
View File
@@ -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<tokio::time::Instant> = 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::<serde_json::Value>().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