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
parent b3633ec525
commit 8f06d88fbf
5 changed files with 138 additions and 9 deletions

View File

@ -383,10 +383,35 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Mint ecash payment token
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
.await
.context("Failed to create ecash payment token — check wallet balance")?;
// Mint an ecash payment token, trying BOTH backends: Cashu first, then
// Fedimint. The seller's verify_payment_token accepts either, so a node
// whose balance lives in one system can still pay (#3). Surface the
// Cashu error only if BOTH paths fail.
let token_str = match ecash::send_token(&self.config.data_dir, price_sats).await {
Ok(t) => t,
Err(cashu_err) => {
match crate::wallet::fedimint_client::spend_from_any(
&self.config.data_dir,
price_sats,
)
.await
{
Ok((notes, _fed_id)) => notes,
Err(fedi_err) => {
tracing::warn!(
"paid download: no ecash backend could pay {price_sats} sats \
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
);
return Ok(serde_json::json!({
"error": format!(
"Couldn't pay {price_sats} sats from your ecash wallet \
(Cashu or Fedimint). Fund either wallet and try again."
)
}));
}
}
}
};
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;

View File

@ -12,8 +12,23 @@ fn is_cashu_token(token: &str) -> bool {
impl RpcHandler {
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let cashu_sats = wallet.balance();
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
// pre-check) see funds available across BOTH backends (#3). Best-effort:
// if fmcd isn't installed/joined this is just 0, never an error.
let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir)
.await
{
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
Ok(serde_json::json!({
"balance_sats": wallet.balance(),
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
// spendable amount across Cashu + Fedimint.
"balance_sats": cashu_sats,
"cashu_sats": cashu_sats,
"fedimint_sats": fedimint_sats,
"total_sats": cashu_sats + fedimint_sats,
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))

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();

View File

@ -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

View File

@ -916,14 +916,16 @@ async function payWithEcash() {
downloading.value = item.id
purchaseError.value = null
try {
// Check ecash balance first
// Check ecash balance first across BOTH backends (Cashu + Fedimint), since
// the payment tries each in turn (#3). Fall back to the Cashu-only field for
// older backends that don't report `total_sats`.
try {
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
const balance = balanceRes?.balance_sats ?? 0
const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0
if (balance < price) {
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
purchaseError.value = `Insufficient ecash balance (${balance} sats across Cashu + Fedimint). Need ${price} sats. Fund either wallet, or pay from another wallet via QR.`
closePayModal()
return
}