From 7831e68d1339316b2fcc02d0b49cf0ff3ea08c7d Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 19 Jun 2026 16:42:06 -0400 Subject: [PATCH] fix(wallet): redeem across all federations, unified ecash history, fmcd healthcheck - reissue_into_any now tries the UNION of the local registry AND fmcd's live joined set (/v2/admin/info) before failing, so a valid Fedimint token isn't wrongly rejected when the registry has drifted. On all-fail it returns a friendly message: notes already redeemed into this wallet (funds safe) vs didn't match any connected federation. - Unified transaction history: a local Fedimint tx log (recorded on each successful redeem) is merged with the Cashu history in wallet.ecash-history, newest-first, each tagged kind=cashu|fedimint. Previously a Fedimint receive appeared nowhere. - fedimint-clientd healthcheck -> type:tcp. It was probing /health, which fmcd doesn't serve (only /v2/*), pinning the container in (starting) forever; the TCP probe is skipped by the Quadlet renderer (host-side lifecycle verifies), so it reports running. Cosmetic for ecash, which worked throughout. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/fedimint-clientd/manifest.yml | 11 +- core/archipelago/src/api/rpc/wallet.rs | 10 +- core/archipelago/src/wallet/ecash.rs | 11 ++ .../archipelago/src/wallet/fedimint_client.rs | 141 +++++++++++++++++- 4 files changed, 162 insertions(+), 11 deletions(-) diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index 12e4d335..aba52999 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -69,10 +69,15 @@ app: # join reliability from a real second node before relying on auto-bundle. - FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp + # fmcd serves only authenticated /v2/* routes — there is no unauthenticated + # /health endpoint, so an http probe to /health 404s forever and pins the + # container in "(starting)". fmcd's own image also ships neither curl nor wget. + # Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the + # host-side lifecycle layer verifies reachability, so the container reports + # "running" instead of a perpetual false-negative "(starting)". health_check: - type: http - endpoint: http://localhost:8080 - path: /health + type: tcp + endpoint: localhost:8080 interval: 30s timeout: 5s retries: 3 diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 1cb5a1b1..7c174938 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -161,9 +161,17 @@ impl RpcHandler { } pub(super) async fn handle_wallet_ecash_history(&self) -> Result { + // Unified history: Cashu transactions (tagged kind="cashu") + the local + // Fedimint transaction log (kind="fedimint"), newest first. Previously + // only Cashu was returned, so a Fedimint receive showed up nowhere. let wallet = ecash::load_wallet(&self.config.data_dir).await?; + let mut transactions = wallet.transactions; + transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await); + // Sort by RFC-3339 timestamp descending (string compare is valid for + // same-offset RFC-3339), newest first. + transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(serde_json::json!({ - "transactions": wallet.transactions, + "transactions": transactions, })) } diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 03d9b9da..00b63bc2 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -42,6 +42,16 @@ pub struct EcashTransaction { /// Peer identifier (DID, pubkey, or onion) if applicable. #[serde(default)] pub peer: String, + /// Which ecash system this entry belongs to: "cashu" or "fedimint". Lets the + /// unified history/UI label each transaction. Defaults to "cashu" so legacy + /// stored entries (written before dual-ecash) read back correctly. + #[serde(default = "default_tx_kind")] + pub kind: String, +} + +/// Default `kind` for transactions persisted before the dual-ecash split. +pub fn default_tx_kind() -> String { + "cashu".to_string() } /// A stored proof with metadata. @@ -190,6 +200,7 @@ impl WalletState { description: description.to_string(), mint_url: mint_url.to_string(), peer: peer.to_string(), + kind: "cashu".to_string(), }); } diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index 8d1c0b1b..fb785a65 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -105,6 +105,62 @@ pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result< Ok(()) } +/// Local Fedimint transaction log. fmcd has no per-node history API, so we +/// record each redeem/spend ourselves and merge it with the Cashu history in +/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere. +const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json"; + +/// Load the local Fedimint transaction log (newest entries last). Empty on any +/// error — history is best-effort and must never block a wallet operation. +pub async fn load_fedimint_txs(data_dir: &Path) -> Vec { + match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// Append a Fedimint transaction to the local log so it appears in unified +/// history with meaningful data. Best-effort: write failures are logged, not +/// propagated, so they never fail the redeem/spend that produced the funds. +pub async fn record_fedimint_tx( + data_dir: &Path, + tx_type: crate::wallet::ecash::TransactionType, + amount_sats: u64, + federation_id: &str, + description: &str, +) { + let mut txs = load_fedimint_txs(data_dir).await; + txs.push(crate::wallet::ecash::EcashTransaction { + id: uuid::Uuid::new_v4().to_string(), + tx_type, + amount_sats, + timestamp: chrono::Utc::now().to_rfc3339(), + description: description.to_string(), + // Cashu uses mint_url; for Fedimint we record the federation id in `peer` + // so the UI can show which federation the funds moved through. + mint_url: String::new(), + peer: federation_id.to_string(), + kind: "fedimint".to_string(), + }); + // Cap the log so it can't grow unbounded. + let len = txs.len(); + if len > 500 { + txs.drain(0..len - 500); + } + if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await { + tracing::warn!("fedimint tx log: could not create wallet dir: {e}"); + return; + } + match serde_json::to_string_pretty(&txs) { + Ok(content) => { + if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await { + tracing::warn!("fedimint tx log: write failed: {e}"); + } + } + Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"), + } +} + /// Idempotently ensure the node has joined the default federation and that it /// is tracked in the local registry. Best-effort: silently no-ops if clientd /// isn't installed/running yet. Joining is idempotent on the clientd side. @@ -146,19 +202,76 @@ pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, Stri let _ = ensure_default_federation(data_dir).await; let client = FedimintClient::from_node(data_dir).await?; - let reg = load_registry(data_dir).await?; - if reg.federations.is_empty() { + + // Build the set of federations to try: the locally-persisted registry PLUS + // every federation the fmcd sidecar actually reports joined. The two can + // drift (a federation joined directly, before tracking, or a registry that + // wasn't written), and a note only validates against the federation that + // minted it — so we must try EVERY connected federation before giving up, + // or a perfectly valid token is wrongly reported as failed. + let mut fed_ids: Vec = Vec::new(); + if let Ok(reg) = load_registry(data_dir).await { + for f in reg.federations { + if !fed_ids.contains(&f.federation_id) { + fed_ids.push(f.federation_id); + } + } + } + for id in client.joined_federation_ids().await { + if !fed_ids.contains(&id) { + fed_ids.push(id); + } + } + if fed_ids.is_empty() { anyhow::bail!("No Fedimint federation joined to redeem these notes into"); } + let mut already_redeemed = false; let mut last_err = None; - for fed in ®.federations { - match client.reissue(&fed.federation_id, notes).await { - Ok(sats) => return Ok((sats, fed.federation_id.clone())), - Err(e) => last_err = Some(e), + for fed_id in &fed_ids { + match client.reissue(fed_id, notes).await { + Ok(sats) => { + // Record the receive so it appears in unified ecash history. + record_fedimint_tx( + data_dir, + crate::wallet::ecash::TransactionType::Receive, + sats, + fed_id, + "Received Fedimint ecash", + ) + .await; + return Ok((sats, fed_id.clone())); + } + Err(e) => { + let msg = e.to_string().to_ascii_lowercase(); + // fmcd reports already-claimed notes as "We already reissued + // these notes" (or "already spent"). That means the funds were + // already redeemed INTO this node's wallet — they're safe, just + // not new — so surface that clearly instead of a raw 500. + if msg.contains("already reissued") || msg.contains("already spent") { + already_redeemed = true; + } + last_err = Some(e); + } } } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed"))) + + if already_redeemed { + anyhow::bail!( + "This ecash was already redeemed into your wallet — the notes are \ + already claimed, so no new balance was added. Check your Fedimint balance." + ); + } + Err(last_err + .map(|e| { + anyhow::anyhow!( + "These notes didn't match any of your {} connected Fedimint \ + federation(s). You may need to join the federation that issued \ + them first. (last error: {e})", + fed_ids.len() + ) + }) + .unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed"))) } /// HTTP client for a `fedimint-clientd` instance. @@ -263,6 +376,20 @@ impl FedimintClient { self.get("/v2/admin/info").await } + /// Every federation id the fmcd sidecar currently reports joined, read from + /// `/v2/admin/info` (the authoritative live set — the locally-persisted + /// registry can drift from it). Returns an empty vec on any error so callers + /// can fall back to the registry rather than fail outright. + pub async fn joined_federation_ids(&self) -> Vec { + match self.info().await { + Ok(info) => info + .as_object() + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(), + Err(_) => Vec::new(), + } + } + /// `POST /v2/admin/join` — join a federation by invite code; returns its federationId. pub async fn join(&self, invite_code: &str) -> Result { let res = self