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";
|
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> {
|
fn parse_content_access(params: &serde_json::Value) -> Result<AccessControl> {
|
||||||
let access_type = match params.get("access") {
|
let access_type = match params.get("access") {
|
||||||
None => "free",
|
None => "free",
|
||||||
@@ -715,63 +755,45 @@ impl RpcHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
// Preserve an explicit choice. Automatic selection uses a read-only
|
||||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
// balance check before either wallet operation starts. A failed Cashu
|
||||||
// verify_payment_token accepts either, so a node whose balance lives in
|
// swap can already have consumed proofs, so never fall through to a
|
||||||
// one system can still pay (#3).
|
// second wallet after that operation has been attempted.
|
||||||
let method = params.get("method").and_then(|v| v.as_str());
|
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 (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)?;
|
||||||
|
let payment = spend_peer_ecash(
|
||||||
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
|
selected,
|
||||||
let mint_fedimint =
|
ecash::send_token(&self.config.data_dir, price_sats),
|
||||||
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
|
async {
|
||||||
|
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats)
|
||||||
let (token_str, used_backend) = match method {
|
.await
|
||||||
Some("cashu") => match mint_cashu().await {
|
.map(|(notes, _federation)| notes)
|
||||||
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."
|
|
||||||
) }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
.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!(
|
tracing::info!(
|
||||||
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||||
use base64::Engine;
|
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`.
|
Logs: `/tmp/archy-legacy-signer-{dev,yaya}-{deploy,browser}.log`.
|
||||||
Receipt: `~/.local/state/archipelago/release-qualification/legacy-signer-ui-08c93f4a/receipt.json`.
|
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