Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e01cd5de |
@@ -21,11 +21,3 @@ While its status is OPEN:
|
||||
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||
in effect across sessions until the documented acceptance criteria are met or the
|
||||
user explicitly changes it.
|
||||
|
||||
## Unit tests on a live node
|
||||
|
||||
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
|
||||
@@ -2,36 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.8.21-alpha (2026-09-30)
|
||||
|
||||
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||
|
||||
## 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.
|
||||
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||
- 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)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
|
||||
@@ -46,14 +46,13 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
// Clean up stale buckets every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000).unref()
|
||||
}, 5 * 60_000)
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -33,7 +33,6 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -57,8 +57,12 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -66,19 +70,6 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
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";
|
||||
fi;
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${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;
|
||||
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;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
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";
|
||||
fi;
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${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;
|
||||
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;
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.21-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.21-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -162,11 +162,25 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// A server-side failure is NOT "not found": reporting it as a 404
|
||||
// hid an unreadable file behind a silent, unlogged response, and a
|
||||
// buyer's client re-sends a 404 over another transport. 5xx it, and
|
||||
// say why in the journal.
|
||||
Err(e) => {
|
||||
tracing::warn!(content_id = %content_id, "content request failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller could not read this file right now. You have not been charged."}"#,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,19 +138,6 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
|
||||
@@ -22,9 +22,11 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. Returns whether
|
||||
/// the value came back: false if the seller already claimed the token (then
|
||||
/// the value is genuinely gone), so callers never tell the buyer they were
|
||||
/// refunded when they weren't.
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> bool {
|
||||
let res = match backend {
|
||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||
.await
|
||||
@@ -33,59 +35,29 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => {
|
||||
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||
format!("Refunded {sats} sats to your wallet.")
|
||||
tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||
tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||
use base64::Engine;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
serde_json::json!({
|
||||
"data": data, "data_base64": data,
|
||||
"size": bytes.len(), "size_bytes": bytes.len(),
|
||||
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String> {
|
||||
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime.starts_with("audio/") {
|
||||
"Music"
|
||||
/// What to tell the buyer about their payment after a failed sale.
|
||||
fn refund_note(reclaimed: bool) -> &'static str {
|
||||
if reclaimed {
|
||||
"Your ecash was refunded to your wallet."
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
"The seller had already claimed the payment, so it could not be refunded \
|
||||
automatically — contact the seller."
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -509,10 +481,17 @@ impl RpcHandler {
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
@@ -586,27 +565,33 @@ impl RpcHandler {
|
||||
// Surface a real reason instead of the generic sanitized error (#30):
|
||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||
// here means the peer is genuinely unreachable on both transports.
|
||||
let (response, transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let refund =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||
}));
|
||||
}
|
||||
};
|
||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
||||
fips_npub.as_deref(),
|
||||
onion,
|
||||
&path,
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Payment-Token", token_str.clone())
|
||||
.timeout(std::time::Duration::from_secs(900))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!(
|
||||
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
// Record which transport actually reached the peer (B14).
|
||||
if let Err(e) = crate::federation::record_peer_transport(
|
||||
&self.config.data_dir,
|
||||
@@ -620,17 +605,29 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// A 402 can mean mint validation, network failure, underpayment,
|
||||
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||
// Payment was rejected by the seller. Surface the most likely cause
|
||||
// per backend — for ecash both sides must share a redemption network
|
||||
// (a Cashu mint, or a Fedimint federation).
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
// The 402 body is generic, so don't assert a cause — a seller that
|
||||
// redeemed the token and then failed to deliver also lands here.
|
||||
let hint = match used_backend {
|
||||
"fedimint" => "the seller may not be in the same Fedimint federation as you",
|
||||
_ => "the seller may not accept your Cashu mint",
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("The seller could not verify the payment. {refund}")
|
||||
"error": format!(
|
||||
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
|
||||
type, or use a shared mint/federation.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -638,9 +635,10 @@ impl RpcHandler {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": format!("Peer returned an error ({status}). {refund}")
|
||||
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -687,21 +685,63 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||
),
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||
Ok(result)
|
||||
Ok(serde_json::json!({
|
||||
"data": encoded,
|
||||
"size": bytes.len(),
|
||||
"paid_sats": price_sats,
|
||||
"ecash_backend": used_backend,
|
||||
"mime_type": mime_type,
|
||||
"owned": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||
@@ -1374,7 +1414,3 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
use base64::Engine;
|
||||
for paid in [0, 1] {
|
||||
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||
assert_eq!(response["data"], response["data_base64"]);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
[0, 255, 123]
|
||||
);
|
||||
assert_eq!(response["size"], 3);
|
||||
assert_eq!(response["size_bytes"], 3);
|
||||
assert_eq!(response["paid_sats"], paid);
|
||||
assert_eq!(response["owned"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
@@ -109,50 +109,7 @@ fn checked_balances(
|
||||
))
|
||||
}
|
||||
|
||||
fn bitcoin_wait_state(
|
||||
installed: bool,
|
||||
running: bool,
|
||||
fresh: bool,
|
||||
ibd: Option<bool>,
|
||||
) -> (&'static str, &'static str) {
|
||||
if !installed {
|
||||
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||
} else if !running {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if !fresh || ibd.is_none() {
|
||||
("waiting_start", "Waiting for Bitcoin to start")
|
||||
} else if ibd == Some(true) {
|
||||
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||
} else {
|
||||
("bitcoin_ready", "Bitcoin is ready")
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
if !data.server_info.status_info.containers_scanned {
|
||||
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||
}
|
||||
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||
.iter()
|
||||
.filter_map(|id| data.package_data.get(*id))
|
||||
.collect();
|
||||
let installed = !nodes.is_empty();
|
||||
let running = nodes
|
||||
.iter()
|
||||
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let ibd = bitcoin
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool());
|
||||
let (state, message) =
|
||||
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||
serde_json::json!({"state": state, "message": message})
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||
@@ -462,44 +419,3 @@ mod tests {
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dependency_readiness_tests {
|
||||
use super::bitcoin_wait_state;
|
||||
#[test]
|
||||
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(false, false, false, None).0,
|
||||
"waiting_install"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, false, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||
"waiting_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
// Previously synced cached information must not hide a current outage.
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, None).0,
|
||||
"waiting_start"
|
||||
);
|
||||
assert_eq!(
|
||||
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||
"bitcoin_ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,36 +133,12 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||
status.ok
|
||||
&& !status.stale
|
||||
&& status.age_ms < 30_000
|
||||
&& status
|
||||
.blockchain_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("initialblockdownload"))
|
||||
.and_then(|v| v.as_bool())
|
||||
== Some(false)
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
let mut last_height: Option<u64> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||
// restart pressure during a days-long initial block download.
|
||||
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||
{
|
||||
bad_minutes = 0;
|
||||
last_height = None;
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
@@ -185,10 +161,6 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -210,12 +182,7 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(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));
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
@@ -272,31 +239,3 @@ impl RpcHandler {
|
||||
Ok((client, macaroon_hex))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watchdog_dependency_tests {
|
||||
use super::bitcoin_ready_for_lnd_watchdog;
|
||||
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||
let mut status = BitcoinNodeStatus::default();
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = true;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.stale = false;
|
||||
status.ok = false;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.ok = true;
|
||||
status.age_ms = 30_000;
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
status.age_ms = 0;
|
||||
status.blockchain_info = Some(json!({}));
|
||||
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,10 +326,6 @@ impl RpcHandler {
|
||||
// an older version pins it so install_fresh resolves that image and the
|
||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
if let Some(value) = params.get("prune") {
|
||||
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||
}
|
||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||
persist_install_version_selection(package_id, version).await;
|
||||
}
|
||||
|
||||
@@ -153,18 +153,8 @@ impl RpcHandler {
|
||||
let default = app_catalog::catalog_default_version(app_id);
|
||||
let cfg = version_config::read(app_id);
|
||||
let installed = installed_version(app_id).await;
|
||||
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||
Some(
|
||||
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||
.await?
|
||||
.prune,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"bitcoinPrune": bitcoin_prune,
|
||||
"id": app_id,
|
||||
"supportsVersions": supports_versions(app_id),
|
||||
"default": default,
|
||||
|
||||
@@ -100,11 +100,7 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim()
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("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") {
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
@@ -344,21 +340,3 @@ mod tests {
|
||||
assert!(msg.len() < 260);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_message_tests {
|
||||
#[test]
|
||||
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||
for cached in [false, true] {
|
||||
let message = super::friendly_transient_error(
|
||||
cached,
|
||||
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||
);
|
||||
assert!(message.contains("loading its block index"));
|
||||
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||
assert!(!message.contains(raw));
|
||||
}
|
||||
assert_eq!(message.contains("last known state"), cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
||||
/// should reference (`localhost/<base>:latest` for build, registry
|
||||
/// URL for pull).
|
||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
|
||||
@@ -322,13 +322,11 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Older installers and self-update create :local themselves. It
|
||||
// must receive source updates too; treating it as a permanent
|
||||
// manual override silently kept the old LND UI after an OTA.
|
||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||
if image_exists(&local_image_compat).await {
|
||||
local_image = local_image_compat.clone();
|
||||
return Ok(local_image_compat);
|
||||
}
|
||||
// Reuse either local tag only when the build context has NOT
|
||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||
// changed since it was built. Without this staleness check an
|
||||
// already-present image is reused forever, so edits to the baked-in
|
||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||
@@ -851,43 +849,20 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||
if !matches_known_shape {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||
for dir in spec.build_dir_candidates {
|
||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||
// Conservative on any timeout/error inside: reuse the cache.
|
||||
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||
["latest", "local"]
|
||||
.iter()
|
||||
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||
.find(|image| build_unit(spec, image).render() == unit)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||
for tag in ["local", "latest"] {
|
||||
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||
let unit = build_unit(spec, &image).render();
|
||||
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||
}
|
||||
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||
assert_eq!(
|
||||
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! starting the container with `--config /data/.filebrowser.json`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::update::host_sudo;
|
||||
@@ -117,197 +117,6 @@ fn shell_quote(s: &str) -> String {
|
||||
s.replace('\'', "'\\''")
|
||||
}
|
||||
|
||||
/// Save a complete purchase without overwriting any existing directory entry.
|
||||
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
|
||||
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
save_new_file_with(dir, name, bytes, write_via_userns).await
|
||||
}
|
||||
|
||||
fn validate_filename(name: &str) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
&& name.len() <= 255,
|
||||
"Invalid purchased filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_new_file_with<F, Fut>(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
validate_filename(name)?;
|
||||
// Never follow a user-created destination directory symlink.
|
||||
match fs::symlink_metadata(dir).await {
|
||||
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
save_after_direct_result(
|
||||
write_direct(dir, name, bytes).await,
|
||||
dir,
|
||||
name,
|
||||
bytes,
|
||||
fallback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_after_direct_result<F, Fut>(
|
||||
result: std::io::Result<PathBuf>,
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
fallback: F,
|
||||
) -> Result<PathBuf>
|
||||
where
|
||||
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<PathBuf>>,
|
||||
{
|
||||
match result {
|
||||
Ok(path) => Ok(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
|
||||
.await
|
||||
.context("Saving purchase in Files user namespace")
|
||||
}
|
||||
Err(error) => Err(error).context("Saving purchase in Files"),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered_name(name: &str, attempt: usize) -> String {
|
||||
if attempt == 1 {
|
||||
return name.to_owned();
|
||||
}
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
|
||||
_ => format!("{name} ({attempt})"),
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFile(PathBuf);
|
||||
impl Drop for PendingFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
fs::create_dir_all(dir).await?;
|
||||
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temp_path)
|
||||
.await?;
|
||||
let temp = PendingFile(temp_path);
|
||||
file.write_all(bytes).await?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o644))
|
||||
.await?;
|
||||
file.sync_all().await?;
|
||||
for attempt in 1..=100 {
|
||||
let target = dir.join(numbered_name(name, attempt));
|
||||
match fs::hard_link(&temp.0, &target).await {
|
||||
Ok(()) => return Ok(target),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Too many existing copies; purchase cache retained",
|
||||
))
|
||||
}
|
||||
|
||||
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
|
||||
// collisions; ln -T refuses files, symlinks and directories, including races.
|
||||
const WRITE_VIA_USERNS: &str = r#"set -eu
|
||||
dir=$1
|
||||
name=$2
|
||||
expected=$3
|
||||
[ ! -L "$dir" ] || exit 1
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p -- "$dir"
|
||||
chown --reference="$(dirname -- "$dir")" -- "$dir"
|
||||
fi
|
||||
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp"
|
||||
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
|
||||
chown --reference="$dir" -- "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
sync -f -- "$tmp"
|
||||
stem=$name
|
||||
ext=
|
||||
case "$name" in
|
||||
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
|
||||
esac
|
||||
n=1
|
||||
while [ "$n" -le 100 ]; do
|
||||
candidate=$name
|
||||
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
|
||||
dst="$dir/$candidate"
|
||||
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
|
||||
printf '%s' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
# A conflict may be a dangling symlink; never follow it or overwrite it.
|
||||
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
|
||||
n=$((n + 1))
|
||||
done
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(&dir)
|
||||
.arg(&name)
|
||||
.arg(bytes.len().to_string())
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Starting Files namespace writer")?;
|
||||
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
|
||||
let operation = async {
|
||||
let fed = stdin.write_all(&bytes).await;
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().await?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Files namespace writer failed: {}",
|
||||
output.status
|
||||
);
|
||||
fed.context("Sending purchase bytes to Files")?;
|
||||
let chosen =
|
||||
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
|
||||
validate_filename(&chosen)?;
|
||||
anyhow::ensure!(
|
||||
(1..=100).any(|n| numbered_name(&name, n) == chosen),
|
||||
"Files writer returned an unexpected name"
|
||||
);
|
||||
Ok(dir.join(chosen))
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
|
||||
.await
|
||||
.context("Files namespace writer timed out")?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -343,231 +152,3 @@ mod tests {
|
||||
assert_eq!(second, EnsureOutcome::Unchanged);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purchase_write_tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
os::unix::fs::{symlink, PermissionsExt},
|
||||
};
|
||||
|
||||
fn no_temps(dir: &Path) {
|
||||
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".archy-saving")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(dir.path().join("song.mp3"), b"original")
|
||||
.await
|
||||
.unwrap();
|
||||
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
|
||||
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
|
||||
assert_eq!(fs::read(target).await.unwrap(), b"new");
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("song.mp3")).await.unwrap(),
|
||||
b"original"
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_saves_publish_unique_complete_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..24u8 {
|
||||
let dir = dir.path().to_owned();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let bytes = vec![n; 32768];
|
||||
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), bytes);
|
||||
path
|
||||
}));
|
||||
}
|
||||
let mut paths = HashSet::new();
|
||||
for task in tasks {
|
||||
assert!(paths.insert(task.await.unwrap()));
|
||||
}
|
||||
assert_eq!(paths.len(), 24);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "name (3)");
|
||||
assert!(dir.path().join("name").is_dir());
|
||||
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_names_and_symlink_destination_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
] {
|
||||
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
|
||||
}
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
symlink(outside.path(), dir.path().join("Music")).unwrap();
|
||||
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for n in 1..=100 {
|
||||
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
|
||||
for n in 1..=100 {
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join(numbered_name("a.txt", n)))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"keep"
|
||||
);
|
||||
}
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_fallback_is_exercised_without_skipping_as_root() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|dir, name, bytes| async move {
|
||||
assert_eq!(bytes, b"abc");
|
||||
Ok(dir.join(name))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, dir.path().join("a"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::PermissionDenied.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { anyhow::bail!("namespace unavailable") }
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("namespace"));
|
||||
assert!(save_after_direct_result(
|
||||
Err(std::io::ErrorKind::StorageFull.into()),
|
||||
dir.path(),
|
||||
"a",
|
||||
b"abc",
|
||||
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
async fn run_script(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
expected: usize,
|
||||
) -> std::process::Output {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut child = tokio::process::Command::new("sh")
|
||||
.args(["-c", WRITE_VIA_USERNS, "sh"])
|
||||
.arg(dir)
|
||||
.arg(name)
|
||||
.arg(expected.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(bytes).await.unwrap();
|
||||
drop(input);
|
||||
child.wait_with_output().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("Music");
|
||||
let name = "song ' $() ; #.mp3";
|
||||
for n in 1..=2 {
|
||||
let output = run_script(&folder, name, b"abc", 3).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let chosen = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(chosen, numbered_name(name, n));
|
||||
let path = folder.join(chosen);
|
||||
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
|
||||
assert_eq!(
|
||||
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
|
||||
0o644
|
||||
);
|
||||
}
|
||||
no_temps(&folder);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
|
||||
assert!(!output.status.success());
|
||||
assert!(!dir.path().join("never.bin").exists());
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn namespace_script_does_not_link_inside_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("name")).await.unwrap();
|
||||
symlink("missing", dir.path().join("name (2)")).unwrap();
|
||||
let output = run_script(dir.path(), "name", b"abc", 3).await;
|
||||
assert!(output.status.success());
|
||||
assert_eq!(output.stdout, b"name (3)");
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
|
||||
0
|
||||
);
|
||||
no_temps(dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_keep_extensions_and_dotfiles() {
|
||||
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
|
||||
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
|
||||
assert_eq!(numbered_name("README", 2), "README (2)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,74 +89,18 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
||||
Ok(EnsureOutcome::Written)
|
||||
}
|
||||
|
||||
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||
async fn bitcoin_rpc_ready() -> bool {
|
||||
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let client = match reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(user, Some(password))
|
||||
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||
.send().await;
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||
value.get("error").is_none_or(|e| e.is_null())
|
||||
&& value
|
||||
.pointer("/result/blocks")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some()
|
||||
&& value
|
||||
.pointer("/result/initialblockdownload")
|
||||
.and_then(|v| v.as_bool())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||
if file_exists_as_root(wallet_db).await {
|
||||
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||
// unlocked. State RPC stays available during that normal startup phase.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(());
|
||||
}
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||
return Ok(());
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !bitcoin_rpc_ready().await {
|
||||
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||
return Ok(());
|
||||
}
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
@@ -314,9 +258,6 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||
// a wrong password still exits on the first pass via `all_rejected`.
|
||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||
return Ok(true);
|
||||
}
|
||||
let mut all_rejected = true;
|
||||
for pw in &candidates {
|
||||
match try_unlock_once(&client, pw).await {
|
||||
@@ -353,10 +294,6 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||
}
|
||||
|
||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||
@@ -1152,44 +1089,3 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bitcoin_readiness_tests {
|
||||
use super::bitcoin_readiness_response;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||
json!({"result":{"blocks":null}}),
|
||||
] {
|
||||
assert!(!bitcoin_readiness_response(&response));
|
||||
}
|
||||
// Initial sync is supported by LND. Loading the database is not.
|
||||
for ibd in [true, false] {
|
||||
assert!(bitcoin_readiness_response(
|
||||
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod syncing_wallet_state_tests {
|
||||
#[test]
|
||||
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||
assert!(super::wallet_is_unlocked(Some(state)));
|
||||
}
|
||||
for state in [
|
||||
None,
|
||||
Some("LOCKED"),
|
||||
Some("NON_EXISTING"),
|
||||
Some("WAITING_TO_START"),
|
||||
Some("unknown"),
|
||||
] {
|
||||
assert!(!super::wallet_is_unlocked(state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,10 +798,6 @@ fn host_port_bindings_drifted(
|
||||
}
|
||||
|
||||
async fn ensure_user_podman_socket() -> Result<()> {
|
||||
// Unit tests inject a runtime; they must not restart the host Podman API.
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||
if podman_socket_accepts_connections(socket_path).await {
|
||||
return Ok(());
|
||||
@@ -1174,21 +1170,15 @@ impl ReconcileReport {
|
||||
fn cascade_pairs_for_report<'r>(
|
||||
report: &'r ReconcileReport,
|
||||
user_stopped: &std::collections::HashSet<String>,
|
||||
changed_backends: &HashSet<String>,
|
||||
) -> Vec<(&'r str, &'static str)> {
|
||||
let mut pairs = Vec::new();
|
||||
for (backend, action) in &report.actions {
|
||||
if !matches!(
|
||||
action,
|
||||
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||
ReconcileAction::Installed | ReconcileAction::Started
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// A successful systemctl start can be a no-op after a transient
|
||||
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||
if !changed_backends.contains(backend) {
|
||||
continue;
|
||||
}
|
||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||
let dep_untouched = report
|
||||
.actions
|
||||
@@ -1202,25 +1192,6 @@ fn cascade_pairs_for_report<'r>(
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||
/// changed start timestamp qualifies. A failed observation never does.
|
||||
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(before) = before else {
|
||||
return true;
|
||||
};
|
||||
if before.id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if before.id != after.id || before.state != ContainerState::Running {
|
||||
return true;
|
||||
}
|
||||
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AdoptionReport {
|
||||
pub adopted: Vec<String>,
|
||||
@@ -1934,40 +1905,14 @@ impl ProdContainerOrchestrator {
|
||||
_ => 2,
|
||||
});
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let listed_containers = self.runtime.list_containers().await.ok();
|
||||
let present_containers: HashSet<String> = listed_containers
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
.list_containers()
|
||||
.await
|
||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
||||
.unwrap_or_default();
|
||||
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||
// fail under load while systemd still has a healthy running backend.
|
||||
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||
for lm in &manifests {
|
||||
let id = &lm.manifest.app.id;
|
||||
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) => {
|
||||
backend_before.insert(id.clone(), Some(status));
|
||||
}
|
||||
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||
backend_before.insert(id.clone(), None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(backend = %id, error = %err,
|
||||
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut report = ReconcileReport::default();
|
||||
let disk_gb = self.disk_gb().await;
|
||||
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||
.await
|
||||
.map(|settings| settings.prune)
|
||||
.unwrap_or(true);
|
||||
// Register every candidate before the (sequential, possibly slow)
|
||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||
@@ -2007,7 +1952,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
if mode == ReconcileMode::ExistingOnly
|
||||
&& requires_archival_bitcoin(&app_id)
|
||||
&& bitcoin_pruned
|
||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||
{
|
||||
report.record(
|
||||
&app_id,
|
||||
@@ -2142,20 +2087,7 @@ impl ProdContainerOrchestrator {
|
||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||
// address behind a running dependent's back — §C "restart lnd after
|
||||
// ANY bitcoin recreate".
|
||||
let mut changed_backends = HashSet::new();
|
||||
for (backend, before) in &backend_before {
|
||||
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||
if backend_instance_changed(before.as_ref(), &after) {
|
||||
changed_backends.insert(backend.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// A user stop during a slow reconcile pass still takes precedence.
|
||||
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||
// across the restart; skip when a worker is mid-sequence.
|
||||
let lock = crate::app_ops::op_lock(dep);
|
||||
@@ -3294,9 +3226,6 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if cfg!(test) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3791,17 +3720,6 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||
if storage.prune {
|
||||
anyhow::ensure!(
|
||||
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||
);
|
||||
env.push("BITCOIN_PRUNE=1".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
@@ -6155,48 +6073,6 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let mut orch = orch_with(rt).await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
orch.set_data_dir(dir.path().to_path_buf());
|
||||
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
// No preference: existing containers need no new environment flag.
|
||||
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||
assert!(!old
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(orch
|
||||
.resolve_dynamic_env(&mut old)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("cannot honor"));
|
||||
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||
current
|
||||
.app
|
||||
.container
|
||||
.custom_args
|
||||
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||
assert!(current
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6468,67 +6344,6 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_cascade_requires_observed_instance_change() {
|
||||
let running = ContainerStatus {
|
||||
id: "container-1".into(),
|
||||
name: "bitcoin-core".into(),
|
||||
state: ContainerState::Running,
|
||||
started_at: Some("start-1".into()),
|
||||
health: None,
|
||||
exit_code: None,
|
||||
image: "bitcoin:1".into(),
|
||||
created: "created-1".into(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
};
|
||||
assert!(!backend_instance_changed(Some(&running), &running));
|
||||
assert!(backend_instance_changed(None, &running));
|
||||
let mut before = running.clone();
|
||||
before.state = ContainerState::Exited;
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.id = "old-container".into();
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before = running.clone();
|
||||
before.started_at = Some("earlier-start".into());
|
||||
assert!(backend_instance_changed(Some(&before), &running));
|
||||
before.started_at = None;
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
before.id.clear();
|
||||
assert!(!backend_instance_changed(Some(&before), &running));
|
||||
let mut after = running.clone();
|
||||
after.state = ContainerState::Exited;
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
after = running.clone();
|
||||
after.id.clear();
|
||||
assert!(!backend_instance_changed(None, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||
let none = HashSet::new();
|
||||
let mut report = ReconcileReport {
|
||||
actions: vec![
|
||||
("bitcoin-core".into(), ReconcileAction::Started),
|
||||
("lnd".into(), ReconcileAction::NoOp),
|
||||
],
|
||||
failures: vec![],
|
||||
};
|
||||
// systemctl start of an already active unit does not move its address.
|
||||
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||
let changed = ["bitcoin-core".into()].into();
|
||||
report.actions[0].1 = ReconcileAction::NoOp;
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&report, &none, &changed),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||
use std::collections::HashSet;
|
||||
@@ -6540,7 +6355,6 @@ app:
|
||||
failures: vec![],
|
||||
};
|
||||
let none = HashSet::new();
|
||||
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||
|
||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||
let r = report(vec![
|
||||
@@ -6548,7 +6362,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
vec![("bitcoin-knots", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6558,7 +6372,7 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none, &changed),
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
|
||||
@@ -6567,7 +6381,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::NoOp),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
|
||||
// Dependent itself (re)started this pass → it already resolved the
|
||||
// fresh address; no cascade.
|
||||
@@ -6575,7 +6389,7 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::Started),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
|
||||
// User-stopped dependent is never bounced.
|
||||
let r = report(vec![
|
||||
@@ -6583,14 +6397,14 @@ app:
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
||||
|
||||
// Non-backend recreates don't cascade anything.
|
||||
let r = report(vec![
|
||||
("grafana", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -184,7 +184,6 @@ pub struct QuadletUnit {
|
||||
pub no_new_privileges: bool,
|
||||
pub cpu_quota: Option<u32>,
|
||||
pub restart_policy: RestartPolicy,
|
||||
pub stop_grace_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl QuadletUnit {
|
||||
@@ -217,10 +216,6 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "[Container]");
|
||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||
let _ = writeln!(s, "Image={}", self.image);
|
||||
let grace = self
|
||||
.stop_grace_secs
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||
let _ = writeln!(s, "StopTimeout={grace}");
|
||||
// Pull=never: companions are pre-pulled or built. A missing image
|
||||
// must surface as a unit start failure, not a silent retry storm.
|
||||
let _ = writeln!(s, "Pull=never");
|
||||
@@ -355,15 +350,6 @@ impl QuadletUnit {
|
||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||
// not a systemd start gate.
|
||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||
// existing container may still carry Podman's old 10-second default;
|
||||
// StopTimeout alone only protects containers created after migration.
|
||||
let _ = writeln!(s, "ExecStop=");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||
);
|
||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||
// from saturating the journal. Companions: Always. Backends:
|
||||
// OnFailure (clean stops stay stopped).
|
||||
@@ -539,9 +525,6 @@ impl QuadletUnit {
|
||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||
restart_policy: RestartPolicy::Always,
|
||||
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||
manifest, name,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,13 +676,6 @@ pub async fn unit_exists(name: &str) -> bool {
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
return Ok(TEST_UNITS
|
||||
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||
.clone());
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||
@@ -809,11 +785,7 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||
match systemctl_user_status(&["stop", service], timeout).await {
|
||||
Ok(status) if status.success() => Ok(()),
|
||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||
@@ -834,29 +806,10 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// The command waiter must outlive both the container grace and systemd's
|
||||
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||
.max(QUADLET_STOP_TIMEOUT)
|
||||
}
|
||||
|
||||
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||
directive_values(unit_body, "StopTimeout=")
|
||||
.last()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||
}
|
||||
|
||||
async fn systemctl_user_status(
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
return Ok(std::process::ExitStatus::from_raw(0));
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -903,10 +856,6 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
||||
}
|
||||
|
||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::bail!("Unit tests have no real user service manager");
|
||||
}
|
||||
let mut cmd = Command::new("systemctl");
|
||||
cmd.arg("--user").args(args);
|
||||
cmd.kill_on_drop(true);
|
||||
@@ -974,10 +923,6 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||
/// that systemd no longer knows about.
|
||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
let svc = format!("{unit_name}.service");
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let timeout = stop_wait_timeout(unit_name, &body);
|
||||
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||
// rootless podman a generated unit can wedge in "deactivating" while
|
||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||
@@ -985,12 +930,13 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||
// blocks reinstall). If the graceful stop times out, escalate to
|
||||
// SIGKILL + reset-failed so teardown always proceeds.
|
||||
if systemctl_user_status(&["stop", &svc], timeout)
|
||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = kill_and_reset_service(&svc).await;
|
||||
}
|
||||
let path = dir.join(format!("{unit_name}.container"));
|
||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
@@ -1003,9 +949,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||
// exists to avoid.
|
||||
let _ = tokio::time::timeout(
|
||||
timeout,
|
||||
QUADLET_STOP_TIMEOUT,
|
||||
Command::new("podman")
|
||||
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||
.args(["rm", "-f", unit_name])
|
||||
.status(),
|
||||
)
|
||||
.await;
|
||||
@@ -1014,9 +960,6 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||
|
||||
/// Is the quadlet-generated service currently active?
|
||||
pub async fn is_active(service: &str) -> bool {
|
||||
if cfg!(test) {
|
||||
return false;
|
||||
}
|
||||
Command::new("systemctl")
|
||||
.args(["--user", "is-active", "--quiet", service])
|
||||
.status()
|
||||
@@ -1030,118 +973,6 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||
for (name, grace) in [
|
||||
("bitcoin-core", 600),
|
||||
("bitcoin-knots", 600),
|
||||
("lnd", 330),
|
||||
("electrumx", 300),
|
||||
("other", 30),
|
||||
] {
|
||||
let unit = QuadletUnit {
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let body = unit.render();
|
||||
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||
assert_eq!(
|
||||
stop_wait_timeout(name, &body),
|
||||
Duration::from_secs(grace + 30)
|
||||
);
|
||||
// Legacy units have no StopTimeout directive yet.
|
||||
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||
let manifest: AppManifest = serde_yaml::from_str(
|
||||
r#"
|
||||
app:
|
||||
id: custom-db
|
||||
name: Custom database
|
||||
version: 1.0.0
|
||||
stop_grace_secs: 900
|
||||
container:
|
||||
image: example/db:1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||
assert_eq!(
|
||||
stop_wait_timeout("custom-db", &unit.render()),
|
||||
Duration::from_secs(930)
|
||||
);
|
||||
assert_eq!(
|
||||
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||
let unit = sample_unit();
|
||||
let new = unit.render();
|
||||
let old = new
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line.starts_with("StopTimeout=")
|
||||
&& !line.starts_with("TimeoutStopSec=")
|
||||
&& !line.starts_with("ExecStop=")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(!exec_changed(&old, &new));
|
||||
assert!(!publish_ports_changed(&old, &new));
|
||||
assert!(!network_aliases_changed(&old, &new));
|
||||
assert!(!health_cmd_changed(&old, &new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||
if !generator.exists() {
|
||||
eprintln!(
|
||||
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let unit = QuadletUnit {
|
||||
name: "grace-test".into(),
|
||||
image: "localhost/test:latest".into(),
|
||||
stop_grace_secs: Some(600),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||
let output = std::process::Command::new(generator)
|
||||
.args(["--user", "--dryrun"])
|
||||
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||
+ &String::from_utf8_lossy(&output.stderr);
|
||||
let stop = generated
|
||||
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||
.unwrap();
|
||||
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||
assert!(
|
||||
stop < remove,
|
||||
"Legacy container must stop gracefully before removal"
|
||||
);
|
||||
assert!(generated.contains("--stop-timeout 600"));
|
||||
assert!(generated.contains("TimeoutStopSec=615"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_emits_secret_env_by_reference_never_value() {
|
||||
let u = QuadletUnit {
|
||||
|
||||
@@ -5,13 +5,110 @@
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CATALOG_FILE: &str = "content/catalog.json";
|
||||
const CONTENT_DIR: &str = "content/files";
|
||||
|
||||
/// How long a redeemed payment token keeps entitling its buyer to re-fetch the
|
||||
/// item it paid for. Long enough to cover a buyer's transport fallback (FIPS →
|
||||
/// Tor re-sends the same request, token included) and a manual retry; short
|
||||
/// enough that the ledger stays tiny and a leaked token isn't a standing pass.
|
||||
const REDEMPTION_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
/// One ledger slot per payment token (keyed by its SHA-256 — the raw bearer
|
||||
/// token is never held here). The inner mutex serialises verification of the
|
||||
/// same token; its value is the content id the token was redeemed for.
|
||||
struct RedemptionSlot {
|
||||
created_at: Instant,
|
||||
redeemed_for: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
static REDEMPTIONS: LazyLock<Mutex<HashMap<String, RedemptionSlot>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Decide whether `token` pays for `content_id`, redeeming it at most once.
|
||||
///
|
||||
/// Payment tokens are single-use: verifying one swaps its proofs at the mint,
|
||||
/// so a second verification of the same token always fails "already spent".
|
||||
/// A buyer's HTTP client can legitimately send the same request twice — its
|
||||
/// FIPS attempt gets a 404/5xx and it re-sends over Tor — and without this
|
||||
/// the seller redeemed the token on the first request, then answered the
|
||||
/// retry `402 Payment required`: money taken, file never delivered.
|
||||
///
|
||||
/// So the first verification that succeeds is remembered (per token, per
|
||||
/// item, for [`REDEMPTION_TTL`]) and later requests for the same item present
|
||||
/// the same token are authorised without touching the mint again. Concurrent
|
||||
/// requests with one token queue on the slot so only one runs `verify`.
|
||||
/// A failed verification is not remembered — the slot is dropped so garbage
|
||||
/// tokens can't accumulate and a legitimate retry gets a fresh attempt.
|
||||
async fn authorize_payment<F, Fut>(token: &str, content_id: &str, verify: F) -> bool
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = bool>,
|
||||
{
|
||||
let key = hex::encode(Sha256::digest(token.as_bytes()));
|
||||
let redeemed_for = {
|
||||
let mut ledger = REDEMPTIONS.lock().await;
|
||||
ledger.retain(|_, s| s.created_at.elapsed() < REDEMPTION_TTL);
|
||||
ledger
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| RedemptionSlot {
|
||||
created_at: Instant::now(),
|
||||
redeemed_for: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
.redeemed_for
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut state = redeemed_for.lock().await;
|
||||
if state.as_deref() == Some(content_id) {
|
||||
debug!(
|
||||
"Payment token already redeemed for '{}' — serving without re-verifying",
|
||||
content_id
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if verify().await {
|
||||
*state = Some(content_id.to_string());
|
||||
return true;
|
||||
}
|
||||
// Keep a slot that already holds a redemption (this token paid for a
|
||||
// different item); drop one that never verified anything.
|
||||
let never_redeemed = state.is_none();
|
||||
drop(state);
|
||||
if never_redeemed {
|
||||
REDEMPTIONS.lock().await.remove(&key);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Confirm the node can actually hand the file over: it exists and this
|
||||
/// process may read it. Must run BEFORE a payment is redeemed — a paid buyer
|
||||
/// who then hits a read error has lost their token for nothing (2026-09-18:
|
||||
/// filebrowser-owned `0640` files the node's service user couldn't open; the
|
||||
/// stat calls passed, `fs::read` failed after the swap, the buyer got a 404).
|
||||
/// Reading a byte (not just opening) also rejects a directory.
|
||||
async fn ensure_servable(file_path: &Path) -> Result<()> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = fs::File::open(file_path)
|
||||
.await
|
||||
.with_context(|| format!("content file {} is not readable", file_path.display()))?;
|
||||
let mut probe = [0u8; 1];
|
||||
file.read(&mut probe)
|
||||
.await
|
||||
.with_context(|| format!("content file {} cannot be read", file_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContentItem {
|
||||
pub id: String,
|
||||
@@ -296,6 +393,9 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the file can be served BEFORE any payment is redeemed. The gate
|
||||
// below swaps the buyer's token at the mint; failing to hand over the file
|
||||
// after that takes their money and delivers nothing.
|
||||
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
|
||||
@@ -313,6 +413,10 @@ pub async fn serve_content(
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
if let Err(e) = ensure_servable(&file_path).await {
|
||||
warn!(content_id = %id, "cannot serve content (payment not taken): {e:#}");
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if !owner_session {
|
||||
@@ -325,13 +429,12 @@ pub async fn serve_content(
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
let method = if token.trim().starts_with("cashu") {
|
||||
"ecash"
|
||||
} else {
|
||||
"fedimint"
|
||||
};
|
||||
if method_accepted(&item.access, method)
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& authorize_payment(token, id, || {
|
||||
verify_payment_token(data_dir, token, *price_sats)
|
||||
})
|
||||
.await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -577,7 +680,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
||||
}
|
||||
|
||||
/// Verify a payment token covers the required amount.
|
||||
/// Accepts real Cashu tokens and Fedimint notes.
|
||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||
@@ -729,3 +832,182 @@ mod prune_missing_content_tests {
|
||||
assert_eq!(reloaded.items[0].id, "present-item");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_delivery_tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A verifier that counts how often it actually runs.
|
||||
fn counting(
|
||||
calls: &Arc<AtomicUsize>,
|
||||
result: bool,
|
||||
) -> impl FnOnce() -> std::future::Ready<bool> {
|
||||
let calls = calls.clone();
|
||||
move || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_token_is_served_without_redeeming_twice() {
|
||||
// The 2026-09-18 incident: the buyer's client re-sent the same request
|
||||
// over Tor after the seller had already redeemed the token, and the
|
||||
// second verification ("already spent") turned into a 402.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "mint must be hit once");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_requests_with_one_token_redeem_once() {
|
||||
// FIPS attempt still in flight when the Tor fallback arrives.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let slow = |calls: Arc<AtomicUsize>| {
|
||||
move || async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
true
|
||||
}
|
||||
};
|
||||
let (a, b) = tokio::join!(
|
||||
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
|
||||
);
|
||||
assert!(a && b, "both requests must be served");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_verification_is_not_remembered() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(!authorize_payment("tok-bad", "item-a", counting(&calls, false)).await);
|
||||
// A retry gets a fresh attempt — and can succeed (e.g. mint was down).
|
||||
assert!(authorize_payment("tok-bad", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
let ledger = REDEMPTIONS.lock().await;
|
||||
let key = hex::encode(Sha256::digest(b"tok-bad"));
|
||||
assert!(ledger.contains_key(&key), "successful redemption is kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_verification_leaves_no_ledger_entry() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(!authorize_payment("tok-garbage", "item-a", counting(&calls, false)).await);
|
||||
let key = hex::encode(Sha256::digest(b"tok-garbage"));
|
||||
assert!(
|
||||
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||
"garbage tokens must not accumulate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_redeemed_for_one_item_does_not_unlock_another() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||
// Item B is verified on its own merits (the real mint would say
|
||||
// "already spent"); it must not ride on item A's redemption…
|
||||
assert!(!authorize_payment("tok-cross", "item-b", counting(&calls, false)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
// …and failing there must not revoke what the token already paid for.
|
||||
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
fn paid_item(id: &str, filename: &str) -> ContentItem {
|
||||
ContentItem {
|
||||
id: id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
size_bytes: 4,
|
||||
description: String::new(),
|
||||
access: AccessControl::Paid {
|
||||
price_sats: 10,
|
||||
accepted: vec!["ecash".to_string()],
|
||||
},
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unreadable_paid_file_errors_before_any_payment_is_redeemed() {
|
||||
// Filebrowser-owned 0640 files the node's service user can't read:
|
||||
// stat() succeeds, read() fails. That must surface as an error BEFORE
|
||||
// the token is verified — never after the swap has taken the money.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path();
|
||||
save_catalog(
|
||||
data_dir,
|
||||
&ContentCatalog {
|
||||
items: vec![paid_item("locked", "locked.mp3")],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let files = data_dir.join("content").join("files");
|
||||
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||
let file = files.join("locked.mp3");
|
||||
tokio::fs::write(&file, b"data").await.unwrap();
|
||||
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
if std::fs::File::open(&file).is_ok() {
|
||||
return; // running as root: permissions can't be enforced here
|
||||
}
|
||||
|
||||
// A token that would fail verification if it were reached: getting
|
||||
// PaymentRequired here would mean the gate ran before the file check.
|
||||
let result = serve_content(
|
||||
data_dir,
|
||||
"locked",
|
||||
Some("cashuBnot-a-real-token"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"unreadable file must be a server error, not 402/404"
|
||||
);
|
||||
let key = hex::encode(Sha256::digest(b"cashuBnot-a-real-token"));
|
||||
assert!(
|
||||
!REDEMPTIONS.lock().await.contains_key(&key),
|
||||
"no redemption may be attempted for an unservable file"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readable_paid_file_with_bad_token_still_requires_payment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = dir.path();
|
||||
save_catalog(
|
||||
data_dir,
|
||||
&ContentCatalog {
|
||||
items: vec![paid_item("ok", "ok.mp3")],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let files = data_dir.join("content").join("files");
|
||||
tokio::fs::create_dir_all(&files).await.unwrap();
|
||||
tokio::fs::write(files.join("ok.mp3"), b"data").await.unwrap();
|
||||
|
||||
let result = serve_content(
|
||||
data_dir,
|
||||
"ok",
|
||||
Some("cashuBnot-a-real-token-2"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(10)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! 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,5 +7,3 @@
|
||||
pub mod ai_permissions;
|
||||
pub mod session_policy;
|
||||
pub mod transport;
|
||||
|
||||
pub mod bitcoin_storage;
|
||||
|
||||
@@ -1481,21 +1481,6 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
/// service unit that inherits systemd's default protections (i.e. none
|
||||
/// of ours), escaping the namespace.
|
||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
@@ -1520,21 +1505,6 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
anyhow::ensure!(
|
||||
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||
);
|
||||
let (program, args) = args.split_first().context("Missing test command")?;
|
||||
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||
return tokio::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.context("isolated test command failed");
|
||||
}
|
||||
|
||||
let mut full: Vec<&str> = vec![
|
||||
"systemd-run",
|
||||
"--wait",
|
||||
|
||||
@@ -775,9 +775,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||
all_target.extend(&change_denoms);
|
||||
|
||||
let swap_result = client
|
||||
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||
.await?;
|
||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
||||
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
@@ -1194,11 +1192,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Verify all mints in the token are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||
{
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||
}
|
||||
}
|
||||
@@ -1223,7 +1217,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||
last_reason = Some(e.to_string());
|
||||
// Continue with other mints if any
|
||||
@@ -1304,10 +1298,22 @@ pub async fn verify_and_receive_payment(
|
||||
token_str: &str,
|
||||
required_sats: u64,
|
||||
) -> Result<u64> {
|
||||
let token_str = token_str.trim();
|
||||
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||
// Handle legacy tokens
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||
let amount = token_str
|
||||
.split('_')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
if amount < required_sats {
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
amount,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
||||
return Ok(received);
|
||||
}
|
||||
|
||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||
@@ -1330,45 +1336,52 @@ pub async fn verify_and_receive_payment(
|
||||
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
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"))?;
|
||||
let total = token.total_amount();
|
||||
|
||||
if total < required_sats {
|
||||
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||
}
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
if !accepted
|
||||
.mints
|
||||
.iter()
|
||||
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||
{
|
||||
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||
anyhow::bail!(
|
||||
"Insufficient payment: {} sats, need {} sats",
|
||||
total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let result = client
|
||||
.swap_at_least(
|
||||
&entry.proofs,
|
||||
&amount_to_denominations(total),
|
||||
required_sats,
|
||||
)
|
||||
.await?;
|
||||
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
// Load after the network call, so an unrelated wallet update during the
|
||||
// swap is not overwritten with a pre-swap snapshot.
|
||||
// Verify mints are accepted
|
||||
let accepted = load_accepted_mints(data_dir).await?;
|
||||
for mint_url in token.mint_urls() {
|
||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
||||
}
|
||||
}
|
||||
|
||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
match client.swap(&entry.proofs, &target_amounts).await {
|
||||
Ok(result) => {
|
||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
||||
received_total += amount;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if received_total < required_sats {
|
||||
anyhow::bail!(
|
||||
"Payment verification failed: only {} of {} sats verified",
|
||||
received_total,
|
||||
required_sats
|
||||
);
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
@@ -2452,7 +2465,3 @@ mod tests {
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "payment_tests.rs"]
|
||||
mod payment_tests;
|
||||
|
||||
@@ -153,20 +153,6 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
|
||||
cause.context(describe_mint_error_body(status, body))
|
||||
}
|
||||
|
||||
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||
let mut outputs = Vec::new();
|
||||
for &amount in requested {
|
||||
if available >= amount {
|
||||
outputs.push(amount);
|
||||
available -= amount;
|
||||
} else {
|
||||
outputs.extend(amount_to_denominations(available));
|
||||
break;
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
@@ -526,21 +512,6 @@ impl MintClient {
|
||||
/// Swap proofs for new proofs of different denominations.
|
||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
self.swap_at_least(inputs, target_amounts, 0).await
|
||||
}
|
||||
|
||||
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||
/// before consuming any input proofs.
|
||||
pub async fn swap_at_least(
|
||||
&self,
|
||||
inputs: &[Proof],
|
||||
target_amounts: &[u64],
|
||||
minimum: u64,
|
||||
) -> Result<SwapResult> {
|
||||
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||
// files and streams) must expand these, not only wallet imports.
|
||||
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||
let inputs = resolved.as_slice();
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
@@ -548,35 +519,16 @@ impl MintClient {
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||
let inputs_total = inputs
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||
.context("Input amount overflow")?;
|
||||
let keysets = self.get_keysets().await?;
|
||||
let mut fee_ppk = 0u64;
|
||||
for proof in inputs {
|
||||
let input_keyset = keysets
|
||||
.iter()
|
||||
.find(|k| k.id == proof.id)
|
||||
.context("The mint does not recognize an input keyset")?;
|
||||
anyhow::ensure!(
|
||||
input_keyset.unit == "sat",
|
||||
"Input keyset is not denominated in sats"
|
||||
);
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(input_keyset.input_fee_ppk)
|
||||
.context("Mint fee overflow")?;
|
||||
}
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
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 requested: u64 = target_amounts.iter().sum();
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
@@ -587,10 +539,7 @@ impl MintClient {
|
||||
debug!(
|
||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||
);
|
||||
// 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 = amount_to_denominations(spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
@@ -635,9 +584,6 @@ impl MintClient {
|
||||
|
||||
let mut new_proofs = Vec::new();
|
||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||
if sig.amount != *amount || sig.id != keyset.id {
|
||||
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||
}
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let mint_key = keyset.key_for_amount(*amount)?;
|
||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
@@ -784,35 +730,43 @@ impl MintClient {
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||
/// or ambiguous short IDs are rejected before redemption.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return Ok(proofs.to_vec());
|
||||
return proofs.to_vec();
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = self.get_cdk_keysets().await?;
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if is_truncated_v2_keyset_id(&p.id) {
|
||||
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
}
|
||||
Ok(p)
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -848,7 +802,7 @@ impl MintClient {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
for entry in &token.token {
|
||||
if entry.mint.trim_end_matches('/') != self.url {
|
||||
if entry.mint != self.url {
|
||||
debug!(
|
||||
"Skipping proofs from different mint {} (ours: {})",
|
||||
entry.mint, self.url
|
||||
@@ -859,7 +813,8 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,428 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-60
@@ -989,7 +989,7 @@
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let unit = 'sats';
|
||||
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||
let peerSort = { col: 'peer', dir: 1 };
|
||||
let activityFilter = 'all';
|
||||
let logsLoaded = false;
|
||||
@@ -1142,19 +1142,9 @@
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
if (icon) icon.classList.add('animate-spin-slow');
|
||||
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([
|
||||
lndSafe('/v1/getinfo', null),
|
||||
lndSafe('/v1/channels', { channels: [] }),
|
||||
@@ -1176,17 +1166,10 @@
|
||||
state.invoices = (invoices && invoices.invoices) || [];
|
||||
state.txns = (txns && txns.transactions) || [];
|
||||
|
||||
// Preserve known balances on outage; never decode an error as zero.
|
||||
const [onchain, chanbal] = await Promise.all([
|
||||
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;
|
||||
// Balances are separate so one failing endpoint can't blank the rest.
|
||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
if (icon) icon.classList.remove('animate-spin-slow');
|
||||
}
|
||||
renderAll();
|
||||
@@ -1209,17 +1192,11 @@
|
||||
const pill = document.getElementById('headerStatusPill');
|
||||
const dot = document.getElementById('headerStatusDot');
|
||||
|
||||
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||
if (!g || waiting) {
|
||||
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||
pill.className = 'pill warn';
|
||||
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, '—');
|
||||
if (!g) {
|
||||
setText('headerStatusText', 'Unreachable');
|
||||
pill.className = 'pill bad';
|
||||
dot.className = 'status-dot-sm bg-red';
|
||||
document.getElementById('syncCard').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1260,11 +1237,6 @@
|
||||
}
|
||||
|
||||
// ── Balances ────────────────────────────────────────────────────
|
||||
function validBalance(value) {
|
||||
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function renderBalances() {
|
||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||
@@ -1281,23 +1253,22 @@
|
||||
const haveOnchain = !!state.onchain;
|
||||
const haveChan = !!cb;
|
||||
|
||||
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||
|
||||
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
|
||||
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
|
||||
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
|
||||
setText('liqLocal', fmtAmount(lnLocal));
|
||||
setText('liqRemote', fmtAmount(lnRemote));
|
||||
const total = lnLocal + lnRemote;
|
||||
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
||||
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
|
||||
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
|
||||
document.getElementById('liqBarLocal').style.width = localPct + '%';
|
||||
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
|
||||
setText('liqHint', total > 0
|
||||
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
||||
: 'Open a channel to start sending and receiving over Lightning.');
|
||||
}
|
||||
@@ -1313,15 +1284,6 @@
|
||||
|
||||
function renderSummary() {
|
||||
const g = state.info;
|
||||
if (!g) {
|
||||
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
|
||||
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
|
||||
for (const id of ['healthChain', 'healthGraph']) {
|
||||
const pill = document.getElementById(id);
|
||||
pill.textContent = '—'; pill.className = 'pill warn';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const chans = state.channels;
|
||||
const active = chans.filter(c => c.active).length;
|
||||
const inactive = chans.length - active;
|
||||
@@ -1359,10 +1321,6 @@
|
||||
function renderChannels() {
|
||||
const el = document.getElementById('channelList');
|
||||
if (!el) return;
|
||||
if (!state.info) {
|
||||
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
|
||||
return;
|
||||
}
|
||||
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
||||
let list = state.channels.slice();
|
||||
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
||||
|
||||
+4
-37
@@ -3,47 +3,14 @@
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Framework incident — closed with operator acceptance
|
||||
## Blocking incident — before unrelated work
|
||||
|
||||
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||
missing Receive address / false zero balance.** Startup, native balances,
|
||||
Cashu address and source integration were verified; the operator accepted the
|
||||
remaining display check and authorized release. See the incident record for evidence.
|
||||
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
|
||||
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.
|
||||
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||
and the repository `AGENTS.md` session-start instructions.
|
||||
|
||||
## Next release after 1.8.21 — reported 2026-09-30
|
||||
|
||||
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
|
||||
and appears underneath the pruning information.** Operator reports white
|
||||
styling with invisible text on the actual kiosk; the same flow works in remote
|
||||
Brave. Reproduce on the X250's kiosk engine and record its version, display
|
||||
scale and resolution. Inspect the native `<select>` in
|
||||
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
|
||||
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
|
||||
not a confirmed cause. Fix contrast and popup visibility without changing
|
||||
version selection or pruning behavior. Validate Core and Knots, open/closed
|
||||
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
|
||||
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
|
||||
alone do not establish that the kiosk rendering is fixed. Track for the next
|
||||
release; the signed 1.8.21 artifacts remain unchanged.
|
||||
|
||||
## 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)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Framework: LND startup, missing Receive address, false zero balance
|
||||
|
||||
**Status: CLOSED WITH OPERATOR ACCEPTANCE — startup, native balances, Cashu address and source integration verified; user accepted the remaining display check and authorized release on 2026-09-30.**
|
||||
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
|
||||
|
||||
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.
|
||||
@@ -376,30 +376,3 @@ 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
|
||||
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||
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.
|
||||
|
||||
### Operator acceptance and release authorization — 2026-09-30
|
||||
|
||||
After being told that final rendered balance/Receive confirmation remained and
|
||||
SSH access was unavailable, the user replied: “that's fine I believe it'd fixed,
|
||||
please release”. This explicitly accepts proceeding past the remaining human
|
||||
display check. Close this incident with operator acceptance based on the earlier
|
||||
controlled reboot, preserved identity/channels/native balances, working Receive
|
||||
address/payment, source integration, and the user's report of no further issues.
|
||||
No new direct Framework inspection or on-screen verification is claimed today.
|
||||
Reopen investigation if the original startup, Receive, or false-zero symptom
|
||||
recurs; preserve the wallet and channels.
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
# 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,120 passed; production build passed. Full release harness and final frontend follow-up passed.
|
||||
- [x] Fault tests and final source review complete.
|
||||
- [x] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||
- [x] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||
- [x] Framework operator acceptance and authorization to release recorded.
|
||||
- [x] 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.
|
||||
|
||||
### Initial candidate live validation — 18:48 UTC
|
||||
|
||||
Source 0f85f588, optimized backend SHA256
|
||||
84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6,
|
||||
deployed to dev and Shorty with matching hashes and rollback copies. Both
|
||||
management services restarted; wallets/channels were not reset. Old embedded
|
||||
runtime assets restored the old doctor on backend startup; updated the live
|
||||
script AND embedded runtime copy on both nodes. Final OTA will contain the new
|
||||
script directly.
|
||||
|
||||
Authenticated dev readiness transitioned from waiting_start to waiting_sync.
|
||||
Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown
|
||||
balance, and no blocked native LND calls. Screenshot review also caught invented
|
||||
zero capacity/channel counts during waiting: corrected them and the empty-channel
|
||||
recommendation; five UI regression tests now pass.
|
||||
|
||||
Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned
|
||||
the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and
|
||||
two cached downloads charged zero. Temporary seller files/catalog entries removed.
|
||||
The first test runner expected data_base64 while the first-purchase API returns
|
||||
data; cached responses use data_base64. Existing purchase clients only consume
|
||||
data, so a follow-up normalizes both response variants to both fields.
|
||||
|
||||
The optional Files copy failed because FileBrowser owns host paths as mapped UID
|
||||
100000. Follow-up uses its authenticated API with override=false and collision
|
||||
suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved
|
||||
original bytes, and cleaned up. New protocol tests cover folder creation, escaped
|
||||
names, collisions, authentication failure, disk-full, and unavailable service.
|
||||
Full backend suite for these follow-ups is running; do not package the earlier
|
||||
backend as final.
|
||||
|
||||
### Follow-up validation and OTA delivery check
|
||||
|
||||
Paid-response and Files API regressions passed in the full backend run: 1,552
|
||||
passed, zero failed, four existing ignored tests. Live browser waiting checks
|
||||
passed again after removing invented zero capacity and channel counts.
|
||||
|
||||
OTA inspection found that companion image :local (created by old installers and
|
||||
used on dev) bypassed both source-staleness detection and rebuilding. The earlier
|
||||
assumption that build-context detection covered these nodes was incorrect.
|
||||
Follow-up applies the existing source-mtime/stamp checks to both :local and
|
||||
:latest, preserving the existing tag and rebuilding only stale source. Existing
|
||||
image-ID comparison then restarts the UI companion onto the new image. This does
|
||||
not restart LND itself. Regression covers every companion's two local tags; final
|
||||
backend suite is running. Verify the resulting live rebuilt image before release.
|
||||
|
||||
### Test isolation finding — release remains blocked
|
||||
|
||||
The next full run passed 1,552 tests but one existing boot-loop timing test failed.
|
||||
Its output and node logs exposed an independent test defect: MockRuntime tests
|
||||
still invoked real Quadlet service operations and Podman socket recovery. These
|
||||
caused further LND/companion restarts during unrestricted unit runs. They were not
|
||||
a recurrence of the repaired doctor port check. Stopped unrestricted testing;
|
||||
LND has remained running since 19:02:46 UTC during isolated test execution.
|
||||
|
||||
New isolated runner hides live wallets, service buses, container storage and host
|
||||
process IDs, supplies a private network and temporary writable fixture paths,
|
||||
and keeps host filesystems read-only. An independent boundary probe passed.
|
||||
Test-only service helpers use a temporary Quadlet directory and simulated service
|
||||
results; mocked runtimes skip real Podman socket/network provisioning. Host file
|
||||
helpers require the isolated-runner marker and execute inside the namespace
|
||||
instead of escaping through sudo/systemd-run. Release harness and AGENTS now
|
||||
require this runner. Initial isolation trials correctly blocked host operations
|
||||
and exposed fixture permission assumptions; final runner compiles and executes
|
||||
the full suite with those fixture paths isolated. No final pass claimed yet.
|
||||
|
||||
Main dashboard candidate and AIUI build at b634f41a are now deployed on dev; served
|
||||
index SHA matches the build. Live package.versions returns bitcoinPrune=false
|
||||
for Core and Knots, preserving current automatic mode. Existing full chain stays
|
||||
unpruned. Final backend (Files/cached response/legacy UI delivery follow-ups) is
|
||||
not yet deployed; earlier 0f85f588 backend remains live on both nodes.
|
||||
|
||||
Final isolated backend run: **1,553 passed, zero failed, four existing ignored**
|
||||
in 13 seconds after compilation. Boundary probe confirms no host service buses,
|
||||
live wallet data, host process IDs, or external network. Bitcoin/LND start times
|
||||
remained unchanged during isolated execution. Production helpers are unchanged;
|
||||
the namespace-specific command behavior is compiled only into unit tests.
|
||||
Release and ISO gates now use the isolated runner.
|
||||
|
||||
### Final backend deployment and App Store follow-up — 19:36 UTC
|
||||
|
||||
Full release harness passed: static/catalog checks, frontend type-check and
|
||||
1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
|
||||
four existing ignored). Final optimized backend built successfully; SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
|
||||
and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
|
||||
Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
|
||||
|
||||
Actual desktop pruning screenshot exposed horizontal overflow; moved the
|
||||
explanation below the app header. The App Store uses Marketplace.vue, a separate
|
||||
install path from Discover.vue. Its first Install button bypassed the version
|
||||
modal. The browser check therefore sent an unintended Knots install request at
|
||||
19:28 UTC. Core remained running, no Knots container was created, and the full
|
||||
chain was not pruned. Removed only the newly created Knots installed-app record
|
||||
and newly created version config; preserved root-only rollback copies.
|
||||
|
||||
Marketplace now uses the shared version/pruning modal. Added integration tests
|
||||
for both Core and Knots: no install request until confirmation, selected version
|
||||
and pruning forwarded, cancellation sends no install request. Four Marketplace
|
||||
tests pass (three new plus existing refresh check). Further browser checks block
|
||||
package.install requests at their network boundary. Final frontend rebuild and
|
||||
post-fix live checks remain pending. Final paid-file follow-up is still pending.
|
||||
|
||||
### Unsigned release candidate ready — 19:46 UTC
|
||||
|
||||
Final frontend source/build attribution: 3612458e. Production dashboard and AIUI
|
||||
builds passed. Final frontend suite: 1,120 tests across 139 files passed.
|
||||
Desktop 1280px and mobile 390px browser checks passed for the app detail pruning
|
||||
choice and App Store version modal; no horizontal overflow and no installation
|
||||
request. Screenshot review confirms readable controls and explanation. Browser
|
||||
installation requests are blocked during these selection-only checks.
|
||||
|
||||
Final backend SHA above matches both dev and Shorty. A fresh one-sat purchase
|
||||
passed on those exact binaries: correct file bytes, both response field aliases,
|
||||
exact one-sat refund on underpayment, zero-charge cached repeat, and exact Files
|
||||
copy. Temporary seller entries/files and Files test copy removed; transaction
|
||||
audit and owned cache retained. Total net transfer during the two live purchase
|
||||
rounds: two sats from dev to Shorty. Desktop/mobile LND waiting checks passed
|
||||
again on the automatically rebuilt companion. Native Bitcoin and LND stayed up.
|
||||
|
||||
Prepared, unsigned files:
|
||||
- releases/pending/v1.8.20-alpha/app-catalog.json
|
||||
- releases/pending/v1.8.20-alpha/manifest.json
|
||||
|
||||
Staged OTA backend: 64,716,656 bytes, SHA256
|
||||
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||
Frontend archive: 97,152,297 bytes, SHA256
|
||||
658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802.
|
||||
Artifact sizes/hashes/release notes validate. Checked actual archive: flat
|
||||
layout, readable root permissions, exact doctor/LND UI source bytes, and fresh
|
||||
AIUI attribution. Catalog has zero metadata drift and passes fleet registry trust.
|
||||
|
||||
Remaining: user-local release-root signatures, Framework's final display
|
||||
confirmation, signed publication to git/ngit, then raw ISO build/boot test/signing
|
||||
and publication. No v1.8.20 public release or tag exists yet. Four pre-existing
|
||||
hardware/live tests remain ignored. Bitcoin sync-to-ready recovery is covered
|
||||
by deterministic tests; the live node remains in initial sync. Do not describe
|
||||
these checks as proof against every possible network/payment failure.
|
||||
|
||||
### Signing and release authorization — 2026-09-30
|
||||
|
||||
Both catalog and OTA signatures verify against the pinned release root. Staged
|
||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||
remaining Framework display check and explicitly authorized release. Publication
|
||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||
|
||||
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||
|
||||
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||
|
||||
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||
and command-wait budgets, including existing containers and uninstall fallback.
|
||||
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||
|
||||
Disposable live regression passed: started an Alpine container with its legacy
|
||||
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||
second graceful stop, verified the same container ID and old internal timeout
|
||||
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||
Fixture had no network or wallet mounts and was removed afterward.
|
||||
|
||||
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||
unlock or restart was used for this recovery.
|
||||
|
||||
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||
|
||||
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||
restart. These service restarts preceded the isolated test executable, whose
|
||||
namespace boundaries remain intact.
|
||||
|
||||
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||
followed by successful systemctl start of an already active unit can produce
|
||||
Started without changing Bitcoin. Dependency restarts now require observed
|
||||
container-ID, running-state, or start-time changes. Failed observations remain
|
||||
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||
are re-read after the potentially slow pass. Added runtime-observation and
|
||||
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||
|
||||
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||
|
||||
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||
suite: 1,120 passed; final production type-check/build passed after the last
|
||||
release-note-only edit. Optimized backend built in 13m22s.
|
||||
|
||||
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||
- Backend: 64,748,176 bytes; SHA256
|
||||
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||
- Frontend archive: 97,152,546 bytes; SHA256
|
||||
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||
|
||||
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||
and start times were preserved. Correct generated graceful-stop commands are
|
||||
present before forced removal on both nodes; temporary grace overrides removed.
|
||||
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||
|
||||
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||
|
||||
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||
transfer across all three live payment rounds in this repair session: three sats.
|
||||
|
||||
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||
|
||||
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||
diagnostic logging on both nodes and restarted only management again; native
|
||||
Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
|
||||
still verified. No temporary graceful-stop overrides remain. Catalog signature
|
||||
verifies against the pinned release root; final 1.8.21 artifact validator passes.
|
||||
The candidate is ready for the user's local OTA signing ceremony.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.21-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.21-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.21-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface PackageVersionsResponse {
|
||||
pinnedVersion: string | null
|
||||
autoUpdate: boolean
|
||||
versions: CatalogVersionInfo[]
|
||||
bitcoinPrune?: boolean | null
|
||||
}
|
||||
|
||||
export interface AppGatePortStatus {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<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"
|
||||
/>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Defaults to mempool.space. Any Mempool-compatible instance works — you can change this
|
||||
Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
|
||||
any time in Settings → System.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,6 @@
|
||||
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
||||
</div>
|
||||
|
||||
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button
|
||||
@@ -60,10 +58,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
|
||||
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
||||
import { displayVersion } from '@/utils/version'
|
||||
|
||||
@@ -76,16 +73,13 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
||||
confirm: [version: string, prune?: boolean]
|
||||
confirm: [version: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const versions = ref<CatalogVersionInfo[]>([])
|
||||
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.
|
||||
function optionLabel(v: CatalogVersionInfo): string {
|
||||
@@ -98,16 +92,12 @@ function optionLabel(v: CatalogVersionInfo): string {
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
prune.value = false
|
||||
pruneKnown.value = false
|
||||
versions.value = []
|
||||
selected.value = ''
|
||||
try {
|
||||
const info = await rpcClient.getPackageVersions(props.appId)
|
||||
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
||||
// is the latest — pre-select it.
|
||||
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
|
||||
prune.value = info.bitcoinPrune === true
|
||||
versions.value = info.versions || []
|
||||
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
||||
} catch (err) {
|
||||
@@ -121,7 +111,7 @@ async function load() {
|
||||
|
||||
function confirm() {
|
||||
if (!selected.value) return
|
||||
emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
|
||||
emit('confirm', selected.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
@change="saveExplorer"
|
||||
/>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Any Mempool-compatible instance works. Default: mempool.space.
|
||||
Any Mempool-compatible instance works. Default: tx1138.com.
|
||||
</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">
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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,24 +76,6 @@ describe('useTxExplorer.openTx', () => {
|
||||
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 () => {
|
||||
containerState = 'not-installed'
|
||||
const { openTx, pendingTx } = useTxExplorer()
|
||||
@@ -102,23 +84,3 @@ describe('useTxExplorer.openTx', () => {
|
||||
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 { useContainerStore } from '@/stores/container'
|
||||
|
||||
export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
|
||||
export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
|
||||
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
|
||||
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
|
||||
|
||||
const KEY = 'archipelago.tx-explorer.v1'
|
||||
|
||||
@@ -30,13 +30,7 @@ interface TxExplorerPrefs {
|
||||
function loadPrefs(): TxExplorerPrefs {
|
||||
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
||||
try {
|
||||
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
|
||||
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
|
||||
@@ -672,11 +672,6 @@ async function handleInstall(app: MarketplaceApp) {
|
||||
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
|
||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||
@@ -691,19 +686,19 @@ async function handleInstall(app: MarketplaceApp) {
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (app.source === 'local') {
|
||||
installApp(app, versionOverride, prune)
|
||||
installApp(app, versionOverride)
|
||||
} else {
|
||||
installCommunityApp(app, versionOverride, prune)
|
||||
installCommunityApp(app, versionOverride)
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||
function onInstallModalConfirm(version: string) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version, prune)
|
||||
if (app) startInstall(app, version)
|
||||
}
|
||||
|
||||
function viewAppDetails(app: MarketplaceApp) {
|
||||
@@ -779,25 +774,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
queueInstall(app)
|
||||
installToast(app)
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000 })
|
||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||
failInstall(app, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
queueInstall(app)
|
||||
installToast(app)
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
||||
if ((app as Record<string, unknown>).containerConfig) {
|
||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
:tier-label="getAppTier(app.id)"
|
||||
:install-blocked-reason="installBlockedReason(app.id)"
|
||||
@view="viewAppDetails"
|
||||
@install="handleInstall(app)"
|
||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
||||
@launch="launchInstalledApp"
|
||||
/>
|
||||
</div>
|
||||
@@ -153,13 +153,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Scrollable Apps Section -->
|
||||
<InstallVersionModal
|
||||
:show="showInstallModal"
|
||||
:app-id="installModalApp?.id || ''"
|
||||
:app="installModalApp"
|
||||
@close="showInstallModal = false; installModalApp = null"
|
||||
@confirm="onInstallModalConfirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -181,13 +175,11 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
INSTALLED_ALIASES,
|
||||
MULTI_VERSION_APP_IDS,
|
||||
getAppTier,
|
||||
categorizeCommunityApp,
|
||||
getCuratedAppList,
|
||||
@@ -214,8 +206,6 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
const showInstallModal = ref(false)
|
||||
const installModalApp = ref<MarketplaceApp | null>(null)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
function installToast(app: MarketplaceApp) {
|
||||
@@ -528,34 +518,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
||||
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
||||
}
|
||||
|
||||
function handleInstall(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
toast.error(blocked)
|
||||
return
|
||||
}
|
||||
if (MULTI_VERSION_APP_IDS.has(app.id)) {
|
||||
installModalApp.value = app
|
||||
showInstallModal.value = true
|
||||
return
|
||||
}
|
||||
startInstall(app)
|
||||
}
|
||||
|
||||
function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) {
|
||||
if (app.source === 'local') void installApp(app, version, prune)
|
||||
else void installCommunityApp(app, version, prune)
|
||||
}
|
||||
|
||||
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||
const app = installModalApp.value
|
||||
showInstallModal.value = false
|
||||
installModalApp.value = null
|
||||
if (app) startInstall(app, version, prune)
|
||||
}
|
||||
|
||||
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
async function installApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
@@ -573,7 +536,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string, prune?:
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
|
||||
params: { id: app.id, url: installUrl, version: app.version },
|
||||
timeout: 600000,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -582,7 +545,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string, prune?:
|
||||
}
|
||||
}
|
||||
|
||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||
async function installCommunityApp(app: MarketplaceApp) {
|
||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||
const blocked = installBlockedReason(app.id)
|
||||
if (blocked) {
|
||||
@@ -595,7 +558,7 @@ async function installCommunityApp(app: MarketplaceApp, versionOverride?: string
|
||||
installToast(app)
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<button
|
||||
v-if="!isInstalled"
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
: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"
|
||||
>
|
||||
@@ -90,12 +90,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BitcoinPruningChoice
|
||||
v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded"
|
||||
v-model="pruneOnInstall"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<!-- Top: Icon + Info -->
|
||||
@@ -143,7 +137,6 @@
|
||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" class="mb-4" />
|
||||
|
||||
<!-- Bottom: Action Buttons -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@@ -160,7 +153,7 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
: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"
|
||||
>
|
||||
@@ -381,7 +374,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@@ -416,10 +408,6 @@ const bitcoinPruned = ref(false)
|
||||
// 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 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 electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
|
||||
@@ -599,13 +587,10 @@ onMounted(() => {
|
||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||
// paints from cache with no new RPC.
|
||||
async function loadInstallVersions() {
|
||||
if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||
await versionsResource.refresh()
|
||||
}
|
||||
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) {
|
||||
installVersions.value = []
|
||||
return
|
||||
@@ -716,7 +701,6 @@ async function installApp() {
|
||||
id: app.value.id,
|
||||
dockerImage: app.value.dockerImage,
|
||||
version: chosenVersion,
|
||||
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||
}
|
||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||
await rpcClient.call({
|
||||
@@ -733,7 +717,6 @@ async function installApp() {
|
||||
id: app.value.id,
|
||||
url: installUrl,
|
||||
version: chosenVersion,
|
||||
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
|
||||
@@ -2,9 +2,6 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Marketplace from '../Marketplace.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue'
|
||||
|
||||
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
||||
// mounting a view directly with its heavier deps mocked at the module
|
||||
@@ -47,37 +44,22 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
|
||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
||||
getPackageVersions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../discover/curatedApps', () => ({
|
||||
fetchAppCatalog: vi.fn().mockResolvedValue({
|
||||
apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({
|
||||
id, title: id, version: '29.0', description: 'Bitcoin node',
|
||||
dockerImage: `registry.example/${id}:29.0`, source: 'community',
|
||||
})),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||
routerPushMock.mockClear()
|
||||
toastErrorMock.mockClear()
|
||||
toastInfoMock.mockClear()
|
||||
vi.mocked(rpcClient.call).mockReset()
|
||||
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
|
||||
supportsVersions: true, default: '29.0', bitcoinPrune: false,
|
||||
versions: [{ version: '29.0', default: true, deprecated: false, eol: null }],
|
||||
} as Awaited<ReturnType<typeof rpcClient.getPackageVersions>>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -107,38 +89,4 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)!
|
||||
expect(card.exists()).toBe(true)
|
||||
card.vm.$emit('install', card.props('app'))
|
||||
await flushPromises()
|
||||
const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')
|
||||
expect(installs()).toHaveLength(0)
|
||||
const modal = wrapper.findComponent(InstallVersionModal)
|
||||
expect(modal.props('show')).toBe(true)
|
||||
await modal.get('input[type="checkbox"]').setValue(true)
|
||||
await modal.get('button.glass-button-warning').trigger('click')
|
||||
await flushPromises()
|
||||
expect(installs()).toHaveLength(1)
|
||||
expect(installs()[0]?.[0].params).toMatchObject({ id, version: '29.0', prune: true })
|
||||
expect(modal.props('show')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cancels Bitcoin selection without sending an installation request', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')!
|
||||
card.vm.$emit('install', card.props('app'))
|
||||
await flushPromises()
|
||||
wrapper.findComponent(InstallVersionModal).vm.$emit('close')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,60 +362,6 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.21-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.21-alpha</span>
|
||||
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
|
||||
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
|
||||
<p>Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.</p>
|
||||
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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>Improved saving paid files into Files and reopening purchases without paying again.</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 -->
|
||||
<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.19-alpha</span>
|
||||
<span class="text-xs text-white/40">September 28, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.</p>
|
||||
<p>Embedded AIUI now stays transparent so the dashboard background appears once.</p>
|
||||
<p>AIUI background fixes are now included reliably in OTA updates and fresh installations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.18-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.18-alpha</span>
|
||||
<span class="text-xs text-white/40">September 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.</p>
|
||||
<p>Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.</p>
|
||||
<p>Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.17-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+18
-18
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
||||
"size_bytes": 98801608
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.21-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n 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\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n 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;\nelse\n 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;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n 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\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n 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;\nelse\n 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;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -768,7 +768,7 @@
|
||||
},
|
||||
"container": {
|
||||
"custom_args": [
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n 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\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n 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;\nelse\n 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;\nfi"
|
||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n 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\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n 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;\nelse\n 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;\nfi"
|
||||
],
|
||||
"data_uid": "100101:100101",
|
||||
"derived_env": [
|
||||
@@ -1378,67 +1378,6 @@
|
||||
},
|
||||
"version": "0.1.0-preview"
|
||||
},
|
||||
"cuprate-ui": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/cuprate-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
"app": {
|
||||
"container": {
|
||||
"build": {
|
||||
"context": "/opt/archipelago/docker/cuprate-ui",
|
||||
"dockerfile": "Dockerfile",
|
||||
"tag": "localhost/cuprate-ui:local"
|
||||
}
|
||||
},
|
||||
"dependencies": [
|
||||
{
|
||||
"app_id": "cuprate"
|
||||
}
|
||||
],
|
||||
"description": "Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx\ninside a container, serves a static status dashboard, and proxies\n/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the\npublished host port for the container's 18089). No credentials are\ninjected — the restricted RPC is Monero's own safe-for-public subset — so\nthe nginx.conf is baked into the image and there is no rendered-config\nbind-mount like bitcoin-ui's.\n",
|
||||
"environment": [],
|
||||
"health_check": {
|
||||
"endpoint": "http://127.0.0.1:18091",
|
||||
"interval": "30s",
|
||||
"path": "/",
|
||||
"retries": 3,
|
||||
"timeout": "5s",
|
||||
"type": "http"
|
||||
},
|
||||
"id": "cuprate-ui",
|
||||
"metadata": {
|
||||
"author": "Archipelago",
|
||||
"category": "money",
|
||||
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||
"repo": "https://github.com/Cuprate/cuprate",
|
||||
"tier": "optional"
|
||||
},
|
||||
"name": "Cuprate UI",
|
||||
"ports": [
|
||||
{
|
||||
"auth": "gated",
|
||||
"bind": "127.0.0.1",
|
||||
"container": 18091,
|
||||
"host": 18091,
|
||||
"protocol": "tcp",
|
||||
"session_passthrough": true
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"memory_limit": "64Mi"
|
||||
},
|
||||
"security": {
|
||||
"network_policy": "host",
|
||||
"readonly_root": false
|
||||
},
|
||||
"upstream": {
|
||||
"kind": "internal"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"volumes": []
|
||||
}
|
||||
},
|
||||
"version": "1.7.123-alpha"
|
||||
},
|
||||
"electrs-ui": {
|
||||
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
|
||||
"manifest": {
|
||||
@@ -5525,7 +5464,7 @@
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"schema": 1,
|
||||
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
|
||||
"signature": "e716a9069021af87a2252d7561c01153f17c5630d7c36d8fdc1be1c7aa40560d09557513c0e09835a6b76b52b4f7edf0619cbaff02ac1d9d818066f67046e401",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"storefront": {
|
||||
"popular": [
|
||||
@@ -5546,10 +5485,10 @@
|
||||
"id": "archipelago-source",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
||||
"tag": "NGIT // NOSTR // NO SILO"
|
||||
}
|
||||
]
|
||||
},
|
||||
"updated": "2026-09-29"
|
||||
"updated": "2026-09-15"
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||
"size_bytes": 64748176
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.21-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||
"new_version": "1.8.21-alpha",
|
||||
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||
"size_bytes": 97152546
|
||||
"current_version": "1.8.17-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
||||
"size_bytes": 98801608
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-30",
|
||||
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.21-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
@@ -112,9 +112,10 @@ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*
|
||||
if [ "$SKIP_GATES" = "0" ]; then
|
||||
stage "release-gate-harness" bash tests/release/run.sh
|
||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# The release harness runs the full backend suite inside namespaces.
|
||||
# Never execute unrestricted tests on a node with live wallets/services.
|
||||
|
||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
||||
else
|
||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||
fi
|
||||
|
||||
+11
-42
@@ -47,33 +47,13 @@ podman_rootless() {
|
||||
}
|
||||
|
||||
port_is_listening() {
|
||||
local port="$1" protocol="${2:-tcp}" listeners
|
||||
local port="$1"
|
||||
local protocol="${2:-tcp}"
|
||||
case "$protocol" in
|
||||
tcp) listeners=$(ss -ltn 2>/dev/null) || return 2 ;;
|
||||
udp) listeners=$(ss -lun 2>/dev/null) || return 2 ;;
|
||||
*) return 2 ;;
|
||||
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
|
||||
tcp) ss -ltn 2>/dev/null ;;
|
||||
udp) ss -lun 2>/dev/null ;;
|
||||
*) return 1 ;;
|
||||
esac | awk '{print $4}' | grep -Eq "(^|:)$port$"
|
||||
}
|
||||
|
||||
run_fix() {
|
||||
@@ -593,27 +573,19 @@ 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)
|
||||
[ -n "$bindings" ] || continue
|
||||
|
||||
local missing=() inspection_failed=false status
|
||||
local missing=()
|
||||
local container_binding host_port protocol
|
||||
while read -r container_binding host_port; do
|
||||
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
|
||||
protocol="${container_binding##*/}"
|
||||
status=0
|
||||
port_is_listening "$host_port" "$protocol" || status=$?
|
||||
case "$status" in
|
||||
0) ;;
|
||||
1) missing+=("$host_port/$protocol") ;;
|
||||
*) inspection_failed=true ;;
|
||||
esac
|
||||
if ! port_is_listening "$host_port" "$protocol"; then
|
||||
missing+=("$host_port/$protocol")
|
||||
fi
|
||||
done <<< "$bindings"
|
||||
|
||||
if $inspection_failed; then
|
||||
log "WARN: cannot inspect listeners for $name; leaving it running"
|
||||
continue
|
||||
fi
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
||||
if restart_rootless_container "$name" >/dev/null 2>&1; then
|
||||
if podman_rootless restart "$name" >/dev/null 2>&1; then
|
||||
fixed=true
|
||||
else
|
||||
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
||||
@@ -704,9 +676,6 @@ fix_archipelago_dialout() {
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
# Allow regression tests to source helpers without running repairs.
|
||||
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0
|
||||
|
||||
# If remote host provided, run via SSH
|
||||
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
||||
REMOTE_HOST="$1"
|
||||
|
||||
@@ -78,17 +78,15 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||
echo "Staging frontend archive in $STAGING_DIR..."
|
||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||
# create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
|
||||
# Never overlay it with the older demo bundle (or nest aiui/aiui/).
|
||||
if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
|
||||
[ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
|
||||
echo "Error: fresh AIUI payload missing from frontend dist" >&2
|
||||
exit 1
|
||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
||||
# carries-forward the existing aiui/ if the tarball lacks one
|
||||
# (update.rs:922), but including it here makes the tarball
|
||||
# the single source of truth instead of relying on a side-
|
||||
# effect of the in-place swap.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
echo " Including AIUI from demo/aiui/"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
||||
fi
|
||||
grep -Fxq "commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)" "$STAGING_DIR/aiui/BUILD-INFO" || {
|
||||
echo "Error: AIUI payload was not built from the current commit" >&2
|
||||
exit 1
|
||||
}
|
||||
# OTA bridge for nodes running older updaters: they only know how to
|
||||
# apply the backend binary and frontend archive. Carry host runtime
|
||||
# assets inside the frontend tarball; the new backend promotes them
|
||||
|
||||
@@ -169,11 +169,15 @@ else
|
||||
fi
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Build AIUI from the same source as the release. The checked-in demo bundle
|
||||
# can predate source fixes and must never overwrite the production payload.
|
||||
bash "$SCRIPT_DIR/build-aiui.sh"
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
|
||||
# bakes it from demo/aiui independently, but build-iso-release.sh's
|
||||
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
|
||||
# consecutive releases (.127, .129) because this fold-in was manual.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
echo " AIUI folded into web/dist from demo/aiui"
|
||||
fi
|
||||
|
||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compile normally; execute unit tests away from real wallets, service buses,
|
||||
# container storage, processes and networking. Never silently fall back to host.
|
||||
set -euo pipefail
|
||||
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
command -v systemd-run >/dev/null
|
||||
command -v unshare >/dev/null
|
||||
command -v setpriv >/dev/null
|
||||
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
|
||||
metadata=$(mktemp)
|
||||
trap 'rm -f "$metadata"' EXIT
|
||||
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \
|
||||
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
|
||||
python3 - "$metadata" <<'PYDIAG'
|
||||
import json,sys
|
||||
for line in open(sys.argv[1]):
|
||||
try: item=json.loads(line)
|
||||
except json.JSONDecodeError: continue
|
||||
rendered=item.get('message',{}).get('rendered') if item.get('reason')=='compiler-message' else None
|
||||
if rendered: print(rendered,file=sys.stderr,end='')
|
||||
PYDIAG
|
||||
exit 1
|
||||
fi
|
||||
executable=$(python3 - "$metadata" <<'PY'
|
||||
import json,sys
|
||||
found=[]
|
||||
for line in open(sys.argv[1]):
|
||||
try: item=json.loads(line)
|
||||
except json.JSONDecodeError: continue
|
||||
if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'):
|
||||
found.append(item['executable'])
|
||||
assert len(found)==1, f'Expected one unit test executable, got {len(found)}'
|
||||
print(found[0])
|
||||
PY
|
||||
)
|
||||
[[ -x "$executable" ]]
|
||||
unit="archy-isolated-tests-$(date +%s)-$$"
|
||||
sudo -n systemd-run --unit="$unit" --wait --pipe --collect \
|
||||
--property="WorkingDirectory=$REPO/core" \
|
||||
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
|
||||
--property=ProtectSystem=strict --property=ProtectHome=read-only \
|
||||
--property=NoNewPrivileges=yes \
|
||||
--property='TemporaryFileSystem=/run:rw /var/lib/archipelago:rw /var/lib/containers:rw /root:rw' \
|
||||
--setenv=ARCHY_TEST_ISOLATED=1 \
|
||||
/usr/bin/unshare --pid --fork --mount-proc --kill-child \
|
||||
/usr/bin/setpriv --bounding-set=-all,+chown,+dac_override,+fowner,+setuid,+setgid,+kill \
|
||||
"$executable" --test-threads=4 "$@"
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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'
|
||||
@@ -1,109 +0,0 @@
|
||||
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'));
|
||||
});
|
||||
|
||||
test('cold waiting never invents zero channel capacity or an empty wallet recommendation',()=>{
|
||||
const {ctx,el}=fixture();
|
||||
Object.assign(ctx,{num:v=>Number(v)||0,fmtAmount:String,fmtAmountShort:String,setBalance:(id,v)=>el(id).value=v});
|
||||
vm.runInContext(extract('renderBalances')+'\n'+extract('renderSummary')+'\n'+extract('renderChannels'),ctx);
|
||||
ctx.state.channels=[];
|
||||
ctx.renderBalances();ctx.renderSummary();ctx.renderChannels();
|
||||
for(const id of ['liqLocal','liqRemote','statActiveChannels','healthPending','chActive']) assert.equal(el(id).textContent,'—');
|
||||
assert.equal(el('balTotal').value,null);
|
||||
assert.match(el('liqHint').textContent,/waiting for LND/);
|
||||
assert.doesNotMatch(el('channelList').innerHTML,/No payment channels yet/);
|
||||
ctx.state.info={};ctx.state.chanbal={local_balance:{sat:'0'}};
|
||||
ctx.renderBalances();
|
||||
assert.equal(el('liqLocal').textContent,'0');
|
||||
});
|
||||
@@ -72,9 +72,6 @@ summary() {
|
||||
stage "git-diff-check" git diff --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 "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
|
||||
|
||||
# Validate the artifact that will actually be signed and published, not only
|
||||
@@ -169,7 +166,9 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
|
||||
# 2026-08-20 1500s died at unit 427/429 (the archipelago bin test, the biggest
|
||||
# link) on a loaded, swapping dev box, again without running a single test.
|
||||
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
||||
stage "cargo-test-isolated" timeout 3600 bash scripts/test-backend-isolated.sh
|
||||
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
||||
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
|
||||
|
||||
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||
if [[ $LIVE -eq 1 ]]; then
|
||||
@@ -216,7 +215,7 @@ if [[ $LIVE -eq 1 ]]; then
|
||||
[ -z "$st" ] && continue
|
||||
seen=1
|
||||
echo "LND($port) state: $st"
|
||||
echo "$st" | grep -qE "UNLOCKED|RPC_ACTIVE|SERVER_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
||||
echo "$st" | grep -q "RPC_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; }
|
||||
done
|
||||
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
||||
|
||||
Reference in New Issue
Block a user