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:
parent
298595069d
commit
c4855526fe
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user