Select peer ecash wallet before spending and forbid ambiguous fallback

This commit is contained in:
archipelago
2026-10-06 12:06:14 -04:00
parent e844bbe1c7
commit a3f0bf0af7
4 changed files with 228 additions and 54 deletions
+76 -54
View File
@@ -19,6 +19,46 @@ fn is_valid_v3_onion(addr: &str) -> bool {
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PeerEcashBackend {
Cashu,
Fedimint,
}
/// Auto-selection happens before spending, never as recovery from an error.
fn select_peer_ecash_backend(
method: Option<&str>,
cashu_available: bool,
) -> Result<PeerEcashBackend> {
match method {
Some("cashu") => Ok(PeerEcashBackend::Cashu),
Some("fedimint") => Ok(PeerEcashBackend::Fedimint),
None | Some("auto") => Ok(if cashu_available {
PeerEcashBackend::Cashu
} else {
PeerEcashBackend::Fedimint
}),
_ => anyhow::bail!("Unsupported ecash payment method"),
}
}
/// A mint can consume inputs before its response is lost. Poll exactly one
/// selected wallet operation; an error must never initiate another payment.
async fn spend_peer_ecash<C, F>(
backend: PeerEcashBackend,
cashu: C,
fedimint: F,
) -> Result<(String, &'static str)>
where
C: std::future::Future<Output = Result<String>>,
F: std::future::Future<Output = Result<String>>,
{
match backend {
PeerEcashBackend::Cashu => Ok((cashu.await?, "cashu")),
PeerEcashBackend::Fedimint => Ok((fedimint.await?, "fedimint")),
}
}
fn parse_content_access(params: &serde_json::Value) -> Result<AccessControl> {
let access_type = match params.get("access") {
None => "free",
@@ -715,63 +755,45 @@ impl RpcHandler {
);
}
// `method` pins the backend the user confirmed in the UI ("cashu" |
// "fedimint"); absent = auto (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).
let method = params.get("method").and_then(|v| v.as_str());
// Preserve an explicit choice. Automatic selection uses a read-only
// balance check before either wallet operation starts. A failed Cashu
// swap can already have consumed proofs, so never fall through to a
// second wallet after that operation has been attempted.
let method = params
.get("method")
.map(|value| value.as_str().context("Invalid ecash payment method"))
.transpose()?;
// Validate before even reading a wallet; unsupported input is not auto.
select_peer_ecash_backend(method, false)?;
let cashu_available = if matches!(method, None | Some("auto")) {
let wallet = ecash::load_wallet(&self.config.data_dir)
.await
.context("Could not check Cashu balance; no payment was attempted")?;
wallet.balance_for_mint(&wallet.mint_url) >= price_sats
} else {
false
};
let selected = select_peer_ecash_backend(method, cashu_available)?;
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
let mint_fedimint =
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
let (token_str, used_backend) = match method {
Some("cashu") => match mint_cashu().await {
Ok(t) => (t, "cashu"),
Err(e) => {
tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}");
return Ok(serde_json::json!({ "error": format!(
"Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \
Fund it, or choose Fedimint."
) }));
}
},
Some("fedimint") => match mint_fedimint().await {
Ok((notes, fed)) => {
tracing::info!(
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
);
(notes, "fedimint")
}
Err(e) => {
tracing::warn!(
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
);
return Ok(serde_json::json!({ "error": format!(
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
Fund it, or choose Cashu."
) }));
}
},
_ => match mint_cashu().await {
Ok(t) => (t, "cashu"),
Err(cashu_err) => match mint_fedimint().await {
Ok((notes, _fed)) => (notes, "fedimint"),
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 payment = spend_peer_ecash(
selected,
ecash::send_token(&self.config.data_dir, price_sats),
async {
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats)
.await
.map(|(notes, _federation)| notes)
},
)
.await;
let (token_str, used_backend) = match payment {
Ok(value) => value,
Err(error) => {
tracing::warn!("paid download: selected ecash operation failed: {error:#}");
return Ok(serde_json::json!({ "error":
"The wallet could not complete this payment. No other wallet was charged. Check the payment status before retrying or changing wallets."
}));
}
};
tracing::info!(
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
@@ -1,5 +1,68 @@
use super::*;
#[tokio::test]
async fn automatic_ecash_selection_never_spends_another_wallet_after_an_ambiguous_failure() {
use std::sync::atomic::{AtomicUsize, Ordering};
for cashu_available in [true, false] {
let cashu_calls = AtomicUsize::new(0);
let fedimint_calls = AtomicUsize::new(0);
let selected = select_peer_ecash_backend(None, cashu_available).unwrap();
let result = spend_peer_ecash(
selected,
async {
cashu_calls.fetch_add(1, Ordering::SeqCst);
anyhow::bail!("mint consumed inputs but response was lost")
},
async {
fedimint_calls.fetch_add(1, Ordering::SeqCst);
anyhow::bail!("federation operation outcome unknown")
},
)
.await;
assert!(result.is_err());
assert_eq!(
cashu_calls.load(Ordering::SeqCst),
usize::from(cashu_available)
);
assert_eq!(
fedimint_calls.load(Ordering::SeqCst),
usize::from(!cashu_available)
);
}
}
#[tokio::test]
async fn explicit_ecash_choice_is_preserved_and_unselected_operation_is_not_polled() {
for (method, expected) in [("cashu", "cashu-token"), ("fedimint", "fedimint-notes")] {
let selected = select_peer_ecash_backend(Some(method), method != "cashu").unwrap();
let result = spend_peer_ecash(
selected,
async {
assert_eq!(method, "cashu");
Ok("cashu-token".to_owned())
},
async {
assert_eq!(method, "fedimint");
Ok("fedimint-notes".to_owned())
},
)
.await
.unwrap();
assert_eq!(result, (expected.to_owned(), method));
}
for unknown in ["", "ecash", "invalid", "lightning"] {
assert!(select_peer_ecash_backend(Some(unknown), true).is_err());
}
assert_eq!(
select_peer_ecash_backend(Some("auto"), true).unwrap(),
PeerEcashBackend::Cashu
);
assert_eq!(
select_peer_ecash_backend(Some("auto"), false).unwrap(),
PeerEcashBackend::Fedimint
);
}
#[test]
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
use base64::Engine;