diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index c456f430..67b9344d 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -577,9 +577,21 @@ impl ApiHandler { let mut paid = crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await; if !paid { - if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { - crate::content_invoice::mark_paid(&self.config.data_dir, address).await?; - paid = true; + match self.rpc_handler.onchain_received(address, price).await { + Ok(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 }); diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 077efbfc..3b66ff55 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -1307,10 +1307,10 @@ impl RpcHandler { .await { 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() { - 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 .json() diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index b08254a0..cc0f3184 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -569,50 +569,15 @@ impl RpcHandler { .send() .await .context("Failed to list transactions")?; - if !resp.status().is_success() { - return Ok(false); - } + anyhow::ensure!( + resp.status().is_success(), + "Wallet transaction verification is unavailable" + ); let body: serde_json::Value = resp .json() .await .context("Failed to parse transactions response")?; - let i64_field = |tx: &serde_json::Value, k: &str| -> i64 { - tx.get(k) - .and_then(|v| v.as_str()) - .and_then(|s| s.parse::().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) + Ok(confirmed_address_sats(&body, address)? >= min_sats) } pub(in crate::api::rpc) async fn handle_lnd_createinvoice( @@ -1379,10 +1344,207 @@ fn psbt_key_origin_report(psbt_base64: &str) -> Result { }) } +/// 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 { + use std::collections::{HashMap, HashSet}; + const MAX_SATS: u64 = 21_000_000 * 100_000_000; + fn integer(value: &serde_json::Value) -> Result { + 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::().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)] mod tests { 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 /// any input. Built programmatically rather than pasted as opaque base64 so /// the fixture states what it is. diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index c233bfaf..7bec132c 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -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"); } + 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; - 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. + for fed_id in fed_ids { match client.federation_balance_sats(fed_id).await { Ok(bal) if bal >= amount_sats => {} Ok(_) => continue, @@ -238,23 +256,13 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String continue; } } - match client.spend(fed_id, amount_sats).await { - Ok(notes) => { - record_fedimint_tx( - data_dir, - 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), - } + let notes = client.spend(fed_id, amount_sats).await.context( + "The selected federation did not confirm the spend; no other federation was charged. Recover its original operation before retrying" + )?; + return Ok((notes, fed_id.clone())); } 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(|| { 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()) .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>>, + 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| { + 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::(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(); + } +} diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 0cf7b68f..3fa0ee92 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -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. 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. + +### 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. diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 78c178ae..3fe5f758 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -479,16 +479,16 @@ switch to the other if it has enough balance. -->