Attribute on-chain receipts to exact outputs and stop ambiguous Fedimint fallback

This commit is contained in:
archipelago
2026-10-07 00:51:14 -04:00
parent 687ec881ca
commit 30b5975534
7 changed files with 455 additions and 75 deletions
+15 -3
View File
@@ -577,9 +577,21 @@ impl ApiHandler {
let mut paid = let mut paid =
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await; crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
if !paid { if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { match self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?; Ok(true) => {
paid = true; crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
paid = true;
}
Ok(false) => {}
Err(_) => return Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"paid": false,
"status": "unknown",
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
}))?),
)),
} }
} }
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
+2 -2
View File
@@ -1307,10 +1307,10 @@ impl RpcHandler {
.await .await
{ {
Ok(v) => v, Ok(v) => v,
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })), Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })),
}; };
if !response.status().is_success() { if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false })); return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }));
} }
let body: serde_json::Value = response let body: serde_json::Value = response
.json() .json()
+202 -40
View File
@@ -569,50 +569,15 @@ impl RpcHandler {
.send() .send()
.await .await
.context("Failed to list transactions")?; .context("Failed to list transactions")?;
if !resp.status().is_success() { anyhow::ensure!(
return Ok(false); resp.status().is_success(),
} "Wallet transaction verification is unavailable"
);
let body: serde_json::Value = resp let body: serde_json::Value = resp
.json() .json()
.await .await
.context("Failed to parse transactions response")?; .context("Failed to parse transactions response")?;
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 { Ok(confirmed_address_sats(&body, address)? >= min_sats)
tx.get(k)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
.unwrap_or(0)
};
let txs = body
.get("transactions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for tx in &txs {
if i64_field(tx, "num_confirmations") < 1 {
continue;
}
if i64_field(tx, "amount") < min_sats as i64 {
continue;
}
let pays_addr = tx
.get("dest_addresses")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
.unwrap_or(false)
|| tx
.get("output_details")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
})
.unwrap_or(false);
if pays_addr {
return Ok(true);
}
}
Ok(false)
} }
pub(in crate::api::rpc) async fn handle_lnd_createinvoice( pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
@@ -1379,10 +1344,207 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result<PsbtKeyOriginReport> {
}) })
} }
/// LND's transaction `amount` is the wallet-wide net amount, not the value
/// paid to a purchase address. Attribute only confirmed output values, once
/// per outpoint. Missing/malformed evidence is unknown, never proof of payment.
fn confirmed_address_sats(body: &serde_json::Value, address: &str) -> Result<u64> {
use std::collections::{HashMap, HashSet};
const MAX_SATS: u64 = 21_000_000 * 100_000_000;
fn integer(value: &serde_json::Value) -> Result<u64> {
match value {
serde_json::Value::String(s)
if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) =>
{
s.parse().context("Invalid on-chain output integer")
}
value => value
.as_u64()
.context("Missing or invalid on-chain output integer"),
}
}
anyhow::ensure!(!address.is_empty(), "Missing purchase address");
let transactions = body
.get("transactions")
.and_then(|v| v.as_array())
.context("Wallet omitted transaction evidence")?;
let mut seen = HashMap::new();
let mut total = 0u64;
for tx in transactions {
let confirmations = match &tx["num_confirmations"] {
serde_json::Value::String(s) => {
s.parse::<i64>().context("Invalid confirmation count")?
}
value => value.as_i64().context("Missing confirmation count")?,
};
if confirmations < 1 {
continue;
}
let hash = tx["tx_hash"]
.as_str()
.context("Missing transaction identifier")?;
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid transaction identifier"
);
if let Some(previous) = seen.insert(hash.to_ascii_lowercase(), tx) {
anyhow::ensure!(previous == tx, "Conflicting duplicate transaction evidence");
continue;
}
let outputs = tx["output_details"]
.as_array()
.context("Wallet omitted output values")?;
let mut indices = HashSet::new();
for output in outputs {
let index =
u32::try_from(integer(&output["output_index"])?).context("Invalid output index")?;
anyhow::ensure!(indices.insert(index), "Duplicate transaction output");
let amount = integer(&output["amount"])?;
anyhow::ensure!(amount <= MAX_SATS, "Invalid output amount");
// A non-address script (e.g. OP_RETURN) has no receiving address.
if output["address"].as_str() != Some(address) {
continue;
}
total = total
.checked_add(amount)
.filter(|sum| *sum <= MAX_SATS)
.context("Invalid total received amount")?;
}
}
Ok(total)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn payment_tx(hash: &str, confirmations: i64, outputs: serde_json::Value) -> serde_json::Value {
serde_json::json!({"tx_hash":hash.repeat(64),"num_confirmations":confirmations,
"amount":"999999","dest_addresses":["purchase"],"output_details":outputs})
}
#[test]
fn onchain_purchase_counts_only_its_outputs_not_wallet_total() {
let tx = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":"0","amount":"1","address":"purchase"},
{"output_index":"1","amount":"999998","address":"other"}
]),
);
assert_eq!(
confirmed_address_sats(&serde_json::json!({"transactions":[tx]}), "purchase").unwrap(),
1
);
}
#[test]
fn onchain_purchase_sums_confirmed_partial_outputs_once() {
let mut first = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":0,"amount":"300","address":"purchase"},
{"output_index":"1","amount":46,"address":"purchase"}
]),
);
first["amount"] = serde_json::json!("-9999"); // net wallet debit is irrelevant
let second = payment_tx(
"b",
2,
serde_json::json!([
{"output_index":"2","amount":"200","address":"purchase"}
]),
);
let unconfirmed = payment_tx(
"c",
0,
serde_json::json!([
{"output_index":0,"amount":"10000","address":"purchase"}
]),
);
let conflicted = payment_tx(
"d",
-1,
serde_json::json!([
{"output_index":0,"amount":"10000","address":"purchase"}
]),
);
assert_eq!(confirmed_address_sats(&serde_json::json!({"transactions":[first.clone(),first,second,unconfirmed,conflicted]}), "purchase").unwrap(), 546);
}
#[test]
fn onchain_purchase_rejects_missing_values_and_ambiguous_outpoints() {
let good = payment_tx(
"a",
1,
serde_json::json!([
{"output_index":"0","amount":"546","address":"purchase"}
]),
);
let mut no_values = good.clone();
no_values.as_object_mut().unwrap().remove("output_details");
let mut duplicate = good.clone();
duplicate["output_details"] = serde_json::json!([
{"output_index":0,"amount":300,"address":"purchase"},
{"output_index":0,"amount":300,"address":"purchase"}
]);
let mut conflicting = good.clone();
conflicting["output_details"][0]["amount"] = serde_json::json!(1000);
for body in [
serde_json::json!({}),
serde_json::json!({"transactions":[no_values]}),
serde_json::json!({"transactions":[duplicate]}),
serde_json::json!({"transactions":[good.clone(),conflicting]}),
] {
assert!(confirmed_address_sats(&body, "purchase").is_err());
}
for amount in [
serde_json::json!(-1),
serde_json::json!("0.00000546"),
serde_json::json!(546.5),
serde_json::json!(null),
serde_json::json!("18446744073709551616"),
serde_json::json!("2100000000000001"),
] {
let mut invalid = good.clone();
invalid["output_details"][0]["amount"] = amount;
assert!(confirmed_address_sats(
&serde_json::json!({"transactions":[invalid]}),
"purchase"
)
.is_err());
}
}
#[test]
fn onchain_purchase_has_no_rounding_or_accumulation_overflow() {
let max = "2100000000000000";
let first = payment_tx(
"a",
1,
serde_json::json!([{"output_index":0,"amount":max,"address":"purchase"}]),
);
assert_eq!(
confirmed_address_sats(
&serde_json::json!({"transactions":[first.clone()]}),
"purchase"
)
.unwrap(),
2_100_000_000_000_000
);
let second = payment_tx(
"b",
1,
serde_json::json!([{"output_index":0,"amount":"1","address":"purchase"}]),
);
assert!(confirmed_address_sats(
&serde_json::json!({"transactions":[first,second]}),
"purchase"
)
.is_err());
}
/// Build a minimal, genuinely unsigned one-input PSBT with no key origin on /// Build a minimal, genuinely unsigned one-input PSBT with no key origin on
/// any input. Built programmatically rather than pasted as opaque base64 so /// any input. Built programmatically rather than pasted as opaque base64 so
/// the fixture states what it is. /// the fixture states what it is.
+138 -18
View File
@@ -226,10 +226,28 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String
anyhow::bail!("No Fedimint federation joined to spend from"); anyhow::bail!("No Fedimint federation joined to spend from");
} }
let (notes, federation) = spend_from_candidates(&client, &fed_ids, amount_sats).await?;
record_fedimint_tx(
data_dir,
crate::wallet::ecash::TransactionType::Send,
amount_sats,
&federation,
"Sent Fedimint ecash",
)
.await;
Ok((notes, federation))
}
/// Balance lookup may try another federation; a dispatched spend never may.
/// A sidecar error (including missing notes or a lost response) can follow a
/// successful debit. Without its original operation receipt it is ambiguous.
async fn spend_from_candidates(
client: &FedimintClient,
fed_ids: &[String],
amount_sats: u64,
) -> Result<(String, String)> {
let mut last_err = None; let mut last_err = None;
for fed_id in &fed_ids { for fed_id in fed_ids {
// Skip federations that can't cover the amount so we don't mint a
// partial/failed spend and leave dangling reserved notes.
match client.federation_balance_sats(fed_id).await { match client.federation_balance_sats(fed_id).await {
Ok(bal) if bal >= amount_sats => {} Ok(bal) if bal >= amount_sats => {}
Ok(_) => continue, Ok(_) => continue,
@@ -238,23 +256,13 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String
continue; continue;
} }
} }
match client.spend(fed_id, amount_sats).await { let notes = client.spend(fed_id, amount_sats).await.context(
Ok(notes) => { "The selected federation did not confirm the spend; no other federation was charged. Recover its original operation before retrying"
record_fedimint_tx( )?;
data_dir, return Ok((notes, fed_id.clone()));
crate::wallet::ecash::TransactionType::Send,
amount_sats,
fed_id,
"Sent Fedimint ecash",
)
.await;
return Ok((notes, fed_id.clone()));
}
Err(e) => last_err = Some(e),
}
} }
Err(last_err Err(last_err
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}")) .map(|e| anyhow::anyhow!("Could not check federation balances: {e}"))
.unwrap_or_else(|| { .unwrap_or_else(|| {
anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available") anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available")
})) }))
@@ -553,3 +561,115 @@ fn sum_msat(info: &serde_json::Value) -> u64 {
.map(|m| m.values().filter_map(federation_msat).sum()) .map(|m| m.values().filter_map(federation_msat).sum())
.unwrap_or(0) .unwrap_or(0)
} }
#[cfg(test)]
mod payment_edge_tests {
use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server, StatusCode,
};
use std::{
convert::Infallible,
sync::{Arc, Mutex},
};
// The mock records a completed sidecar spend BEFORE producing each fault.
// No environment override, installed sidecar, wallet or live service is used.
async fn sidecar(
first_balance: u64,
reply: &'static str,
status: StatusCode,
) -> (
FedimintClient,
Arc<Mutex<Vec<String>>>,
tokio::task::JoinHandle<()>,
) {
let spent = Arc::new(Mutex::new(Vec::new()));
let recorded = spent.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let address = listener.local_addr().unwrap();
let service = make_service_fn(move |_| {
let spent = recorded.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
let spent = spent.clone();
async move {
if request.uri().path() == "/v2/admin/info" {
return Ok::<_, Infallible>(Response::new(Body::from(
serde_json::json!({
"first":{"totalAmountMsat":first_balance * 1000},
"second":{"totalAmountMsat":100000}
})
.to_string(),
)));
}
assert_eq!(request.uri().path(), "/v2/mint/spend");
let body = hyper::body::to_bytes(request.into_body()).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
spent
.lock()
.unwrap()
.push(body["federationId"].as_str().unwrap().to_owned());
let response_body = if reply == "disconnect" {
Body::wrap_stream(futures_util::stream::once(async {
Err::<hyper::body::Bytes, _>(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"lost response after debit",
))
}))
} else {
Body::from(reply)
};
Ok(Response::builder()
.status(status)
.body(response_body)
.unwrap())
}
}))
}
});
let server = Server::from_tcp(listener).unwrap().serve(service);
let task = tokio::spawn(async move {
server.await.unwrap();
});
(
FedimintClient::new(&format!("http://{address}"), "test").unwrap(),
spent,
task,
)
}
#[tokio::test]
async fn dispatched_fedimint_spend_never_falls_through_after_ambiguous_reply() {
for (reply, status) in [
("disconnect", StatusCode::OK),
("not-json", StatusCode::OK),
("{}", StatusCode::OK),
("unavailable", StatusCode::SERVICE_UNAVAILABLE),
] {
let (client, spent, task) = sidecar(100, reply, status).await;
let result =
spend_from_candidates(&client, &["first".into(), "second".into()], 50).await;
assert!(result
.unwrap_err()
.to_string()
.contains("no other federation was charged"));
assert_eq!(*spent.lock().unwrap(), vec!["first".to_string()]);
task.abort();
}
}
#[tokio::test]
async fn fedimint_selection_skips_insufficient_balance_before_dispatch() {
let (client, spent, task) =
sidecar(1, r#"{"notes":"original-notes"}"#, StatusCode::OK).await;
let result = spend_from_candidates(&client, &["first".into(), "second".into()], 50)
.await
.unwrap();
assert_eq!(result, ("original-notes".into(), "second".into()));
assert_eq!(*spent.lock().unwrap(), vec!["second".to_string()]);
task.abort();
}
}
+37
View File
@@ -813,3 +813,40 @@ closing, aborting or timing out cancels only its own pending prompt. A payment
already dispatched remains journaled and is recovered using its original ID. already dispatched remains journaled and is recovered using its original ID.
The host rejects malformed states, changed observed windows and playback URLs in The host rejects malformed states, changed observed windows and playback URLs in
non-started replies. The focused host/provider run passed 29 tests across two files; actual `vue-tsc -b` passed, with all 542 captured host inputs unchanged. Logs are `/tmp/archy-rental-protocol2-host-tests.log` and `/tmp/archy-rental-protocol2-host-typecheck.log`; provenance is `/tmp/archy-rental-protocol2-host-provenance.json`. The rental Rust remains uncompiled pending the combined backend candidate. non-started replies. The focused host/provider run passed 29 tests across two files; actual `vue-tsc -b` passed, with all 542 captured host inputs unchanged. Logs are `/tmp/archy-rental-protocol2-host-tests.log` and `/tmp/archy-rental-protocol2-host-typecheck.log`; provenance is `/tmp/archy-rental-protocol2-host-provenance.json`. The rental Rust remains uncompiled pending the combined backend candidate.
### Isolated on-chain attribution and Fedimint fallback correction
Next candidate, based on `fba3273c`; not yet compiled or deployed. On-chain
verification counts only confirmed output values for the original address, with
integer satoshi arithmetic and transaction/outpoint deduplication. It no longer
uses the wallet-wide transaction amount or mere address presence. Malformed or
missing output evidence cannot authorize delivery. Read-only inspection of the
dev node's LND `0.21.2-beta` confirmed `output_details`, with string `amount` and
`output_index` fields and integer confirmation counts; no private wallet rows
were included in the schema receipt. Field meanings were checked against the
[LND protocol schema](https://github.com/lightningnetwork/lnd/blob/v0.21.2-beta/lnrpc/lightning.proto).
Fedimint balance selection may skip insufficient/unavailable federations, but
once a spend is attempted its error cannot trigger a spend in another federation.
Loopback mock cases cover lost body, malformed response, missing notes and server
failure after the mock records a debit. Ark is removed from peer-file payment
choices because this endpoint supports only Cashu/Fedimint; stale unsupported
selections also fail before an RPC.
These changes do **not** complete on-chain or legacy token recovery. A durable
buyer-bound address/transaction operation, cross-rail admission for these older
paths, seller snapshot/receipt retention, and original Fedimint spend lookup are
still required. A Bitcoin address already shown to an external payer remains
payable; a timeout or empty transaction lookup cannot cancel it. Legacy token
redemption followed by lost delivery still needs a durable seller receipt.
Missing output evidence or an unavailable wallet now propagates as an explicit
unknown verification result with a retain-address/no-repayment message. The
buyer also preserves unknown status on transport/HTTP failure and displays it
while read-only polling continues. This does not add cancellation or method
switching authority to a Bitcoin address.
Written regression coverage: four output-attribution cases, two mocked sidecar
spend cases, and two mounted UI cases (unsupported Ark and unknown on-chain
verification). No test execution is claimed until the queued isolated backend
and focused UI runs complete.
+18 -12
View File
@@ -479,16 +479,16 @@
switch to the other if it has enough balance. --> switch to the other if it has enough balance. -->
<div class="space-y-2"> <div class="space-y-2">
<button <button
v-for="b in (['cashu', 'fedimint', 'ark'] as const)" v-for="b in (['cashu', 'fedimint'] as const)"
:key="b" :key="b"
@click="selectEcashBackend(b)" @click="selectEcashBackend(b)"
:disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && ecashBalanceOf(b) < getItemPrice(payItem.access))" :disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && ecashBalanceOf(b) < getItemPrice(payItem.access))"
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="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'" :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' ? '🥜' : b === 'fedimint' ? '🤝' : '⚓' }}</span> <span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
<span class="flex-1 min-w-0"> <span class="flex-1 min-w-0">
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : b === 'fedimint' ? 'Fedimint' : 'Ark' }}</span> <span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</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 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> </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"> <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">
@@ -840,7 +840,7 @@ const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
// Ecash confirmation step: after the user picks "pay from this node's ecash", // Ecash confirmation step: after the user picks "pay from this node's ecash",
// we look at both balances, decide which backend covers the price, and show a // we look at both balances, decide which backend covers the price, and show a
// confirm screen so they see (and can switch) which ecash is spent (#3). // confirm screen so they see (and can switch) which ecash is spent (#3).
type EcashBackend = 'cashu' | 'fedimint' | 'ark' type EcashBackend = 'cashu' | 'fedimint'
const ecashPlan = ref<{ const ecashPlan = ref<{
cashu: number cashu: number
fedimint: number fedimint: number
@@ -1428,8 +1428,9 @@ async function pollOnchain(address: string, original?: OnchainPollScope) {
const scope = original || { item, onion, address, generation: paymentGeneration.value } const scope = original || { item, onion, address, generation: paymentGeneration.value }
if (!onchainScopeSelected(scope)) return if (!onchainScopeSelected(scope)) return
try { try {
const res = await rpcClient.call<{ paid?: boolean }>({ method: 'content.onchain-status', params: { onion, content_id: item.id, address: scope.address }, timeout: 30000 }) const res = await rpcClient.call<{ paid?: boolean; status?: string; error?: string }>({ method: 'content.onchain-status', params: { onion, content_id: item.id, address: scope.address }, timeout: 30000 })
if (!onchainScopeSelected(scope)) return if (!onchainScopeSelected(scope)) return
if (res?.error) lnError.value = res.error
if (res?.paid === true) { if (res?.paid === true) {
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({ const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-onchain', params: { onion, content_id: item.id, address: scope.address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true }, timeout: 960000, maxRetries: 1, method: 'content.download-peer-onchain', params: { onion, content_id: item.id, address: scope.address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true }, timeout: 960000, maxRetries: 1,
@@ -1441,14 +1442,17 @@ async function pollOnchain(address: string, original?: OnchainPollScope) {
} else lnError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this address.' } else lnError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this address.'
return return
} }
} catch { /* Retry read-only status for the original item only. */ } } catch {
if (onchainScopeSelected(scope)) lnError.value = 'Payment verification is unavailable. Keep the original address and do not pay again.'
// Retry read-only status for the original item only.
}
if (onchainScopeSelected(scope) && onchainPaying.value) onchainPollTimer = setTimeout(() => pollOnchain(scope.address, scope), 5000) if (onchainScopeSelected(scope) && onchainPaying.value) onchainPollTimer = setTimeout(() => pollOnchain(scope.address, scope), 5000)
} }
/** Spendable balance for a given ecash backend in the current plan. */ /** Spendable balance for a given ecash backend in the current plan. */
function ecashBalanceOf(b: EcashBackend): number { function ecashBalanceOf(b: EcashBackend): number {
if (!ecashPlan.value) return 0 if (!ecashPlan.value) return 0
return b === 'cashu' ? ecashPlan.value.cashu : b === 'fedimint' ? ecashPlan.value.fedimint : ecashPlan.value.ark return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
} }
/** /**
@@ -1485,14 +1489,14 @@ async function prepareEcashPay() {
// Couldn't read balances — let the user try anyway (auto backend). // Couldn't read balances — let the user try anyway (auto backend).
} }
if (!paymentOperations.selected(operation)) return if (!paymentOperations.selected(operation)) return
const total = cashu + fedimint + ark const total = cashu + fedimint
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else // Cashu uses its durable quote; otherwise only supported Fedimint
// leave null (insufficient — shown in the confirm screen, Confirm disabled). // funds can cover this purchase. Ark is not a peer-file payment rail.
const chosen: EcashBackend | null = const chosen: EcashBackend | null =
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : null
ecashPlan.value = { cashu, fedimint, ark, total, chosen } ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!chosen) { if (!chosen) {
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.` 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.`
} }
payMode.value = 'ecash-confirm' payMode.value = 'ecash-confirm'
if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false) if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false)
@@ -1579,6 +1583,7 @@ async function requestCashuPurchase(item: CatalogItem, onion: string, operation:
} }
async function selectEcashBackend(backend: EcashBackend) { async function selectEcashBackend(backend: EcashBackend) {
if (!ecashPlan.value || paymentActionBusy.value) return 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' && hasBlockingCashuPurchase.value) { purchaseError.value = 'Cancel the original unpaid Cashu quote before selecting another wallet.'; return }
ecashPlan.value.chosen = backend ecashPlan.value.chosen = backend
if (backend === 'cashu') await prepareEcashPay() if (backend === 'cashu') await prepareEcashPay()
@@ -1613,6 +1618,7 @@ async function confirmEcashPay() {
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen const method = ecashPlan.value?.chosen
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return 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' && !await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('ecash-send', onion, item.id) const operation = paymentOperations.begin('ecash-send', onion, item.id)
if (!operation) return if (!operation) return
@@ -729,3 +729,46 @@ describe('Explicit retry of a confirmed native-only failure',()=>{
wrapper.unmount() wrapper.unmount()
}) })
}) })
describe('Supported peer-file ecash choices', () => {
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 }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['fedimint'] } } })
await flushPromises()
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBeNull()
expect(vm.ecashPlan.total).toBe(0)
expect(vm.purchaseError).toContain('Ark cannot pay for peer files yet')
expect(wrapper.findAll('button').some(button => button.text().includes('Ark'))).toBe(false)
await vm.selectEcashBackend('ark')
expect(vm.ecashPlan.chosen).toBeNull()
// A stale or injected unsupported selection must also fail before RPC.
vm.ecashPlan.chosen = 'ark'
await vm.confirmEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.download-peer-paid')).toBe(false)
wrapper.unmount()
})
})
describe('Unknown on-chain verification', () => {
it('shows verification errors without downloading, paying or creating another address', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-status') return { paid: false, status: 'unknown', error: 'Exact outputs unavailable; do not pay again.' }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.pollOnchain('original-address')
expect(vm.lnError).toContain('do not pay again')
const calls = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call.method)
expect(calls).not.toContain('content.download-peer-onchain')
expect(calls).not.toContain('content.request-onchain')
expect(calls).not.toContain('lnd.sendcoins')
wrapper.unmount()
})
})