Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e01cd5de | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 | ||
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac | ||
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 |
@@ -0,0 +1,23 @@
|
||||
# Blocking incident: Framework LND startup and false zero balance
|
||||
|
||||
Before starting or resuming work in this repository, read
|
||||
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||
|
||||
The user explicitly required this incident to take priority over later work.
|
||||
While its status is OPEN:
|
||||
|
||||
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||
work unless the user explicitly overrides this priority.
|
||||
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||
reboot with access and recovery arrangements in place.
|
||||
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||
|
||||
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.
|
||||
@@ -2,10 +2,25 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## v1.8.16-alpha (2026-09-15)
|
||||
|
||||
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
|
||||
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
|
||||
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
|
||||
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
|
||||
|
||||
## v1.8.15-alpha (2026-09-13)
|
||||
|
||||
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
|
||||
- Added regression coverage for Cuprate install and installed-state grouping.
|
||||
- Release validation was rerun on the corrected tree before OTA and ISO publication.
|
||||
|
||||
## v1.8.14-alpha (2026-09-13)
|
||||
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.15-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.15-alpha"
|
||||
version = "1.8.17-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -162,11 +162,25 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// A server-side failure is NOT "not found": reporting it as a 404
|
||||
// hid an unreadable file behind a silent, unlogged response, and a
|
||||
// buyer's client re-sends a 404 over another transport. 5xx it, and
|
||||
// say why in the journal.
|
||||
Err(e) => {
|
||||
tracing::warn!(content_id = %content_id, "content request failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller could not read this file right now. You have not been charged."}"#,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. Fails silently if
|
||||
/// the seller already claimed the token (then the value is genuinely gone).
|
||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||
/// un-redeemed notes; for Cashu the proofs are received back. 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
|
||||
@@ -32,13 +34,29 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
||||
_ => ecash::receive_token(data_dir, token).await,
|
||||
};
|
||||
match res {
|
||||
Ok(sats) => tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
),
|
||||
Ok(sats) => {
|
||||
tracing::info!(
|
||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||
claimed it): {e:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
"The seller had already claimed the payment, so it could not be refunded \
|
||||
automatically — contact the seller."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,9 +582,13 @@ impl RpcHandler {
|
||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||
// The token was already minted/spent — reclaim it so the buyer
|
||||
// doesn't lose the value when the seller was simply unreachable.
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let reclaimed =
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
return Ok(serde_json::json!({
|
||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||
"error": format!(
|
||||
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -592,15 +614,19 @@ impl RpcHandler {
|
||||
);
|
||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||
let 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 isn't in the same Fedimint federation as you",
|
||||
_ => "the seller doesn't accept your Cashu mint",
|
||||
"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!(
|
||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
|
||||
type, or use a shared mint/federation.",
|
||||
refund_note(reclaimed)
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -609,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}");
|
||||
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}). Your ecash was refunded to your wallet.")
|
||||
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,42 @@ struct LndChannelBalanceResponse {
|
||||
pending_open_local_balance: Option<LndAmount>,
|
||||
}
|
||||
|
||||
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
macaroon_hex: &str,
|
||||
) -> Result<T> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND is unavailable; balance could not be checked")?
|
||||
.error_for_status()
|
||||
.context("LND is not ready; balance could not be checked")?
|
||||
.json()
|
||||
.await
|
||||
.context("LND returned invalid wallet data")
|
||||
}
|
||||
|
||||
fn checked_balances(
|
||||
wallet: LndBalanceResponse,
|
||||
channels: LndChannelBalanceResponse,
|
||||
) -> Result<(i64, i64, i64)> {
|
||||
fn sats(value: Option<String>) -> Result<i64> {
|
||||
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||
Ok(amount)
|
||||
}
|
||||
Ok((
|
||||
sats(wallet.total_balance)?,
|
||||
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||
@@ -85,45 +121,26 @@ impl RpcHandler {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let get_info: LndGetInfoResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND getinfo response")?;
|
||||
|
||||
let channel_balance: LndChannelBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
}),
|
||||
Err(_) => LndChannelBalanceResponse {
|
||||
local_balance: None,
|
||||
pending_open_local_balance: None,
|
||||
},
|
||||
};
|
||||
|
||||
let wallet_balance: LndBalanceResponse = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
||||
total_balance: None,
|
||||
}),
|
||||
Err(_) => LndBalanceResponse {
|
||||
total_balance: None,
|
||||
},
|
||||
};
|
||||
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||
&client,
|
||||
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||
&macaroon_hex,
|
||||
)
|
||||
.await?;
|
||||
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||
checked_balances(wallet_balance, channel_balance)?;
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
@@ -135,18 +152,9 @@ impl RpcHandler {
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||
block_height: get_info.block_height.unwrap_or(0),
|
||||
balance_sats: wallet_balance
|
||||
.total_balance
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
channel_balance_sats: channel_balance
|
||||
.local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
pending_open_balance: channel_balance
|
||||
.pending_open_local_balance
|
||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
||||
.unwrap_or(0),
|
||||
balance_sats,
|
||||
channel_balance_sats,
|
||||
pending_open_balance,
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(info)?)
|
||||
@@ -268,6 +276,76 @@ impl RpcHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_balances_are_not_zero() {
|
||||
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||
assert!(checked_balances(
|
||||
serde_json::from_str(body).unwrap(),
|
||||
serde_json::from_str(body).unwrap(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
for value in ["bad", "-1", "9223372036854775808"] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(value.into()),
|
||||
};
|
||||
let channels = serde_json::from_str(
|
||||
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked_balances(wallet, channels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_zero_and_nonzero_balances_survive() {
|
||||
for expected in [0, 42] {
|
||||
let wallet = LndBalanceResponse {
|
||||
total_balance: Some(expected.to_string()),
|
||||
};
|
||||
let channels = serde_json::from_value(serde_json::json!({
|
||||
"local_balance":{"sat":expected.to_string()},
|
||||
"pending_open_local_balance":{"sat":"0"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
checked_balances(wallet, channels).unwrap(),
|
||||
(expected, expected, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0; 2048];
|
||||
stream.read(&mut buf).await.unwrap();
|
||||
let body =
|
||||
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(), body
|
||||
).as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||
&client,
|
||||
&format!("http://{addr}/v1/getinfo"),
|
||||
"test"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ impl RpcHandler {
|
||||
let package_id_spawn = package_id.clone();
|
||||
tokio::spawn(async move {
|
||||
match handler.handle_package_update(params).await {
|
||||
Ok(_) => {
|
||||
Ok(result) => {
|
||||
info!("package.update {}: complete", package_id_spawn);
|
||||
// Same reasoning as install: the merge_preserving_transitional
|
||||
// helper treats Updating as RPC-owned, so we MUST write the
|
||||
@@ -345,7 +345,11 @@ impl RpcHandler {
|
||||
set_package_state(
|
||||
&handler.state_manager,
|
||||
&package_id_spawn,
|
||||
PackageState::Running,
|
||||
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
|
||||
pre_state.clone().unwrap_or(PackageState::Running)
|
||||
} else {
|
||||
PackageState::Running
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
impl RpcHandler {
|
||||
/// Update a package to the version pinned in image-versions.sh.
|
||||
/// Update a package to the freshly verified catalog target.
|
||||
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||
pub(in crate::api::rpc) async fn handle_package_update(
|
||||
&self,
|
||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// An Update click must not act on an hourly cache that predates the
|
||||
// button. Fetch and verify first; failure leaves running containers alone.
|
||||
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.context(
|
||||
"Cannot check the signed app catalog; update cancelled before changing containers",
|
||||
)?;
|
||||
if let Some(orch) = &self.orchestrator {
|
||||
// Reload even when bytes did not change: a previous reload may have
|
||||
// failed after the cache was written, or another refresher wrote it.
|
||||
orch.reload_manifests()
|
||||
.await
|
||||
.context("Cannot load current app manifests; update cancelled")?;
|
||||
}
|
||||
|
||||
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||
@@ -42,6 +57,22 @@ impl RpcHandler {
|
||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||
|
||||
let targets = pinned
|
||||
.as_ref()
|
||||
.map(|target| self.resolve_images_to_pull(package_id, target));
|
||||
if let Some(targets) = &targets {
|
||||
let installed = inspect_update_images(package_id).await?;
|
||||
if !update_targets_need_change(targets, &installed)? {
|
||||
install_log(&format!(
|
||||
"UPDATE SKIP: {} — target versions already installed",
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||
// (the async wrapper that dispatch actually routes to). By the time
|
||||
// this inner function runs, the wrapper has already flipped state to
|
||||
@@ -80,6 +111,12 @@ impl RpcHandler {
|
||||
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||
Ok(()) => {
|
||||
if let Some(targets) = &targets {
|
||||
verify_update_targets(
|
||||
targets,
|
||||
&inspect_update_images(package_id).await?,
|
||||
)?;
|
||||
}
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
|
||||
@@ -133,7 +170,8 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Resolve images to pull — either a stack or single container
|
||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||
let images_to_pull =
|
||||
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
|
||||
|
||||
// Get all containers for this app
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
||||
.await;
|
||||
if let Ok(o) = status {
|
||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if state == "exited" {
|
||||
warn!(
|
||||
"Update {}: container {} exited after recreate",
|
||||
package_id, name
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
o.status.success() && state == "running",
|
||||
"Update {}: container {} is not running after recreate",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Update {}: cannot inspect recreated container {}",
|
||||
package_id,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -514,6 +559,98 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
anyhow::ensure!(
|
||||
!containers.is_empty(),
|
||||
"No containers found for {}",
|
||||
package_id
|
||||
);
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.arg("inspect").args(&containers).kill_on_drop(true);
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
|
||||
.await
|
||||
.context("Timed out checking installed images")??;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Cannot inspect installed images; update cancelled"
|
||||
);
|
||||
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
|
||||
inspected
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let name = entry
|
||||
.get("Name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
|
||||
let image = entry
|
||||
.get("ImageName")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
|
||||
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn installed_image_for_target<'a>(
|
||||
app_id: &str,
|
||||
installed: &'a [(String, String)],
|
||||
) -> Option<&'a str> {
|
||||
installed
|
||||
.iter()
|
||||
.find(|(name, _)| {
|
||||
candidate_app_ids_for_container(name)
|
||||
.iter()
|
||||
.any(|id| id == app_id)
|
||||
})
|
||||
.map(|(_, image)| image.as_str())
|
||||
}
|
||||
|
||||
/// A successful recreate is not proof that it used the downloaded image.
|
||||
fn verify_update_targets(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<()> {
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
|
||||
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
|
||||
})?;
|
||||
anyhow::ensure!(
|
||||
image_versions::extract_version_from_image(target)
|
||||
== image_versions::extract_version_from_image(running)
|
||||
|| image_versions::compare_image_versions(target, running)
|
||||
== Some(std::cmp::Ordering::Equal),
|
||||
"Update {}: recreated container did not reach target version {}",
|
||||
app_id,
|
||||
image_versions::extract_version_from_image(target)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check every stack component, not just the version shown on its tile. A
|
||||
/// newer backend must still update when its frontend version is unchanged.
|
||||
/// A stale target for any component cancels before pulling or stopping anything.
|
||||
fn update_targets_need_change(
|
||||
targets: &[(String, String)],
|
||||
installed: &[(String, String)],
|
||||
) -> Result<bool> {
|
||||
use std::cmp::Ordering;
|
||||
let mut changed = false;
|
||||
for (app_id, target) in targets {
|
||||
let running = installed_image_for_target(app_id, installed);
|
||||
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
|
||||
Some(Ordering::Less) => anyhow::bail!(
|
||||
"Catalog target for {} is older than the installed image; refusing downgrade",
|
||||
app_id
|
||||
),
|
||||
Some(Ordering::Equal) => {}
|
||||
Some(Ordering::Greater) | None => changed = true,
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||
}
|
||||
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
||||
}
|
||||
|
||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
// A primary container already at its target does not mean its backend or
|
||||
// database is current. Route every mapped stack through the component flow.
|
||||
!image_versions::containers_for_stack(package_id).is_empty()
|
||||
|| matches!(
|
||||
package_id,
|
||||
// Multi-container stacks still updated via the stack-aware path.
|
||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||
"archy-lnd-ui" => push("lnd-ui"),
|
||||
"archy-electrs-ui" => push("electrs-ui"),
|
||||
"mempool" => {
|
||||
"mysql-mempool" => push("archy-mempool-db"),
|
||||
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
|
||||
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
|
||||
"fedimintd" => push("fedimint"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" => {
|
||||
push("archy-mempool-web");
|
||||
push("mempool");
|
||||
}
|
||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
||||
should_try_orchestrator_update, uses_legacy_update_flow,
|
||||
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
|
||||
verify_update_targets,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
|
||||
let installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let stale = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&stale, &installed).is_err());
|
||||
let current = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &legacy).unwrap());
|
||||
let newer = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
|
||||
)];
|
||||
assert!(update_targets_need_change(&newer, &installed).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_update_checks_backend_even_when_frontend_matches() {
|
||||
let installed = vec![
|
||||
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
|
||||
];
|
||||
let mut targets = vec![
|
||||
(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/team/web:3.3.1-archy1".into(),
|
||||
),
|
||||
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
|
||||
];
|
||||
assert!(update_targets_need_change(&targets, &installed).unwrap());
|
||||
targets[0].1 = "r.test/team/web:3.3.1".into();
|
||||
assert!(update_targets_need_change(&targets, &installed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_completion_requires_the_target_version_to_be_installed() {
|
||||
let targets = vec![(
|
||||
"archy-mempool-web".into(),
|
||||
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
let mut installed = vec![(
|
||||
"mempool".into(),
|
||||
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
|
||||
)];
|
||||
assert!(verify_update_targets(&targets, &installed).is_err());
|
||||
assert!(verify_update_targets(&targets, &[]).is_err());
|
||||
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
|
||||
assert!(verify_update_targets(&targets, &installed).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_flow_for_stack_apps() {
|
||||
for app in ["immich", "penpot", "indeedhub"] {
|
||||
for app in [
|
||||
"immich",
|
||||
"penpot",
|
||||
"indeedhub",
|
||||
"mempool",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
] {
|
||||
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_flow_for_single_apps() {
|
||||
for app in [
|
||||
"lnd",
|
||||
"bitcoin-core",
|
||||
"searxng",
|
||||
"grafana",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"fedimint",
|
||||
] {
|
||||
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||
assert!(
|
||||
!uses_legacy_update_flow(app),
|
||||
"{app} should be orchestrator-first"
|
||||
|
||||
@@ -400,7 +400,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||
// Catalog covers this app with a concrete image -> authoritative.
|
||||
return crate::container::image_versions::available_update_for_images(
|
||||
return crate::container::image_versions::available_catalog_update_for_images(
|
||||
&catalog_image,
|
||||
running_image,
|
||||
);
|
||||
|
||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||
|
||||
// Match VAR="value" or VAR=value
|
||||
if let Some((key, val)) = parse_assignment(line) {
|
||||
// Read a self-default assignment without evaluating shell code.
|
||||
let default_prefix = format!("${{{key}:-");
|
||||
let val = val
|
||||
.strip_prefix(&default_prefix)
|
||||
.and_then(|v| v.strip_suffix('}'))
|
||||
.unwrap_or(val);
|
||||
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||
if key == "ARCHY_REGISTRY" {
|
||||
registry = expanded.clone();
|
||||
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
}
|
||||
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(&pinned);
|
||||
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
|
||||
return None;
|
||||
}
|
||||
available_catalog_update_for_images(pinned, running_image)
|
||||
}
|
||||
|
||||
/// A signed catalog binds the image to an app id, so a publisher namespace
|
||||
/// migration must not hide a real upgrade. Baseline pins still require the
|
||||
/// same repository via `available_update_for_images` above.
|
||||
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
let pinned_version = extract_version_from_image(pinned);
|
||||
if is_floating_tag(&pinned_version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let running_version = extract_version_from_image(running_image);
|
||||
if pinned_version == running_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pinned_repo = image_without_registry_or_tag(&pinned);
|
||||
let running_repo = image_without_registry_or_tag(running_image);
|
||||
if pinned_repo != running_repo {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Never advertise a LOWER version as an update.
|
||||
//
|
||||
// Everything upstream of here is a version claim that can go stale: the
|
||||
// signed catalog, a legacy catalog entry with no manifest, the
|
||||
// image-versions.sh baseline pin. When one lags behind what a node is
|
||||
// actually running, a bare `pinned != running` check turns that staleness
|
||||
// into an "Update" button that rolls the node BACKWARDS — and a rollback
|
||||
// to a version withdrawn for a vulnerability is precisely the case where
|
||||
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
|
||||
// 2.3.9 pin, and the UI offering "update" to the exploited release.
|
||||
//
|
||||
// Only suppress when both tags parse as comparable version numbers, so
|
||||
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
|
||||
// keep the previous behaviour rather than silently losing updates.
|
||||
if let (Some(p), Some(r)) = (
|
||||
parse_version_parts(&pinned_version),
|
||||
parse_version_parts(&running_version),
|
||||
if matches!(
|
||||
compare_image_versions(pinned, running_image),
|
||||
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||
) {
|
||||
if p < r {
|
||||
return None;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(pinned_version)
|
||||
}
|
||||
|
||||
/// Compare explicit image tags, ignoring registry and namespace. `None` means
|
||||
/// unknown ordering (including floating tags), never permission to downgrade.
|
||||
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
|
||||
/// release, not a SemVer prerelease below it.
|
||||
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
|
||||
use std::cmp::Ordering;
|
||||
let target = extract_version_from_image(target);
|
||||
let running = extract_version_from_image(running);
|
||||
if is_floating_tag(&target) || is_floating_tag(&running) {
|
||||
return None;
|
||||
}
|
||||
let target = target.strip_prefix('v').unwrap_or(&target);
|
||||
let running = running.strip_prefix('v').unwrap_or(&running);
|
||||
if target == running {
|
||||
return Some(Ordering::Equal);
|
||||
}
|
||||
let mut target_core = parse_version_parts(target)?;
|
||||
let mut running_core = parse_version_parts(running)?;
|
||||
while target_core.last() == Some(&0) {
|
||||
target_core.pop();
|
||||
}
|
||||
while running_core.last() == Some(&0) {
|
||||
running_core.pop();
|
||||
}
|
||||
match target_core.cmp(&running_core) {
|
||||
Ordering::Equal => {
|
||||
fn patch_revision(tag: &str) -> Option<u64> {
|
||||
if let Some((base, revision)) = tag.rsplit_once("-archy") {
|
||||
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return revision.parse().ok();
|
||||
}
|
||||
}
|
||||
tag.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.')
|
||||
.then_some(0)
|
||||
}
|
||||
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
|
||||
}
|
||||
order => Some(order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Numeric components of a version tag, for ordering comparisons only.
|
||||
///
|
||||
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
|
||||
@@ -423,6 +452,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
|
||||
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
|
||||
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
|
||||
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
|
||||
assert_eq!(available_update_for_images(upstream, patch1), None);
|
||||
assert_eq!(available_update_for_images(patch1, patch2), None);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch1, upstream),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images(patch2, patch1),
|
||||
Some("v3.3.1-archy2".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
|
||||
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
|
||||
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(patched, old),
|
||||
Some("v3.3.1-archy1".into())
|
||||
);
|
||||
assert_eq!(
|
||||
available_catalog_update_for_images(
|
||||
patched,
|
||||
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(available_update_for_images(patched, old), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_version_spelling_does_not_offer_update() {
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_image_versions() {
|
||||
let content = r#"
|
||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
|
||||
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
|
||||
assert_eq!(
|
||||
images["MEMPOOL_WEB_IMAGE"],
|
||||
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
);
|
||||
assert_eq!(
|
||||
images["MEMPOOL_BACKEND_IMAGE"],
|
||||
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
||||
);
|
||||
assert!(images
|
||||
.values()
|
||||
.all(|v| !v.contains('$') && !v.contains('}')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_var_mapping() {
|
||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||
|
||||
@@ -96,129 +96,21 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||
return Ok(());
|
||||
}
|
||||
match unlock_existing_wallet().await? {
|
||||
true => {
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
false => {
|
||||
// Every candidate password was actively rejected: this wallet was
|
||||
// created with a password this node no longer has, so it can never
|
||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
||||
// locked with an unknown password is already inaccessible, so wipe +
|
||||
// recreate it on the per-node secret to self-heal at boot.
|
||||
recreate_wallet_destructively().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
init_wallet_via_rest().await?;
|
||||
wait_for_admin_macaroon(admin_macaroon).await
|
||||
}
|
||||
|
||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
||||
const LND_STATE_DIRS: &[&str] = &[
|
||||
"/var/lib/archipelago/lnd/data/chain",
|
||||
"/var/lib/archipelago/lnd/data/graph",
|
||||
];
|
||||
|
||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
||||
const LND_CONTAINER: &str = "lnd";
|
||||
|
||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||
const LND_ADMIN_MACAROON: &str =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
|
||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||
|
||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
||||
/// candidate password can open the existing wallet.
|
||||
async fn recreate_wallet_destructively() -> Result<()> {
|
||||
tracing::warn!(
|
||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
||||
);
|
||||
|
||||
// The health monitor restarts any container it sees stopped; mark LND
|
||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
||||
// Always cleared below so LND auto-recovers normally afterwards.
|
||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
let result = wipe_and_reinit_wallet().await;
|
||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
||||
.await
|
||||
.context("stopping lnd before wallet wipe")?;
|
||||
|
||||
for dir in LND_STATE_DIRS {
|
||||
let status = host_sudo(&["rm", "-rf", dir])
|
||||
.await
|
||||
.with_context(|| format!("removing {dir}"))?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("removing {dir} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
podman_user_scoped(&["start", LND_CONTAINER])
|
||||
.await
|
||||
.context("restarting lnd after wallet wipe")?;
|
||||
|
||||
wait_for_wallet_state("NON_EXISTING").await?;
|
||||
init_wallet_via_rest().await
|
||||
}
|
||||
|
||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
||||
/// container out of the archipelago service's cgroup).
|
||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
||||
let out = tokio::process::Command::new("systemd-run")
|
||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("building LND REST client")?;
|
||||
for _ in 0..120 {
|
||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
||||
}
|
||||
|
||||
async fn file_exists_as_root(path: &str) -> bool {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return true;
|
||||
@@ -390,14 +282,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
||||
///
|
||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
||||
/// still wants. Rotation calls this instead, so there is no code path from
|
||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
||||
/// as an error the caller reports, never as a wipe.
|
||||
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||
match unlock_existing_wallet().await? {
|
||||
true => Ok(()),
|
||||
@@ -538,7 +424,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
{
|
||||
UnlockerResponse::Value(seed) => seed,
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -569,7 +455,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
UnlockerResponse::WalletAlreadyExists => {
|
||||
unlock_existing_wallet().await?;
|
||||
unlock_existing_wallet_no_wipe().await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1864,7 +1864,7 @@ impl ProdContainerOrchestrator {
|
||||
// Durable installation record, consulted alongside the perishable
|
||||
// `was_running` snapshot for desired-state recovery below.
|
||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||
let (manifests, container_name_by_app_id): (
|
||||
let (mut manifests, container_name_by_app_id): (
|
||||
Vec<LoadedManifest>,
|
||||
std::collections::HashMap<String, String>,
|
||||
) = {
|
||||
@@ -1895,6 +1895,15 @@ impl ProdContainerOrchestrator {
|
||||
.collect();
|
||||
(filtered, names)
|
||||
};
|
||||
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||
// A running LND container can still be locked after boot; its post-start
|
||||
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||
"lnd" => 1,
|
||||
_ => 2,
|
||||
});
|
||||
// Live container names (any state), for the same recovery check.
|
||||
let present_containers: std::collections::HashSet<String> = self
|
||||
.runtime
|
||||
@@ -4667,6 +4676,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let lock = self.app_lock(app_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
let name = compute_container_name(&lm.manifest);
|
||||
let mut resolved = lm.manifest.clone();
|
||||
resolve_catalog_image(&mut resolved);
|
||||
if resolved.app.container.build.is_none() {
|
||||
if let Some(target) = &resolved.app.container.image {
|
||||
if let Ok(running) = self.runtime.get_container_status(&name).await {
|
||||
match crate::container::image_versions::compare_image_versions(
|
||||
target,
|
||||
&running.image,
|
||||
) {
|
||||
Some(std::cmp::Ordering::Less) => anyhow::bail!(
|
||||
"Refusing to downgrade {} from {} to {} during update",
|
||||
app_id,
|
||||
running.image,
|
||||
target
|
||||
),
|
||||
Some(std::cmp::Ordering::Equal) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = self.runtime.stop_container(&name).await;
|
||||
let _ = self.runtime.remove_container(&name).await;
|
||||
self.install_fresh(&lm).await
|
||||
@@ -5076,6 +5106,7 @@ mod tests {
|
||||
calls: StdMutex<Vec<String>>,
|
||||
/// container_name -> ContainerState. Absence = "doesn't exist".
|
||||
containers: StdMutex<HashMap<String, ContainerState>>,
|
||||
running_images: StdMutex<HashMap<String, String>>,
|
||||
/// container_name -> Podman health status.
|
||||
health: StdMutex<HashMap<String, String>>,
|
||||
/// image_ref -> present. Absence = "not present in local storage".
|
||||
@@ -5200,7 +5231,13 @@ mod tests {
|
||||
health,
|
||||
exit_code: None,
|
||||
started_at: None,
|
||||
image: "test-image".to_string(),
|
||||
image: self
|
||||
.running_images
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "test-image".to_string()),
|
||||
created: "now".to_string(),
|
||||
ports: vec![],
|
||||
lan_address: None,
|
||||
@@ -6370,6 +6407,42 @@ app:
|
||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||
rt.set_state("lnd", ContainerState::Exited);
|
||||
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||
let mut orch = orch_with(rt.clone()).await;
|
||||
orch.set_disk_gb_for_test(2000);
|
||||
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||
PathBuf::from(format!("/tmp/{id}")),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let report = orch.reconcile_all().await;
|
||||
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||
let calls = rt.calls();
|
||||
let bitcoin = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:bitcoin-knots")
|
||||
.unwrap();
|
||||
let lnd = calls
|
||||
.iter()
|
||||
.position(|c| c == "start_container:lnd")
|
||||
.unwrap();
|
||||
let pull = calls
|
||||
.iter()
|
||||
.position(|c| c.starts_with("pull_image:"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
bitcoin < lnd && lnd < pull,
|
||||
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_starts_exited_container() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
@@ -6771,6 +6844,41 @@ app:
|
||||
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
|
||||
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.set_state("update-regression", ContainerState::Running);
|
||||
rt.running_images.lock().unwrap().insert(
|
||||
"update-regression".into(),
|
||||
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
);
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest(
|
||||
"update-regression",
|
||||
&format!("registry.test/new/mempool-frontend:{target}"),
|
||||
),
|
||||
PathBuf::from("/tmp/update-regression"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
orch.upgrade("update-regression").await.is_err(),
|
||||
should_error
|
||||
);
|
||||
assert!(
|
||||
!rt.calls()
|
||||
.iter()
|
||||
.any(|call| call.starts_with("stop_container:")
|
||||
|| call.starts_with("remove_container:")
|
||||
|| call.starts_with("pull_image:")
|
||||
|| call.starts_with("create_container:")),
|
||||
"{:?}",
|
||||
rt.calls()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_removes_and_reinstalls() {
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
|
||||
@@ -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,31 @@ 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
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
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 {
|
||||
match &item.access {
|
||||
@@ -309,7 +431,10 @@ pub async fn serve_content(
|
||||
if let Some(token) = payment_token {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
&& authorize_payment(token, id, || {
|
||||
verify_payment_token(data_dir, token, *price_sats)
|
||||
})
|
||||
.await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
@@ -336,24 +461,6 @@ pub async fn serve_content(
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = content_file_path(data_dir, item);
|
||||
if !file_path.exists() {
|
||||
// The catalog entry survived (it's a separate JSON file) but its
|
||||
// backing file is gone — most likely lost in an unrelated data-dir
|
||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||
// Leaving the entry in place would keep advertising it as available
|
||||
// to every peer forever, each hitting the exact same dead end this
|
||||
// one just did. Prune it so it stops being offered.
|
||||
warn!(
|
||||
content_id = %id,
|
||||
filename = %item.filename,
|
||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||
);
|
||||
prune_missing_content_entry(data_dir, id).await;
|
||||
return Ok(ServeResult::NotFound);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path)
|
||||
.await
|
||||
.context("Failed to read file metadata")?;
|
||||
@@ -725,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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2159,20 +2159,25 @@ async fn apply_per_app_auto_updates(
|
||||
}
|
||||
}
|
||||
|
||||
/// After a catalog refresh that changed the cached bytes, rebuild the
|
||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
||||
/// effect now instead of at the next service restart.
|
||||
async fn reload_manifests_if_changed(
|
||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||
/// may have been written before a previous reload failed. Auto-updates only run
|
||||
/// when the catalog and the orchestrator's manifests are ready together.
|
||||
async fn reload_catalog_manifests(
|
||||
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||
) {
|
||||
if !refresh.changed {
|
||||
return;
|
||||
}
|
||||
let Some(orch) = orchestrator else { return };
|
||||
) -> bool {
|
||||
let Some(orch) = orchestrator else {
|
||||
return false;
|
||||
};
|
||||
match orch.reload_manifests().await {
|
||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
||||
Ok(n) => {
|
||||
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2188,7 +2193,9 @@ pub async fn run_update_scheduler(
|
||||
// Refresh the app catalog once at startup so per-app "update available"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Ok(refresh) => {
|
||||
reload_catalog_manifests(refresh, &orchestrator).await;
|
||||
}
|
||||
Err(e) => debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
@@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler(
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||
// catalog-shipped manifest fixes apply without a service restart.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
|
||||
}
|
||||
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// Per-app auto-update-to-latest (multi-version support). Runs every tick
|
||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||
// may still show cached badges but must not trigger container changes.
|
||||
if catalog_ready {
|
||||
apply_per_app_auto_updates(&orchestrator).await;
|
||||
}
|
||||
|
||||
let state = match load_state(&data_dir).await {
|
||||
Ok(s) => s,
|
||||
|
||||
@@ -1205,6 +1205,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// for the log. Remember the last one so a total failure can tell the user
|
||||
// *why* instead of just "nothing was received".
|
||||
let mut last_reason: Option<String> = None;
|
||||
let mut all_already_redeemed = true;
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
@@ -1217,6 +1218,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
}
|
||||
Err(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
|
||||
}
|
||||
@@ -1224,10 +1226,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
if received_total == 0 {
|
||||
match last_reason {
|
||||
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
|
||||
None => anyhow::bail!("Failed to receive any proofs from token"),
|
||||
}
|
||||
return Err(receive_failure(last_reason, all_already_redeemed));
|
||||
}
|
||||
|
||||
wallet.record_tx(
|
||||
@@ -1243,6 +1242,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
Ok(received_total)
|
||||
}
|
||||
|
||||
fn receive_failure(last_reason: Option<String>, all_already_redeemed: bool) -> anyhow::Error {
|
||||
match last_reason {
|
||||
Some(reason) if all_already_redeemed => {
|
||||
anyhow::Error::new(super::mint_client::AlreadyRedeemed)
|
||||
.context(format!("Could not receive this ecash: {reason}"))
|
||||
}
|
||||
Some(reason) => anyhow::anyhow!("Could not receive this ecash: {reason}"),
|
||||
None => anyhow::anyhow!("Failed to receive any proofs from token"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
|
||||
/// For backwards compatibility during migration period.
|
||||
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
@@ -1632,6 +1642,18 @@ fn default_mint_url() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn mixed_mint_failures_do_not_discard_a_retryable_claim() {
|
||||
let reason = super::super::mint_client::ALREADY_REDEEMED_MSG.to_string();
|
||||
assert!(super::receive_failure(Some(reason.clone()), true)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(!super::receive_failure(Some(reason), false)
|
||||
.is::<super::super::mint_client::AlreadyRedeemed>());
|
||||
assert!(
|
||||
!super::receive_failure(None, true).is::<super::super::mint_client::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
|
||||
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
|
||||
//! redeem is attempted, and stays there — retried on every later poll — until
|
||||
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still
|
||||
//! it succeeds or every mint reports that it was already spent.
|
||||
//! `ClaimOutcome::failed_count` reports how many are still
|
||||
//! stuck so the caller can surface it instead of it being a log-only event.
|
||||
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
|
||||
//! accepted-mints allow-list: the address is inherently backed by that one
|
||||
@@ -167,6 +168,9 @@ pub struct MinibitsState {
|
||||
/// already-spent token) but wasteful and noisy.
|
||||
#[serde(default)]
|
||||
pub last_dm_seen_at: u64,
|
||||
/// Resume a bounded backward scan before advancing to newer relay events.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relay_scan: Option<RelayScan>,
|
||||
/// Event ids already queued from the relay. `created_at` has only
|
||||
/// one-second resolution, so a strict `since = last + 1` watermark can
|
||||
/// permanently miss a second payment published later in the same second.
|
||||
@@ -627,6 +631,7 @@ async fn register_new_state(
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
pending_claims: Vec::new(),
|
||||
last_dm_seen_at: 0,
|
||||
relay_scan: None,
|
||||
seen_dm_ids: Vec::new(),
|
||||
last_receipt_id: 0,
|
||||
last_receipt_sats: 0,
|
||||
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
|
||||
pub receipt_at: u64,
|
||||
}
|
||||
|
||||
/// True when `ecash::receive_token` failed because the token was already
|
||||
/// redeemed (mint error 11001, see `mint_client::describe_mint_error_code`) —
|
||||
/// a terminal condition, not a reason to retry. Seen on a deployed node,
|
||||
/// 2026-09-15: a claim that had already been swept kept failing this way on
|
||||
/// every poll forever, since nothing distinguished it from a transient
|
||||
/// failure worth retrying.
|
||||
fn is_already_redeemed(err: &anyhow::Error) -> bool {
|
||||
err.is::<super::mint_client::AlreadyRedeemed>()
|
||||
}
|
||||
|
||||
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
|
||||
claimed_count: 0,
|
||||
received_sats: 0,
|
||||
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
|
||||
/// three real payments that `/claim` never surfaced. Best-effort: a relay
|
||||
/// error here must not abort the poll, since `pending_claims` may still hold
|
||||
/// earlier fetches worth retrying.
|
||||
///
|
||||
/// Queries `RELAY_URL` (the service's own relay) alone first — the happy
|
||||
/// path for a poll is one WebSocket connection, not three, and the wallet's
|
||||
/// derived Nostr pubkey isn't broadcast to the public fallback relays unless
|
||||
/// it's actually needed. Only when that relay is unreachable does it fall
|
||||
/// back to all of `CLAIM_RELAY_URLS`. Results are paged (capped at
|
||||
/// `CLAIM_MAX_PAGES`) since a relay returns only the newest `limit` events for
|
||||
/// a filter. A durable backward cursor keeps older pages reachable even after
|
||||
/// newly queued claims advance the normal forward watermark.
|
||||
async fn fetch_relay_dms(
|
||||
our_pubkey: nostr_sdk::PublicKey,
|
||||
server_pubkey: nostr_sdk::PublicKey,
|
||||
since: u64,
|
||||
) -> Vec<(String, u64, String, String)> {
|
||||
resume: Option<RelayScan>,
|
||||
) -> RelayBatch {
|
||||
let client = Client::default();
|
||||
for url in CLAIM_RELAY_URLS {
|
||||
if let Err(e) = client.add_relay(*url).await {
|
||||
warn!("Minibits: could not add relay {url}: {e}");
|
||||
if let Err(e) = client.add_relay(RELAY_URL).await {
|
||||
warn!("Minibits: could not add relay {RELAY_URL}: {e}");
|
||||
}
|
||||
let primary_reachable = client
|
||||
.try_connect_relay(RELAY_URL, std::time::Duration::from_secs(3))
|
||||
.await
|
||||
.is_ok();
|
||||
if !primary_reachable {
|
||||
warn!("Minibits: primary relay {RELAY_URL} unreachable, falling back to public relays too");
|
||||
for url in &CLAIM_RELAY_URLS[1..] {
|
||||
if let Err(e) = client.add_relay(*url).await {
|
||||
warn!("Minibits: could not add relay {url}: {e}");
|
||||
}
|
||||
}
|
||||
client.connect().await;
|
||||
}
|
||||
client.connect().await;
|
||||
// Give relays a moment to finish the WebSocket handshake before the
|
||||
// fetch's own timeout starts consuming that time.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
|
||||
// Nostr timestamps have one-second resolution. Query the boundary second
|
||||
// inclusively: a later-published payment may legitimately share that
|
||||
// timestamp. `seen_dm_ids` performs the exact deduplication locally.
|
||||
let filter = Filter::new()
|
||||
.author(server_pubkey)
|
||||
.pubkey(our_pubkey)
|
||||
.kind(Kind::from(4u16))
|
||||
.since(Timestamp::from(since))
|
||||
.limit(200);
|
||||
|
||||
let result = match client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(5))
|
||||
.await
|
||||
{
|
||||
Ok(events) => {
|
||||
let mut out: Vec<(String, u64, String, String)> = events
|
||||
let batch = collect_relay_pages(since, resume, |scan| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let mut filter = Filter::new()
|
||||
.author(server_pubkey)
|
||||
.pubkey(our_pubkey)
|
||||
.kind(Kind::from(4u16))
|
||||
.since(Timestamp::from(scan.since))
|
||||
.limit(scan.limit);
|
||||
if let Some(until) = scan.until {
|
||||
filter = filter.until(Timestamp::from(until));
|
||||
}
|
||||
let events = client
|
||||
.fetch_events(filter, std::time::Duration::from_secs(5))
|
||||
.await?;
|
||||
Ok(events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
(
|
||||
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
|
||||
e.id.to_hex(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|(_, created_at, _, _)| *created_at);
|
||||
out
|
||||
.collect())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits: relay fetch for claim DMs failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
})
|
||||
.await;
|
||||
client.shutdown().await;
|
||||
result
|
||||
batch
|
||||
}
|
||||
|
||||
const CLAIM_PAGE_LIMIT: usize = 200;
|
||||
const CLAIM_MAX_PAGES: usize = 5;
|
||||
type RelayDm = (String, u64, String, String);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct RelayScan {
|
||||
since: u64,
|
||||
until: Option<u64>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
struct RelayBatch {
|
||||
dms: Vec<RelayDm>,
|
||||
resume: Option<RelayScan>,
|
||||
}
|
||||
|
||||
/// NIP-01 returns newest events first. Walk backward with an inclusive `until`
|
||||
/// boundary, deduplicating event ids. A full boundary second needs a larger
|
||||
/// limit, not `until - 1`, which would skip payments sharing that timestamp.
|
||||
/// Persist the cursor at the page cap or on failure so older claims cannot be
|
||||
/// hidden by the newest timestamp already queued in `last_dm_seen_at`.
|
||||
async fn collect_relay_pages<F, Fut>(
|
||||
since: u64,
|
||||
resume: Option<RelayScan>,
|
||||
mut fetch: F,
|
||||
) -> RelayBatch
|
||||
where
|
||||
F: FnMut(RelayScan) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Vec<RelayDm>>>,
|
||||
{
|
||||
let mut scan = resume.unwrap_or(RelayScan {
|
||||
since,
|
||||
until: None,
|
||||
limit: CLAIM_PAGE_LIMIT,
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut resume = Some(scan);
|
||||
for _ in 0..CLAIM_MAX_PAGES {
|
||||
let events = match fetch(scan).await {
|
||||
Ok(events) => events,
|
||||
Err(e) => {
|
||||
warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let count = events.len();
|
||||
let oldest = events.iter().map(|e| e.1).min();
|
||||
for event in events {
|
||||
if ids.insert(event.3.clone()) {
|
||||
out.push(event);
|
||||
}
|
||||
}
|
||||
if count < scan.limit {
|
||||
resume = None;
|
||||
break;
|
||||
}
|
||||
if let Some(oldest) = oldest {
|
||||
if scan.until == Some(oldest) {
|
||||
scan.limit = scan.limit.saturating_add(CLAIM_PAGE_LIMIT);
|
||||
} else {
|
||||
scan.until = Some(oldest);
|
||||
scan.limit = CLAIM_PAGE_LIMIT;
|
||||
}
|
||||
}
|
||||
resume = Some(scan);
|
||||
}
|
||||
out.sort_by(|a, b| (a.1, &a.3).cmp(&(b.1, &b.3)));
|
||||
RelayBatch { dms: out, resume }
|
||||
}
|
||||
|
||||
fn queue_relay_dm(
|
||||
@@ -907,8 +1007,15 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
// NIP-04 DM on relays, not via `/claim` above. `since` is our own
|
||||
// watermark (Nostr events never expire off a relay, so without it we'd
|
||||
// re-fetch and re-attempt every claim ever sent on every poll).
|
||||
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await;
|
||||
for (content, created_at, author, event_id) in dms {
|
||||
let batch = fetch_relay_dms(
|
||||
identity.keys.public_key(),
|
||||
server_pk,
|
||||
state.last_dm_seen_at,
|
||||
state.relay_scan,
|
||||
)
|
||||
.await;
|
||||
state.relay_scan = batch.resume;
|
||||
for (content, created_at, author, event_id) in batch.dms {
|
||||
if author != state.server_nostr_pubkey {
|
||||
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
|
||||
continue;
|
||||
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
sats += got;
|
||||
info!("Minibits: redeemed a claimed payment ({got} sats)");
|
||||
}
|
||||
Err(e) if is_already_redeemed(&e) => {
|
||||
// Terminal: the value was already swept (a relay-watermark
|
||||
// replay, or a claim redeemed by an earlier run before a
|
||||
// crash lost track of it). Retrying can never succeed, so
|
||||
// drop it instead of leaving `failed_count` stuck non-zero
|
||||
// forever — see archy-x250-pa3, 2026-09-15.
|
||||
info!("Minibits mint reports this claim was already redeemed; removing it from the retry queue");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
|
||||
still_pending.push(claim.clone());
|
||||
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec<RelayDm> {
|
||||
let mut page: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.1 >= scan.since && scan.until.is_none_or(|until| e.1 <= until))
|
||||
.cloned()
|
||||
.collect();
|
||||
page.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.3.cmp(&b.3)));
|
||||
page.truncate(scan.limit);
|
||||
page
|
||||
}
|
||||
|
||||
fn relay_fixture(count: usize, same_second: bool) -> Vec<RelayDm> {
|
||||
(1..=count)
|
||||
.map(|n| {
|
||||
(
|
||||
format!("claim-{n}"),
|
||||
if same_second { 100 } else { n as u64 },
|
||||
"service".into(),
|
||||
format!("id-{n:06}"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_fetches_older_claims_in_newest_first_backlog() {
|
||||
let events = relay_fixture(450, false);
|
||||
let batch = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 450);
|
||||
assert!(batch.resume.is_none());
|
||||
assert_eq!(batch.dms.first().unwrap().1, 1);
|
||||
assert_eq!(batch.dms.last().unwrap().1, 450);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_paging_preserves_payments_at_the_same_timestamp() {
|
||||
let events = relay_fixture(250, true);
|
||||
let batch = collect_relay_pages(100, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(batch.dms.len(), 250);
|
||||
assert!(batch.resume.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_page_cap_resumes_older_claims_after_watermark_advances() {
|
||||
let events = relay_fixture(1300, false);
|
||||
let mut state = MinibitsState::default();
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(first.resume.is_some());
|
||||
state.relay_scan = first.resume;
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for (content, time, author, id) in first.dms {
|
||||
ids.insert(id.clone());
|
||||
queue_relay_dm(&mut state, content, time, id, author);
|
||||
}
|
||||
assert_eq!(state.last_dm_seen_at, 1300);
|
||||
let state: MinibitsState =
|
||||
serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
|
||||
let second = collect_relay_pages(state.last_dm_seen_at, state.relay_scan, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
assert!(second.resume.is_none());
|
||||
ids.extend(second.dms.into_iter().map(|e| e.3));
|
||||
assert_eq!(ids.len(), 1300);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_fetch_failure_keeps_the_unfinished_page_cursor() {
|
||||
let events = relay_fixture(450, false);
|
||||
let mut requests = 0;
|
||||
let first = collect_relay_pages(0, None, |scan| {
|
||||
requests += 1;
|
||||
std::future::ready(if requests == 1 {
|
||||
Ok(simulated_relay_page(&events, scan))
|
||||
} else {
|
||||
Err(anyhow!("relay timeout"))
|
||||
})
|
||||
})
|
||||
.await;
|
||||
assert_eq!(first.dms.len(), 200);
|
||||
assert_eq!(first.resume.unwrap().until, Some(251));
|
||||
let second = collect_relay_pages(450, first.resume, |scan| {
|
||||
std::future::ready(Ok(simulated_relay_page(&events, scan)))
|
||||
})
|
||||
.await;
|
||||
let ids: std::collections::HashSet<_> = first
|
||||
.dms
|
||||
.into_iter()
|
||||
.chain(second.dms)
|
||||
.map(|e| e.3)
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 450);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_typed_spent_claims_are_terminal_even_with_wrapped_errors() {
|
||||
let spent = anyhow::Error::new(super::super::mint_client::AlreadyRedeemed)
|
||||
.context("receive token")
|
||||
.context("claim failed");
|
||||
assert!(is_already_redeemed(&spent));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
super::super::mint_client::ALREADY_REDEEMED_MSG
|
||||
)));
|
||||
assert!(!is_already_redeemed(&anyhow!(
|
||||
"mint temporarily unreachable"
|
||||
)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,10 +71,28 @@ pub struct MintResult {
|
||||
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
|
||||
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
|
||||
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
|
||||
///
|
||||
/// Text of the NUT error-code-11001 translation, exposed so callers that
|
||||
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
|
||||
/// Minibits claim) can recognize an already-spent token as terminal rather
|
||||
/// than retrying it forever.
|
||||
pub const ALREADY_REDEEMED_MSG: &str =
|
||||
"This ecash has already been redeemed — it can't be claimed twice.";
|
||||
|
||||
/// Typed terminal condition: never infer spent proofs from a mint's free text.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AlreadyRedeemed;
|
||||
impl std::fmt::Display for AlreadyRedeemed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(ALREADY_REDEEMED_MSG)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for AlreadyRedeemed {}
|
||||
|
||||
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
10001 => "The mint rejected these coins as invalid.",
|
||||
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
|
||||
11001 => ALREADY_REDEEMED_MSG,
|
||||
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
|
||||
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
|
||||
11004 => "This request is still being processed by the mint — try again in a moment.",
|
||||
@@ -124,8 +142,15 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||
/// translation layered on top via `.context()` so `{}` — what reaches the
|
||||
/// wallet user — shows something actionable instead of raw mint JSON.
|
||||
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
|
||||
let friendly = describe_mint_error_body(status, body);
|
||||
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
|
||||
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
|
||||
if serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
|
||||
== Some(11001)
|
||||
{
|
||||
return cause.context(AlreadyRedeemed);
|
||||
}
|
||||
cause.context(describe_mint_error_body(status, body))
|
||||
}
|
||||
|
||||
/// HTTP client for a single Cashu mint.
|
||||
@@ -803,6 +828,28 @@ impl MintClient {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
|
||||
let spent = super::mint_error(
|
||||
"Swap",
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
r#"{"code":11001,"detail":"Token Already Spent"}"#,
|
||||
)
|
||||
.context("Receive failed");
|
||||
assert!(spent.is::<super::AlreadyRedeemed>());
|
||||
let body =
|
||||
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
|
||||
assert!(
|
||||
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
|
||||
.is::<super::AlreadyRedeemed>()
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||
#
|
||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
||||
# after any backend restart (podman reassigns the IP). See the script header.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
|
||||
# Keep the upstream startup logic; repair its rendered proxy configuration.
|
||||
# Publish this derived image under an Archipelago-specific tag, never the
|
||||
# upstream version tag that the registry mirror can overwrite.
|
||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
|
||||
FROM ${BASE}
|
||||
|
||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
||||
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||
# nginx -g "daemon off;") are inherited unchanged.
|
||||
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
|
||||
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
|
||||
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Mempool frontend DNS recovery
|
||||
|
||||
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
|
||||
start. Recreating the backend can change its Podman address while the frontend
|
||||
continues to serve its static page, leaving all API/WebSocket requests offline.
|
||||
|
||||
Build and test the derived image before publishing:
|
||||
|
||||
```sh
|
||||
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
|
||||
python3 scripts/test-mempool-dns-recovery.py
|
||||
```
|
||||
|
||||
The base is pinned by digest. The wrapper preserves upstream runtime options,
|
||||
then repairs all four local API/WebSocket routes after placeholder rendering.
|
||||
DNS is cached for five seconds using the container network resolver. Explicit
|
||||
rewrites preserve API prefixes and query arguments; backend absence does not
|
||||
prevent nginx startup. An unexpected upstream configuration fails startup
|
||||
instead of silently omitting the fix.
|
||||
|
||||
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
|
||||
mirror when updating mempool. Every upstream update must rebuild this wrapper
|
||||
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
|
||||
mapping, repeated repair, and frontend restart).
|
||||
|
||||
Publish the tested image before publishing the signed app catalog. Both the
|
||||
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
|
||||
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
|
||||
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
|
||||
Regular → Executable
+4
-136
@@ -1,137 +1,5 @@
|
||||
#!/bin/sh
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
CONF=/etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
# ─── archipelago patch ────────────────────────────────────────────────────
|
||||
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
|
||||
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
|
||||
# start and caches it for the process lifetime. Podman reassigns the backend
|
||||
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
|
||||
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
|
||||
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
|
||||
#
|
||||
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
|
||||
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
|
||||
# location→URI rewriting, each block is rewritten to preserve its original
|
||||
# path mapping exactly:
|
||||
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
|
||||
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
|
||||
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
|
||||
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
|
||||
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
|
||||
# Resolver address: podman's aardvark-dns answers on the network gateway
|
||||
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
|
||||
# works on any podman network/subnet (and still falls back for Docker).
|
||||
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
|
||||
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
|
||||
|
||||
if ! grep -q 'set \$mp_backend' "$CONF"; then
|
||||
awk -v res_addr="$ARCHY_RESOLVER" '
|
||||
BEGIN { res = 0 }
|
||||
/^[[:space:]]*location / && res == 0 {
|
||||
print "\tresolver " res_addr " valid=10s ipv6=off;"
|
||||
res = 1
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
|
||||
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
|
||||
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
|
||||
fi
|
||||
# ─── end archipelago patch ────────────────────────────────────────────────
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
|
||||
export LIGHTNING=true
|
||||
fi
|
||||
|
||||
# Runtime overrides - read env vars defined in docker compose
|
||||
|
||||
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
|
||||
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
|
||||
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
|
||||
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
|
||||
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
|
||||
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
|
||||
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
|
||||
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
|
||||
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
|
||||
__NGINX_PORT__=${NGINX_PORT:=8999}
|
||||
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__BASE_MODULE__=${BASE_MODULE:=mempool}
|
||||
__ROOT_NETWORK__=${ROOT_NETWORK:=}
|
||||
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
|
||||
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
|
||||
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
|
||||
__LIGHTNING__=${LIGHTNING:=false}
|
||||
__AUDIT__=${AUDIT:=false}
|
||||
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
|
||||
__ACCELERATOR__=${ACCELERATOR:=false}
|
||||
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
|
||||
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
|
||||
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
|
||||
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
|
||||
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
|
||||
|
||||
# Export as environment variables to be used by envsubst
|
||||
export __MAINNET_ENABLED__
|
||||
export __TESTNET_ENABLED__
|
||||
export __TESTNET4_ENABLED__
|
||||
export __SIGNET_ENABLED__
|
||||
export __LIQUID_ENABLED__
|
||||
export __LIQUID_TESTNET_ENABLED__
|
||||
export __ITEMS_PER_PAGE__
|
||||
export __KEEP_BLOCKS_AMOUNT__
|
||||
export __NGINX_PROTOCOL__
|
||||
export __NGINX_HOSTNAME__
|
||||
export __NGINX_PORT__
|
||||
export __BLOCK_WEIGHT_UNITS__
|
||||
export __MEMPOOL_BLOCKS_AMOUNT__
|
||||
export __BASE_MODULE__
|
||||
export __ROOT_NETWORK__
|
||||
export __MEMPOOL_WEBSITE_URL__
|
||||
export __LIQUID_WEBSITE_URL__
|
||||
export __MINING_DASHBOARD__
|
||||
export __LIGHTNING__
|
||||
export __AUDIT__
|
||||
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
|
||||
export __ACCELERATOR__
|
||||
export __ACCELERATOR_BUTTON__
|
||||
export __SERVICES_API__
|
||||
export __PUBLIC_ACCELERATIONS__
|
||||
export __HISTORICAL_PRICE__
|
||||
export __ADDITIONAL_CURRENCIES__
|
||||
|
||||
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
|
||||
echo ${folder}
|
||||
envsubst < ${folder}/config.template.js > ${folder}/config.js
|
||||
|
||||
exec "$@"
|
||||
set -eu
|
||||
# Preserve the pinned upstream entrypoint (including new runtime options).
|
||||
# Apply our DNS repair only after it has rendered the nginx configuration.
|
||||
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# Resolve the backend again after container IP changes. Run after upstream
|
||||
# placeholder substitution, so the repair also works on an existing container.
|
||||
set -eu
|
||||
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
|
||||
resolv=${2:-/etc/resolv.conf}
|
||||
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
|
||||
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
|
||||
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
|
||||
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
|
||||
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
|
||||
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
|
||||
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
|
||||
|
||||
tmp=$(mktemp "${conf}.archy.XXXXXX")
|
||||
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
||||
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
|
||||
BEGIN {
|
||||
base = "http://" backend ":" port
|
||||
print "# Archipelago: refresh backend DNS after container replacement."
|
||||
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
|
||||
print "resolver_timeout 3s; # archy-dns"
|
||||
}
|
||||
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
|
||||
/^[[:space:]]*location[[:space:]]/ { location = $2 }
|
||||
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
|
||||
target = $2
|
||||
sub(/;$/, "", target)
|
||||
path = substr(target, length(base) + 1)
|
||||
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
|
||||
print "Unexpected backend location: " location > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
|
||||
print "Unexpected backend URI mapping" > "/dev/stderr"
|
||||
failed = 1; exit 1
|
||||
}
|
||||
# Explicitly preserve prefix substitution and query arguments. A variable
|
||||
# proxy_pass without a URI forwards the rewritten URI and original args.
|
||||
print "\t\tset $mp_backend " backend ";"
|
||||
if (path != location)
|
||||
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
|
||||
print "\t\tproxy_pass http://$mp_backend:" port ";"
|
||||
count++
|
||||
next
|
||||
}
|
||||
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
|
||||
{ print }
|
||||
END {
|
||||
if (failed || count != 4) {
|
||||
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$conf" > "$tmp"
|
||||
cat "$tmp" > "$conf"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
/patch/repair-nginx.sh
|
||||
nginx -t
|
||||
exec "$@"
|
||||
@@ -3,6 +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.
|
||||
|
||||
## Blocking incident — before unrelated work
|
||||
|
||||
- **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.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
|
||||
|
||||
## Report
|
||||
|
||||
User: "The cashu server is unable to get it's tokens from nostr on
|
||||
[affected node]" — clarified as the Cashu **client wallet**
|
||||
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
|
||||
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
|
||||
|
||||
## Root cause
|
||||
|
||||
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
|
||||
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
|
||||
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
|
||||
token that fails to redeem is kept in `MinibitsState.pending_claims` and
|
||||
retried on the next poll — by design, so a *transient* failure (mint briefly
|
||||
down, decrypt hiccup) never drops real money.
|
||||
|
||||
But one queued claim had already been redeemed (mint error **11001 "Token
|
||||
Already Spent"** — most likely double-delivered by the relay, or redeemed
|
||||
by an earlier run before a crash lost track of it). That's a *terminal*
|
||||
condition, not a transient one: the code didn't distinguish the two, so it
|
||||
retried the same dead claim every ~6 seconds forever:
|
||||
|
||||
```
|
||||
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
|
||||
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
|
||||
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
|
||||
```
|
||||
|
||||
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
|
||||
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
|
||||
`pending_claims` entry. Each poll also unconditionally queried all three
|
||||
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
|
||||
instead of the primary relay only, adding needless churn and leaking the
|
||||
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
|
||||
was additionally failing NIP-42 auth / 503ing on every poll.
|
||||
|
||||
**No funds were at risk** — an already-redeemed token has zero remaining
|
||||
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
|
||||
banner and wasted relay connections.
|
||||
|
||||
### Why this had already been "fixed" once and came back
|
||||
|
||||
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
|
||||
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
|
||||
`489995c`) and pushed to `origin`. **That branch was never merged into
|
||||
`main`.** `main` carries its own, independently-diverged rewrite of
|
||||
`minibits.rs` that never got those two hardening fixes. The affected node
|
||||
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
|
||||
resurfaced on the first replayed/double-delivered claim after that update.
|
||||
|
||||
## Fix
|
||||
|
||||
Two parts:
|
||||
|
||||
### 1. Immediate unstick (affected node, operational, no code change)
|
||||
|
||||
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
|
||||
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
|
||||
state file, restarted the service.
|
||||
- Verified via `journalctl` that polling resumed cleanly with no further
|
||||
"already been redeemed" warnings.
|
||||
|
||||
### 2. Code fix, ported into `main`
|
||||
|
||||
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
|
||||
NUT error-code-11001 translation as a public constant,
|
||||
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
|
||||
only by the structured mint error code. Remote text cannot impersonate it.
|
||||
- **`core/archipelago/src/wallet/minibits.rs`**:
|
||||
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
|
||||
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
|
||||
preserves it only when all failed mint entries report already-spent proofs;
|
||||
mixed terminal/transient failures remain retryable.
|
||||
- In the claim redeem loop, a redeem failure matching
|
||||
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
|
||||
instead of being pushed back onto `pending_claims`. Every other failure
|
||||
still retries next poll, unchanged.
|
||||
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
|
||||
alone first via `try_connect_relay`, and only adds the two public
|
||||
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
|
||||
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
|
||||
backward with an inclusive `until` boundary. The cursor persists across
|
||||
polls when capped or interrupted, independently of the forward watermark.
|
||||
A full same-second boundary is fetched with a larger limit rather than
|
||||
skipped, so multiple payments sharing a timestamp remain reachable.
|
||||
|
||||
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
|
||||
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
|
||||
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
|
||||
than skips — a different but equally valid way to close the same race), and
|
||||
an attempt-count backstop would have required reshaping the `PendingClaim`
|
||||
enum for marginal extra protection beyond what the 11001 fix already covers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo build -p archipelago` — clean, no new warnings.
|
||||
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
|
||||
suite still green (see PR/commit for the run).
|
||||
- Live on the affected node: claim poll loop confirmed quiet post-unstick
|
||||
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
|
||||
|
||||
## Lesson (recorded in memory)
|
||||
|
||||
A fix that lives only on an unmerged feature branch is not a fix that's
|
||||
actually deployed. Before trusting a memory or changelog claim that
|
||||
something "shipped," check which branch the running/released build was
|
||||
built from (`git log <branch>..main` / `main..<branch>`) rather than
|
||||
assuming a pushed branch was merged.
|
||||
|
||||
## Pre-merge review regressions
|
||||
|
||||
- A 450-event newest-first backlog is completely fetched.
|
||||
- 250 distinct payments sharing one timestamp are preserved.
|
||||
- A 1,300-event backlog resumes after the five-page cap and a state reload.
|
||||
- An interrupted relay fetch retains its unfinished cursor.
|
||||
- Only structured error 11001 is terminal, including when errors are wrapped;
|
||||
remote free text and mixed mint failures cannot discard a retryable claim.
|
||||
@@ -0,0 +1,378 @@
|
||||
# Framework: LND startup, missing Receive address, false zero balance
|
||||
|
||||
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
|
||||
|
||||
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.
|
||||
|
||||
## Mandatory priority across sessions
|
||||
|
||||
The user explicitly requested that this be investigated and fixed on the node
|
||||
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||
Only live verification below, or an explicit user change of priority, clears it.
|
||||
|
||||
## Reported observations
|
||||
|
||||
- Framework stopped showing its Lightning address in Receive.
|
||||
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||
- Manually restarting LND restored operation.
|
||||
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||
operation, or deployment was performed during this offline investigation.
|
||||
- Still clarify whether the restart was a full reboot or management-service
|
||||
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||
address, or the Cashu tab's `@minibits.cash` address.
|
||||
|
||||
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||
The zero display does not establish that any funds were lost. Its relation to
|
||||
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||
|
||||
## Confirmed source findings
|
||||
|
||||
### 1. LND errors can be presented as successful zero balances
|
||||
|
||||
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||
|
||||
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||
can deserialize with every expected field absent instead of rejecting the call.
|
||||
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||
substitute responses with absent balances. HTTP status is not checked here either.
|
||||
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||
preserves prior balances only when the RPC actually rejects.
|
||||
|
||||
This is a confirmed code defect and a plausible explanation for the reported
|
||||
display. It is not proof of the Framework's failure sequence.
|
||||
|
||||
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||
availability explicitly end to end. Never translate unavailable data into a
|
||||
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||
|
||||
### 2. Startup readiness and wallet unlock need live evidence
|
||||
|
||||
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||
- `crash_recovery.rs` can start existing containers directly.
|
||||
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||
and on normal reconciliation of already-running containers. Therefore it is
|
||||
incorrect to conclude that running containers categorically skip unlock.
|
||||
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||
|
||||
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||
|
||||
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||
|
||||
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||
function can delete the LND chain and graph data directories. Its comment assumes
|
||||
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||
|
||||
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||
wallet deletion. Review and disable this destructive fallback before using a
|
||||
modified initialization path as a repair. The existing
|
||||
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||
|
||||
### 4. The missing address must be identified precisely
|
||||
|
||||
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||
tab and response once the user clarifies and the node can be inspected.
|
||||
|
||||
## Next session: live investigation order
|
||||
|
||||
1. Request Framework access and verify node identity without publishing its hostname,
|
||||
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||
before another restart can obscure evidence. Keep raw logs private and redact
|
||||
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||
dependency, refreshed networking/credentials, or masked another failure.
|
||||
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||
deploying or deliberately rebooting the node.
|
||||
|
||||
## Acceptance criteria — all required to close
|
||||
|
||||
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||
- [x] Fix implemented and focused regression tests pass.
|
||||
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||
fresh zero balance; genuine zero remains correct.
|
||||
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||
full reboot, without manually restarting LND.
|
||||
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||
outages show an actionable state and recover without requiring a page reload.
|
||||
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||
|
||||
## Work completed so far
|
||||
|
||||
2026-09-15: source investigation and persistent session-start instructions only.
|
||||
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||
|
||||
## Live evidence captured 2026-09-15
|
||||
|
||||
Access was provided during the same session. Read-only inspection confirmed:
|
||||
|
||||
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||
- The management service's first sequential reconcile pass was occupied by
|
||||
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||
graph sync and two active channels. Both authenticated balance endpoints
|
||||
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||
ecash wallet is present with proofs and an August modification timestamp.
|
||||
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||
which Receive item the user meant before declaring this part repaired.
|
||||
|
||||
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||
|
||||
Candidate changes on `investigate/framework-lnd-startup`:
|
||||
|
||||
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||
balances, and clear the warning after a successful refresh.
|
||||
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||
|
||||
These changes are not yet deployed or verified through a Framework reboot.
|
||||
|
||||
### Candidate validation and staging
|
||||
|
||||
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||
|
||||
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||
- 58 additional reconciliation/update tests passed.
|
||||
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||
- Rust formatting, frontend type checking and production build passed.
|
||||
- Optimized backend build passed in 8m02s.
|
||||
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||
- Private on-node baseline and static channel backup are under
|
||||
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||
because it interrupts all node services. Do not reboot while that is pending.
|
||||
- SSH works through the temporary control socket
|
||||
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||
are authenticated using its existing local macaroon without printing it.
|
||||
|
||||
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||
|
||||
### Authorized deployment and full reboot — 2026-09-15
|
||||
|
||||
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||
approval is no longer pending. Applied the staged backend and dashboard after
|
||||
rechecking both checksums and rollback copies. There were no pending channel
|
||||
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||
|
||||
Live results:
|
||||
|
||||
- A different boot ID confirms a full reboot occurred.
|
||||
- Running backend on disk matches candidate SHA-256
|
||||
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||
restart or interactive unlock was used after this reboot.
|
||||
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||
are identical to the private pre-reboot baseline; both channels are active.
|
||||
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||
- LND container and systemd restart counts are zero after recovery.
|
||||
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||
Home bundle; their bytes match the installed candidate, including the new
|
||||
unavailable-balance notice.
|
||||
- Captured post-reboot management and LND journals in the private local evidence
|
||||
directory. Detailed before/after identity, channel, and balance records remain
|
||||
in the root-only support directory on Framework.
|
||||
|
||||
The user was asked to refresh the dashboard and confirm the originally missing
|
||||
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||
fleet release. The source branch must be integrated before a subsequent release
|
||||
can preserve this fix across the fleet.
|
||||
|
||||
### Cashu Receive follow-up
|
||||
|
||||
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||
“Lightning address unavailable — you can still paste a token below.”
|
||||
|
||||
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||
The existing Minibits handler requires an ecash seed, but setup was available
|
||||
only through the Settings backup screen; Receive hid the actionable cause.
|
||||
|
||||
UI fix commit: `a3b64670`.
|
||||
|
||||
- Receive checks the non-secret seed status when registration fails.
|
||||
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||
component directly in Receive, with import/restore controls excluded from this
|
||||
focused setup screen. Setup derives from the saved node seed when present.
|
||||
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||
and are never emitted to Receive. Receive retries registration after Done.
|
||||
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||
- Ten focused Receive/backup tests and the production UI build passed.
|
||||
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||
setup bundle returned 200 and byte-matched the candidate.
|
||||
- Backed up original Cashu proofs to the root-only support directory as
|
||||
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||
|
||||
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||
After completion, verify saved seed/profile presence, registration success,
|
||||
address display, and unchanged original proofs before closing the incident.
|
||||
|
||||
### Cashu setup completed and verified — 2026-09-15
|
||||
|
||||
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||
independent-seed fallback was implemented or used. The user completed the existing
|
||||
password-verified setup themselves; the assistant did not receive recovery words.
|
||||
|
||||
Read-only node verification confirmed:
|
||||
|
||||
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||
pre-setup copy; every original proof is preserved.
|
||||
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||
and no funded payment test was performed.
|
||||
|
||||
LND automatic startup and native balances were already verified after the full
|
||||
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||
|
||||
Remaining: integrate the tested source branch before the next fleet release;
|
||||
record final human confirmation of the rendered dashboard balance (native balances
|
||||
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||
to repeat already completed checks.
|
||||
|
||||
### Backup copy and layout — 2026-09-15
|
||||
|
||||
At the user's request, shortened the ecash backup explanations and stacked each
|
||||
card section's text and full-width action vertically. Kept the distinction
|
||||
between node-derived and separate phrases, and the warning that a newly created
|
||||
phrase covers future coins rather than existing legacy coins.
|
||||
|
||||
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||
Framework without a restart; served index and backup-component bundle match the
|
||||
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||
protected incident directory. Source integration and final rendered dashboard
|
||||
balance confirmation remain pending as above.
|
||||
|
||||
### LNURL comment-length report — 2026-09-15
|
||||
|
||||
User reports a maximum-comment-length error in some sending wallets. Live
|
||||
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||
contains the address only; Archy's Receive UI does not add a comment. The
|
||||
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||
invoice requests at the advertised minimum amount; no funds were sent.
|
||||
|
||||
The callback did not reproduce the error, including beyond its advertised limit.
|
||||
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||
an empty comment also fails. Need that result before selecting a code fix.
|
||||
The service controls the advertised limit; changing local Receive text or QR
|
||||
cannot raise it for other wallets.
|
||||
|
||||
### Primal Spark: automatic recipient note exceeds the address limit
|
||||
|
||||
User clarified that no comment was entered and the sender is Primal Spark.
|
||||
Checked Framework's management journal over the preceding 20 minutes: no
|
||||
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||
claim polling connected to and disconnected from the relay normally. Historical
|
||||
seed-authentication failures preceded the successful setup already documented.
|
||||
|
||||
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||
while `commentAllowed` is **100**. Description template (address redacted):
|
||||
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||
|
||||
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||
limit with the exact reported error before requesting the callback.
|
||||
|
||||
This identifies a concrete compatibility failure: the address description can
|
||||
become an automatic over-limit comment without the sender typing anything.
|
||||
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||
the payment work, and supplied the same description observed in live metadata.
|
||||
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||
platform/version was not captured. Node logs alone cannot show sender-side
|
||||
validation or requests to the external Minibits callback.
|
||||
|
||||
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||
also shorten its description or raise its advertised comment limit. Archy does
|
||||
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||
|
||||
### Primal workaround confirmed by user
|
||||
|
||||
The user confirmed successful payment after removing the automatic description.
|
||||
The permanent sender-side correction is to leave the recipient comment empty by
|
||||
default and retain receiver metadata only as display text. In Primal Android,
|
||||
remove the assignment of the LNURL description to the draft recipient note in
|
||||
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||
Existing Framework addresses and wallet identities remain unchanged.
|
||||
|
||||
### Can Archy shorten the current address description?
|
||||
|
||||
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||
comment-limit setting. Its public web repository also contains no implementation
|
||||
of the LNURL metadata endpoint or description template.
|
||||
|
||||
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||
to shorten this text was found. Do not send guessed profile-update fields or
|
||||
rename the address to disguise the problem. A Minibits server change could use
|
||||
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||
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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.15-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.15-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.15-alpha",
|
||||
"version": "1.8.17-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
defineProps<{ setupOnly?: boolean }>()
|
||||
const emit = defineEmits<{ ready: [] }>()
|
||||
|
||||
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
||||
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
||||
// would be a third thing to learn.
|
||||
@@ -102,12 +105,14 @@ async function submitReveal() {
|
||||
}
|
||||
|
||||
function closeReveal() {
|
||||
const established = revealedWords.value.length > 0
|
||||
showRevealModal.value = false
|
||||
revealedWords.value = []
|
||||
revealPassword.value = ''
|
||||
revealCode.value = ''
|
||||
revealPassphrase.value = ''
|
||||
showRevealPassphrase.value = false
|
||||
if (established) emit('ready')
|
||||
}
|
||||
|
||||
async function copyRevealedWords() {
|
||||
@@ -221,61 +226,54 @@ async function restoreFromPhrase() {
|
||||
Your ecash has no backup yet
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}</h2>
|
||||
|
||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
|
||||
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
|
||||
phrase — so the words you already wrote down cover your ecash too. Reveal it here
|
||||
if you want to restore your ecash into another wallet (Minibits, Nutstash,
|
||||
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
|
||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm leading-relaxed text-white/60">
|
||||
Your node's recovery phrase also recovers this ecash phrase. Reveal its 24 words
|
||||
to restore in a compatible Cashu wallet without sharing your node's phrase.
|
||||
</p>
|
||||
<p v-else-if="status?.active" class="text-sm text-white/60">
|
||||
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
|
||||
restore your ecash into another wallet (Minibits, Nutstash,
|
||||
<span class="font-mono">cdk-cli</span>).
|
||||
<p v-else-if="status?.active" class="text-sm leading-relaxed text-white/60">
|
||||
Save your 24-word ecash phrase to restore this wallet here or in another
|
||||
compatible Cashu wallet.
|
||||
</p>
|
||||
<p v-else class="text-sm text-white/60">
|
||||
Ecash is a bearer instrument: the coins live in a file on this node, and right now
|
||||
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
|
||||
that for every coin minted from then on.
|
||||
<p v-else class="text-sm leading-relaxed text-white/60">
|
||||
If this node's coin file is lost, your ecash is lost. Set up a phrase to recover
|
||||
future coins; existing coins aren't covered.
|
||||
<template v-if="status?.derivable_from_node_seed">
|
||||
It's derived from this node's recovery phrase, so there's nothing new to write down.
|
||||
Your node's recovery phrase will also recover this phrase.
|
||||
</template>
|
||||
<template v-else>
|
||||
This node has no encrypted seed backup to derive from, so the phrase will be its
|
||||
own — you'll need to write these words down and keep them.
|
||||
This node has no saved seed, so write down and keep the new phrase separately.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
||||
This wallet's phrase was <strong>not</strong> derived from the node's recovery
|
||||
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
|
||||
the node will not bring the ecash back. Only these words will.
|
||||
{{ status?.source === 'imported' ? 'This imported phrase' : 'This phrase' }} is separate
|
||||
from your node's backup. <strong>Only these words recover this ecash wallet.</strong>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||
@click="openReveal"
|
||||
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
|
||||
>{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
||||
Asks your mint which coins it has signed for these words and puts back any that
|
||||
are still unspent. Safe to run at any time — it never duplicates coins you already
|
||||
hold.
|
||||
Recover unspent coins from your mint. Safe to repeat; coins you already hold
|
||||
won't be duplicated.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||
:disabled="restoring"
|
||||
@click="restoreFromPhrase"
|
||||
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
||||
@@ -284,16 +282,16 @@ async function restoreFromPhrase() {
|
||||
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<p class="text-sm text-white/60 min-w-0">
|
||||
<div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
||||
Point this wallet at a phrase you already have — from Minibits, Nutstash or
|
||||
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
|
||||
Import a phrase from Minibits, Nutstash or <span class="font-mono">cdk-cli</span>
|
||||
to restore its coins here.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||
@click="openImport"
|
||||
>Import</button>
|
||||
</div>
|
||||
@@ -319,7 +317,7 @@ async function restoreFromPhrase() {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
||||
stay spendable either way.
|
||||
</p>
|
||||
@@ -376,9 +374,8 @@ async function restoreFromPhrase() {
|
||||
</h3>
|
||||
|
||||
<template v-if="revealedWords.length === 0">
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
Confirm your credentials to
|
||||
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
|
||||
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||
Confirm your credentials to {{ status?.active ? 'reveal' : 'set up' }} your ecash phrase.
|
||||
</p>
|
||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||
<div>
|
||||
@@ -407,12 +404,12 @@ async function restoreFromPhrase() {
|
||||
<SeedRevealPanel :words="revealedWords" />
|
||||
<p class="text-xs text-white/40 mt-3">
|
||||
<template v-if="revealedSource === 'node-seed'">
|
||||
Derived from this node's recovery phrase — restoring the node restores this
|
||||
ecash wallet too. These words also restore it into any NUT-13 wallet.
|
||||
Your node's recovery phrase recovers this ecash wallet too. Use these words
|
||||
separately in a compatible Cashu (NUT-13) wallet.
|
||||
</template>
|
||||
<template v-else>
|
||||
This phrase is independent of the node's recovery phrase. It is the
|
||||
<strong>only</strong> way to restore this ecash wallet — write it down.
|
||||
Write these words down. They are the <strong>only</strong> way to recover
|
||||
this ecash wallet; your node's phrase won't recover it.
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 pt-4">
|
||||
|
||||
@@ -77,8 +77,13 @@
|
||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||
</div>
|
||||
<div v-else-if="lnAddressNeedsSetup" class="mb-3">
|
||||
<p class="text-sm text-white/70 mb-3">Set up this wallet's recovery phrase once to enable its Lightning address.</p>
|
||||
<EcashSeedBackup setup-only @ready="loadLnAddress" />
|
||||
</div>
|
||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import CopyButton from '@/components/CopyButton.vue'
|
||||
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||
@@ -214,6 +220,7 @@ const error = ref('')
|
||||
const lnAddress = ref('')
|
||||
const lnAddressLoading = ref(false)
|
||||
const lnAddressError = ref(false)
|
||||
const lnAddressNeedsSetup = ref(false)
|
||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
||||
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
||||
// operator should see it rather than have it be a silent, unbounded wait.
|
||||
@@ -230,6 +237,7 @@ async function loadLnAddress() {
|
||||
if (lnAddress.value || lnAddressLoading.value) return
|
||||
lnAddressLoading.value = true
|
||||
lnAddressError.value = false
|
||||
lnAddressNeedsSetup.value = false
|
||||
try {
|
||||
const res = await rpcClient.call<{ address?: string }>({
|
||||
method: 'wallet.ecash-lnaddress',
|
||||
@@ -245,6 +253,16 @@ async function loadLnAddress() {
|
||||
}
|
||||
} catch {
|
||||
lnAddressError.value = true
|
||||
// A legacy wallet may hold valid proofs without having a recovery phrase.
|
||||
// Use the existing authenticated setup flow; never silently create a new
|
||||
// identity or send the user to an unexplained generic service error.
|
||||
try {
|
||||
const seedStatus = await rpcClient.call<{ active: boolean; can_activate: boolean }>({
|
||||
method: 'wallet.ecash-seed-status',
|
||||
timeout: 5000,
|
||||
})
|
||||
lnAddressNeedsSetup.value = seedStatus.active === false && seedStatus.can_activate === true
|
||||
} catch { /* Keep the retryable service error when status is unavailable. */ }
|
||||
} finally {
|
||||
lnAddressLoading.value = false
|
||||
}
|
||||
|
||||
@@ -22,6 +22,37 @@ describe('EcashSeedBackup reveal credentials (#127)', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('signals readiness only after authenticated setup is finished and clears the words', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active: false, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-reveal') {
|
||||
return { words: [...Array(23).fill('abandon'), 'art'], source: 'node-seed' } as never
|
||||
}
|
||||
throw new Error('unexpected request')
|
||||
})
|
||||
wrapper = mount(EcashSeedBackup, { props: { setupOnly: true }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const cancel = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!
|
||||
cancel.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
await wrapper.get('button').trigger('click')
|
||||
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||
password.value = 'test-password'
|
||||
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Done')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('ready')).toEqual([[]])
|
||||
expect(document.body.querySelector('[aria-labelledby="reveal-ecash-seed-title"]')).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('abandon')
|
||||
})
|
||||
|
||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||
vi.mocked(rpcClient.call)
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -39,6 +40,51 @@ beforeEach(() => {
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||
let active = false
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-lnaddress') {
|
||||
if (!active) throw new Error('The ecash wallet has no seed yet')
|
||||
return { address: 'someone@minibits.cash' } as never
|
||||
}
|
||||
if (method === 'wallet.ecash-seed-status') {
|
||||
return { active, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
const tab = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('Set up your Cashu Lightning address')
|
||||
expect(document.body.textContent).not.toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([r]) => r.method === 'wallet.ecash-seed-reveal')).toBe(false)
|
||||
active = true
|
||||
wrapper.findComponent(EcashSeedBackup).vm.$emit('ready')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('someone@minibits.cash')
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps a seeded wallet on the retry path during a service outage', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'wallet.ecash-seed-status') return { active: true, can_activate: true } as never
|
||||
throw new Error('service unavailable')
|
||||
})
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(EcashSeedBackup).exists()).toBe(false)
|
||||
expect(document.body.textContent).toContain('receiveBitcoin.lnAddressUnavailable')
|
||||
const retry = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Retry')!
|
||||
expect(retry).toBeTruthy()
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
class="order-2 lg:order-none"
|
||||
:animate="animateCards"
|
||||
:wallet-connected="walletConnected"
|
||||
:wallet-balance-unavailable="walletBalanceUnavailable"
|
||||
:wallet-onchain="walletOnchain"
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
@@ -685,6 +686,7 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
|
||||
// readout instead; a rail only becomes a number when a call actually
|
||||
// succeeds, so a real 0 is still a real 0.
|
||||
const walletConnected = ref(false)
|
||||
const walletBalanceUnavailable = ref(false)
|
||||
const walletOnchain = ref<number | null>(null)
|
||||
const walletLightning = ref<number | null>(null)
|
||||
const walletEcash = ref<number | null>(null)
|
||||
@@ -775,13 +777,24 @@ async function loadWeb5Status() {
|
||||
// call, which is what makes the card feel like an app launch.
|
||||
const balances = Promise.allSettled([
|
||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
||||
.then(res => {
|
||||
if (!Number.isSafeInteger(res.balance_sats) || res.balance_sats < 0 ||
|
||||
!Number.isSafeInteger(res.channel_balance_sats) || res.channel_balance_sats < 0) {
|
||||
throw new Error('LND balance is unavailable')
|
||||
}
|
||||
walletOnchain.value = res.balance_sats
|
||||
walletLightning.value = res.channel_balance_sats
|
||||
walletConnected.value = true
|
||||
walletBalanceUnavailable.value = false
|
||||
walletInfoFailures = 0
|
||||
})
|
||||
.catch(() => {
|
||||
// A single slow poll must NOT flip the card to "disconnected" and
|
||||
// hide balances the user already knows — busy nodes routinely blow
|
||||
// the 5s budget mid-payment or during IO storms (a test node user
|
||||
// report: balances vanished while a payment settled). Only call it
|
||||
// disconnected after three consecutive failures (~30s of silence).
|
||||
walletBalanceUnavailable.value = true
|
||||
walletInfoFailures += 1
|
||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
||||
}),
|
||||
|
||||
@@ -238,6 +238,47 @@ describe('Home tab cache (Task 2): system/update/storage groups + wallet freshne
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['failure', 'missing', 'partial'])('preserves known balances during %s and clears the warning on recovery', async (failure) => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const home = wrapper.findComponent(Home)
|
||||
const refresh = () => (home.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
|
||||
rpcCallMock.mockImplementationOnce(async () => {
|
||||
if (failure === 'failure') throw new Error('wallet locked')
|
||||
return failure === 'partial' ? { balance_sats: 0 } : {}
|
||||
})
|
||||
await refresh()
|
||||
await settle()
|
||||
const card = wrapper.findComponent(HomeWalletCard)
|
||||
expect(card.props('walletOnchain')).toBe(5000)
|
||||
expect(card.props('walletLightning')).toBe(2500)
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('last known')
|
||||
const snapshot = JSON.parse(localStorage.getItem('archy-wallet-snapshot-v1')!)
|
||||
expect(snapshot.onchain).toBe(5000)
|
||||
expect(snapshot.lightning).toBe(2500)
|
||||
rpcCallMock.mockImplementationOnce(async () => ({ balance_sats: 0, channel_balance_sats: 0, synced_to_chain: true }))
|
||||
await refresh()
|
||||
await settle()
|
||||
expect(card.props('walletOnchain')).toBe(0)
|
||||
expect(card.props('walletLightning')).toBe(0)
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows unknown rather than zero when the first LND request fails', async () => {
|
||||
rpcCallMock.mockImplementation(async (request) => {
|
||||
if (request.method === 'lnd.getinfo') throw new Error('wallet locked')
|
||||
return defaultRpcCallImpl(request)
|
||||
})
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
const card = wrapper.findComponent(HomeWalletCard)
|
||||
expect(card.props('walletOnchain')).toBeNull()
|
||||
expect(card.props('walletLightning')).toBeNull()
|
||||
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('unavailable')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
||||
const wrapper = mountHomeHost()
|
||||
await settle()
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="walletBalanceUnavailable" data-testid="wallet-balance-unavailable" class="text-sm text-amber-200 mb-3" role="status">
|
||||
{{ walletOnchain != null || walletLightning != null
|
||||
? 'Bitcoin and Lightning balances could not be refreshed. Showing last known amounts.'
|
||||
: 'Bitcoin and Lightning balances are unavailable while the wallet starts or reconnects.' }}
|
||||
</p>
|
||||
|
||||
<!-- Incoming Transactions Panel -->
|
||||
<transition name="incoming-tx-slide">
|
||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||
@@ -221,6 +227,7 @@ export interface WalletTransaction {
|
||||
const props = defineProps<{
|
||||
animate: boolean
|
||||
walletConnected: boolean
|
||||
walletBalanceUnavailable?: boolean
|
||||
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
||||
// what lets the card show a pixel readout instead of claiming a figure.
|
||||
walletOnchain: number | null
|
||||
|
||||
@@ -362,6 +362,32 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.17-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.17-alpha</span>
|
||||
<span class="text-xs text-white/40">September 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>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.</p>
|
||||
<p>Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.</p>
|
||||
<p>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.</p>
|
||||
<p>Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.16-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.16-alpha</span>
|
||||
<span class="text-xs text-white/40">September 15, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.</p>
|
||||
<p>Fixed repeated Mempool update offers: downstream -archyN patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.</p>
|
||||
<p>Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.</p>
|
||||
<p>Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.15-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -371,6 +397,7 @@ init()
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.</p>
|
||||
<p>Added regression coverage for Cuprate install and installed-state grouping.</p>
|
||||
<p>Release validation was rerun on the corrected tree before OTA and ISO publication.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.14-alpha -->
|
||||
|
||||
+18
-18
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.",
|
||||
"**Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.",
|
||||
"**GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.",
|
||||
"**Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites."
|
||||
"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.14-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-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.14-alpha",
|
||||
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
|
||||
"size_bytes": 64568360
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.14-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.14-alpha.tar.gz",
|
||||
"new_version": "1.8.14-alpha",
|
||||
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
|
||||
"size_bytes": 98792963
|
||||
"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-13",
|
||||
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.14-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
+3251
-3249
File diff suppressed because one or more lines are too long
+18
-18
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.",
|
||||
"**Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.",
|
||||
"**GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.",
|
||||
"**Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites."
|
||||
"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.14-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-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.14-alpha",
|
||||
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
|
||||
"size_bytes": 64568360
|
||||
"new_version": "1.8.17-alpha",
|
||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
||||
"size_bytes": 64953344
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.14-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.14-alpha.tar.gz",
|
||||
"new_version": "1.8.14-alpha",
|
||||
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
|
||||
"size_bytes": 98792963
|
||||
"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-13",
|
||||
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
|
||||
"release_date": "2026-09-15",
|
||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.14-alpha"
|
||||
"version": "1.8.17-alpha"
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"changelog": [
|
||||
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
|
||||
"Added regression coverage for Cuprate install and installed-state grouping."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.8.15-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.8.15-alpha",
|
||||
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
|
||||
"size_bytes": 64571544
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.15-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
|
||||
"new_version": "1.8.15-alpha",
|
||||
"sha256": "41ad8a2ec3f3338f66a5ffffecbbf23872a61524cd0c64d7b392e00b9b40576b",
|
||||
"size_bytes": 98798913
|
||||
}
|
||||
],
|
||||
"release_date": "2026-09-13",
|
||||
"signature": "9b4c074e5014ea940e58b3a195c9da90f975f0664c93b9576db3055cff8cb09e59b2ffbdb2f48e407e9890afaf3ca03d28f257ede925387b5ffb30ffb688b00d",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.15-alpha"
|
||||
}
|
||||
@@ -33,7 +33,8 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
|
||||
|
||||
# Mempool stack
|
||||
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
|
||||
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
|
||||
# The patched frontend is published by chaum on the same trusted registry.
|
||||
MEMPOOL_WEB_IMAGE="source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||
|
||||
# BTCPay
|
||||
|
||||
@@ -53,24 +53,33 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||
# https is accepted as well as http. Requiring http:// meant the only remote
|
||||
# whose credential actually works for git push (the https one) was rejected,
|
||||
# while the http remote it forced you to use had a dead token — so publishing
|
||||
# failed on auth after the manifest had already passed every check
|
||||
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
|
||||
# rather than assumed.
|
||||
# Remote URLs are public metadata: ngit can include them in repository
|
||||
# announcements. Keep credentials in Git's credential helper, never in URLs.
|
||||
case "$remote_url" in
|
||||
http://*@*|https://*@*) ;;
|
||||
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
||||
http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;;
|
||||
http://*|https://*) ;;
|
||||
*) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;;
|
||||
esac
|
||||
|
||||
scheme=${remote_url%%://*}
|
||||
rest=${remote_url#*://}
|
||||
auth=${rest%%@*}
|
||||
host_path=${rest#*@}
|
||||
host_path=${remote_url#*://}
|
||||
host=${host_path%%/*}
|
||||
repo_path=${host_path#*/}
|
||||
repo_path=${repo_path%.git}
|
||||
credential=$(printf 'url=%s\n\n' "$remote_url" | GIT_TERMINAL_PROMPT=0 git -C "$PROJECT_ROOT" credential fill) \
|
||||
|| fail "no Git credential available for $REMOTE; configure a credential helper first"
|
||||
auth_user=""
|
||||
auth_password=""
|
||||
while IFS= read -r field; do
|
||||
case "$field" in
|
||||
username=*) auth_user=${field#username=} ;;
|
||||
password=*) auth_password=${field#password=} ;;
|
||||
esac
|
||||
done <<< "$credential"
|
||||
[ -n "$auth_user" ] && [ -n "$auth_password" ] \
|
||||
|| fail "Git credential helper did not provide a username and password for $REMOTE"
|
||||
auth="$auth_user:$auth_password"
|
||||
unset credential auth_user auth_password
|
||||
api="$scheme://$host/api/v1/repos/$repo_path"
|
||||
release_url="$api/releases/tags/v${VERSION}"
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the built frontend against a backend that disappears and changes IP.
|
||||
|
||||
Uses an isolated Podman network and disposable containers, never the node stack.
|
||||
Usage: python3 scripts/test-mempool-dns-recovery.py [frontend-image]
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
IMAGE = sys.argv[1] if len(sys.argv) > 1 else (
|
||||
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
|
||||
)
|
||||
BACKEND = "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
|
||||
prefix = "mempool-dns-test-" + uuid.uuid4().hex[:8]
|
||||
network, frontend, backend = prefix, prefix + "-web", prefix + "-api"
|
||||
|
||||
|
||||
def podman(*args, check=True):
|
||||
return subprocess.run(["podman", *args], capture_output=True, text=True,
|
||||
check=check, timeout=60).stdout.strip()
|
||||
|
||||
|
||||
def eventually(check, timeout=25):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
return check()
|
||||
except (AssertionError, OSError, urllib.error.URLError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
server = r"""
|
||||
const http = require('http'), crypto = require('crypto');
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({url: req.url, instance: process.env.INSTANCE}));
|
||||
});
|
||||
server.on('upgrade', (req, socket) => {
|
||||
const key = crypto.createHash('sha1')
|
||||
.update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
||||
.digest('base64');
|
||||
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' +
|
||||
'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + key + '\r\n' +
|
||||
'X-Upstream-Url: ' + req.url + '\r\nX-Instance: ' + process.env.INSTANCE + '\r\n\r\n');
|
||||
});
|
||||
server.listen(8999, '0.0.0.0');
|
||||
"""
|
||||
|
||||
try:
|
||||
podman("network", "create", network)
|
||||
subnet = ipaddress.ip_network(json.loads(podman("network", "inspect", network))[0]["subnets"][0]["subnet"])
|
||||
podman("run", "-d", "--name", frontend, "--network", network,
|
||||
"-p", "127.0.0.1::8080", "-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
|
||||
"-e", "FRONTEND_HTTP_PORT=8080", IMAGE)
|
||||
port = int(podman("port", frontend, "8080/tcp").rsplit(":", 1)[1])
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
|
||||
def static_ready():
|
||||
assert urllib.request.urlopen(url, timeout=4).status == 200
|
||||
|
||||
eventually(static_ready)
|
||||
started = podman("inspect", frontend, "--format", "{{.State.StartedAt}}")
|
||||
try:
|
||||
urllib.request.urlopen(url + "/api/v1/backend-info", timeout=6)
|
||||
raise AssertionError("An absent backend must not appear healthy")
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 502
|
||||
print("PASS: frontend starts while backend DNS is absent", flush=True)
|
||||
|
||||
for instance, offset in [("first", 10), ("replacement", 11)]:
|
||||
if instance == "replacement":
|
||||
podman("rm", "-f", backend)
|
||||
# Ensure the cached address has expired while the backend is absent.
|
||||
time.sleep(6)
|
||||
podman("run", "-d", "--name", backend, "--network", network,
|
||||
"--network-alias", "mempool-api", "--ip", str(subnet[offset]),
|
||||
"-e", "INSTANCE=" + instance, "--entrypoint", "node", BACKEND,
|
||||
"-e", server)
|
||||
|
||||
for path, expected in [
|
||||
("/api/blocks/tip/height?probe=one", "/api/v1/blocks/tip/height?probe=one"),
|
||||
("/api/v1/fees/recommended?probe=two", "/api/v1/fees/recommended?probe=two"),
|
||||
]:
|
||||
def check_http():
|
||||
with urllib.request.urlopen(url + path, timeout=4) as response:
|
||||
result = json.load(response)
|
||||
assert result == {"url": expected, "instance": instance}, result
|
||||
eventually(check_http)
|
||||
|
||||
for path in ["/api/v1/ws?probe=ws", "/ws?probe=ws"]:
|
||||
def check_ws():
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
|
||||
sock.sendall((f"GET {path} HTTP/1.1\r\nHost: localhost\r\n"
|
||||
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").encode())
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response:
|
||||
part = sock.recv(4096)
|
||||
assert part, response
|
||||
response += part
|
||||
assert b"101 Switching Protocols" in response, response
|
||||
assert b"X-Upstream-Url: /?probe=ws" in response, response
|
||||
assert ("X-Instance: " + instance).encode() in response, response
|
||||
eventually(check_ws)
|
||||
assert podman("inspect", frontend, "--format", "{{.State.StartedAt}}") == started
|
||||
print(f"PASS: {instance} backend at {subnet[offset]}: HTTP paths, query strings, both WebSocket routes; frontend never restarted", flush=True)
|
||||
|
||||
before = podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf")
|
||||
podman("exec", frontend, "/patch/repair-nginx.sh")
|
||||
assert podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf") == before
|
||||
podman("exec", frontend, "nginx", "-t")
|
||||
print("PASS: repeated repair is idempotent and nginx configuration is valid", flush=True)
|
||||
podman("restart", frontend)
|
||||
eventually(static_ready)
|
||||
|
||||
def after_restart():
|
||||
with urllib.request.urlopen(url + "/api/blocks/tip/height?restart=1", timeout=4) as response:
|
||||
assert json.load(response) == {
|
||||
"url": "/api/v1/blocks/tip/height?restart=1", "instance": "replacement"
|
||||
}
|
||||
eventually(after_restart)
|
||||
eventually(check_ws)
|
||||
print("PASS: frontend restart preserves DNS recovery and HTTP/WebSocket routing", flush=True)
|
||||
finally:
|
||||
podman("rm", "-f", frontend, backend, check=False)
|
||||
podman("network", "rm", network, check=False)
|
||||
@@ -168,7 +168,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
|
||||
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
||||
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
||||
update:: lnd container::image_versions scanner drift missing_secret collision
|
||||
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
|
||||
|
||||
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||
if [[ $LIVE -eq 1 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user