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"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint ecash payment token
|
// Mint an ecash payment token, trying BOTH backends: Cashu first, then
|
||||||
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
// Fedimint. The seller's verify_payment_token accepts either, so a node
|
||||||
.await
|
// whose balance lives in one system can still pay (#3). Surface the
|
||||||
.context("Failed to create ecash payment token — check wallet balance")?;
|
// 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 (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
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 {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
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!({
|
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(),
|
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||||
"mint_url": wallet.mint_url,
|
"mint_url": wallet.mint_url,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1118,6 +1118,24 @@ pub async fn verify_and_receive_payment(
|
|||||||
return Ok(received);
|
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
|
// Parse and validate cashuA token
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
let token = CashuToken::deserialize(token_str)?;
|
||||||
let total = token.total_amount();
|
let total = token.total_amount();
|
||||||
|
|||||||
@ -191,6 +191,75 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||||
/// per-federation, but a token only validates against the federation that
|
/// per-federation, but a token only validates against the federation that
|
||||||
/// minted it, so we try each joined federation (default first) and return the
|
/// minted it, so we try each joined federation (default first) and return the
|
||||||
|
|||||||
@ -916,14 +916,16 @@ async function payWithEcash() {
|
|||||||
downloading.value = item.id
|
downloading.value = item.id
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
try {
|
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 {
|
try {
|
||||||
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({
|
||||||
method: 'wallet.ecash-balance',
|
method: 'wallet.ecash-balance',
|
||||||
})
|
})
|
||||||
const balance = balanceRes?.balance_sats ?? 0
|
const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0
|
||||||
if (balance < price) {
|
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()
|
closePayModal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user