Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 19e01cd5de fix(content): never take a paid buyer's ecash and then fail to deliver
2026-09-18: a peer purchase paid 10 sats, the seller redeemed them, and the
buyer got no file plus a "seller doesn't accept your Cashu mint" error.
Three defects lined up:

1. The seller checked file existence with stat() but only read the file
   AFTER redeeming the payment. Filebrowser-owned 0640 files (uid 100999)
   passed stat but failed fs::read for the archipelago service user.
   serve_content now checks existence and readability BEFORE the payment
   gate, so an unservable file costs the buyer nothing.
2. The HTTP handler mapped every serve_content error to a bare, unlogged
   404. A server-side failure is now a logged 500. (A 404 also makes the
   buyer's Auto transport re-send the request over Tor.)
3. That re-send carried the same single-use token, which the mint had
   already spent, so the seller answered 402. Redemption is now
   idempotent: a token that verified for an item keeps authorising that
   item for 10 minutes (per token, per item; SHA-256 keyed, in-memory,
   concurrent requests serialised, failures never cached).

Buyer side: reclaim_spent_ecash now reports whether the refund worked, and
the error text no longer claims "refunded" when it wasn't, or asserts the
seller rejects the mint when the cause is unknown.

Adds tests for replay, concurrency, failure-not-cached, cross-item, and
unreadable-file-before-payment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:28:19 +00:00
62 changed files with 704 additions and 3208 deletions
-8
View File
@@ -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.
-30
View File
@@ -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.
+2 -3
View File
@@ -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'
-1
View File
@@ -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),
+5 -5
View File
@@ -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
+6 -15
View File
@@ -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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.21-alpha"
version = "1.8.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -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"
+15 -1
View File
@@ -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."}"#,
),
))
}
}
}
-13
View File
@@ -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
+132 -96
View File
@@ -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());
}
-84
View File
@@ -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"
);
}
}
+1 -62
View File
@@ -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,
+1 -23
View File
@@ -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);
}
}
}
+6 -31
View File
@@ -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, &registry).render()),
None
);
}
}
use super::*;
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
+1 -420
View File
@@ -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)");
}
}
-104
View File
@@ -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]
+5 -174
View File
@@ -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 {
+290 -8
View File
@@ -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());
}
}
-2
View File
@@ -7,5 +7,3 @@
pub mod ai_permissions;
pub mod session_policy;
pub mod transport;
pub mod bitcoin_storage;
-30
View File
@@ -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",
+60 -51
View File
@@ -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;
+32 -77
View File
@@ -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
View File
@@ -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
View File
@@ -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 -28
View File
@@ -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.
-358
View File
@@ -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.
+2 -2
View File
@@ -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 -1
View File
@@ -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",
-1
View File
@@ -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)
})
})
+3 -9
View File
@@ -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
}
+9 -14
View File
@@ -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
}
+6 -43
View File
@@ -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',
+3 -20
View File
@@ -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
View File
@@ -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"
}
+5 -66
View File
@@ -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
View File
@@ -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"
}
+4 -3
View File
@@ -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
View File
@@ -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"
+8 -10
View File
@@ -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
+9 -5
View File
@@ -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
-47
View File
@@ -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'
-109
View File
@@ -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');
});
+4 -5
View File
@@ -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; }