feat(wallet): pay for peer files from BOTH Cashu and Fedimint ecash (#3)

Paying for a peer file minted a Cashu-only token, so a node whose ecash balance
lived in Fedimint couldn't pay even with funds. Now both backends are tried:

- payer (content.download-peer-paid): mint a Cashu token first; on failure fall
  back to spending Fedimint notes. Only error if BOTH backends can't cover it.
- seller (verify_and_receive_payment): accept Fedimint notes as well as Cashu —
  anything not starting with "cashu" is redeemed via reissue_into_any.
- new fedimint_client::spend_from_any() — spend from whichever joined federation
  has the balance, returning the notes + federation id (mirrors reissue_into_any).
- wallet.ecash-balance now also reports fedimint_sats + combined total_sats; the
  pay-for-file pre-check uses the combined total so a Fedimint-funded node isn't
  wrongly blocked.

Compiles (cargo check + vue-tsc). Live cross-node federation validation pending
(dual-ecash phase 6) — needs two nodes sharing a federation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-20 08:13:23 -04:00
co-authored by Claude Opus 4.8
parent b3633ec525
commit 8f06d88fbf
5 changed files with 138 additions and 9 deletions
+18
View File
@@ -1118,6 +1118,24 @@ pub async fn verify_and_receive_payment(
return Ok(received);
}
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
// rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/
// cashuB, or the legacy form handled above), so anything else is treated as
// Fedimint notes and redeemed into a joined federation. reissue_into_any
// verifies the notes are unspent and credits this node's wallet.
if !token_str.starts_with("cashu") {
let (received, _fed_id) =
crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?;
if received < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
received,
required_sats
);
}
return Ok(received);
}
// Parse and validate cashuA token
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
@@ -191,6 +191,75 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
Ok(())
}
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
/// the seller) and the federation id that minted them. Federations are tried in
/// registry order (default first); only one with sufficient balance is used so
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
/// federation is joined or none has the balance — the caller falls back to (or
/// from) the Cashu path.
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
if amount_sats == 0 {
anyhow::bail!("payment amount must be greater than zero");
}
let _ = ensure_default_federation(data_dir).await;
let client = FedimintClient::from_node(data_dir).await?;
// Same union-of-sources approach as reissue_into_any: the persisted registry
// and what fmcd actually reports joined can drift, so consider both.
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 spend from");
}
let mut last_err = None;
for fed_id in &fed_ids {
// Skip federations that can't cover the amount so we don't mint a
// partial/failed spend and leave dangling reserved notes.
match client.federation_balance_sats(fed_id).await {
Ok(bal) if bal >= amount_sats => {}
Ok(_) => continue,
Err(e) => {
last_err = Some(e);
continue;
}
}
match client.spend(fed_id, amount_sats).await {
Ok(notes) => {
record_fedimint_tx(
data_dir,
crate::wallet::ecash::TransactionType::Send,
amount_sats,
fed_id,
"Sent Fedimint ecash",
)
.await;
return Ok((notes, fed_id.clone()));
}
Err(e) => last_err = Some(e),
}
}
Err(last_err
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
.unwrap_or_else(|| {
anyhow::anyhow!(
"No joined Fedimint federation has {amount_sats} sats available"
)
}))
}
/// 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