diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index afe7805f..1b867981 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -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 { + 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( + backend: PeerEcashBackend, + cashu: C, + fedimint: F, +) -> Result<(String, &'static str)> +where + C: std::future::Future>, + F: std::future::Future>, +{ + match backend { + PeerEcashBackend::Cashu => Ok((cashu.await?, "cashu")), + PeerEcashBackend::Fedimint => Ok((fedimint.await?, "fedimint")), + } +} + fn parse_content_access(params: &serde_json::Value) -> Result { 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" diff --git a/core/archipelago/src/api/rpc/content_tests.rs b/core/archipelago/src/api/rpc/content_tests.rs index 97caffe5..083e8e3e 100644 --- a/core/archipelago/src/api/rpc/content_tests.rs +++ b/core/archipelago/src/api/rpc/content_tests.rs @@ -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; diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md new file mode 100644 index 00000000..030a297a --- /dev/null +++ b/docs/paid-content-recovery-followup.md @@ -0,0 +1,78 @@ +# Paid content: recovery before response headers + +Status: OPEN, identified during IndeeHub rental integration on 2026-10-06. +This is a source-confirmed gap. No new real-money failure was induced. + +## Confirmed boundaries + +`api/rpc/content.rs::handle_content_download_peer_paid` checks the existing +purchase index and FIPS route before calling a wallet. It persists ownership +through `content_owned::record_purchase_stream` only after successful response +headers. Thus the previously qualified interrupted cached-body/seek cases do +not establish recovery during wallet preparation or after seller settlement but +before headers reach the buyer. + +Cashu `send_token_at` may swap inputs remotely before saving the local wallet +and returning the prepared token. Its deterministic output derivation supports +wallet restoration, but is not a correlated purchase operation journal. +Concurrent mutations also need an operation-wide wallet reservation/commit +boundary, beyond the existing atomic file writer. Fedimint operation IDs and +out-of-band note recovery must be handled using that backend's actual semantics. + +The seller currently redeems a presented token in `content_server::serve_content` +after preparing readable media. It does not persist a Cashu purchase receipt +that can authorize subsequent delivery without attempting redemption again. +A token hash alone is not evidence that redemption settled. + +## Immediate correction being qualified + +Choose the ecash backend before either wallet operation begins. An explicit +choice remains pinned; automatic selection reads the home-mint spendable Cashu +balance and selects Fedimint only when that balance is insufficient. Never fall +through to another wallet after an attempted operation returns an error: the +remote mint may already have consumed inputs. Reject unknown method names. +This prevents a second backend operation in the same RPC; it does **not** solve +crash recovery or make a fresh user retry safe. + +## Required implementation and acceptance + +1. Persist an unpredictable purchase ID and immutable seller/buyer/content/hash, + terms, amount, method and protocol capability before any mint/spend. A damaged + or ambiguous journal must block a second payment, preserving original data. +2. Journal wallet input reservations and recoverable output derivation/operation + IDs before a remote mutation. Commit wallet change, prepared token and operation + result durably. Serialize all competing wallet mutations, including receives, + melts and restores, without deadlocking nested operations. +3. Persist the seller's receipt/settlement transition. Recover ambiguous receipt + writes through correlated wallet operation results, not balance changes or + acceptance of the client's claimed outcome. Repeated delivery requests for + the same settled purchase must not redeem/pay again. +4. Bind delivery authorization to authenticated buyer and immutable content/terms, + with an unpredictable capability. Never turn a public on-chain address into + a bearer authorization credential. Negotiate updated peer capability; do not + assume old nodes implement this receipt protocol. +5. Retain prepared tokens privately until delivery or confirmed refund settles. + Record actual refunded amounts/fees. A failed refund is not proof of payment + failure and must not clear an ambiguous purchase for another charge. +6. Exercise interruption at every write/send/receipt boundary, duplicate requests, + process reconstruction, full restart, corrupt journal, disk-full, changed + offers, wrong buyer, incompatible peer, and mint/federation rejection. Prove + wallet conservation and one settlement with disposable deterministic fixtures + before bounded live payments. Keep rented cache authorization separate from + permanent paid-file ownership. + +The existing two-node 1-sat purchases and cached-delivery tests remain valid for +their documented scope. They must not be relabeled as acceptance of these open +initial-payment/recovery requirements. No additional real payment is needed to +prove the immediate backend-selection regression. + +## Backend-selection qualification + +The immediate correction passes **12 focused content RPC tests**, including +injected ambiguous Cashu/Fedimint failures proving the unselected wallet future +is never polled, explicit-choice preservation and unknown-method rejection. +The full isolated suite passes **1,749 tests, zero failures, five existing +ignored tests**. Logs: `/tmp/archy-peer-payment-selection-tests.log` and +`/tmp/archy-peer-payment-selection-full-tests.log`. Only the required isolated +runner was used. No live wallet data or real payments were involved. This is +source/test qualification; production build and deployment are separate. diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index cd1f8296..11f8e453 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -697,3 +697,14 @@ companion or actual IndeeHub login acceptance. Logs: `/tmp/archy-legacy-signer-{dev,yaya}-{deploy,browser}.log`. Receipt: `~/.local/state/archipelago/release-qualification/legacy-signer-ui-08c93f4a/receipt.json`. + +## Ecash backend selection recovery correction + +`content.download-peer-paid` now selects Cashu/Fedimint before spending, preserves +explicit choices, and does not fall through to a second wallet after an ambiguous +first attempt. Auto selection reads spendable home-mint balance; wallet-read +errors fail closed. Unknown method names are rejected. Twelve focused content +RPC tests and the complete isolated suite (**1,749 pass, zero fail, five existing +ignores**) pass. No real payment or live wallet mutation was used. This is not yet +in the running backend. Durable pre-mint purchase journaling and seller receipt +recovery remain open in [the recovery follow-up](paid-content-recovery-followup.md).