feat(wallet): wire fmcd as core app + dual-ecash receive

Fedimint never appeared in Wallet > Settings > Fedimint because the
fmcd (fedimint-clientd) sidecar was never installed: ensure_default_
federation() needs the fmcd password to reach the daemon, found none,
and silently no-oped, leaving the registry empty.

- prod_orchestrator: add fedimint-clientd to the baseline auto-install
  set so it self-heals onto every node and auto-joins the default
  federation; generate the fmcd-password secret before secret_env
  resolves.
- fedimint_client: ensure_fmcd_password (random hex, 0600) shared with
  the container's secret_env; from_node reads the same secret (legacy
  fmcd/password kept as fallback); reissue_into_any redeems received
  notes into the first joined federation that accepts them.
- wallet.ecash-receive: dual-token — cashu* tokens redeem at the mint,
  anything else is reissued via fmcd; returns the kind + federation_id.
- UI: receive box advertises "Cashu or Fedimint" and reports which kind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-19 09:52:26 -04:00
co-authored by Claude Opus 4.8
parent 298595069d
commit c4855526fe
4 changed files with 119 additions and 14 deletions
+78 -8
View File
@@ -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 `<data_dir>/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<FederationRegistry> {
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 &reg.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 <data_dir>/secrets).
// Legacy <data_dir>/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)