Prevent alternate and legacy payments bypassing file recovery

This commit is contained in:
archipelago
2026-10-07 17:32:03 -04:00
parent 65d265f267
commit 18b087202d
8 changed files with 331 additions and 391 deletions
+7 -292
View File
@@ -19,46 +19,6 @@ 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",
@@ -698,7 +658,7 @@ impl RpcHandler {
Ok(body)
}
/// Download paid content from a peer: mint ecash token, send with request.
/// Legacy entry point: serve already-owned bytes, never start a new payment.
pub(super) async fn handle_content_download_peer_paid(
&self,
params: Option<serde_json::Value>,
@@ -748,152 +708,7 @@ impl RpcHandler {
return Ok(cached);
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
if fips_npub.is_none() {
return Ok(
serde_json::json!({ "error": "Connect with this node over FIPS before buying its files. No payment was made." }),
);
}
// 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 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"
);
let path = format!("/content/{}", content_id);
// Surface a real reason instead of the generic sanitized error (#30):
// A bearer token must not be replayed after an ambiguous delivery.
// A transport error can mean the seller received it without replying.
let (response, transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.require_fips()
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.send_content_get(&self.config.data_dir)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable.
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The purchase could not be completed. {refund}")
}));
}
};
// Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// A 402 can mean mint validation, network failure, underpayment,
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
drop(response);
tracing::warn!(
"paid download: seller rejected {used_backend} payment of {price_sats} sats"
);
// Reclaim only proofs the mint still considers unspent.
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The seller could not verify the payment. {refund}")
}));
}
if !response.status().is_success() {
let status = response.status();
let body = bounded_seller_error(response).await;
tracing::warn!("paid download: seller {onion} returned {status}");
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("{} {refund}", seller_error_message(status, &body))
}));
}
// Capture the content type BEFORE consuming the body so the local cache
// can render the right viewer (image vs video) later.
let mime_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string());
let filename = params
.get("filename")
.and_then(|v| v.as_str())
.unwrap_or(content_id);
let item = cache_peer_response(&self.config.data_dir,onion,content_id,filename,&mime_type,price_sats,used_backend,response)
.await.context("Paid file delivery could not be saved. Do not send another payment; recover this purchase first")?;
if let Err(error) = file_cached_purchase_in_files(&self.config.data_dir, &item).await {
tracing::warn!("Purchase cached; optional Files copy failed: {error:#}");
}
let mut result = cached_purchase_response(
&self.config.data_dir,
onion,
content_id,
params
.get("cache_only")
.and_then(|v| v.as_bool())
.unwrap_or(false),
price_sats,
)
.await?;
result["ecash_backend"] = serde_json::json!(used_backend);
Ok(result)
anyhow::bail!("This legacy payment route cannot safely recover interrupted purchases. Open the file in Peers and use Cashu, Lightning or Bitcoin. Fedimint file purchases are unavailable until recoverable payments are supported. No payment was sent.")
}
/// Owner-authenticated local recovery lookup. No mint, peer request or
@@ -952,64 +767,12 @@ impl RpcHandler {
}))
}
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
/// paid item so the buyer can pay from any external wallet. Returns the
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
/// Retired creation route; existing invoice status and delivery stay available.
pub(super) async fn handle_content_request_invoice(
&self,
params: Option<serde_json::Value>,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
// give Tor a short-but-real window (onion circuits need a few seconds).
let path = format!("/content/{}/invoice", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_content_get(&self.config.data_dir)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
let status = response.status();
let body = bounded_seller_error(response).await;
return Ok(
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
);
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse invoice response")?;
Ok(body)
anyhow::bail!("Legacy invoice creation is unavailable. Open the file in Peers to use the saved Lightning purchase flow. Recover any existing payment instead of paying again.")
}
/// Buyer side (#46): poll the selling node for invoice settlement.
@@ -1214,57 +977,9 @@ impl RpcHandler {
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
pub(super) async fn handle_content_request_onchain(
&self,
params: Option<serde_json::Value>,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Issuing an address is a tiny request/response — fast-fail FIPS, short
// Tor window (same budget shape as the invoice path, #6).
let path = format!("/content/{}/onchain", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_content_get(&self.config.data_dir)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
let status = response.status();
let body = bounded_seller_error(response).await;
return Ok(
serde_json::json!({ "error": seller_error_message(status, &body), "payment_started": false }),
);
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse onchain response")?;
Ok(body)
anyhow::bail!("Legacy payment-address creation is unavailable. Open the file in Peers to use the saved Bitcoin purchase flow. Recover any existing payment instead of paying again.")
}
/// Buyer side (#46): poll the selling node for on-chain payment detection.
+80 -63
View File
@@ -1,68 +1,5 @@
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;
@@ -416,3 +353,83 @@ async fn repeated_share_returns_stable_id_and_complete_policy() {
AccessControl::Paid { price_sats: 2, .. }
));
}
#[tokio::test]
async fn legacy_payment_routes_refuse_fresh_spending_but_preserve_paid_cache() {
let data = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let onion = format!("{}.onion", "a".repeat(56));
for method in ["cashu", "fedimint", "auto"] {
let error = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": method, "cache_only": true
})))
.await
.unwrap_err();
assert!(
error.to_string().contains("No payment was sent"),
"{error:#}"
);
}
assert!(handler
.handle_content_request_invoice(None)
.await
.unwrap_err()
.to_string()
.contains("saved Lightning purchase flow"));
assert!(handler
.handle_content_request_onchain(None)
.await
.unwrap_err()
.to_string()
.contains("saved Bitcoin purchase flow"));
assert!(!data.path().join("wallet").exists());
crate::content_owned::record_purchase(
data.path(),
&onion,
"file",
"paid.txt",
"text/plain",
b"previously paid",
1,
"fedimint",
"2026-10-01T00:00:00Z",
)
.await
.unwrap();
for cache_only in [true, false] {
let result = handler
.handle_content_download_peer_paid(Some(serde_json::json!({
"onion": onion, "content_id": "file", "price_sats": 1,
"method": "fedimint", "cache_only": cache_only
})))
.await
.unwrap();
assert_eq!(result["paid_sats"], 0);
assert_eq!(result["already_owned"], true);
if cache_only {
assert_eq!(result["owned"], true);
} else {
use base64::Engine;
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().unwrap())
.unwrap(),
b"previously paid"
);
}
}
assert!(!data.path().join("wallet").exists());
}
@@ -41,6 +41,8 @@ impl RpcHandler {
)
.await?;
if matches!(action, "create" | "pay" | "retry") || params.external_exposure {
self.ensure_onchain_allows_other_rail(&buyer, &peer.did, &params.content_id)
.await?;
let cashu = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
anyhow::ensure!(
cashu
@@ -468,6 +468,129 @@ mod tests {
price_sats: 546,
}
}
#[tokio::test]
async fn unresolved_onchain_operation_blocks_cashu_and_every_lightning_spend_entry() {
let data = tempfile::tempdir().unwrap();
let identity = crate::identity::NodeIdentity::load_or_create(&data.path().join("identity"))
.await
.unwrap();
let mut binding = binding();
binding.buyer_did = identity.did_key().unwrap();
let onion = format!("{}.onion", "a".repeat(56));
let peer = serde_json::from_value(json!({
"did": binding.seller_did, "pubkey": hex::encode([8; 32]),
"onion": onion, "trust_level": "trusted", "added_at": "now",
"fips_npub": "fixture-no-network"
}))
.unwrap();
crate::federation::save_nodes(data.path(), &[peer])
.await
.unwrap();
let mut config = crate::config::Config::default();
config.data_dir = data.path().to_path_buf();
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
None,
None,
)
.await
.unwrap();
let journal = Journal::open(data.path(), &binding.id).await.unwrap();
journal
.save(&Record::new(binding.clone(), onion.clone()).unwrap())
.unwrap();
drop(journal);
let original = std::fs::read(
data.path()
.join("content-onchain")
.join(format!("{}.json", binding.id)),
)
.unwrap();
// Exercise the real RPC entry points, not just the admission helper.
// Each must reject before a Cashu/Lightning journal, offer, invoice or
// wallet dispatch is created, including delayed callbacks and retries.
for consent in [
None,
Some(json!({
"operation_id": uuid::Uuid::new_v4().to_string(),
"envelope_sha256": "ab".repeat(32), "wallet_debit_sats": 546
})),
] {
let error = handler
.handle_content_purchase(Some(json!({
"onion": onion, "content_id": binding.content_id,
"max_wallet_debit": 546, "consent": consent
})))
.await
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{error:#}"
);
}
for (action, exposure) in [
("create", false),
("pay", false),
("retry", false),
("create", true),
("status", true),
] {
let error = handler
.handle_lightning_operation(
Some(json!({
"onion": onion, "content_id": binding.content_id,
"price_sats": 546, "external_exposure": exposure
})),
action,
)
.await
.unwrap_err();
assert!(
error.to_string().contains("original on-chain purchase"),
"{action}: {error:#}"
);
}
// Read-only recovery lookup remains available despite the blocked rail.
let lookup = handler
.handle_lightning_operation(
Some(json!({
"onion": onion, "content_id": binding.content_id
})),
"lookup",
)
.await
.unwrap();
assert!(lookup["attempt"].is_null());
assert_eq!(
std::fs::read(
data.path()
.join("content-onchain")
.join(format!("{}.json", binding.id))
)
.unwrap(),
original
);
assert!(!data.path().join("wallet").exists());
// No replacement operation has been persisted by any rejected call.
assert!(crate::content_purchase::Journal::open(data.path())
.await
.unwrap()
.find_buyers(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.await
.unwrap()
.is_empty());
assert!(crate::content_lightning::Journal::open(data.path())
.await
.unwrap()
.buyer_for(&binding.buyer_did, &binding.seller_did, &binding.content_id)
.unwrap()
.is_none());
}
#[tokio::test]
async fn buyer_discovery_retains_unresolved_address_and_rejects_duplicate_operations() {
let data = tempfile::tempdir().unwrap();
+2
View File
@@ -47,6 +47,8 @@ impl RpcHandler {
&params.content_id,
)
.await?;
self.ensure_onchain_allows_other_rail(&buyer, transport.seller_did(), &params.content_id)
.await?;
self.ensure_invoice_allows_other_rail(&buyer, transport.seller_did(), &params.content_id)
.await?;
@@ -0,0 +1,78 @@
# Paid-file recovery qualification — 2026-10-07
Status: **OPEN — safety fixes in source; final combined validation and actual-node initial-payment interruption acceptance remain required.**
## Findings repaired
The on-chain cross-rail admission helper had no callers. Both current Cashu
purchase and Lightning create/pay/retry/external exposure could bypass an
existing on-chain operation, despite sharing its admission lock. They now check
the durable on-chain journal while holding that lock, before any alternate
wallet operation or externally payable invoice can be created. Read-only
Lightning lookup remains available. Retired unallocated on-chain operations
retain the journal's existing release policy; unresolved and funded operations
must be recovered, not paid again.
The old `content.download-peer-paid` route still directly spent ecash before a
recoverable operation/receipt existed. Its `cache_only` flag controls response
format, not payment authorization. The old `content.request-invoice` and
`content.request-onchain` methods likewise bypassed the durable purchase flows.
Fresh legacy spending and invoice/address creation now return actionable errors.
Already-owned exact/alias cache reads, existing invoice/on-chain status and
original-payment download endpoints remain available. No automatic conversion
to another method, payment retry, or bypass of reviewed fee consent was added.
Compatibility impact: current PeerFiles used the legacy spender for Fedimint;
Cashu already uses `content.purchase`. New Fedimint file purchases are therefore
temporarily unavailable, explicitly shown in the payment UI and guarded against
stale callbacks. Existing purchased files remain accessible. Restore Fedimint
purchases only with durable dispatch, ambiguous-outcome recovery and receipt
handling. Hidden `Web5SharedContent.vue` also references the legacy spender;
its import is currently commented out in `Web5.vue`. No current UI callers of
the two legacy invoice/address creation methods were found.
## Verification
- Focused PeerFiles payment suite: **62 passed**, zero failed.
`/tmp/archy-paid-file-ui-20261007.log`. TypeScript `vue-tsc --noEmit` also
passed; `/tmp/archy-paid-file-typecheck-20261007.log`.
- New backend regression exercises actual Cashu and Lightning RPC entry points
with a persisted on-chain attempt, including consent, retry and external
invoice exposure; checks unchanged original journal, absent replacement
records and no wallet creation. Separate real-handler regression checks
rejection of all legacy ecash choices and invoice/address creation, then
exact cached Fedimint bytes and zero-payment repeat access.
- Backend tests must run through `scripts/test-backend-isolated.sh`. Combined
run is queued behind the already-running IndeeHub executable/companion build;
the new tests are not yet claimed passed.
- No actual funds, wallet state, live services, files or peer policies were
changed by this qualification. No deployment or publication has occurred.
## Reconciled prior evidence — do not repeat payments
Older summaries retain stale open subitems. Existing receipts establish:
- Framework's October 2 one-sat Lightning purchase: seller settlement,
exact 121-byte cache, durable ownership and operator-confirmed free reopen.
Framework/Shorty were on the documented older binaries; this is not current
seller-journal acceptance.
- `/tmp/archy-190-framework-paid-files-readonly-final.log` records one durable
ownership entry and exact accepted bytes in Files/Documents. Optional Files
copy was subsequently verified, despite an earlier SSH failure in the ledger.
- October 6 dev↔Yaya purchases: two distinct 1-sat Cashu fixtures, seller credit,
exact 20 MiB bytes, range reads and free cached reopen after temporary shares
were removed. The existing private cumulative spend ledger must not be reset.
- `/tmp/archy-paid-cache-restart-qualification.log` records twenty cached
interruptions across both nodes, management restart on each, preserved app
containers/cache/ownership and zero additional sats.
- Existing isolated tests cover lost offer/acceptance/settlement replies,
persistent native invoice dispatch recovery, damaged journals, corrupt/truncated
delivery and one-wallet debit. These are fixtures, not actual-node fault
injection during initial payment.
Still required: verify current corrected artifact, initial payment/settlement
response-loss recovery before successful delivery headers, and current seller
persistence across restart. Never send a new payment to recover the historical
sales. The original missing-file incident was individually accepted by the
operator; broader release acceptance remains separate. Timed IndeeHub rental
and producer payout acceptance belongs to the independent IndeeHub workstream.
+17 -36
View File
@@ -399,8 +399,9 @@
<!-- Step 1: choose a payment method — only the methods the SELLER
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3">
<p v-if="acceptsMethod(payItem.access, 'fedimint')" class="text-xs text-white/60">Fedimint file purchases are temporarily unavailable. Choose another accepted payment method; previously purchased files remain available.</p>
<button
v-if="!hasBlockingLightningReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
v-if="!hasBlockingLightningReceipt && acceptsMethod(payItem.access, 'ecash')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="paymentActionBusy"
@click="prepareEcashPay"
@@ -410,7 +411,7 @@
</svg>
<span>
<span class="block text-base text-white">{{ ecashPreparing ? 'Checking your wallets…' : 'Pay from this node’s ecash wallet' }}</span>
<span class="block text-sm text-white/50">Instant, using your Cashu or Fedimint balance</span>
<span class="block text-sm text-white/50">Using your Cashu balance</span>
</span>
</button>
@@ -481,20 +482,20 @@
File: {{ cashuQuote.seller_net_sats }} sats · fees/rounding: {{ cashuQuote.wallet_debit_sats - cashuQuote.seller_net_sats }} sats
</p>
<p v-if="hasBlockingCashuPurchase" class="text-xs text-white/60">The original purchase is saved on your node. Retry recovers it without starting another payment.</p>
<!-- Backend selector: the chosen one is highlighted; the user can
switch to the other if it has enough balance. -->
<!-- Display other balances without offering unsafe payment paths. -->
<div class="space-y-2">
<button
v-for="b in (['cashu', 'fedimint'] as const)"
:key="b"
@click="selectEcashBackend(b)"
:disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && ecashBalanceOf(b) < getItemPrice(payItem.access))"
:disabled="paymentActionBusy || b !== 'cashu'"
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
>
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
<span class="flex-1 min-w-0">
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
<span v-if="b === 'fedimint'" class="block text-xs text-white/60">File purchases temporarily unavailable</span>
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
</span>
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1476,9 +1477,8 @@ function ecashBalanceOf(b: EcashBackend): number {
}
/**
* Step 1b: look at BOTH ecash balances, pick the backend that covers the price
* (Cashu preferred, else Fedimint), and show a confirmation screen so the user
* sees exactly which wallet is spent and can switch before committing (#3).
* Read balances and obtain the durable Cashu quote for explicit confirmation.
* Other ecash balances are informative and never selected as a fallback.
*/
async function prepareEcashPay() {
const item = payItem.value
@@ -1492,7 +1492,6 @@ async function prepareEcashPay() {
if(onchainAttempt.value || onchainLookupError.value){lnError.value='Recover the original on-chain purchase first.';return}
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
if (!operation) return
const price = getItemPrice(item.access)
ecashPreparing.value = true
purchaseError.value = null
try {
@@ -1511,13 +1510,13 @@ async function prepareEcashPay() {
}
if (!paymentOperations.selected(operation)) return
const total = cashu + fedimint
// Cashu uses its durable quote; otherwise only supported Fedimint
// funds can cover this purchase. Ark is not a peer-file payment rail.
// Only Cashu currently has recoverable ecash peer-file payments.
// Never fall through to another wallet when its quote cannot be funded.
const chosen: EcashBackend | null =
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : null
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : null
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!chosen) {
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Ark cannot pay for peer files yet. Fund a supported wallet, or pay another way.`
purchaseError.value = 'Fedimint file purchases are temporarily unavailable. Ark cannot pay for peer files yet. Choose another accepted payment method.'
}
payMode.value = 'ecash-confirm'
if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false)
@@ -1604,8 +1603,7 @@ async function requestCashuPurchase(item: CatalogItem, onion: string, operation:
}
async function selectEcashBackend(backend: EcashBackend) {
if (!ecashPlan.value || paymentActionBusy.value) return
if (backend !== 'cashu' && backend !== 'fedimint') { purchaseError.value = 'This wallet cannot pay for peer files yet.'; return }
if (backend !== 'cashu' && hasBlockingCashuPurchase.value) { purchaseError.value = 'Cancel the original unpaid Cashu quote before selecting another wallet.'; return }
if (backend !== 'cashu') { purchaseError.value = 'This wallet cannot pay for peer files yet. Fedimint file purchases are temporarily unavailable.'; return }
ecashPlan.value.chosen = backend
if (backend === 'cashu') await prepareEcashPay()
}
@@ -1633,37 +1631,20 @@ async function cancelCashuPurchase() {
} finally { paymentOperations.finish(operation) }
}
/** Confirm the ecash payment with the backend the user selected. */
/** Confirm the reviewed Cashu quote; other ecash rails are not recoverable yet. */
async function confirmEcashPay() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
if (method !== 'cashu' && method !== 'fedimint') { purchaseError.value = 'This wallet cannot pay for peer files yet.'; return }
if (method !== 'cashu' && !await permitFreshOtherRail(item, onion)) return
if (method !== 'cashu') { purchaseError.value = 'This wallet cannot pay for peer files yet. Fedimint file purchases are temporarily unavailable.'; return }
const operation = paymentOperations.begin('ecash-send', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
const price = getItemPrice(item.access)
purchaseError.value = null
try {
if (method === 'cashu') { await requestCashuPurchase(item, onion, operation, true); return }
if (hasBlockingCashuPurchase.value) throw new Error('Recover or cancel the saved Cashu purchase first.')
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
timeout: 960000, maxRetries: 1,
})
if (result?.data !== undefined || result?.owned === true) {
// The purchase is now cached + owned by this node (backend persisted it).
if (selected()) openPurchased(item, result.data, result.mime_type, onion, result.owned_content_id)
} else if (selected() && result?.error) {
// Preserve the backend's recovery explanation; an error is not proof of nonpayment.
purchaseError.value = result.error
}
await requestCashuPurchase(item, onion, operation, true)
} catch (e: unknown) {
if (selected()) purchaseError.value = e instanceof Error ? e.message : 'Download failed'
if (paymentOperations.selected(operation)) purchaseError.value = e instanceof Error ? e.message : 'Download failed'
} finally {
paymentOperations.finish(operation)
}
@@ -733,6 +733,28 @@ describe('Explicit retry of a confirmed native-only failure',()=>{
describe('Supported peer-file ecash choices', () => {
it('never selects or spends Fedimint even when funded, including stale callbacks', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'wallet.ecash-balance') return { cashu_sats: 0, fedimint_sats: 1000 }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['fedimint'] } } })
await flushPromises()
expect(wrapper.text()).toContain('Fedimint file purchases are temporarily unavailable')
expect(wrapper.findAll('button').some(button => button.text().includes('Pay from this node’s ecash wallet'))).toBe(false)
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBeNull()
expect(vm.ecashPlan.fedimint).toBe(1000)
await vm.selectEcashBackend('fedimint')
expect(vm.ecashPlan.chosen).toBeNull()
vm.ecashPlan.chosen = 'fedimint'
await vm.confirmEcashPay()
expect(vm.purchaseError).toContain('Fedimint file purchases are temporarily unavailable')
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.download-peer-paid', 'content.purchase'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it('does not offer or spend Ark even when its wallet alone covers the file', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'wallet.ecash-balance') return { cashu_sats: 0, fedimint_sats: 0, ark_sats: 1000 }