//! Thin HTTP bridge to the `fedimint-clientd` sidecar container. //! //! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the //! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation //! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth), //! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API. //! //! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd //! v0.3.x and must be pinned to the vendored image tag. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::debug; const CLIENTD_TIMEOUT_SECS: u64 = 15; const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60; /// Default host port the `fedimint-clientd` container is mapped to (its own /// default 8080 collides with LND REST, so the manifest maps it to 8178). const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178"; /// Federation joined out-of-the-box on every node. The fmcd container also /// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync. /// /// The preferred default federation (guardian on .116, iroh transport). /// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does /// NAT traversal, so it's reachable fleet-wide — the right fleet default. /// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT /// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate /// reliability from a separate node. ensure_default_federation is best-effort. /// See docs/dual-ecash-design.md. pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"; /// One joined federation, persisted locally so the list survives clientd being /// temporarily down. Balances are always read live from clientd. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinedFederation { pub federation_id: String, #[serde(default)] pub name: Option, } #[derive(Debug, Default, Serialize, Deserialize)] pub struct FederationRegistry { pub federations: Vec, } const REGISTRY_FILE: &str = "wallet/fedimint_federations.json"; /// Shared HTTP-Basic password between the fmcd container and this bridge. The /// fedimint-clientd manifest generates it via `generated_secrets: [fmcd-password]` /// and injects it through `secret_env`; the bridge reads the same file in /// `from_node`. (Generation lives in `container::secrets`, not here — it's a /// generic, manifest-declared concern, not fedimint-specific.) const FMCD_PASSWORD_SECRET: &str = "fmcd-password"; pub async fn load_registry(data_dir: &Path) -> Result { let path = data_dir.join(REGISTRY_FILE); if !path.exists() { return Ok(FederationRegistry::default()); } let content = fs::read_to_string(&path) .await .context("Failed to read fedimint federation registry")?; Ok(serde_json::from_str(&content).unwrap_or_default()) } pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> { let dir = data_dir.join("wallet"); fs::create_dir_all(&dir) .await .context("Failed to create wallet dir")?; let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?; fs::write(data_dir.join(REGISTRY_FILE), content) .await .context("Failed to write fedimint federation registry")?; 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. pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> { let client = match FedimintClient::from_node(data_dir).await { Ok(c) => c, Err(_) => return Ok(()), // clientd not configured yet }; // Fast path: if fmcd already reports a joined federation, do NOT re-issue the // POST /v2/admin/join. That call re-syncs federation config against the // guardians and adds seconds of latency — and ensure_default_federation runs // on every wallet.fedimint-list / spend / reissue, so the join was being paid // on each balance refresh (the "mints take ages to load" report). The cheap // GET /v2/admin/info is enough to confirm membership; just reconcile the local // registry against the live joined set and return. let joined = client.joined_federation_ids().await; if !joined.is_empty() { let mut reg = load_registry(data_dir).await?; let mut changed = false; for id in joined { if !reg.federations.iter().any(|f| f.federation_id == id) { reg.federations.push(JoinedFederation { federation_id: id, name: Some("Archipelago Federation".to_string()), }); changed = true; } } if changed { save_registry(data_dir, ®).await?; } return Ok(()); } // Cold start only: nothing joined yet, so join the default federation once. let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await { Ok(id) => id, Err(e) => { debug!("default federation autojoin skipped: {e}"); return Ok(()); } }; let mut reg = load_registry(data_dir).await?; if !reg .federations .iter() .any(|f| f.federation_id == federation_id) { reg.federations.push(JoinedFederation { federation_id, name: Some("Archipelago Federation".to_string()), }); save_registry(data_dir, ®).await?; } Ok(()) } /// Spend `amount_sats` of Fedimint ecash from whichever joined federation can /// cover it, returning the serialized notes (the X-Payment-Token a buyer hands /// the seller) and the federation id that minted them. Federations are tried in /// registry order (default first); only one with sufficient balance is used so /// the resulting notes redeem cleanly on the other side. Errors clearly when no /// federation is joined or none has the balance — the caller falls back to (or /// from) the Cashu path. pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> { if amount_sats == 0 { anyhow::bail!("payment amount must be greater than zero"); } let _ = ensure_default_federation(data_dir).await; let client = FedimintClient::from_node(data_dir).await?; // Same union-of-sources approach as reissue_into_any: the persisted registry // and what fmcd actually reports joined can drift, so consider both. 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 spend from"); } let mut last_err = None; for fed_id in &fed_ids { // Skip federations that can't cover the amount so we don't mint a // partial/failed spend and leave dangling reserved notes. match client.federation_balance_sats(fed_id).await { Ok(bal) if bal >= amount_sats => {} Ok(_) => continue, Err(e) => { last_err = Some(e); continue; } } match client.spend(fed_id, amount_sats).await { Ok(notes) => { record_fedimint_tx( data_dir, crate::wallet::ecash::TransactionType::Send, amount_sats, fed_id, "Sent Fedimint ecash", ) .await; return Ok((notes, fed_id.clone())); } Err(e) => last_err = Some(e), } } Err(last_err .map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}")) .unwrap_or_else(|| { anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available") })) } /// Redeem received Fedimint notes into a joined federation. fmcd's reissue is /// per-federation, but a token only validates against the federation that /// minted it, so we try each joined federation (default first) and return the /// first that accepts the notes, along with its id. Errors clearly when the /// fmcd sidecar isn't installed or no federation is joined — the Cashu path is /// handled separately by the caller. pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> { // Make sure at least the default federation is tracked before we try. let _ = ensure_default_federation(data_dir).await; let client = FedimintClient::from_node(data_dir).await?; // 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_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); } } } 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. pub struct FedimintClient { base_url: String, password: String, client: reqwest::Client, } impl FedimintClient { pub fn new(base_url: &str, password: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS)) .build() .context("Failed to build HTTP client for fedimint-clientd")?; Ok(Self::with_client(base_url, password, client)) } pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), password: password.to_string(), client, } } /// Resolve URL + password from env / node secret, with sane defaults. /// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port. /// Password: `FEDIMINT_CLIENTD_PASSWORD` else `/fedimint-clientd/password`. pub async fn from_node(data_dir: &Path) -> Result { let base_url = std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string()); let password = match std::env::var("FMCD_PASSWORD") { Ok(p) if !p.is_empty() => p, _ => { // The shared secret the fmcd container also reads (manifest // secret_env: fmcd-password, resolved from /secrets). // Legacy /fmcd/password kept as a fallback. let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET); let legacy = data_dir.join("fmcd").join("password"); let mut found = None; for candidate in [shared, legacy] { if let Ok(s) = fs::read_to_string(&candidate).await { let s = s.trim().to_string(); if !s.is_empty() { found = Some(s); break; } } } found.context( "Fedimint client not configured (no FMCD_PASSWORD and no \ fmcd-password secret). Install the Fedimint client app.", )? } }; Self::new(&base_url, &password) } fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { // fmcd uses HTTP Basic auth with a fixed username `fmcd`. req.basic_auth("fmcd", Some(&self.password)) } async fn post(&self, path: &str, body: serde_json::Value) -> Result { let url = format!("{}{}", self.base_url, path); let resp = self .auth(self.client.post(&url)) .json(&body) .send() .await .with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?; Self::parse(resp, path).await } async fn get(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); let resp = self .auth(self.client.get(&url)) .timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS)) .send() .await .with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?; Self::parse(resp, path).await } async fn parse(resp: reqwest::Response, path: &str) -> Result { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); if !status.is_success() { anyhow::bail!("fedimint-clientd {path} returned {status}: {text}"); } if text.is_empty() { return Ok(serde_json::json!({})); } serde_json::from_str(&text) .with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}")) } /// `GET /v2/admin/info` — per-federation holdings keyed by federationId. pub async fn info(&self) -> Result { 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 .post( "/v2/admin/join", serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }), ) .await?; let id = res .get("thisFederationId") .or_else(|| res.get("federationId")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); match id { Some(id) => { debug!("joined fedimint federation {id}"); Ok(id) } // Older/newer clientd may return the full info map; fall back to info(). None => self.latest_federation_id().await, } } /// Total balance across all joined federations, in sats. pub async fn total_balance_sats(&self) -> Result { let info = self.info().await?; Ok(sum_msat(&info) / 1000) } /// Balance of one federation in sats (0 if unknown). pub async fn federation_balance_sats(&self, federation_id: &str) -> Result { let info = self.info().await?; let msat = info .get(federation_id) .and_then(federation_msat) .unwrap_or(0); Ok(msat / 1000) } /// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes. pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result { let res = self .post( "/v2/mint/spend", serde_json::json!({ "federationId": federation_id, "amountMsat": amount_sats * 1000, "allowOverpay": true, "timeout": 3600, "includeInvite": false, }), ) .await?; res.get("notes") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response")) } /// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats. pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result { let res = self .post( "/v2/mint/reissue", serde_json::json!({ "federationId": federation_id, "notes": notes }), ) .await?; let msat = res .get("amountMsat") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?; Ok(msat / 1000) } async fn latest_federation_id(&self) -> Result { let info = self.info().await?; info.as_object() .and_then(|m| m.keys().next_back().cloned()) .ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none")) } } fn federation_msat(entry: &serde_json::Value) -> Option { entry .get("totalAmountMsat") .or_else(|| entry.get("totalMsat")) .and_then(|v| v.as_u64()) } fn sum_msat(info: &serde_json::Value) -> u64 { info.as_object() .map(|m| m.values().filter_map(federation_msat).sum()) .unwrap_or(0) }