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:
co-authored by
Claude Opus 4.8
parent
298595069d
commit
c4855526fe
@@ -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 ®.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)
|
||||
|
||||
Reference in New Issue
Block a user