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;
+78
View File
@@ -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.
+11
View File
@@ -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).