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:
parent
b3633ec525
commit
8f06d88fbf
@ -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)?;
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user