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:
co-authored by
Claude Opus 4.8
parent
b3633ec525
commit
8f06d88fbf
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user