Compare commits

...
Author SHA1 Message Date
ssmithxandClaude Opus 5.5 03e38d1ca3 test: regression tests for the paid-download fixes
- mint_client: a stub mint shows swap() sends the full v2 keyset id when
  given a cashuB short id, and leaves complete v1/v2 ids unchanged.
- fips::dial: the single-delivery decisions are now small functions
  (fips_answer_is_final, fips_retryable). Tests cover them and, against a
  silent local peer, check that a single-delivery request isn't resent
  after a timeout while an ordinary one still is.
- content_server: an unreadable paid file returns Unavailable before the
  payment gate runs, and a readable one still returns 402. Also covers
  ensure_readable's grant/reopen behaviour. The podman grant is replaced
  by a refusal under cfg(test) so results don't depend on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:16 +00:00
ssmithxandClaude Opus 5.5 e5fc99d66c fix(content): never charge for a file the seller can't serve or replay a spent token
After the keyset-id fix, a Minibits paid download still failed and the
buyer lost the sats. What happened, 2026-09-29, amishparadise:

1. The seller redeemed the token, then failed to read the file. It was a
   FileBrowser upload owned by the container subuid (100999) with mode
   0640. The handler mapped that Err to 404.
2. The buyer's FIPS dial treats 404 as "fall back to Tor" and resent the
   request with the same, now spent, token. The seller answered 402, and
   the buyer showed "seller doesn't accept your Cashu mint".

Fixes:
- serve_content checks the file is readable before the paid gate. If it
  isn't, it grants read with `podman unshare chmod a+r`, which matches
  the other shared files. If that also fails it returns Unavailable (503)
  without taking payment.
- The content handler returns 500 on internal errors and logs them,
  instead of a silent 404.
- New PeerRequest::single_delivery(), used for the paid download: the
  FIPS answer is final, FIPS retries only when it never connected, and
  there's no Tor replay once the request may have been delivered.
- The buyer shows the seller's error text for non-402 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:42:20 +00:00
ssmithxandClaude Opus 5.5 8b74803290 fix(ecash): repair short v2 keyset ids on every swap, not just receive
Paid cloud downloads paid with Minibits ecash were always rejected. The
buyer sends a cashuB token, which carries NUT-02 v2 keyset ids in their
8-byte short form. Minibits rotated its active keyset to a v2 id, and the
seller's verify_and_receive_payment called MintClient::swap directly,
skipping the short->full id repair that only receive_token applied. The
mint answered 422 ("ID length invalid"). The buyer then showed the
misleading "seller doesn't accept your Cashu mint" hint.

Move the repair into swap() so every caller is covered: payment verify,
streaming gate, send change, and cross-mint swaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:47:23 +00:00
archipelago 540639d2c1 chore: publish release v1.8.19-alpha 2026-09-28 13:21:51 -04:00
archipelago 562871b1ce chore: prepare signed release v1.8.19-alpha 2026-09-28 13:18:34 -04:00
archipelago cca3f8bfcd docs: include AIUI packaging fix in v1.8.19 release notes
Demo images / Build & push demo images (push) Failing after 44s
2026-09-28 13:13:31 -04:00
archipelago 89c08be712 fix(aiui): match host color scheme for iframe transparency
Demo images / Build & push demo images (push) Failing after 56s
2026-09-28 12:37:40 -04:00
archipelago b4ecf86c13 chore: stage v1.8.19-alpha version bump 2026-09-28 12:33:59 -04:00
archipelago b14fe78306 fix(aiui): expose host wallpaper and package fresh production builds 2026-09-28 12:32:18 -04:00
archipelago 3f0c1038c3 docs: add v1.8.19 release notes to settings 2026-09-28 12:23:57 -04:00
archipelago 1fbefce6df docs: add v1.8.19-alpha release notes 2026-09-28 12:23:05 -04:00
archipelago 63cb68451a fix(aiui): keep embedded chat background transparent 2026-09-22 05:04:59 -04:00
archipelago 17cfebbe26 chore: publish release v1.8.18-alpha 2026-09-20 11:49:39 -04:00
archipelago 379fb930fc chore: prepare release v1.8.18-alpha
Demo images / Build & push demo images (push) Failing after 43s
2026-09-20 11:45:35 -04:00
archipelago 1bebdeac0f Prepare v1.8.18-alpha release notes 2026-09-18 06:36:43 -04:00
19 changed files with 657 additions and 96 deletions
+12
View File
@@ -2,6 +2,18 @@
## Unreleased
## v1.8.19-alpha (2026-09-28)
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
- Embedded AIUI now stays transparent so the dashboard background appears once.
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
## v1.8.18-alpha (2026-09-18)
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
## v1.8.17-alpha (2026-09-15)
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
+3 -2
View File
@@ -46,13 +46,14 @@ interface RateBucket {
const rateBuckets = new Map<string, RateBucket>()
// Clean up stale buckets every 5 minutes
// Vite imports this module during builds too; cleanup must not keep the
// process alive once compilation has finished.
setInterval(() => {
const now = Date.now()
for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key)
}
}, 5 * 60_000)
}, 5 * 60_000).unref()
function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown'
+1
View File
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
// Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
+5 -5
View File
@@ -2,13 +2,13 @@
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' }
:style="isEmbedded
? { background: 'transparent' }
: isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: { backgroundColor: '#f5f4f1' }"
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div
+15 -6
View File
@@ -57,12 +57,8 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
EXCEPT the embedded Chat page, which intentionally goes transparent so
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
no background-color here, "transparent" fell through to the browser's
default white canvas instead. Match the theme's own dark/light default so
nothing above this ever needs to guess. */
/* Standalone canvas fallback. Embedded mode overrides this below so
Archy's wallpaper remains visible through the iframe. */
background-color: #0a0a0a;
}
@@ -70,6 +66,19 @@ html.light body {
background-color: #faf9f6;
}
/* The host owns the wallpaper when AIUI is embedded. The document canvas
must be transparent too, otherwise it hides the host behind ChatPage. */
html.aiui-embedded {
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
with a different scheme an opaque canvas despite transparent CSS. */
color-scheme: dark;
}
html.aiui-embedded,
html.aiui-embedded body {
background: transparent;
}
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
@layer components {
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.17-alpha"
version = "1.8.19-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.17-alpha"
version = "1.8.19-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+18 -1
View File
@@ -162,11 +162,28 @@ 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::Unavailable) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"The seller's node can't read this file right now. No payment was taken."}"#,
),
)),
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
// Not a 404: a paid request may already have been charged by the
// time this fails, and "not found" hid the real error entirely.
Err(e) => {
tracing::error!("Serving content {content_id} failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from("Failed to serve content"),
))
}
}
}
+8 -1
View File
@@ -555,6 +555,9 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
// The token is a bearer instrument the seller redeems on first sight:
// a Tor replay after FIPS delivered it can only arrive spent.
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
@@ -610,8 +613,12 @@ impl RpcHandler {
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 reason = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
.unwrap_or_else(|| format!("Peer returned an error ({status})."));
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
"error": format!("{reason} Your ecash was refunded to your wallet.")
}));
}
+225 -18
View File
@@ -7,7 +7,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const CATALOG_FILE: &str = "content/catalog.json";
const CONTENT_DIR: &str = "content/files";
@@ -238,6 +238,9 @@ pub enum ServeResult {
Forbidden,
/// Content not found.
NotFound,
/// The catalog entry and file exist but this node can't read the file.
/// Returned before any payment is taken.
Unavailable,
}
/// Serve a content item by ID with access control and optional range request.
@@ -296,6 +299,37 @@ 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);
}
// Confirm the file is readable BEFORE the paid gate below redeems the
// buyer's token. Reading it only afterwards meant a permission error
// surfaced after the sale: the buyer was charged and got an error
// instead of the file (2026-09-29, a FileBrowser upload left 0640).
if let Err(e) = ensure_readable(&file_path).await {
warn!(
content_id = %id,
path = %file_path.display(),
"shared content file is not readable by this node: {e:#}"
);
return Ok(ServeResult::Unavailable);
}
// Check access control
if !owner_session {
match &item.access {
@@ -336,23 +370,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
@@ -572,6 +589,62 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
}
}
/// Make sure this service can open `path`, granting read access if it can't.
///
/// FileBrowser writes uploads as its container user (a rootless subuid such
/// as 100999), and some arrive 0640 — unreadable by this service, although
/// most shared files are already 0644. Inside the rootless user namespace
/// that subuid is ours, so `podman unshare chmod a+r` grants the same read
/// access the other shared files have, without sudo.
async fn ensure_readable(path: &Path) -> Result<()> {
ensure_readable_with(path, grant_read_access).await
}
async fn ensure_readable_with<F, Fut>(path: &Path, grant: F) -> Result<()>
where
F: FnOnce(PathBuf) -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
match fs::File::open(path).await {
Ok(_) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(e) => return Err(e).context("Failed to open content file"),
}
grant(path.to_path_buf()).await?;
info!("Granted read access to shared content file {}", path.display());
fs::File::open(path)
.await
.context("Content file still unreadable after chmod")?;
Ok(())
}
// Tests must not shell out to podman: whether it exists (and can chmod a
// file the test user owns) would decide the outcome.
#[cfg(not(test))]
use grant_read_via_podman as grant_read_access;
#[cfg(test)]
async fn grant_read_access(_path: PathBuf) -> Result<()> {
anyhow::bail!("granting read access is disabled in tests")
}
#[cfg_attr(test, allow(dead_code))]
async fn grant_read_via_podman(path: PathBuf) -> Result<()> {
let out = tokio::process::Command::new("podman")
.args(["unshare", "chmod", "a+r"])
.arg(&path)
.output()
.await
.context("Failed to run podman unshare chmod")?;
if !out.status.success() {
anyhow::bail!(
"podman unshare chmod a+r failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Verify a payment token covers the required amount.
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
/// Swaps proofs at the mint to verify they're unspent before accepting.
@@ -725,3 +798,137 @@ mod prune_missing_content_tests {
assert_eq!(reloaded.items[0].id, "present-item");
}
}
#[cfg(test)]
mod unreadable_content_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
/// Writes `bytes` to the FileBrowser area and makes it unreadable, the
/// way a 0640 upload owned by a container subuid looks to this service.
/// `None` when the test runs as root, where mode bits don't stop reads.
fn unreadable_file(data_dir: &Path, name: &str) -> Option<PathBuf> {
let dir = data_dir.join("filebrowser").join("Music");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, b"audio").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
std::fs::File::open(&path).is_err().then_some(path)
}
fn paid_item(filename: &str) -> ContentItem {
ContentItem {
id: "paid-item".to_string(),
filename: filename.to_string(),
mime_type: "audio/mpeg".to_string(),
size_bytes: 5,
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(),
}
}
/// Regression (2026-09-29): the seller redeemed the buyer's token and
/// only then failed to read the file, so the buyer paid for nothing.
/// An unreadable file must be refused before the payment gate runs,
/// which is why a token that would never verify still gets Unavailable
/// rather than PaymentRequired.
#[tokio::test]
async fn an_unreadable_paid_file_is_refused_before_any_payment_is_taken() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path();
let Some(_path) = unreadable_file(data_dir, "song.mp3") else {
return; // running as root
};
save_catalog(
data_dir,
&ContentCatalog {
items: vec![paid_item("Music/song.mp3")],
},
)
.await
.unwrap();
let result = serve_content(
data_dir,
"paid-item",
Some("cashuBnot-a-real-token"),
None,
None,
None,
false,
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
// An unreadable file is not a missing one: keep the catalog entry.
assert_eq!(load_catalog(data_dir).await.unwrap().items.len(), 1);
}
#[tokio::test]
async fn a_readable_paid_file_still_demands_payment() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path();
let music = data_dir.join("filebrowser").join("Music");
std::fs::create_dir_all(&music).unwrap();
std::fs::write(music.join("song.mp3"), b"audio").unwrap();
save_catalog(
data_dir,
&ContentCatalog {
items: vec![paid_item("Music/song.mp3")],
},
)
.await
.unwrap();
let result = serve_content(data_dir, "paid-item", None, None, None, None, false)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
}
#[tokio::test]
async fn ensure_readable_grants_access_once_then_reopens() {
let dir = tempfile::tempdir().unwrap();
let Some(path) = unreadable_file(dir.path(), "a.mp3") else {
return;
};
let calls = std::sync::atomic::AtomicUsize::new(0);
ensure_readable_with(&path, |p| {
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644))?;
Ok(())
}
})
.await
.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn ensure_readable_leaves_a_readable_file_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ok.mp3");
std::fs::write(&path, b"x").unwrap();
ensure_readable_with(&path, |_| async { anyhow::bail!("must not grant") })
.await
.unwrap();
}
#[tokio::test]
async fn ensure_readable_reports_a_failed_grant() {
let dir = tempfile::tempdir().unwrap();
let Some(path) = unreadable_file(dir.path(), "b.mp3") else {
return;
};
let err = ensure_readable_with(&path, |_| async { anyhow::bail!("no podman") })
.await
.unwrap_err();
assert!(err.to_string().contains("no podman"));
}
}
+152 -3
View File
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// Is this FIPS answer the final one, or should the request go again over
/// Tor? A single-delivery request already reached the peer, so any answer
/// is final: a Tor replay would carry the same (possibly spent) payload.
fn fips_answer_is_final(
pref: crate::settings::transport::TransportPref,
single_delivery: bool,
status: reqwest::StatusCode,
) -> bool {
pref == crate::settings::transport::TransportPref::Fips
|| single_delivery
|| !fips_should_fall_back(status)
}
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
/// peer never saw it; a timeout can land after the request was delivered.
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
e.is_connect() || (!single_delivery && e.is_timeout())
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -130,10 +149,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
}
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
async fn send_with_retry_if(
rb: reqwest::RequestBuilder,
retryable: impl Fn(&reqwest::Error) -> bool,
) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
Err(e) if retryable(&e) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
@@ -350,6 +377,9 @@ pub struct PeerRequest<'a> {
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
/// The request carries something that must reach the peer at most once
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
pub single_delivery: bool,
}
impl<'a> PeerRequest<'a> {
@@ -363,9 +393,25 @@ impl<'a> PeerRequest<'a> {
fips_timeout: None,
service: None,
record_data_dir: None,
single_delivery: false,
}
}
/// Never send this request twice. A paid download carries a bearer ecash
/// token that the seller redeems on first sight; replaying it over Tor
/// after FIPS already delivered it hands the seller a spent token, so the
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
/// answered 404 after the seller redeemed, the Tor retry got 402).
///
/// With this set, whatever FIPS answers is final, the FIPS retry fires
/// only when the first attempt never connected, and Tor is used only when
/// FIPS could not have delivered the request. An attempt that may have
/// been delivered but timed out is an error, not a fallback.
pub fn single_delivery(mut self) -> Self {
self.single_delivery = true;
self
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
@@ -481,7 +527,7 @@ impl<'a> PeerRequest<'a> {
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -617,8 +663,23 @@ impl<'a> PeerRequest<'a> {
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
Ok(Ok(r)) => Ok(Some(r)),
// Anything but a failed connect may have reached the peer.
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS GET {} failed after the request may have been delivered \
(not retrying over Tor): {}",
self.path,
e
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS GET {} exceeded its {:?} budget after the request may have \
been delivered (not retrying over Tor)",
self.path,
budget
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -759,4 +820,92 @@ mod tests {
let err = decode_response(0xAABB, &r, "x").unwrap_err();
assert!(err.to_string().contains("no AAAA"));
}
#[test]
fn a_single_delivery_answer_is_final_whatever_its_status() {
use crate::settings::transport::TransportPref;
use reqwest::StatusCode;
// Regression (2026-09-29): the seller redeemed a paid download's
// token, answered 404, and the Tor fallback replayed the spent token.
for status in [
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::OK,
] {
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
}
// Everything else keeps the existing fallback rules.
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::NOT_FOUND
));
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::BAD_GATEWAY
));
assert!(fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::PAYMENT_REQUIRED
));
assert!(fips_answer_is_final(
TransportPref::Fips,
false,
StatusCode::NOT_FOUND
));
}
/// A listener that accepts connections and never answers, counting them.
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
held.push(stream); // keep it open, never reply
}
});
(format!("http://{addr}/content/x"), seen)
}
#[tokio::test]
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
.await
.expect_err("peer never answers");
assert!(err.is_timeout());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_single_delivery_request_still_retries_a_refused_connect() {
// Nothing listening: the peer provably never saw the request.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let c = client_with_timeout(Duration::from_millis(500));
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
fips_retryable(true, e)
})
.await
.expect_err("nothing listening");
assert!(err.is_connect());
assert!(fips_retryable(true, &err));
}
}
+140 -2
View File
@@ -514,6 +514,13 @@ impl MintClient {
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
let keyset = self.get_active_sat_keyset().await?;
// cashuB tokens carry NUT-02 v2 keyset ids in their 8-byte short form,
// which the mint rejects (bare 422). Repair here, not at each caller:
// the paid-download seller path swapped directly and every Minibits
// payment failed once the mint rotated to a v2 keyset.
let repaired = self.resolve_truncated_keyset_ids(inputs).await;
let inputs: &[Proof] = &repaired;
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
// outright unless outputs == inputs - fee (`11005 Transaction inputs
// should equal outputs less fee`). Applied here rather than at each
@@ -813,8 +820,7 @@ impl MintClient {
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(total);
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
let result = self.swap(&proofs, &target_amounts).await?;
let result = self.swap(&entry.proofs, &target_amounts).await?;
all_new_proofs.extend(result.new_proofs);
}
@@ -863,4 +869,136 @@ mod tests {
let client = MintClient::new("http://mint.example.com").unwrap();
assert_eq!(client.url(), "http://mint.example.com");
}
/// A minimal mint on 127.0.0.1 answering `/v1/keys` and `/v1/keysets`
/// with one v2 keyset, and rejecting every `/v1/swap` as already spent.
/// Each swap request body is sent back on the returned channel.
async fn stub_mint(
full_id: &'static str,
) -> (String, tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>) {
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 (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
// Read headers, then as much body as Content-Length says.
let (head, body) = loop {
let n = stream.read(&mut chunk).await.unwrap_or(0);
if n == 0 {
break (String::new(), Vec::new());
}
buf.extend_from_slice(&chunk[..n]);
let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
continue;
};
let head = String::from_utf8_lossy(&buf[..pos]).to_string();
let len = head
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())?
})
.unwrap_or(0);
while buf.len() < pos + 4 + len {
let n = stream.read(&mut chunk).await.unwrap_or(0);
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
break (head, buf[pos + 4..].to_vec());
};
let request_line = head.lines().next().unwrap_or_default().to_string();
let (status, reply) = if request_line.starts_with("GET /v1/keys ") {
let key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
let keys: serde_json::Map<String, serde_json::Value> = (0..16)
.map(|i| ((1u64 << i).to_string(), serde_json::json!(key)))
.collect();
(
"200 OK",
serde_json::json!({"keysets": [
{"id": full_id, "unit": "sat", "active": true, "keys": keys}
]}),
)
} else if request_line.starts_with("GET /v1/keysets ") {
(
"200 OK",
serde_json::json!({"keysets": [
{"id": full_id, "unit": "sat", "active": true, "input_fee_ppk": 0}
]}),
)
} else if request_line.starts_with("POST /v1/swap ") {
let _ = tx.send(serde_json::from_slice(&body).unwrap_or_default());
(
"400 Bad Request",
serde_json::json!({"code": 11001, "detail": "Token Already Spent"}),
)
} else {
("404 Not Found", serde_json::json!({}))
};
let reply = reply.to_string();
let _ = stream
.write_all(
format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{reply}",
reply.len()
)
.as_bytes(),
)
.await;
}
});
(format!("http://{addr}"), rx)
}
fn proof_with_id(id: &str) -> Proof {
Proof {
amount: 8,
id: id.to_string(),
secret: "test-secret".to_string(),
c: "02".to_string() + &"11".repeat(32),
}
}
/// Regression (2026-09-29): the paid-download seller called `swap`
/// directly with a cashuB token's short v2 keyset id and the mint
/// answered 422. `swap` itself must send the full id.
#[tokio::test]
async fn swap_expands_a_short_v2_keyset_id_before_calling_the_mint() {
const FULL: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
let (url, mut swaps) = stub_mint(FULL).await;
let client = MintClient::new(&url).unwrap();
let Err(err) = client
.swap(&[proof_with_id("01fc0ec0e59cd6fa")], &[8])
.await
else {
panic!("stub mint rejects every swap");
};
assert!(err.is::<AlreadyRedeemed>(), "unexpected error: {err:#}");
let body = swaps.recv().await.expect("swap reached the mint");
assert_eq!(body["inputs"][0]["id"], FULL);
}
#[tokio::test]
async fn swap_passes_complete_keyset_ids_through_unchanged() {
const FULL: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
let (url, mut swaps) = stub_mint(FULL).await;
let client = MintClient::new(&url).unwrap();
for id in [FULL, "009a1f293253e41e"] {
let _ = client.swap(&[proof_with_id(id)], &[8]).await;
let body = swaps.recv().await.expect("swap reached the mint");
assert_eq!(body["inputs"][0]["id"], id);
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.17-alpha",
"version": "1.8.19-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.17-alpha",
"version": "1.8.19-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.17-alpha",
"version": "1.8.19-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -362,6 +362,30 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.19-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.19-alpha</span>
<span class="text-xs text-white/40">September 28, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.</p>
<p>Embedded AIUI now stays transparent so the dashboard background appears once.</p>
<p>AIUI background fixes are now included reliably in OTA updates and fresh installations.</p>
</div>
</div>
<!-- v1.8.18-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.18-alpha</span>
<span class="text-xs text-white/40">September 18, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.</p>
<p>Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.</p>
<p>Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.</p>
</div>
</div>
<!-- v1.8.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+17 -18
View File
@@ -1,30 +1,29 @@
{
"changelog": [
"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."
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
"Embedded AIUI now stays transparent so the dashboard background appears once.",
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
],
"components": [
{
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"current_version": "1.8.19-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
"new_version": "1.8.19-alpha",
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
"size_bytes": 64495784
},
{
"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
"current_version": "1.8.19-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
"new_version": "1.8.19-alpha",
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
"size_bytes": 97142031
}
],
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"release_date": "2026-09-28",
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.17-alpha"
"version": "1.8.19-alpha"
}
+17 -18
View File
@@ -1,30 +1,29 @@
{
"changelog": [
"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."
"Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.",
"Embedded AIUI now stays transparent so the dashboard background appears once.",
"AIUI background fixes are now included reliably in OTA updates and fresh installations."
],
"components": [
{
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"current_version": "1.8.19-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
"new_version": "1.8.19-alpha",
"sha256": "bb500d02567ad16179179d43138cc6fdafee680835262026eeaa7d1bc8cdd307",
"size_bytes": 64495784
},
{
"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
"current_version": "1.8.19-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.19-alpha/archipelago-frontend-1.8.19-alpha.tar.gz",
"name": "archipelago-frontend-1.8.19-alpha.tar.gz",
"new_version": "1.8.19-alpha",
"sha256": "fbbaa237e2ea4e9e576dda9b15fdcf3c59c40bc55bfee4ebbea303b0172fc49b",
"size_bytes": 97142031
}
],
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"release_date": "2026-09-28",
"signature": "4da9bf766d94c2de6641619a36663106791c7a1d342b729c666662cdde1feabdf11c51994c1cbbe2a0e2b270c316d77763435e976ce22730855b3822bc9d390a",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.17-alpha"
"version": "1.8.19-alpha"
}
+10 -8
View File
@@ -78,15 +78,17 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
echo "Staging frontend archive in $STAGING_DIR..."
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
# Bake AIUI in so fresh installs pick it up. OTA already
# carries-forward the existing aiui/ if the tarball lacks one
# (update.rs:922), but including it here makes the tarball
# the single source of truth instead of relying on a side-
# effect of the in-place swap.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
echo " Including AIUI from demo/aiui/"
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
# create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
# Never overlay it with the older demo bundle (or nest aiui/aiui/).
if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
[ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
echo "Error: fresh AIUI payload missing from frontend dist" >&2
exit 1
fi
grep -Fxq "commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)" "$STAGING_DIR/aiui/BUILD-INFO" || {
echo "Error: AIUI payload was not built from the current commit" >&2
exit 1
}
# OTA bridge for nodes running older updaters: they only know how to
# apply the backend binary and frontend archive. Carry host runtime
# assets inside the frontend tarball; the new backend promotes them
+5 -9
View File
@@ -169,15 +169,11 @@ else
fi
cd "$PROJECT_ROOT"
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
# bakes it from demo/aiui independently, but build-iso-release.sh's
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
# consecutive releases (.127, .129) because this fold-in was manual.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
echo " AIUI folded into web/dist from demo/aiui"
fi
# Build AIUI from the same source as the release. The checked-in demo bundle
# can predate source fixes and must never overwrite the production payload.
bash "$SCRIPT_DIR/build-aiui.sh"
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
# dist would ship with a perfectly valid sha256. Require the freshly built