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 super::RpcHandler;
|
||||||
use crate::wallet::{ecash, profits};
|
use crate::wallet::{ecash, fedimint_client, profits};
|
||||||
use anyhow::Result;
|
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 {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||||
@ -129,11 +136,27 @@ impl RpcHandler {
|
|||||||
let token = params
|
let token = params
|
||||||
.get("token")
|
.get("token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
.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!({
|
Ok(serde_json::json!({
|
||||||
"received_sats": amount,
|
"received_sats": amount,
|
||||||
|
"kind": "fedimint",
|
||||||
|
"federation_id": federation_id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,10 @@ fn is_required_baseline_app(app_id: &str) -> bool {
|
|||||||
| "mempool"
|
| "mempool"
|
||||||
| "archy-mempool-db"
|
| "archy-mempool-db"
|
||||||
| "filebrowser"
|
| "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
|
.await
|
||||||
.context("ensuring bitcoin tx-relay credentials")?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,39 @@ pub struct FederationRegistry {
|
|||||||
|
|
||||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
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> {
|
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||||
let path = data_dir.join(REGISTRY_FILE);
|
let path = data_dir.join(REGISTRY_FILE);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@ -102,6 +135,32 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// HTTP client for a `fedimint-clientd` instance.
|
||||||
pub struct FedimintClient {
|
pub struct FedimintClient {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@ -135,14 +194,25 @@ impl FedimintClient {
|
|||||||
let password = match std::env::var("FMCD_PASSWORD") {
|
let password = match std::env::var("FMCD_PASSWORD") {
|
||||||
Ok(p) if !p.is_empty() => p,
|
Ok(p) if !p.is_empty() => p,
|
||||||
_ => {
|
_ => {
|
||||||
let secret = data_dir.join("fmcd").join("password");
|
// The shared secret the fmcd container also reads (manifest
|
||||||
fs::read_to_string(&secret)
|
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
||||||
.await
|
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
||||||
.map(|s| s.trim().to_string())
|
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
||||||
.context(
|
let legacy = data_dir.join("fmcd").join("password");
|
||||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
let mut found = None;
|
||||||
fmcd/password secret). Install the Fedimint client app.",
|
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)
|
Self::new(&base_url, &password)
|
||||||
|
|||||||
@ -157,8 +157,8 @@
|
|||||||
|
|
||||||
<div v-if="receiveMethod === 'ecash'">
|
<div v-if="receiveMethod === 'ecash'">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
|
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
|
||||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
|
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -487,11 +487,12 @@ async function unifiedReceive() {
|
|||||||
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await rpcClient.call<{ received_sats: number }>({
|
const res = await rpcClient.call<{ received_sats: number; kind?: string }>({
|
||||||
method: 'wallet.ecash-receive',
|
method: 'wallet.ecash-receive',
|
||||||
params: { token: ecashReceiveToken.value.trim() },
|
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 = ''
|
ecashReceiveToken.value = ''
|
||||||
emit('balancesChanged')
|
emit('balancesChanged')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user