Compare commits

...
Author SHA1 Message Date
archipelago abfbccc906 fix(ecash): preserve retryable claims and resume relay backlogs 2026-09-15 12:31:49 -04:00
ssmithxandClaude Sonnet 5 9d4e74e094 docs: redact node hostname from the Minibits incident writeup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:19:23 +00:00
ssmithxandClaude Sonnet 5 db355b759c fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn
claim_and_redeem retried every redeem failure indefinitely, including a
terminal one: mint error 11001 "Token Already Spent" (a claim replayed by a
relay-watermark edge case, or already redeemed by an earlier run). On
archy-x250-pa3 this pinned pending_claims at 1 forever and hammered
mint.minibits.cash's swap endpoint every ~6s, with the UI permanently
showing "a payment arrived but couldn't be redeemed yet".

- mint_client: expose the NUT error-code-11001 message as
  ALREADY_REDEEMED_MSG so callers can recognize it without duplicating the
  string.
- minibits: drop (not retry) a redeem failure that matches
  is_already_redeemed — the value was already swept, so retrying can never
  succeed.
- fetch_relay_dms: query the primary relay.minibits.cash alone first,
  falling back to the public relay.damus.io/nos.lol only if it's
  unreachable, and page past a 200-DM backlog instead of silently
  stranding older DMs behind an un-advanced watermark.

This fix already existed on feat/minibits-lnurl-receive (4e410d7, 489995c,
2026-09-09) but that branch was never merged into main, which has its own
independently-diverged minibits.rs — so the bug shipped again in
1.8.16-alpha. Ported directly onto main's current implementation this time.

Immediate unblock on archy-x250-pa3: cleared the one poisoned
pending_claims entry from wallet/minibits.json by hand (already-redeemed,
zero value at risk) and restarted archipelago.service; confirmed via
journalctl that polling is quiet again.

See docs/incident-2026-09-15-minibits-already-redeemed.md for the full
writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:12:00 +00:00
archipelago 31d77f01ac chore: publish release v1.8.16-alpha
Demo images / Build & push demo images (push) Failing after 34s
2026-09-15 04:02:57 -04:00
archipelago 1b0ed281b2 chore: prepare release v1.8.16-alpha 2026-09-15 03:59:43 -04:00
archipelago 9c6580f5c0 fix: prevent stale catalog updates and redundant container recreation
Demo images / Build & push demo images (push) Failing after 40s
2026-09-15 03:40:21 -04:00
23 changed files with 999 additions and 171 deletions
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
## 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.
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.15-alpha"
version = "1.8.16-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.15-alpha"
version = "1.8.16-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -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;
}
+232 -25
View File
@@ -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(&current, &installed).unwrap());
let legacy = vec![(
"mempool-web".into(),
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &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,
);
+130 -34
View File
@@ -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", &registry);
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"));
@@ -4667,6 +4667,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 +5097,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 +5222,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,
@@ -6771,6 +6799,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());
+35 -20
View File
@@ -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,
+26 -4
View File
@@ -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;
+266 -34
View File
@@ -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]
+50 -3
View File
@@ -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]
@@ -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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+2 -2
View File
@@ -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",
@@ -362,6 +362,19 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- 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 +384,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 -17
View File
@@ -1,29 +1,30 @@
{
"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.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
"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."
],
"components": [
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"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": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
}
],
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.15-alpha"
"version": "1.8.16-alpha"
}
+18 -17
View File
@@ -1,29 +1,30 @@
{
"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.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
"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."
],
"components": [
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"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": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
}
],
"release_date": "2026-09-13",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.15-alpha"
"version": "1.8.16-alpha"
}
+1 -1
View File
@@ -34,7 +34,7 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
# The patched frontend is published by chaum on the same trusted registry.
MEMPOOL_WEB_IMAGE="${MEMPOOL_WEB_IMAGE:-source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1}"
MEMPOOL_WEB_IMAGE="source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
+1 -1
View File
@@ -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