Select peer ecash wallet before spending and forbid ambiguous fallback
This commit is contained in:
@@ -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."
|
||||
) }));
|
||||
}
|
||||
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)
|
||||
},
|
||||
Some("fedimint") => match mint_fedimint().await {
|
||||
Ok((notes, fed)) => {
|
||||
tracing::info!(
|
||||
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
|
||||
);
|
||||
(notes, "fedimint")
|
||||
)
|
||||
.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."
|
||||
}));
|
||||
}
|
||||
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."
|
||||
) }));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user