diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 748459a0..1cb5a1b1 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -1,7 +1,14 @@ use super::RpcHandler; -use crate::wallet::{ecash, profits}; +use crate::wallet::{ecash, fedimint_client, profits}; use anyhow::Result; +/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form) +/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu` +/// string is routed to the Fedimint reissue path. +fn is_cashu_token(token: &str) -> bool { + token.trim_start().starts_with("cashu") +} + impl RpcHandler { pub(super) async fn handle_wallet_ecash_balance(&self) -> Result { let wallet = ecash::load_wallet(&self.config.data_dir).await?; @@ -129,11 +136,27 @@ impl RpcHandler { let token = params .get("token") .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("Missing token"))?; - let amount = ecash::receive_token(&self.config.data_dir, token).await?; + // Dual-ecash: one "Receive ecash" box accepts either a Cashu token + // (redeemed at the mint) or Fedimint notes (reissued via the fmcd + // sidecar). Detect by prefix and route accordingly. + if is_cashu_token(token) { + let amount = ecash::receive_token(&self.config.data_dir, token).await?; + return Ok(serde_json::json!({ + "received_sats": amount, + "kind": "cashu", + })); + } + + let (amount, federation_id) = + fedimint_client::reissue_into_any(&self.config.data_dir, token).await?; Ok(serde_json::json!({ "received_sats": amount, + "kind": "fedimint", + "federation_id": federation_id, })) } diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index fd544b21..21ed90c0 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -70,6 +70,10 @@ fn is_required_baseline_app(app_id: &str) -> bool { | "mempool" | "archy-mempool-db" | "filebrowser" + // fmcd: bundled on every node so the wallet's Fedimint side works + // out of the box (auto-joins the default federation). Self-heals if + // removed, like the other baseline services. + | "fedimint-clientd" ) } @@ -2728,6 +2732,13 @@ impl ProdContainerOrchestrator { .await .context("ensuring bitcoin tx-relay credentials")?; } + if app_id == "fedimint-clientd" { + // The fmcd container's secret_env (fmcd-password) and the wallet + // bridge both read this; generate it before secret_env resolves. + crate::wallet::fedimint_client::ensure_fmcd_password(&self.secrets_dir) + .await + .context("ensuring fmcd password secret")?; + } Ok(()) } diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index 8e0b77e4..8d1c0b1b 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -49,6 +49,39 @@ pub struct FederationRegistry { const REGISTRY_FILE: &str = "wallet/fedimint_federations.json"; +/// Shared HTTP-Basic password between the fmcd container and this bridge. The +/// fedimint-clientd manifest reads it via `secret_env: fmcd-password`, resolved +/// from `/secrets/`; the bridge reads the same file in `from_node`. +const FMCD_PASSWORD_SECRET: &str = "fmcd-password"; + +/// Generate the fmcd Basic-auth password once, so the fmcd container +/// (`secret_env: fmcd-password`) and this bridge (`from_node`) agree on it. +/// Idempotent: a non-empty existing secret is left untouched. Mirrors the +/// bitcoin-rpc secret pattern (random hex, 0600). Called from the orchestrator's +/// `ensure_app_secrets` before the container's `secret_env` is resolved. +pub async fn ensure_fmcd_password(secrets_dir: &Path) -> Result<()> { + let path = secrets_dir.join(FMCD_PASSWORD_SECRET); + if let Ok(existing) = fs::read_to_string(&path).await { + if !existing.trim().is_empty() { + return Ok(()); + } + } + fs::create_dir_all(secrets_dir) + .await + .context("creating secrets dir for fmcd password")?; + let bytes: [u8; 16] = rand::random(); + let password = hex::encode(bytes); + fs::write(&path, &password) + .await + .context("writing fmcd password secret")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await; + } + Ok(()) +} + pub async fn load_registry(data_dir: &Path) -> Result { let path = data_dir.join(REGISTRY_FILE); if !path.exists() { @@ -102,6 +135,32 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> { Ok(()) } +/// 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?; + let reg = load_registry(data_dir).await?; + if reg.federations.is_empty() { + anyhow::bail!("No Fedimint federation joined to redeem these notes into"); + } + + 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), + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed"))) +} + /// HTTP client for a `fedimint-clientd` instance. pub struct FedimintClient { base_url: String, @@ -135,14 +194,25 @@ impl FedimintClient { let password = match std::env::var("FMCD_PASSWORD") { Ok(p) if !p.is_empty() => p, _ => { - let secret = data_dir.join("fmcd").join("password"); - fs::read_to_string(&secret) - .await - .map(|s| s.trim().to_string()) - .context( - "Fedimint client not configured (no FMCD_PASSWORD and no \ - fmcd/password secret). Install the Fedimint client app.", - )? + // 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) diff --git a/neode-ui/src/views/web5/Web5SendReceiveModals.vue b/neode-ui/src/views/web5/Web5SendReceiveModals.vue index 45c42129..cc596c36 100644 --- a/neode-ui/src/views/web5/Web5SendReceiveModals.vue +++ b/neode-ui/src/views/web5/Web5SendReceiveModals.vue @@ -157,8 +157,8 @@
- - + +
{{ ecashReceiveResult }}
@@ -487,11 +487,12 @@ async function unifiedReceive() { unifiedReceiveError.value = t('web5.pasteEcashToken') return } - const res = await rpcClient.call<{ received_sats: number }>({ + const res = await rpcClient.call<{ received_sats: number; kind?: string }>({ method: 'wallet.ecash-receive', params: { token: ecashReceiveToken.value.trim() }, }) - ecashReceiveResult.value = `Received ${res.received_sats} sats!` + const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu' + ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!` ecashReceiveToken.value = '' emit('balancesChanged') }