Fix Cashu file redemption and Bitcoin-dependent wallet readiness
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v1.8.20-alpha (2026-09-29)
|
||||||
|
|
||||||
|
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||||
|
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||||
|
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||||
|
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||||
|
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||||
|
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||||
|
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||||
|
|
||||||
## v1.8.19-alpha (2026-09-28)
|
## v1.8.19-alpha (2026-09-28)
|
||||||
|
|
||||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ app:
|
|||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ app:
|
|||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.19-alpha"
|
version = "1.8.20-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.19-alpha"
|
version = "1.8.20-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
|||||||
cors_origin: &str,
|
cors_origin: &str,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
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}");
|
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||||
// A bare reqwest::get() uses the default client, which rejects the
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||||
/// the seller already claimed the token (then the value is genuinely gone).
|
/// 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) {
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||||
let res = match backend {
|
let res = match backend {
|
||||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
.await
|
.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,
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
};
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(sats) => tracing::info!(
|
Ok(sats) => {
|
||||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||||
),
|
format!("Refunded {sats} sats to your wallet.")
|
||||||
Err(e) => tracing::warn!(
|
}
|
||||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
Err(e) => {
|
||||||
claimed it): {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,11 +548,8 @@ impl RpcHandler {
|
|||||||
// Surface a real reason instead of the generic sanitized error (#30):
|
// 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
|
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||||
// here means the peer is genuinely unreachable on both transports.
|
// here means the peer is genuinely unreachable on both transports.
|
||||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
let (response, transport) =
|
||||||
fips_npub.as_deref(),
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
onion,
|
|
||||||
&path,
|
|
||||||
)
|
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.header("X-Payment-Token", token_str.clone())
|
.header("X-Payment-Token", token_str.clone())
|
||||||
@@ -564,9 +562,10 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
// The token was already minted/spent — reclaim it so the buyer
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
// doesn't lose the value when the seller was simply unreachable.
|
// 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;
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
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."
|
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -583,25 +582,17 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
// Payment was rejected by the seller. Surface the most likely cause
|
// A 402 can mean mint validation, network failure, underpayment,
|
||||||
// per backend — for ecash both sides must share a redemption network
|
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||||
// (a Cashu mint, or a Fedimint federation).
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
"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
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
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;
|
||||||
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",
|
|
||||||
};
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": format!("The seller could not verify the payment. {refund}")
|
||||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
|
||||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,9 +600,9 @@ impl RpcHandler {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
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!({
|
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 {
|
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> {
|
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_bytes = read_lnd_admin_macaroon().await?;
|
||||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
@@ -419,3 +462,44 @@ mod tests {
|
|||||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
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
|
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||||
/// operator.
|
/// 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() {
|
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut bad_minutes: u32 = 0;
|
let mut bad_minutes: u32 = 0;
|
||||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||||
|
let mut last_height: Option<u64> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
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 {
|
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||||
continue;
|
continue;
|
||||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
bad_minutes = 0; // down/locked — not the wedge signature
|
bad_minutes = 0; // down/locked — not the wedge signature
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
bad_minutes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
.get("num_pending_channels")
|
.get("num_pending_channels")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0);
|
.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 {
|
if !wedged {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
|||||||
Ok((client, macaroon_hex))
|
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
|
// an older version pins it so install_fresh resolves that image and the
|
||||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
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()) {
|
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||||
persist_install_version_selection(package_id, version).await;
|
persist_install_version_selection(package_id, version).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
|||||||
let default = app_catalog::catalog_default_version(app_id);
|
let default = app_catalog::catalog_default_version(app_id);
|
||||||
let cfg = version_config::read(app_id);
|
let cfg = version_config::read(app_id);
|
||||||
let installed = installed_version(app_id).await;
|
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!({
|
Ok(serde_json::json!({
|
||||||
|
"bitcoinPrune": bitcoin_prune,
|
||||||
"id": app_id,
|
"id": app_id,
|
||||||
"supportsVersions": supports_versions(app_id),
|
"supportsVersions": supports_versions(app_id),
|
||||||
"default": default,
|
"default": default,
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('.');
|
.trim_end_matches('.');
|
||||||
let lower = detail.to_lowercase();
|
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")
|
Some("verifying blocks after restart")
|
||||||
} else if lower.contains("connection reset") {
|
} else if lower.contains("connection reset") {
|
||||||
Some("starting up and not yet accepting RPC connections")
|
Some("starting up and not yet accepting RPC connections")
|
||||||
@@ -340,3 +344,21 @@ mod tests {
|
|||||||
assert!(msg.len() < 260);
|
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)
|
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<()> {
|
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
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";
|
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||||
if file_exists_as_root(wallet_db).await {
|
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 {
|
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||||
return Ok(());
|
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?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||||
return Ok(());
|
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?;
|
init_wallet_via_rest().await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).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 —
|
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||||
// a wrong password still exits on the first pass via `all_rejected`.
|
// a wrong password still exits on the first pass via `all_rejected`.
|
||||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
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;
|
let mut all_rejected = true;
|
||||||
for pw in &candidates {
|
for pw in &candidates {
|
||||||
match try_unlock_once(&client, pw).await {
|
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
|
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||||
@@ -1089,3 +1152,44 @@ mod tests {
|
|||||||
.is_empty());
|
.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();
|
.unwrap_or_default();
|
||||||
let mut report = ReconcileReport::default();
|
let mut report = ReconcileReport::default();
|
||||||
let disk_gb = self.disk_gb().await;
|
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)
|
// Register every candidate before the (sequential, possibly slow)
|
||||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||||
@@ -1952,7 +1957,7 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
if mode == ReconcileMode::ExistingOnly
|
if mode == ReconcileMode::ExistingOnly
|
||||||
&& requires_archival_bitcoin(&app_id)
|
&& requires_archival_bitcoin(&app_id)
|
||||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
&& bitcoin_pruned
|
||||||
{
|
{
|
||||||
report.record(
|
report.record(
|
||||||
&app_id,
|
&app_id,
|
||||||
@@ -3720,6 +3725,17 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
let mut env = manifest.app.environment.clone();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
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}}
|
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
// 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]
|
#[tokio::test]
|
||||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
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
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -307,8 +325,12 @@ pub async fn serve_content(
|
|||||||
// Each path only counts when the sharer accepts that method.
|
// Each path only counts when the sharer accepts that method.
|
||||||
let mut authorized = false;
|
let mut authorized = false;
|
||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if (method_accepted(&item.access, "ecash")
|
let method = if token.trim().starts_with("cashu") {
|
||||||
|| method_accepted(&item.access, "fedimint"))
|
"ecash"
|
||||||
|
} else {
|
||||||
|
"fedimint"
|
||||||
|
};
|
||||||
|
if method_accepted(&item.access, method)
|
||||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
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)
|
let metadata = fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
.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.
|
/// 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.
|
/// 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 {
|
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 {
|
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 ai_permissions;
|
||||||
pub mod session_policy;
|
pub mod session_policy;
|
||||||
pub mod transport;
|
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();
|
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||||
all_target.extend(&change_denoms);
|
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
|
// Mark original proofs as spent
|
||||||
wallet.mark_spent(&indices);
|
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
|
// Verify all mints in the token are accepted
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
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);
|
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;
|
received_total += amount;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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>();
|
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||||
last_reason = Some(e.to_string());
|
last_reason = Some(e.to_string());
|
||||||
// Continue with other mints if any
|
// Continue with other mints if any
|
||||||
@@ -1298,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
|||||||
token_str: &str,
|
token_str: &str,
|
||||||
required_sats: u64,
|
required_sats: u64,
|
||||||
) -> Result<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_") {
|
if token_str.starts_with("cashuSend_") {
|
||||||
let amount = token_str
|
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
// 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)
|
// Parse and validate the token (cashuA or cashuB)
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
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 {
|
if total < required_sats {
|
||||||
anyhow::bail!(
|
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||||
"Insufficient payment: {} sats, need {} sats",
|
|
||||||
total,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mints are accepted
|
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
if !accepted
|
||||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
.mints
|
||||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
.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 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 client = mint_client(data_dir, &entry.mint).await?;
|
||||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let result = client
|
||||||
let target_amounts = amount_to_denominations(entry_total);
|
.swap_at_least(
|
||||||
|
&entry.proofs,
|
||||||
match client.swap(&entry.proofs, &target_amounts).await {
|
&amount_to_denominations(total),
|
||||||
Ok(result) => {
|
required_sats,
|
||||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
)
|
||||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
.await?;
|
||||||
received_total += amount;
|
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||||
}
|
// Load after the network call, so an unrelated wallet update during the
|
||||||
Err(e) => {
|
// swap is not overwritten with a pre-swap snapshot.
|
||||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
}
|
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if received_total < required_sats {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Payment verification failed: only {} of {} sats verified",
|
|
||||||
received_total,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
wallet.record_tx(
|
wallet.record_tx(
|
||||||
TransactionType::Receive,
|
TransactionType::Receive,
|
||||||
@@ -2465,3 +2452,7 @@ mod tests {
|
|||||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
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))
|
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.
|
/// HTTP client for a single Cashu mint.
|
||||||
pub struct MintClient {
|
pub struct MintClient {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -512,6 +526,21 @@ impl MintClient {
|
|||||||
/// Swap proofs for new proofs of different denominations.
|
/// Swap proofs for new proofs of different denominations.
|
||||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
/// 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> {
|
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?;
|
let keyset = self.get_active_sat_keyset().await?;
|
||||||
|
|
||||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
// 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
|
// should equal outputs less fee`). Applied here rather than at each
|
||||||
// call site so send, receive and cross-mint swaps are all covered.
|
// call site so send, receive and cross-mint swaps are all covered.
|
||||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||||
let fee = match self.get_keysets().await {
|
let inputs_total = inputs
|
||||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
.iter()
|
||||||
Err(e) => {
|
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
.context("Input amount overflow")?;
|
||||||
0
|
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 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 owned_targets: Vec<u64>;
|
||||||
let target_amounts: &[u64] = if requested > spendable {
|
let target_amounts: &[u64] = if requested > spendable {
|
||||||
if spendable == 0 {
|
if spendable == 0 {
|
||||||
@@ -539,7 +587,10 @@ impl MintClient {
|
|||||||
debug!(
|
debug!(
|
||||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
"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
|
&owned_targets
|
||||||
} else {
|
} else {
|
||||||
target_amounts
|
target_amounts
|
||||||
@@ -584,6 +635,9 @@ impl MintClient {
|
|||||||
|
|
||||||
let mut new_proofs = Vec::new();
|
let mut new_proofs = Vec::new();
|
||||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
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 c_prime = sig.c_prime_as_pubkey()?;
|
||||||
let mint_key = keyset.key_for_amount(*amount)?;
|
let mint_key = keyset.key_for_amount(*amount)?;
|
||||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
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.
|
/// 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
|
/// 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
|
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
/// 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
|
/// bytes, and rejects the swap — reported as
|
||||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||||
/// a Minibits-issued token, 2026-08-17).
|
/// a Minibits-issued token, 2026-08-17).
|
||||||
///
|
///
|
||||||
/// The id only names which keyset signed the proof, so restoring the full
|
/// 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
|
/// 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
|
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||||
/// verification at the mint and no coins move. Anything already valid, or
|
/// or ambiguous short IDs are rejected before redemption.
|
||||||
/// with no unambiguous match, is passed through untouched so the mint's
|
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||||
/// own error is what the operator sees.
|
|
||||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
|
||||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||||
if !needs_repair {
|
if !needs_repair {
|
||||||
return proofs.to_vec();
|
return Ok(proofs.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mint's own keyset list, in the reference implementation's shape
|
// The mint's own keyset list, in the reference implementation's shape
|
||||||
// so its NUT-02 resolver can consume it directly.
|
// so its NUT-02 resolver can consume it directly.
|
||||||
let known = match self.get_cdk_keysets().await {
|
let known = 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proofs
|
proofs
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|mut p| {
|
.map(|mut p| {
|
||||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
if is_truncated_v2_keyset_id(&p.id) {
|
||||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||||
p.id = full;
|
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||||
}
|
}
|
||||||
p
|
Ok(p)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -802,7 +848,7 @@ impl MintClient {
|
|||||||
let mut all_new_proofs = Vec::new();
|
let mut all_new_proofs = Vec::new();
|
||||||
|
|
||||||
for entry in &token.token {
|
for entry in &token.token {
|
||||||
if entry.mint != self.url {
|
if entry.mint.trim_end_matches('/') != self.url {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping proofs from different mint {} (ours: {})",
|
"Skipping proofs from different mint {} (ours: {})",
|
||||||
entry.mint, self.url
|
entry.mint, self.url
|
||||||
@@ -813,8 +859,7 @@ impl MintClient {
|
|||||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(total);
|
let target_amounts = amount_to_denominations(total);
|
||||||
|
|
||||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||||
let result = self.swap(&proofs, &target_amounts).await?;
|
|
||||||
all_new_proofs.extend(result.new_proofs);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-13
@@ -989,7 +989,7 @@
|
|||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────
|
||||||
let unit = 'sats';
|
let unit = 'sats';
|
||||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||||
let peerSort = { col: 'peer', dir: 1 };
|
let peerSort = { col: 'peer', dir: 1 };
|
||||||
let activityFilter = 'all';
|
let activityFilter = 'all';
|
||||||
let logsLoaded = false;
|
let logsLoaded = false;
|
||||||
@@ -1142,9 +1142,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
|
if (state.refreshing) return;
|
||||||
|
state.refreshing = true;
|
||||||
const icon = document.getElementById('refreshIcon');
|
const icon = document.getElementById('refreshIcon');
|
||||||
if (icon) icon.classList.add('animate-spin-slow');
|
if (icon) icon.classList.add('animate-spin-slow');
|
||||||
try {
|
try {
|
||||||
|
state.readiness = await lndSafe('/archy-status', null);
|
||||||
|
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
|
||||||
|
state.info = null;
|
||||||
|
state.onchainStale = true;
|
||||||
|
state.chanbalStale = true;
|
||||||
|
renderAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
||||||
lndSafe('/v1/getinfo', null),
|
lndSafe('/v1/getinfo', null),
|
||||||
lndSafe('/v1/channels', { channels: [] }),
|
lndSafe('/v1/channels', { channels: [] }),
|
||||||
@@ -1166,10 +1176,17 @@
|
|||||||
state.invoices = (invoices && invoices.invoices) || [];
|
state.invoices = (invoices && invoices.invoices) || [];
|
||||||
state.txns = (txns && txns.transactions) || [];
|
state.txns = (txns && txns.transactions) || [];
|
||||||
|
|
||||||
// Balances are separate so one failing endpoint can't blank the rest.
|
// Preserve known balances on outage; never decode an error as zero.
|
||||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
const [onchain, chanbal] = await Promise.all([
|
||||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
lndSafe('/v1/balance/blockchain', null),
|
||||||
|
lndSafe('/v1/balance/channels', null),
|
||||||
|
]);
|
||||||
|
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
|
||||||
|
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
|
||||||
|
if (!state.onchainStale) state.onchain = onchain;
|
||||||
|
if (!state.chanbalStale) state.chanbal = chanbal;
|
||||||
} finally {
|
} finally {
|
||||||
|
state.refreshing = false;
|
||||||
if (icon) icon.classList.remove('animate-spin-slow');
|
if (icon) icon.classList.remove('animate-spin-slow');
|
||||||
}
|
}
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -1192,11 +1209,17 @@
|
|||||||
const pill = document.getElementById('headerStatusPill');
|
const pill = document.getElementById('headerStatusPill');
|
||||||
const dot = document.getElementById('headerStatusDot');
|
const dot = document.getElementById('headerStatusDot');
|
||||||
|
|
||||||
if (!g) {
|
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||||
setText('headerStatusText', 'Unreachable');
|
if (!g || waiting) {
|
||||||
pill.className = 'pill bad';
|
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||||
dot.className = 'status-dot-sm bg-red';
|
pill.className = 'pill warn';
|
||||||
document.getElementById('syncCard').style.display = 'none';
|
dot.className = 'status-dot-sm bg-yellow';
|
||||||
|
document.getElementById('syncCard').style.display = '';
|
||||||
|
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
|
||||||
|
setText('syncBlockLabel', '');
|
||||||
|
setText('syncPercent', '');
|
||||||
|
document.getElementById('syncProgressBar').style.width = '0%';
|
||||||
|
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1260,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Balances ────────────────────────────────────────────────────
|
// ── Balances ────────────────────────────────────────────────────
|
||||||
|
function validBalance(value) {
|
||||||
|
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||||
|
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function renderBalances() {
|
function renderBalances() {
|
||||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||||
@@ -1253,13 +1281,13 @@
|
|||||||
const haveOnchain = !!state.onchain;
|
const haveOnchain = !!state.onchain;
|
||||||
const haveChan = !!cb;
|
const haveChan = !!cb;
|
||||||
|
|
||||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||||
|
|
||||||
setText('liqLocal', fmtAmount(lnLocal));
|
setText('liqLocal', fmtAmount(lnLocal));
|
||||||
|
|||||||
+18
-1
@@ -7,10 +7,27 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
|||||||
|
|
||||||
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
|
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
|
||||||
User requires investigation and a verified fix on the actual node before later
|
User requires investigation and a verified fix on the actual node before later
|
||||||
unrelated work. Access is pending; a manual LND restart is only a workaround.
|
unrelated work. Startup and native balances were verified on the actual node;
|
||||||
|
final display confirmation is pending. See the incident record for evidence.
|
||||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||||
and the repository `AGENTS.md` session-start instructions.
|
and the repository `AGENTS.md` session-start instructions.
|
||||||
|
|
||||||
|
## Current repair and release tasks — 2026-09-29
|
||||||
|
|
||||||
|
Release is blocked until these pass; see [execution record](repair-release-20260929.md).
|
||||||
|
|
||||||
|
- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
||||||
|
mint errors, fees, and refund reporting before live validation.
|
||||||
|
- [ ] Complete the remaining Framework incident verification and evidence.
|
||||||
|
- [ ] Replace the unavailable tx1138.com explorer default with mempool.space;
|
||||||
|
migrate the old default with fresh consent and preserve custom/local explorers.
|
||||||
|
- [ ] Offer pruning in the Bitcoin installation version modal, using the same
|
||||||
|
pruning settings as automatic pruning even on large disks.
|
||||||
|
- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
||||||
|
RPC readiness and show install/start/sync waiting states with automatic recovery.
|
||||||
|
- [ ] Test the completed changes on this development box, then publish a new
|
||||||
|
signed OTA and raw ISO release. Record any remaining verification gaps.
|
||||||
|
|
||||||
## Dev & build process (priority)
|
## Dev & build process (priority)
|
||||||
|
|
||||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Framework: LND startup, missing Receive address, false zero balance
|
# Framework: LND startup, missing Receive address, false zero balance
|
||||||
|
|
||||||
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
|
**Status: OPEN — Framework startup, native balances, Cashu address and source integration verified; final rendered dashboard confirmation remains.**
|
||||||
|
|
||||||
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||||
The Framework's installed version and exact incident time have not been verified.
|
The Framework's installed version and exact incident time have not been verified.
|
||||||
@@ -376,3 +376,18 @@ rename the address to disguise the problem. A Minibits server change could use
|
|||||||
Archy would instead require an Archy-hosted LNURL service/address and correct
|
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||||
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||||
is insufficient. No wallet/profile mutations were made during this investigation.
|
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||||
|
|
||||||
|
### Source integration confirmed — 2026-09-29
|
||||||
|
|
||||||
|
`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at
|
||||||
|
`540639d2`. The previously tested startup ordering, safe unlock, and unavailable
|
||||||
|
balance fixes are integrated and included in the intervening releases. The
|
||||||
|
earlier “source integration pending” notes above are historical, not current.
|
||||||
|
The user reports no further Framework incidents. Requested final confirmation
|
||||||
|
of rendered balances and Receive; do not mark closed without that response.
|
||||||
|
|
||||||
|
A separate startup failure was observed on the development box today when Core
|
||||||
|
was installed against existing block data: Core made steady replay progress,
|
||||||
|
while LND exited on its short “bitcoind start timeout”. Candidate work defers
|
||||||
|
unlock until authenticated Bitcoin RPC answers, with dependency waiting states
|
||||||
|
in the LND UI. This is not evidence of a new failure on Framework.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Repair and release execution — 2026-09-29
|
||||||
|
|
||||||
|
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||||
|
|
||||||
|
User requires all tasks completed and tested on the development box before the
|
||||||
|
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||||
|
|
||||||
|
## Confirmed evidence
|
||||||
|
|
||||||
|
- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and
|
||||||
|
buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both
|
||||||
|
attempted purchases were refunded 100 sats. The old message guessed a mint
|
||||||
|
mismatch without evidence.
|
||||||
|
- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption
|
||||||
|
bypassed that repair. Central swap repair and protocol-level regression tests now pass.
|
||||||
|
- Core installation on dev reused existing chain data. At 17:42 UTC it was
|
||||||
|
advancing through block replay with no Core container restarts. At 17:49 UTC
|
||||||
|
it had connected to peers and started transaction-index synchronization.
|
||||||
|
- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After
|
||||||
|
Core became available LND stayed running and reported waiting for backend sync.
|
||||||
|
- Framework source fix 4237fb5e is already an ancestor of main. Existing live
|
||||||
|
reboot/native balance evidence is in the incident document. Final display
|
||||||
|
confirmation remains pending.
|
||||||
|
|
||||||
|
## Changes under validation
|
||||||
|
|
||||||
|
- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection;
|
||||||
|
single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or
|
||||||
|
unconditional refund claims. Missing content checked before redemption.
|
||||||
|
- mempool.space default; migrate old tx1138 default with fresh consent, retain
|
||||||
|
local explorer priority and custom preferences.
|
||||||
|
- Core/Knots optional pruning on the version modal and app detail install path;
|
||||||
|
persist choice across runtime restarts; use identical 50,000 MiB automatic
|
||||||
|
pruning entrypoint behavior on large and small disks.
|
||||||
|
- Plain Bitcoin block-index startup message; defer LND wallet initialization or
|
||||||
|
unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI
|
||||||
|
waiting states; no partial total displayed as a complete balance.
|
||||||
|
|
||||||
|
## Validation and release gates
|
||||||
|
|
||||||
|
- [x] Final backend regression suite passes (including mock mint HTTP and real
|
||||||
|
curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption).
|
||||||
|
- [x] Initial explorer and pruning modal tests pass: 15 tests.
|
||||||
|
- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across
|
||||||
|
6 disk/choice combinations each. No existing chain pruned for this test.
|
||||||
|
- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass.
|
||||||
|
- [x] Frontend production build and relevant existing wallet tests pass (34
|
||||||
|
focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,117 passed; production build passed. Updated gate rerun pending.
|
||||||
|
- [ ] Fault tests and final source review complete.
|
||||||
|
- [ ] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||||
|
- [ ] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||||
|
- [ ] Live waiting/recovery and UI state verified on dev.
|
||||||
|
- [ ] Framework final confirmation recorded.
|
||||||
|
- [ ] Release version/changelog, catalog/image implications, signing prepared.
|
||||||
|
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||||
|
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||||
|
|
||||||
|
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||||
|
or claim that arbitrary failures can never happen. Record material gaps before
|
||||||
|
release. Signing keys remain with the user; prepare concrete artifacts first.
|
||||||
|
|
||||||
|
### Further startup findings
|
||||||
|
|
||||||
|
Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during
|
||||||
|
Bitcoin initial sync. Candidate startup now recognizes the already-unlocked
|
||||||
|
state instead of repeating unlock attempts for ten minutes. The health watchdog
|
||||||
|
also now excludes Bitcoin initial sync, warmup, unavailable/stale status and
|
||||||
|
LND height progress from its restart criteria. A later observed `podman restart`
|
||||||
|
was externally initiated; its precise caller has not yet been established, so
|
||||||
|
the watchdog defect is a source finding rather than a confirmed attribution.
|
||||||
|
|
||||||
|
Framework SSH rejected the previously provided login on 2026-09-29. No password
|
||||||
|
was saved and no wallet changes were attempted. The human display-confirmation
|
||||||
|
question remains pending. Do not repeat a Framework reboot to reconfirm old work.
|
||||||
|
|
||||||
|
LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render
|
||||||
|
tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block
|
||||||
|
until sync, and prevent overlapping refreshes.
|
||||||
|
|
||||||
|
### Final source validation
|
||||||
|
|
||||||
|
The final backend suite passed: 1,548 passed, zero failed, four existing ignored
|
||||||
|
live/hardware tests. Includes saved pruning preference, rejecting an old catalog
|
||||||
|
that cannot honor explicit pruning, and all nine paid-Cashu protocol tests.
|
||||||
|
Unsigned candidate catalog passes strict drift and fleet registry trust checks.
|
||||||
|
The release gate caught a missing What's New entry; generated it from the curated
|
||||||
|
changelog and reran the frontend gate/build. No public release has been changed.
|
||||||
|
|
||||||
|
At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container
|
||||||
|
is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin
|
||||||
|
is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues
|
||||||
|
to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its
|
||||||
|
readiness deferral live before declaring this fixed. Do not attribute the Bitcoin
|
||||||
|
exit to a specific actor without evidence.
|
||||||
|
|
||||||
|
### Doctor restart cause established and repaired
|
||||||
|
|
||||||
|
Full system journal identifies container-doctor at 18:23:21 UTC issuing raw
|
||||||
|
`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same
|
||||||
|
script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening.
|
||||||
|
Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep
|
||||||
|
exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener.
|
||||||
|
The raw restart also enforces a short stop timeout and races Quadlet cleanup.
|
||||||
|
|
||||||
|
The repaired check consumes the entire socket snapshot, distinguishes inspection
|
||||||
|
failure from a missing port, and leaves containers running when inspection fails.
|
||||||
|
Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged
|
||||||
|
Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses
|
||||||
|
20,000 socket rows plus mocked service/container commands and passes. Thirty
|
||||||
|
read-only checks of the actual Bitcoin listener pass. Script deployed to dev and
|
||||||
|
Shorty with root-only rollback copies. OTA runtime payload includes scripts/.
|
||||||
|
This evidence supersedes the earlier unknown-caller/unknown-exit attribution.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.19-alpha",
|
"version": "1.8.20-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface PackageVersionsResponse {
|
|||||||
pinnedVersion: string | null
|
pinnedVersion: string | null
|
||||||
autoUpdate: boolean
|
autoUpdate: boolean
|
||||||
versions: CatalogVersionInfo[]
|
versions: CatalogVersionInfo[]
|
||||||
|
bitcoinPrune?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppGatePortStatus {
|
export interface AppGatePortStatus {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-5 space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||||
|
<input v-model="model" type="checkbox" class="accent-orange-400" />
|
||||||
|
Prune Bitcoin to save disk space
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-white/50">
|
||||||
|
Keeps about 50 GB of recent blocks, using the same settings as automatic
|
||||||
|
pruning on smaller disks. All blocks are still downloaded and verified.
|
||||||
|
Mempool and other apps that need the full blockchain won’t be available.
|
||||||
|
Turning pruning off later requires downloading the blockchain again.
|
||||||
|
</p>
|
||||||
|
<p v-if="!model" class="text-xs text-white/50">
|
||||||
|
Automatic pruning still applies on disks smaller than 1 TB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<boolean>({ default: false })
|
||||||
|
</script>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
|
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
|
||||||
/>
|
/>
|
||||||
<p class="text-[11px] text-white/40 mt-1">
|
<p class="text-[11px] text-white/40 mt-1">
|
||||||
Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
|
Defaults to mempool.space. Any Mempool-compatible instance works — you can change this
|
||||||
any time in Settings → System.
|
any time in Settings → System.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex gap-2 mt-6">
|
<div class="flex gap-2 mt-6">
|
||||||
<button
|
<button
|
||||||
@@ -58,9 +60,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseModal from './BaseModal.vue'
|
import BaseModal from './BaseModal.vue'
|
||||||
|
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
|
||||||
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
||||||
import { displayVersion } from '@/utils/version'
|
import { displayVersion } from '@/utils/version'
|
||||||
|
|
||||||
@@ -73,13 +76,16 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
||||||
confirm: [version: string]
|
confirm: [version: string, prune?: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const versions = ref<CatalogVersionInfo[]>([])
|
const versions = ref<CatalogVersionInfo[]>([])
|
||||||
const selected = ref('')
|
const selected = ref('')
|
||||||
|
const prune = ref(false)
|
||||||
|
const pruneKnown = ref(false)
|
||||||
|
const isBitcoin = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(props.appId))
|
||||||
|
|
||||||
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
||||||
function optionLabel(v: CatalogVersionInfo): string {
|
function optionLabel(v: CatalogVersionInfo): string {
|
||||||
@@ -92,12 +98,16 @@ function optionLabel(v: CatalogVersionInfo): string {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
prune.value = false
|
||||||
|
pruneKnown.value = false
|
||||||
versions.value = []
|
versions.value = []
|
||||||
selected.value = ''
|
selected.value = ''
|
||||||
try {
|
try {
|
||||||
const info = await rpcClient.getPackageVersions(props.appId)
|
const info = await rpcClient.getPackageVersions(props.appId)
|
||||||
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
||||||
// is the latest — pre-select it.
|
// is the latest — pre-select it.
|
||||||
|
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
|
||||||
|
prune.value = info.bitcoinPrune === true
|
||||||
versions.value = info.versions || []
|
versions.value = info.versions || []
|
||||||
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,7 +121,7 @@ async function load() {
|
|||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
emit('confirm', selected.value)
|
emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
@change="saveExplorer"
|
@change="saveExplorer"
|
||||||
/>
|
/>
|
||||||
<p class="text-[11px] text-white/40 mt-1">
|
<p class="text-[11px] text-white/40 mt-1">
|
||||||
Any Mempool-compatible instance works. Default: tx1138.com.
|
Any Mempool-compatible instance works. Default: mempool.space.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
|
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import InstallVersionModal from '../InstallVersionModal.vue'
|
||||||
|
const versions = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('../../api/rpc-client', () => ({ rpcClient: { getPackageVersions: versions } }))
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: { common: { install: 'Install', cancel: 'Cancel' } } } })
|
||||||
|
function modal(id = 'bitcoin-core') {
|
||||||
|
return mount(InstallVersionModal, {
|
||||||
|
props: { show: true, appId: id, app: { id, title: id } },
|
||||||
|
global: { plugins: [i18n], stubs: { BaseModal: { template: '<div><slot/><slot name="footer"/></div>' } } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
describe('Bitcoin install storage choice', () => {
|
||||||
|
it.each(['bitcoin-core', 'bitcoin-knots'])('sends chosen version and explicit pruning for %s', async id => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] })
|
||||||
|
const wrapper = modal(id)
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('select').setValue('28.4')
|
||||||
|
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['28.4', true]])
|
||||||
|
expect(wrapper.text()).toContain('automatic pruning')
|
||||||
|
expect(wrapper.text()).toContain('Mempool')
|
||||||
|
})
|
||||||
|
it('keeps automatic disk selection by default and resets on reopening', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', false]])
|
||||||
|
await wrapper.get('input').setValue(true)
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(false)
|
||||||
|
})
|
||||||
|
it('still allows choosing pruning when version lookup fails', async () => {
|
||||||
|
versions.mockRejectedValue(new Error('offline'))
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('input').setValue(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||||
|
})
|
||||||
|
it('remembers the node pruning preference when reinstalling or switching Bitcoin variants', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal('bitcoin-knots')
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input').element as HTMLInputElement).checked).toBe(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||||
|
})
|
||||||
|
it('does not turn off a saved pruning preference when its lookup fails', async () => {
|
||||||
|
versions.mockRejectedValue(new Error('offline'))
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', undefined]])
|
||||||
|
})
|
||||||
|
it('does not offer Bitcoin settings for other apps', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal('other')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('input').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -76,6 +76,24 @@ describe('useTxExplorer.openTx', () => {
|
|||||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not open an external explorer while container discovery is pending', async () => {
|
||||||
|
const external = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
let finish!: () => void
|
||||||
|
ensureFetched.mockImplementationOnce(() => new Promise<void>(resolve => {
|
||||||
|
finish = () => { fetched = true; resolve() }
|
||||||
|
}))
|
||||||
|
const { openTx, setExplorer } = useTxExplorer()
|
||||||
|
setExplorer(DEFAULT_TX_EXPLORER, true)
|
||||||
|
const opening = openTx(TX)
|
||||||
|
expect(external).not.toHaveBeenCalled()
|
||||||
|
expect(openSession).not.toHaveBeenCalled()
|
||||||
|
finish()
|
||||||
|
await opening
|
||||||
|
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||||
|
expect(external).not.toHaveBeenCalled()
|
||||||
|
external.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||||
containerState = 'not-installed'
|
containerState = 'not-installed'
|
||||||
const { openTx, pendingTx } = useTxExplorer()
|
const { openTx, pendingTx } = useTxExplorer()
|
||||||
@@ -84,3 +102,23 @@ describe('useTxExplorer.openTx', () => {
|
|||||||
expect(pendingTx.value).toBe(TX)
|
expect(pendingTx.value).toBe(TX)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('explorer default migration', () => {
|
||||||
|
beforeEach(() => { localStorage.clear(); vi.resetModules() })
|
||||||
|
it('uses mempool.space with consent for a new browser', async () => {
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||||
|
})
|
||||||
|
it.each(['https://tx1138.com', 'https://tx1138.com/', 'http://tx1138.com'])('migrates %s and resets consent', async url => {
|
||||||
|
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify({ url, acknowledged: true }))
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||||
|
expect(JSON.parse(localStorage.getItem('archipelago.tx-explorer.v1')!)).toEqual(useTxExplorer().prefs.value)
|
||||||
|
})
|
||||||
|
it('preserves a custom explorer and its consent', async () => {
|
||||||
|
const prefs = { url: 'https://my-explorer.example', acknowledged: true }
|
||||||
|
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify(prefs))
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual(prefs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { ref } from 'vue'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useContainerStore } from '@/stores/container'
|
import { useContainerStore } from '@/stores/container'
|
||||||
|
|
||||||
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
|
export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
|
||||||
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
|
export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
|
||||||
|
|
||||||
const KEY = 'archipelago.tx-explorer.v1'
|
const KEY = 'archipelago.tx-explorer.v1'
|
||||||
|
|
||||||
@@ -30,7 +30,13 @@ interface TxExplorerPrefs {
|
|||||||
function loadPrefs(): TxExplorerPrefs {
|
function loadPrefs(): TxExplorerPrefs {
|
||||||
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
||||||
try {
|
try {
|
||||||
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
const stored = { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||||
|
// Changing operators requires fresh consent, even if the old one was trusted.
|
||||||
|
if (typeof stored.url === 'string' && /^https?:\/\/tx1138\.com\/*$/i.test(stored.url.trim())) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(defaults))
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
return stored
|
||||||
} catch {
|
} catch {
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,6 +672,11 @@ async function handleInstall(app: MarketplaceApp) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
|
if (['bitcoin-core', 'bitcoin-knots'].includes(app.id)) {
|
||||||
|
installModalApp.value = app
|
||||||
|
showInstallModal.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
||||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||||
@@ -686,19 +691,19 @@ async function handleInstall(app: MarketplaceApp) {
|
|||||||
startInstall(app)
|
startInstall(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (app.source === 'local') {
|
if (app.source === 'local') {
|
||||||
installApp(app, versionOverride)
|
installApp(app, versionOverride, prune)
|
||||||
} else {
|
} else {
|
||||||
installCommunityApp(app, versionOverride)
|
installCommunityApp(app, versionOverride, prune)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInstallModalConfirm(version: string) {
|
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||||
const app = installModalApp.value
|
const app = installModalApp.value
|
||||||
showInstallModal.value = false
|
showInstallModal.value = false
|
||||||
installModalApp.value = null
|
installModalApp.value = null
|
||||||
if (app) startInstall(app, version)
|
if (app) startInstall(app, version, prune)
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewAppDetails(app: MarketplaceApp) {
|
function viewAppDetails(app: MarketplaceApp) {
|
||||||
@@ -774,25 +779,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
|||||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
queueInstall(app)
|
queueInstall(app)
|
||||||
installToast(app)
|
installToast(app)
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||||
failInstall(app, err)
|
failInstall(app, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||||
queueInstall(app)
|
queueInstall(app)
|
||||||
installToast(app)
|
installToast(app)
|
||||||
try {
|
try {
|
||||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||||
if ((app as Record<string, unknown>).containerConfig) {
|
if ((app as Record<string, unknown>).containerConfig) {
|
||||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,10 +71,11 @@
|
|||||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" />
|
||||||
<button
|
<button
|
||||||
v-if="!isInstalled"
|
v-if="!isInstalled"
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@@ -137,6 +138,7 @@
|
|||||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" />
|
||||||
|
|
||||||
<!-- Bottom: Action Buttons -->
|
<!-- Bottom: Action Buttons -->
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||||
>
|
>
|
||||||
@@ -374,6 +376,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
@@ -408,6 +411,10 @@ const bitcoinPruned = ref(false)
|
|||||||
// Hidden when an app offers only one version — install stays one-click.
|
// Hidden when an app offers only one version — install stays one-click.
|
||||||
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||||
const selectedInstallVersion = ref('')
|
const selectedInstallVersion = ref('')
|
||||||
|
const pruneOnInstall = ref(false)
|
||||||
|
const pruneSettingKnown = ref(false)
|
||||||
|
const prunePrefsLoaded = ref(false)
|
||||||
|
const isBitcoinInstall = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(app.value?.id || ''))
|
||||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
|
||||||
@@ -587,10 +594,13 @@ onMounted(() => {
|
|||||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||||
// paints from cache with no new RPC.
|
// paints from cache with no new RPC.
|
||||||
async function loadInstallVersions() {
|
async function loadInstallVersions() {
|
||||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||||
await versionsResource.refresh()
|
await versionsResource.refresh()
|
||||||
}
|
}
|
||||||
const info = versionsResource.data.value
|
const info = versionsResource.data.value
|
||||||
|
pruneSettingKnown.value = !versionsResource.error.value && typeof info?.bitcoinPrune === 'boolean'
|
||||||
|
pruneOnInstall.value = pruneSettingKnown.value && info?.bitcoinPrune === true
|
||||||
|
prunePrefsLoaded.value = true
|
||||||
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
||||||
installVersions.value = []
|
installVersions.value = []
|
||||||
return
|
return
|
||||||
@@ -701,6 +711,7 @@ async function installApp() {
|
|||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
dockerImage: app.value.dockerImage,
|
dockerImage: app.value.dockerImage,
|
||||||
version: chosenVersion,
|
version: chosenVersion,
|
||||||
|
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||||
}
|
}
|
||||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
@@ -717,6 +728,7 @@ async function installApp() {
|
|||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
url: installUrl,
|
url: installUrl,
|
||||||
version: chosenVersion,
|
version: chosenVersion,
|
||||||
|
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||||
},
|
},
|
||||||
timeout: 600000,
|
timeout: 600000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -362,6 +362,22 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.8.20-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.20-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 29, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.</p>
|
||||||
|
<p>Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.</p>
|
||||||
|
<p>Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.</p>
|
||||||
|
<p>Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.</p>
|
||||||
|
<p>LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.</p>
|
||||||
|
<p>Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.</p>
|
||||||
|
<p>Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.8.19-alpha -->
|
<!-- v1.8.19-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
+42
-11
@@ -47,13 +47,33 @@ podman_rootless() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
port_is_listening() {
|
port_is_listening() {
|
||||||
local port="$1"
|
local port="$1" protocol="${2:-tcp}" listeners
|
||||||
local protocol="${2:-tcp}"
|
|
||||||
case "$protocol" in
|
case "$protocol" in
|
||||||
tcp) ss -ltn 2>/dev/null ;;
|
tcp) listeners=$(ss -ltn 2>/dev/null) || return 2 ;;
|
||||||
udp) ss -lun 2>/dev/null ;;
|
udp) listeners=$(ss -lun 2>/dev/null) || return 2 ;;
|
||||||
*) return 1 ;;
|
*) return 2 ;;
|
||||||
esac | awk '{print $4}' | grep -Eq "(^|:)$port$"
|
esac
|
||||||
|
# Consume the whole snapshot. grep -q closed the old pipe early, so awk
|
||||||
|
# received SIGPIPE and pipefail turned a FOUND port into a failed check.
|
||||||
|
awk -v port="$port" '$4 ~ ("(^|:)" port "$") { found=1 } END { exit !found }' <<< "$listeners"
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_rootless_container() {
|
||||||
|
local name="$1" unit
|
||||||
|
unit=$(podman_rootless inspect "$name" --format '{{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}}' 2>/dev/null) || return 1
|
||||||
|
if [[ "$unit" =~ ^[a-zA-Z0-9_.@-]+\.service$ ]]; then
|
||||||
|
# Respect the managed service's shutdown timeout and --rm lifecycle.
|
||||||
|
# Raw podman restart uses a short timeout and races Quadlet cleanup.
|
||||||
|
if [ "$(id -u)" = 0 ]; then
|
||||||
|
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$(id -u archipelago)" systemctl --user restart "$unit"
|
||||||
|
else
|
||||||
|
systemctl --user restart "$unit"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
local grace=30
|
||||||
|
case "$name" in bitcoin|bitcoin-core|bitcoin-knots) grace=600 ;; lnd) grace=330 ;; esac
|
||||||
|
podman_rootless restart --time "$grace" "$name"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_fix() {
|
run_fix() {
|
||||||
@@ -573,19 +593,27 @@ fix_missing_rootless_ports() {
|
|||||||
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||||
[ -n "$bindings" ] || continue
|
[ -n "$bindings" ] || continue
|
||||||
|
|
||||||
local missing=()
|
local missing=() inspection_failed=false status
|
||||||
local container_binding host_port protocol
|
local container_binding host_port protocol
|
||||||
while read -r container_binding host_port; do
|
while read -r container_binding host_port; do
|
||||||
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
|
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
|
||||||
protocol="${container_binding##*/}"
|
protocol="${container_binding##*/}"
|
||||||
if ! port_is_listening "$host_port" "$protocol"; then
|
status=0
|
||||||
missing+=("$host_port/$protocol")
|
port_is_listening "$host_port" "$protocol" || status=$?
|
||||||
fi
|
case "$status" in
|
||||||
|
0) ;;
|
||||||
|
1) missing+=("$host_port/$protocol") ;;
|
||||||
|
*) inspection_failed=true ;;
|
||||||
|
esac
|
||||||
done <<< "$bindings"
|
done <<< "$bindings"
|
||||||
|
|
||||||
|
if $inspection_failed; then
|
||||||
|
log "WARN: cannot inspect listeners for $name; leaving it running"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
||||||
if podman_rootless restart "$name" >/dev/null 2>&1; then
|
if restart_rootless_container "$name" >/dev/null 2>&1; then
|
||||||
fixed=true
|
fixed=true
|
||||||
else
|
else
|
||||||
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
||||||
@@ -676,6 +704,9 @@ fix_archipelago_dialout() {
|
|||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Allow regression tests to source helpers without running repairs.
|
||||||
|
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0
|
||||||
|
|
||||||
# If remote host provided, run via SSH
|
# If remote host provided, run via SSH
|
||||||
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
||||||
REMOTE_HOST="$1"
|
REMOTE_HOST="$1"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise actual manifest entrypoints with a fake bitcoind; no node data touched."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
class PruningEntrypoint(unittest.TestCase):
|
||||||
|
def test_auto_and_user_choice_for_both_bitcoin_implementations(self):
|
||||||
|
for app in ('bitcoin-core', 'bitcoin-knots'):
|
||||||
|
manifest = yaml.safe_load((ROOT / 'apps' / app / 'manifest.yml').read_text())
|
||||||
|
command = manifest['app']['container']['custom_args'][0]
|
||||||
|
for disk, choice, pruned in [(500,'0',True),(999,'0',True),(1000,'0',False),(2000,'0',False),(2000,'1',True),(500,'1',True)]:
|
||||||
|
with self.subTest(app=app,disk=disk,choice=choice), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
binary = root / 'bitcoind'
|
||||||
|
binary.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n')
|
||||||
|
binary.chmod(0o755)
|
||||||
|
env = dict(os.environ, PATH=directory+':'+os.environ['PATH'], DISK_GB=str(disk), BITCOIN_PRUNE=choice,
|
||||||
|
BITCOIN_RPC_USER='test',BITCOIN_RPC_PASS='test')
|
||||||
|
# Isolate the ephemeral RPC config too.
|
||||||
|
script = command.replace('/tmp/rpc.conf',str(root/'rpc.conf'))
|
||||||
|
args = json.loads(subprocess.check_output(['sh','-c',script],env=env,text=True))
|
||||||
|
self.assertEqual('-prune=50000' in args,pruned)
|
||||||
|
self.assertEqual('-txindex=1' in args,not pruned)
|
||||||
|
self.assertIn('-server=1',args)
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# No real service/container operations: all external operations are replaced.
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "$0")/../../scripts/container-doctor.sh"
|
||||||
|
ss() {
|
||||||
|
[[ "${SS_FAIL:-0}" == 0 ]] || return 1
|
||||||
|
printf 'LISTEN 0 4096 *:8333 *:*\n'
|
||||||
|
# More than a pipe buffer, reliably reproducing grep -q / pipefail SIGPIPE.
|
||||||
|
awk 'BEGIN { for(i=0;i<20000;i++) print "LISTEN 0 4096 127.0.0.1:1234 *:*" }'
|
||||||
|
}
|
||||||
|
port_is_listening 8333
|
||||||
|
port_is_listening 1234 udp
|
||||||
|
if port_is_listening 833; then exit 1; else [[ $? == 1 ]]; fi
|
||||||
|
if SS_FAIL=1 port_is_listening 8333; then exit 1; else [[ $? == 2 ]]; fi
|
||||||
|
calls=$(mktemp)
|
||||||
|
trap 'rm -f "$calls"' EXIT
|
||||||
|
podman_rootless() {
|
||||||
|
case "$1" in
|
||||||
|
ps) echo bitcoin-core ;;
|
||||||
|
inspect)
|
||||||
|
if [[ "$*" == *PODMAN_SYSTEMD_UNIT* ]]; then echo "${TEST_UNIT:-bitcoin-core.service}";
|
||||||
|
else echo '8333/tcp 8333'; fi ;;
|
||||||
|
restart) echo "podman $*" >> "$calls" ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
id() { echo 1000; }
|
||||||
|
systemctl() { echo "systemctl $*" >> "$calls"; }
|
||||||
|
# Healthy listener and failed ss inspection must not restart anything.
|
||||||
|
fix_missing_rootless_ports && exit 1
|
||||||
|
SS_FAIL=1 fix_missing_rootless_ports && exit 1
|
||||||
|
[[ ! -s "$calls" ]]
|
||||||
|
# A real missing listener restarts its managed unit, preserving stop timeout.
|
||||||
|
ss() { echo 'LISTEN 0 4096 *:1234 *:*'; }
|
||||||
|
fix_missing_rootless_ports
|
||||||
|
grep -Fx 'systemctl --user restart bitcoin-core.service' "$calls"
|
||||||
|
: > "$calls"
|
||||||
|
TEST_UNIT='<no value>' restart_rootless_container bitcoin-core
|
||||||
|
grep -Fx 'podman restart --time 600 bitcoin-core' "$calls"
|
||||||
|
echo 'PASS: healthy/missing/failed listener checks and safe managed/unmanaged restart'
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const html = fs.readFileSync('docker/lnd-ui/index.html', 'utf8');
|
||||||
|
function extract(name) {
|
||||||
|
const start = html.indexOf(' function '+name+'(');
|
||||||
|
const end = html.indexOf('\n }', start)+10;
|
||||||
|
assert.ok(start >= 0 && end > start);
|
||||||
|
return html.slice(start, end);
|
||||||
|
}
|
||||||
|
function fixture() {
|
||||||
|
const elements = new Map();
|
||||||
|
const el = id => { if (!elements.has(id)) elements.set(id,{style:{},textContent:'',className:''}); return elements.get(id) };
|
||||||
|
const ctx = {state:{info:null,readiness:null}, document:{getElementById:el}, setText:(id,v)=>el(id).textContent=v,fmtCount:String};
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(extract('renderHeader')+'\n'+extract('validBalance'),ctx);
|
||||||
|
return {ctx,el};
|
||||||
|
}
|
||||||
|
test('missing, starting and syncing Bitcoin each show waiting and recover',()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
for (const [state,message] of [['waiting_install','Waiting for Bitcoin to be installed'],['waiting_start','Waiting for Bitcoin to start'],['waiting_sync','Waiting for Bitcoin to sync']]) {
|
||||||
|
ctx.state.readiness={state,message}; ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,message);
|
||||||
|
assert.equal(el('syncCard').style.display,'');
|
||||||
|
assert.match(el('syncSubtitle').textContent,/automatically/);
|
||||||
|
assert.equal(el('syncPercent').textContent,'');
|
||||||
|
}
|
||||||
|
ctx.state.readiness={state:'bitcoin_ready'};
|
||||||
|
ctx.state.info={synced_to_chain:true,synced_to_graph:true};
|
||||||
|
ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Running');
|
||||||
|
assert.equal(el('syncCard').style.display,'none');
|
||||||
|
ctx.state.info=null;ctx.state.readiness=null;ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Connecting to LND');
|
||||||
|
});
|
||||||
|
test('balances reject missing, malformed, fractional and negative values; real zero remains valid',()=>{
|
||||||
|
const {ctx}=fixture();
|
||||||
|
for (const v of [null,undefined,'',{},false,-1,'-1','garbage',1.5,'1.5',Infinity,Number.MAX_SAFE_INTEGER+1]) assert.equal(ctx.validBalance(v),false,String(v));
|
||||||
|
for(const v of [0,'0',123,'123']) assert.equal(ctx.validBalance(v),true,String(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed and partial balance polls retain known balances and label them stale; recovery clears flags',async()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
el('refreshIcon').classList={add(){},remove(){}};
|
||||||
|
const start=html.indexOf(' async function refreshAll()');
|
||||||
|
const end=html.indexOf('\n }',start)+10;
|
||||||
|
vm.runInContext(html.slice(start,end),ctx);
|
||||||
|
let responses={
|
||||||
|
'/v1/getinfo':{synced_to_chain:true},
|
||||||
|
'/v1/balance/blockchain':{confirmed_balance:'500',unconfirmed_balance:'0'},
|
||||||
|
'/v1/balance/channels':{local_balance:{sat:'250'}},
|
||||||
|
};
|
||||||
|
ctx.lndSafe=async(path,fallback)=>responses[path]??fallback;
|
||||||
|
ctx.renderAll=()=>{};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'500');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
responses={};await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'500');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
assert.equal(ctx.state.onchainStale,true);assert.equal(ctx.state.chanbalStale,true);
|
||||||
|
responses={'/v1/balance/blockchain':{confirmed_balance:'0'},'/v1/balance/channels':{error:'locked'}};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'0');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,true);
|
||||||
|
responses={'/v1/balance/blockchain':{confirmed_balance:'600'},'/v1/balance/channels':{local_balance:{sat:'300'}}};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'600');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'300');
|
||||||
|
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting renders promptly without querying unavailable LND endpoints, then resumes after Bitcoin sync',async()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
el('refreshIcon').classList={add(){},remove(){}};
|
||||||
|
const start=html.indexOf(' async function refreshAll()');
|
||||||
|
vm.runInContext(html.slice(start,html.indexOf('\n }',start)+10),ctx);
|
||||||
|
let readiness={state:'waiting_sync',message:'Waiting for Bitcoin to sync'};
|
||||||
|
const calls=[];let renders=0;
|
||||||
|
ctx.lndSafe=async(path,fallback)=>{calls.push(path);return path==='/archy-status'?readiness:fallback};
|
||||||
|
ctx.renderAll=()=>{renders++;ctx.renderHeader()};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.deepEqual(calls,['/archy-status']);
|
||||||
|
assert.equal(renders,1);
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Waiting for Bitcoin to sync');
|
||||||
|
assert.equal(ctx.state.onchain,undefined);
|
||||||
|
assert.equal(ctx.state.refreshing,false);
|
||||||
|
readiness={state:'bitcoin_ready',message:'Bitcoin is ready'};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.ok(calls.includes('/v1/getinfo'));
|
||||||
|
assert.ok(calls.includes('/v1/balance/blockchain'));
|
||||||
|
});
|
||||||
@@ -72,6 +72,9 @@ summary() {
|
|||||||
stage "git-diff-check" git diff --check
|
stage "git-diff-check" git diff --check
|
||||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||||
|
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
|
||||||
|
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
|
||||||
|
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs
|
||||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||||
|
|
||||||
# Validate the artifact that will actually be signed and published, not only
|
# Validate the artifact that will actually be signed and published, not only
|
||||||
@@ -168,7 +171,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
|
|||||||
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
||||||
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
||||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
||||||
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
|
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision payment_tests bitcoin_storage bitcoin_status
|
||||||
|
|
||||||
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||||
if [[ $LIVE -eq 1 ]]; then
|
if [[ $LIVE -eq 1 ]]; then
|
||||||
@@ -215,7 +218,7 @@ if [[ $LIVE -eq 1 ]]; then
|
|||||||
[ -z "$st" ] && continue
|
[ -z "$st" ] && continue
|
||||||
seen=1
|
seen=1
|
||||||
echo "LND($port) state: $st"
|
echo "LND($port) state: $st"
|
||||||
echo "$st" | grep -q "RPC_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
echo "$st" | grep -qE "UNLOCKED|RPC_ACTIVE|SERVER_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
||||||
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
|
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
|
||||||
done
|
done
|
||||||
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user