Fix Cashu file redemption and Bitcoin-dependent wallet readiness
This commit is contained in:
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.19-alpha"
|
||||
version = "1.8.20-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.19-alpha"
|
||||
version = "1.8.20-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -32,13 +32,14 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,29 +548,27 @@ impl RpcHandler {
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
@@ -583,25 +582,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
};
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
)
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -609,9 +600,9 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("Peer returned an error ({status}). {refund}")
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,50 @@ fn checked_balances(
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -419,3 +462,44 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||
let progressing = height
|
||||
.zip(last_height)
|
||||
.is_some_and(|(now, before)| now > before);
|
||||
last_height = height;
|
||||
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,10 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
|
||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
let state = if lower.contains("loading block index") {
|
||||
Some("loading its block index. This can take a while after installation or restart")
|
||||
} else if lower.contains("replaying blocks") {
|
||||
Some("checking saved blocks before startup completes")
|
||||
} else if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -340,3 +344,21 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,18 +89,74 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
@@ -258,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -294,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -1089,3 +1152,44 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1913,6 +1913,11 @@ impl ProdContainerOrchestrator {
|
||||
.unwrap_or_default();
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||
.await
|
||||
.map(|settings| settings.prune)
|
||||
.unwrap_or(true);
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||
@@ -1952,7 +1957,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
&& bitcoin_pruned
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
@@ -3720,6 +3725,17 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||
if storage.prune {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||
);
|
||||
env.push("BITCOIN_PRUNE=1".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
@@ -6073,6 +6089,48 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
orch.set_data_dir(dir.path().to_path_buf());
|
||||
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
// No preference: existing containers need no new environment flag.
|
||||
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||
assert!(!old
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch
|
||||
.resolve_dynamic_env(&mut old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot honor"));
|
||||
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
current
|
||||
.app
|
||||
.container
|
||||
.custom_args
|
||||
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||
assert!(current
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -296,6 +296,24 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
match &item.access {
|
||||
@@ -307,8 +325,12 @@ pub async fn serve_content(
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
@@ -336,24 +358,6 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
@@ -573,7 +577,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
}
|
||||
|
||||
/// Verify a payment token covers the required amount.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||
//! Missing preference preserves the existing disk-based automatic selection.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct BitcoinStorage {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||
let dir = data_dir.join("settings");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("bitcoin-storage.json");
|
||||
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||
tokio::fs::rename(temporary, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), true).await.unwrap();
|
||||
assert!(load(dir.path()).await.unwrap().prune);
|
||||
save(dir.path(), false).await.unwrap();
|
||||
assert!(!load(dir.path()).await.unwrap().prune);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), true).await.unwrap();
|
||||
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load(dir.path()).await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,5 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||
all_target.extend(&change_denoms);
|
||||
|
||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||||
let swap_result = client
|
||||
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||
.await?;
|
||||
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Verify all mints in the token are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||
}
|
||||
}
|
||||
@@ -1217,7 +1223,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
@@ -1298,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
||||
token_str: &str,
|
||||
required_sats: u64,
|
||||
) -> Result<u64> {
|
||||
// Handle legacy tokens
|
||||
let token_str = token_str.trim();
|
||||
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
let amount = token_str
|
||||
.split('_')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
if amount < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
amount,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||||
return Ok(received);
|
||||
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
@@ -1336,52 +1330,45 @@ pub async fn verify_and_receive_payment(
|
||||
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
if token.unit.as_deref().unwrap_or("sat") != "sat" {
|
||||
anyhow::bail!("Payment must be denominated in sats");
|
||||
}
|
||||
// A sale must redeem atomically at one mint. Otherwise a later mint
|
||||
// failure can consume earlier inputs without delivering the purchase.
|
||||
let entry = match token.token.as_slice() {
|
||||
[entry] => entry,
|
||||
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||
};
|
||||
let total = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
|
||||
if total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
total,
|
||||
required_sats
|
||||
);
|
||||
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||
}
|
||||
|
||||
// Verify mints are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||||
}
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||
}
|
||||
|
||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let result = client
|
||||
.swap_at_least(
|
||||
&entry.proofs,
|
||||
&amount_to_denominations(total),
|
||||
required_sats,
|
||||
)
|
||||
.await?;
|
||||
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
// Load after the network call, so an unrelated wallet update during the
|
||||
// swap is not overwritten with a pre-swap snapshot.
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
match client.swap(&entry.proofs, &target_amounts).await {
|
||||
Ok(result) => {
|
||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Payment verification failed: only {} of {} sats verified",
|
||||
received_total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
@@ -2465,3 +2452,7 @@ mod tests {
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "payment_tests.rs"]
|
||||
mod payment_tests;
|
||||
|
||||
@@ -153,6 +153,20 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
|
||||
cause.context(describe_mint_error_body(status, body))
|
||||
}
|
||||
|
||||
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||
let mut outputs = Vec::new();
|
||||
for &amount in requested {
|
||||
if available >= amount {
|
||||
outputs.push(amount);
|
||||
available -= amount;
|
||||
} else {
|
||||
outputs.extend(amount_to_denominations(available));
|
||||
break;
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -512,6 +526,21 @@ impl MintClient {
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
self.swap_at_least(inputs, target_amounts, 0).await
|
||||
}
|
||||
|
||||
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||
/// before consuming any input proofs.
|
||||
pub async fn swap_at_least(
|
||||
&self,
|
||||
inputs: &[Proof],
|
||||
target_amounts: &[u64],
|
||||
minimum: u64,
|
||||
) -> Result<SwapResult> {
|
||||
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||
// files and streams) must expand these, not only wallet imports.
|
||||
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||
let inputs = resolved.as_slice();
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
@@ -519,16 +548,35 @@ impl MintClient {
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||
let inputs_total = inputs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.context("Input amount overflow")?;
|
||||
let keysets = self.get_keysets().await?;
|
||||
let mut fee_ppk = 0u64;
|
||||
for proof in inputs {
|
||||
let input_keyset = keysets
|
||||
.iter()
|
||||
.find(|k| k.id == proof.id)
|
||||
.context("The mint does not recognize an input keyset")?;
|
||||
anyhow::ensure!(
|
||||
input_keyset.unit == "sat",
|
||||
"Input keyset is not denominated in sats"
|
||||
);
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(input_keyset.input_fee_ppk)
|
||||
.context("Mint fee overflow")?;
|
||||
}
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
if spendable < minimum {
|
||||
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
|
||||
}
|
||||
let requested = target_amounts
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
|
||||
.context("Output amount overflow")?;
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
@@ -539,7 +587,10 @@ impl MintClient {
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
// Callers put payment outputs before change. Keep that prefix
|
||||
// intact while fees reduce change; re-splitting the entire sum
|
||||
// can omit a payment denomination after consuming the inputs.
|
||||
owned_targets = fee_adjusted_targets(target_amounts, spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
@@ -584,6 +635,9 @@ impl MintClient {
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
if sig.amount != *amount || sig.id != keyset.id {
|
||||
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||
}
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
@@ -730,43 +784,35 @@ impl MintClient {
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||
/// or ambiguous short IDs are rejected before redemption.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
return Ok(proofs.to_vec());
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
let known = self.get_cdk_keysets().await?;
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
if is_truncated_v2_keyset_id(&p.id) {
|
||||
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||
}
|
||||
p
|
||||
Ok(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -802,7 +848,7 @@ impl MintClient {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint != self.url {
|
||||
if entry.mint.trim_end_matches('/') != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
@@ -813,8 +859,7 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
||||
use super::*;
|
||||
use crate::wallet::{bdhke, cashu::Proof};
|
||||
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Request, Response, Server,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const ACTIVE: &str = "0011223344556677";
|
||||
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
||||
|
||||
struct Mint {
|
||||
url: String,
|
||||
requests: Arc<Mutex<Vec<Value>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||
}
|
||||
impl Drop for Mint {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn signing_key() -> SecretKey {
|
||||
SecretKey::from_slice(&[7; 32]).unwrap()
|
||||
}
|
||||
fn signed_point(point: PublicKey) -> String {
|
||||
point
|
||||
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
fn proof(id: &str, amount: u64) -> Proof {
|
||||
let secret = format!("test-{id}-{amount}");
|
||||
Proof {
|
||||
amount,
|
||||
id: id.into(),
|
||||
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
impl Mint {
|
||||
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = requests.clone();
|
||||
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
||||
let rejection = failure.clone();
|
||||
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
||||
let service = make_service_fn(move |_| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
let spent = spent.clone();
|
||||
async move {
|
||||
let mut status = 200;
|
||||
let body = match req.uri().path() {
|
||||
"/v1/keysets" => json!({"keysets":[
|
||||
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||
]}),
|
||||
"/v1/keys" => {
|
||||
let public =
|
||||
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||
.to_string();
|
||||
let keys: serde_json::Map<String, Value> = (0..16)
|
||||
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||
.collect();
|
||||
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
||||
}
|
||||
"/v1/swap" => {
|
||||
let body: Value = serde_json::from_slice(
|
||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
seen.lock().unwrap().push(body.clone());
|
||||
let inputs = body["inputs"].as_array().unwrap();
|
||||
let outputs = body["outputs"].as_array().unwrap();
|
||||
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if code != 0 {
|
||||
status = code;
|
||||
json!({"detail":"mock mint rejection"})
|
||||
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
||||
{
|
||||
status = 422;
|
||||
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
||||
} else if inputs.iter().any(|p| {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(p["secret"].as_str().unwrap())
|
||||
}) {
|
||||
status = 400;
|
||||
json!({"code":11001,"detail":"Token Already Spent"})
|
||||
} else {
|
||||
let total: u64 =
|
||||
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
let out: u64 =
|
||||
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||
assert_eq!(
|
||||
out,
|
||||
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
||||
);
|
||||
for p in inputs {
|
||||
spent
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(p["secret"].as_str().unwrap().into());
|
||||
}
|
||||
json!({"signatures":outputs.iter().map(|o| json!({
|
||||
"amount":o["amount"],"id":ACTIVE,
|
||||
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
||||
})).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status = 404;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
let server = Server::from_tcp(listener).unwrap().serve(service);
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
requests,
|
||||
task,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
async fn wallet(&self) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_accepted_mints(
|
||||
dir.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![format!("{}/", self.url)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let decoded = CashuToken::deserialize(&token).unwrap();
|
||||
assert_eq!(
|
||||
decoded.token[0].proofs[0].id.len(),
|
||||
16,
|
||||
"reproduce the short V4 ID"
|
||||
);
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
100
|
||||
);
|
||||
let wallet = load_wallet(dir.path()).await.unwrap();
|
||||
assert_eq!(wallet.balance(), 100);
|
||||
for p in wallet.proofs {
|
||||
assert_eq!(
|
||||
p.proof.c,
|
||||
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
||||
);
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|p| p["id"] == V2));
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_v3_full_v2_and_v1_ids_work() {
|
||||
for id in [V2, ACTIVE] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.unwrap(),
|
||||
128
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("after mint fees"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
verify_and_receive_payment(dir.path(), &token, 127)
|
||||
.await
|
||||
.unwrap(),
|
||||
127
|
||||
);
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_mint_response_does_not_credit_wallet() {
|
||||
for status in [200, 400, 422, 500, 503] {
|
||||
let mint = Mint::start(0, Some(status)).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
||||
let mut invalid = vec![
|
||||
"cashuSend_500_abc_1700000000".into(),
|
||||
"cashuBinvalid".into(),
|
||||
];
|
||||
let mut wrong_unit = token.clone();
|
||||
wrong_unit.unit = Some("usd".into());
|
||||
invalid.push(wrong_unit.serialize().unwrap());
|
||||
let mut multi = token.clone();
|
||||
multi.token.push(token.token[0].clone());
|
||||
invalid.push(multi.serialize().unwrap());
|
||||
let mut untrusted = token.clone();
|
||||
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
||||
invalid.push(untrusted.serialize().unwrap());
|
||||
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
||||
invalid.push(
|
||||
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||
.serialize()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for value in invalid {
|
||||
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
assert!(
|
||||
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
||||
let mint = Mint::start(0, Some(422)).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let seller = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let token = send_token(buyer.path(), 100).await.unwrap();
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(receive_token(buyer.path(), &token).await.is_err());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_mint_does_not_credit_seller() {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let dir = mint.wallet().await;
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
mint.task.abort();
|
||||
tokio::task::yield_now().await;
|
||||
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
||||
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
||||
// which is needed for a 65-sat payment, after consuming the inputs.
|
||||
let mint = Mint::start(1000, None).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
let first = proof(V2, 64);
|
||||
let mut second = first.clone();
|
||||
second.secret.push_str("-second");
|
||||
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
||||
wallet.add_proofs(&mint.url, vec![first, second]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
||||
assert_eq!(
|
||||
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
||||
65
|
||||
);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
||||
use crate::content_server::{
|
||||
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
||||
};
|
||||
for (exists, accepts_cashu, price) in [
|
||||
(true, true, 100),
|
||||
(true, false, 100),
|
||||
(false, true, 100),
|
||||
(true, true, 129),
|
||||
] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let seller = mint.wallet().await;
|
||||
let item = ContentItem {
|
||||
id: "paid-test".into(),
|
||||
filename: "test.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 5,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: Availability::AllPeers,
|
||||
access: AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
||||
},
|
||||
};
|
||||
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
||||
.await
|
||||
.unwrap();
|
||||
if exists {
|
||||
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||
.serialize_v4()
|
||||
.unwrap();
|
||||
let result = content_server::serve_content(
|
||||
seller.path(),
|
||||
"paid-test",
|
||||
Some(&token),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if exists && accepts_cashu && price <= 128 {
|
||||
match result {
|
||||
ServeResult::Ok(bytes, mime) => {
|
||||
assert_eq!(bytes, b"hello");
|
||||
assert_eq!(mime, "text/plain");
|
||||
}
|
||||
_ => panic!("paid content was not delivered"),
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||
));
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user