Attribute on-chain receipts to exact outputs and stop ambiguous Fedimint fallback
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user