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 =
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 });
+2 -2
View File
@@ -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()
+202 -40
View File
@@ -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::<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)
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<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)]
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.
+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");
}
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<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();
}
}