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:
parent
0f2e6f6aaf
commit
7831e68d13
@ -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
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 ®.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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user