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-18 12:10:07 -04:00
parent 298595069d
commit c4855526fe
4 changed files with 119 additions and 14 deletions

View File

@ -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<serde_json::Value> {
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,
}))
}

View File

@ -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(())
}

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)

View File

@ -157,8 +157,8 @@
<div v-if="receiveMethod === 'ecash'">
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
</div>
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
</div>
@ -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')
}