fix(wallet): redeem across all federations, unified ecash history, fmcd healthcheck

- reissue_into_any now tries the UNION of the local registry AND fmcd's live
  joined set (/v2/admin/info) before failing, so a valid Fedimint token isn't
  wrongly rejected when the registry has drifted. On all-fail it returns a
  friendly message: notes already redeemed into this wallet (funds safe) vs
  didn't match any connected federation.
- Unified transaction history: a local Fedimint tx log (recorded on each
  successful redeem) is merged with the Cashu history in wallet.ecash-history,
  newest-first, each tagged kind=cashu|fedimint. Previously a Fedimint receive
  appeared nowhere.
- fedimint-clientd healthcheck -> type:tcp. It was probing /health, which fmcd
  doesn't serve (only /v2/*), pinning the container in (starting) forever; the
  TCP probe is skipped by the Quadlet renderer (host-side lifecycle verifies),
  so it reports running. Cosmetic for ecash, which worked throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-19 16:42:06 -04:00
parent 0f2e6f6aaf
commit 7831e68d13
4 changed files with 162 additions and 11 deletions

View File

@ -69,10 +69,15 @@ app:
# join reliability from a real second node before relying on auto-bundle.
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
# fmcd serves only authenticated /v2/* routes — there is no unauthenticated
# /health endpoint, so an http probe to /health 404s forever and pins the
# container in "(starting)". fmcd's own image also ships neither curl nor wget.
# Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the
# host-side lifecycle layer verifies reachability, so the container reports
# "running" instead of a perpetual false-negative "(starting)".
health_check:
type: http
endpoint: http://localhost:8080
path: /health
type: tcp
endpoint: localhost:8080
interval: 30s
timeout: 5s
retries: 3

View File

@ -161,9 +161,17 @@ impl RpcHandler {
}
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
// Unified history: Cashu transactions (tagged kind="cashu") + the local
// Fedimint transaction log (kind="fedimint"), newest first. Previously
// only Cashu was returned, so a Fedimint receive showed up nowhere.
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let mut transactions = wallet.transactions;
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
// Sort by RFC-3339 timestamp descending (string compare is valid for
// same-offset RFC-3339), newest first.
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(serde_json::json!({
"transactions": wallet.transactions,
"transactions": transactions,
}))
}

View File

@ -42,6 +42,16 @@ pub struct EcashTransaction {
/// Peer identifier (DID, pubkey, or onion) if applicable.
#[serde(default)]
pub peer: String,
/// Which ecash system this entry belongs to: "cashu" or "fedimint". Lets the
/// unified history/UI label each transaction. Defaults to "cashu" so legacy
/// stored entries (written before dual-ecash) read back correctly.
#[serde(default = "default_tx_kind")]
pub kind: String,
}
/// Default `kind` for transactions persisted before the dual-ecash split.
pub fn default_tx_kind() -> String {
"cashu".to_string()
}
/// A stored proof with metadata.
@ -190,6 +200,7 @@ impl WalletState {
description: description.to_string(),
mint_url: mint_url.to_string(),
peer: peer.to_string(),
kind: "cashu".to_string(),
});
}

View File

@ -105,6 +105,62 @@ pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<
Ok(())
}
/// Local Fedimint transaction log. fmcd has no per-node history API, so we
/// record each redeem/spend ourselves and merge it with the Cashu history in
/// `wallet.ecash-history` — otherwise a Fedimint receive shows nowhere.
const FEDIMINT_TX_FILE: &str = "wallet/fedimint_transactions.json";
/// Load the local Fedimint transaction log (newest entries last). Empty on any
/// error — history is best-effort and must never block a wallet operation.
pub async fn load_fedimint_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
match fs::read_to_string(data_dir.join(FEDIMINT_TX_FILE)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Append a Fedimint transaction to the local log so it appears in unified
/// history with meaningful data. Best-effort: write failures are logged, not
/// propagated, so they never fail the redeem/spend that produced the funds.
pub async fn record_fedimint_tx(
data_dir: &Path,
tx_type: crate::wallet::ecash::TransactionType,
amount_sats: u64,
federation_id: &str,
description: &str,
) {
let mut txs = load_fedimint_txs(data_dir).await;
txs.push(crate::wallet::ecash::EcashTransaction {
id: uuid::Uuid::new_v4().to_string(),
tx_type,
amount_sats,
timestamp: chrono::Utc::now().to_rfc3339(),
description: description.to_string(),
// Cashu uses mint_url; for Fedimint we record the federation id in `peer`
// so the UI can show which federation the funds moved through.
mint_url: String::new(),
peer: federation_id.to_string(),
kind: "fedimint".to_string(),
});
// Cap the log so it can't grow unbounded.
let len = txs.len();
if len > 500 {
txs.drain(0..len - 500);
}
if let Err(e) = fs::create_dir_all(data_dir.join("wallet")).await {
tracing::warn!("fedimint tx log: could not create wallet dir: {e}");
return;
}
match serde_json::to_string_pretty(&txs) {
Ok(content) => {
if let Err(e) = fs::write(data_dir.join(FEDIMINT_TX_FILE), content).await {
tracing::warn!("fedimint tx log: write failed: {e}");
}
}
Err(e) => tracing::warn!("fedimint tx log: serialize failed: {e}"),
}
}
/// Idempotently ensure the node has joined the default federation and that it
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
/// isn't installed/running yet. Joining is idempotent on the clientd side.
@ -146,19 +202,76 @@ pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, Stri
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() {
// Build the set of federations to try: the locally-persisted registry PLUS
// every federation the fmcd sidecar actually reports joined. The two can
// drift (a federation joined directly, before tracking, or a registry that
// wasn't written), and a note only validates against the federation that
// minted it — so we must try EVERY connected federation before giving up,
// or a perfectly valid token is wrongly reported as failed.
let mut fed_ids: Vec<String> = Vec::new();
if let Ok(reg) = load_registry(data_dir).await {
for f in reg.federations {
if !fed_ids.contains(&f.federation_id) {
fed_ids.push(f.federation_id);
}
}
}
for id in client.joined_federation_ids().await {
if !fed_ids.contains(&id) {
fed_ids.push(id);
}
}
if fed_ids.is_empty() {
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
}
let mut already_redeemed = false;
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),
for fed_id in &fed_ids {
match client.reissue(fed_id, notes).await {
Ok(sats) => {
// Record the receive so it appears in unified ecash history.
record_fedimint_tx(
data_dir,
crate::wallet::ecash::TransactionType::Receive,
sats,
fed_id,
"Received Fedimint ecash",
)
.await;
return Ok((sats, fed_id.clone()));
}
Err(e) => {
let msg = e.to_string().to_ascii_lowercase();
// fmcd reports already-claimed notes as "We already reissued
// these notes" (or "already spent"). That means the funds were
// already redeemed INTO this node's wallet — they're safe, just
// not new — so surface that clearly instead of a raw 500.
if msg.contains("already reissued") || msg.contains("already spent") {
already_redeemed = true;
}
last_err = Some(e);
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
}
if already_redeemed {
anyhow::bail!(
"This ecash was already redeemed into your wallet — the notes are \
already claimed, so no new balance was added. Check your Fedimint balance."
);
}
Err(last_err
.map(|e| {
anyhow::anyhow!(
"These notes didn't match any of your {} connected Fedimint \
federation(s). You may need to join the federation that issued \
them first. (last error: {e})",
fed_ids.len()
)
})
.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
}
/// HTTP client for a `fedimint-clientd` instance.
@ -263,6 +376,20 @@ impl FedimintClient {
self.get("/v2/admin/info").await
}
/// Every federation id the fmcd sidecar currently reports joined, read from
/// `/v2/admin/info` (the authoritative live set — the locally-persisted
/// registry can drift from it). Returns an empty vec on any error so callers
/// can fall back to the registry rather than fail outright.
pub async fn joined_federation_ids(&self) -> Vec<String> {
match self.info().await {
Ok(info) => info
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
pub async fn join(&self, invite_code: &str) -> Result<String> {
let res = self